1515from codeclash .analysis .significance import calculate_p_value
1616from codeclash .analysis .viz .utils import MODEL_TO_DISPLAY_NAME
1717from codeclash .constants import LOCAL_LOG_DIR , RESULT_TIE
18- from codeclash .utils .log import get_logger
18+ from codeclash .utils .log import add_file_handler , get_logger
1919
2020logger = get_logger ("elo2" )
2121
@@ -297,50 +297,6 @@ def get_nonparametric_bootstrap(
297297 boot_matrix ["ALL" ] = {k : [v [0 ], v [1 ]] for k , v in combined .items ()}
298298 return boot_matrix
299299
300- def get_parametric_bootstrap (
301- self , * , rng : np .random .Generator | None = None
302- ) -> dict [str , dict [tuple [str , str ], list [float ]]]:
303- """Return a parametric bootstrap sample based on a Bernoulli model.
304-
305- For each matchup, we estimate the win probability p = w1/(w1+w2) and then
306- sample n = w1+w2 Bernoulli trials with that probability to get new counts.
307- """
308- if self .all_normalization_scheme != "none" :
309- raise NotImplementedError ("get_nonparametric_bootstrap supports all_normalization_scheme='none' only" )
310- if self .score_type != "per_tournament_boolean_drop_draws" :
311- raise NotImplementedError (
312- "get_nonparametric_bootstrap supports score_type='per_tournament_boolean_drop_draws' only"
313- )
314- if rng is None :
315- rng = np .random .default_rng ()
316-
317- boot_matrix : dict [str , dict [tuple [str , str ], list [float ]]] = defaultdict (
318- lambda : defaultdict (lambda : [0.0 , 0.0 ])
319- )
320-
321- for game_name , matchups in self .win_matrix .items ():
322- if game_name == "ALL" :
323- continue
324- for pair , (w1 , w2 ) in matchups .items ():
325- n = int (w1 + w2 )
326- if n == 0 :
327- boot_matrix [game_name ][pair ] = [0.0 , 0.0 ]
328- continue
329- p = w1 / (w1 + w2 )
330- w1_new = float (rng .binomial (n , p ))
331- w2_new = float (n - w1_new )
332- boot_matrix [game_name ][pair ] = [w1_new , w2_new ]
333-
334- # Build combined 'ALL' game by summing
335- combined : dict [tuple [str , str ], list [float ]] = defaultdict (lambda : [0.0 , 0.0 ])
336- for matchups in boot_matrix .values ():
337- for pair , (w1 , w2 ) in matchups .items ():
338- combined [pair ][0 ] += w1
339- combined [pair ][1 ] += w2
340-
341- boot_matrix ["ALL" ] = {k : [v [0 ], v [1 ]] for k , v in combined .items ()}
342- return boot_matrix
343-
344300 def print_matrix (self ) -> None :
345301 for game , matchups in sorted (self .win_matrix .items ()):
346302 print (f"\n { game } :" )
@@ -490,6 +446,41 @@ def fit(self) -> dict:
490446 self .result = out
491447 return out
492448
449+ def get_parametric_bootstrap (self , * , rng : np .random .Generator | None = None ) -> dict [tuple [str , str ], list [float ]]:
450+ """Return a parametric bootstrap sample based on the fitted Bradley-Terry model.
451+
452+ Uses the fitted strengths to compute win probabilities P(i beats j) = sigmoid(s_i - s_j),
453+ then samples new win counts from binomial distributions with those probabilities.
454+ This is a true parametric bootstrap, unlike a semi-parametric approach that uses empirical win rates.
455+ """
456+ if self .result is None :
457+ raise RuntimeError ("Must call fit() before get_parametric_bootstrap()" )
458+ if rng is None :
459+ rng = np .random .default_rng ()
460+
461+ players = self .result ["players" ]
462+ strengths = self .result ["strengths" ]
463+ player_to_idx = {p : i for i , p in enumerate (players )}
464+
465+ boot_matrix : dict [tuple [str , str ], list [float ]] = {}
466+
467+ for pair , (w1 , w2 ) in self .matchups .items ():
468+ n = int (w1 + w2 )
469+ if n == 0 :
470+ boot_matrix [pair ] = [0.0 , 0.0 ]
471+ continue
472+
473+ p1 , p2 = pair
474+ i , j = player_to_idx [p1 ], player_to_idx [p2 ]
475+ diff = strengths [i ] - strengths [j ]
476+ p = self ._sigmoid (diff )
477+
478+ w1_new = float (rng .binomial (n , p ))
479+ w2_new = float (n - w1_new )
480+ boot_matrix [pair ] = [w1_new , w2_new ]
481+
482+ return boot_matrix
483+
493484
494485class BradleyTerryFitterPlots :
495486 def __init__ (self , results : dict [str , dict ], win_matrix : dict [str , dict [tuple [str , str ], list [float ]]]):
@@ -685,7 +676,7 @@ def create_validation_plots(self, output_dir: Path, regularization: float = 0.01
685676 # Plot
686677 ax .plot (strength_range , neg_lls , "b-" , linewidth = 2 )
687678 ax .axvline (optimal_strength , color = "r" , linestyle = "--" , label = "Optimal" , linewidth = 2 )
688- ax .axhline (min_neg_ll , color = "r" , linestyle = ":" , alpha = 0.5 , linewidth = 1 )
679+ ax .axhline (min_neg_ll , color = "r" , linestyle = ":" , alpha = 0.5 , linewidth = 1 , label = "Min NLL" )
689680
690681 # Add text annotation with minimum NLL and optimal strength
691682 text_str = f"Min NLL: { min_neg_ll :.2f} \n BT Strength: { optimal_strength :.3f} "
@@ -702,7 +693,7 @@ def create_validation_plots(self, output_dir: Path, regularization: float = 0.01
702693 ax .set_xlabel ("BT Strength" , fontsize = 10 )
703694 ax .set_ylabel ("Negative Log-Likelihood" , fontsize = 10 )
704695 ax .set_title (f"{ player } " , fontsize = 11 , fontweight = "bold" )
705- ax .legend (fontsize = 8 )
696+ ax .legend (fontsize = 8 , loc = "upper right" )
706697 ax .grid (True , alpha = 0.3 )
707698
708699 # Hide unused subplots
@@ -882,12 +873,17 @@ def run(self) -> None:
882873 base_pos = self ._positions (baseline_ranking )
883874
884875 rng = np .random .default_rng (42 )
876+ baseline_fitter = BradleyTerryFitter (
877+ self .builder .win_matrix [game ], regularization = self .regularization , compute_uncertainties = False
878+ )
885879 for _ in tqdm (range (self .n_bootstrap ), desc = "Bootstrap samples" ):
886880 if self .bootstrap_type == "nonparametric" :
887881 boot = self .builder .get_nonparametric_bootstrap (rng = rng )
882+ res = self ._fit_on_matrix (boot [game ])
888883 else :
889- boot = self .builder .get_parametric_bootstrap (rng = rng )
890- res = self ._fit_on_matrix (boot [game ])
884+ baseline_fitter .fit ()
885+ boot_matrix = baseline_fitter .get_parametric_bootstrap (rng = rng )
886+ res = self ._fit_on_matrix (boot_matrix )
891887 elos = self ._elos_from_result (res )
892888 ranking = self ._ranking_from_elos (elos )
893889 pos = self ._positions (ranking )
@@ -928,29 +924,27 @@ def run(self) -> None:
928924 top1_consistency = top1_match / self .n_bootstrap if self .n_bootstrap > 0 else float ("nan" )
929925 pairwise_agreement = (pair_agree / (self .n_bootstrap * total_pairs )) if total_pairs > 0 else float ("nan" )
930926
931- lines = []
932- lines .append ("\n Rank stability (bootstrap)" )
933- lines .append (f"Game: { game } " )
934- lines .append (f"Bootstraps: { self .n_bootstrap } " )
935- lines .append (f"Bootstrap type: { self .bootstrap_type } " )
936- lines .append ("" )
937- lines .append (f"{ 'Metric' :<28} { 'Value' :>10} " )
938- lines .append ("-" * 40 )
939- lines .append (f"{ 'Kendall tau (avg)' :<28} { mean_tau :>10.3f} " )
940- lines .append (f"{ 'Spearman rho (avg)' :<28} { mean_rho :>10.3f} " )
941- lines .append (f"{ 'Footrule (avg, norm)' :<28} { mean_foot :>10.3f} " )
942- lines .append (f"{ 'Top-1 consistency' :<28} { top1_consistency :>10.3f} " )
943- lines .append (f"{ 'Pairwise order agree' :<28} { pairwise_agreement :>10.3f} " )
927+ logger .info ("\n Rank stability (bootstrap)" )
928+ logger .info (f"Game: { game } " )
929+ logger .info (f"Bootstraps: { self .n_bootstrap } " )
930+ logger .info (f"Bootstrap type: { self .bootstrap_type } " )
931+ logger .info ("" )
932+ logger .info (f"{ 'Metric' :<28} { 'Value' :>10} " )
933+ logger .info ("-" * 40 )
934+ logger .info (f"{ 'Kendall tau (avg)' :<28} { mean_tau :>10.3f} " )
935+ logger .info (f"{ 'Spearman rho (avg)' :<28} { mean_rho :>10.3f} " )
936+ logger .info (f"{ 'Footrule (avg, norm)' :<28} { mean_foot :>10.3f} " )
937+ logger .info (f"{ 'Top-1 consistency' :<28} { top1_consistency :>10.3f} " )
938+ logger .info (f"{ 'Pairwise order agree' :<28} { pairwise_agreement :>10.3f} " )
944939 for k in topks :
945- lines .append (f"{ f'Top-{ k } overlap (avg)' :<28} { float (np .mean (topk_overlap [k ])):>10.3f} " )
946- for ln in lines :
947- logger .info (ln )
940+ logger .info (f"{ f'Top-{ k } overlap (avg)' :<28} { float (np .mean (topk_overlap [k ])):>10.3f} " )
948941
949942 header = f"\n { 'Model' :<30} { 'BaseElo' :>8} { 'StdElo' :>8} { 'MeanRank' :>9} { 'StdRank' :>8} " + " " .join (
950943 [f"P@{ k :>2} " for k in topks ]
951944 )
952945 logger .info (header )
953- logger .info ("-" * max (40 , len (header )))
946+ separator = "-" * max (40 , len (header ))
947+ logger .info (separator )
954948 for p in players :
955949 ranks = np .array (rank_samples [p ], dtype = float )
956950 elos_arr = np .array (elo_samples [p ], dtype = float )
@@ -961,10 +955,10 @@ def run(self) -> None:
961955 probs = []
962956 for k in topks :
963957 probs .append (np .mean (ranks <= k ))
964- logger .info (
965- f"{ p :<30} { base_elo :8.0f} { std_elo :8.0f} { mean_r :9.2f} { std_r :8.2f} "
966- + " " .join ([f"{ float (pr ):>5.2f} " for pr in probs ])
958+ player_line = f"{ p :<30} { base_elo :8.0f} { std_elo :8.0f} { mean_r :9.2f} { std_r :8.2f} " + " " .join (
959+ [f"{ float (pr ):>5.2f} " for pr in probs ]
967960 )
961+ logger .info (player_line )
968962
969963 if self .output_dir is not None :
970964 bootstrap_dir = self .output_dir / "bootstrap"
@@ -1205,19 +1199,18 @@ def print_results(results: dict[str, dict]) -> None:
12051199
12061200 Args:
12071201 results: Dictionary mapping game name to fit results
1208- regularization: L2 regularization strength used in fitting
12091202 """
12101203 for game_name in sorted (results .keys ()):
12111204 result = results [game_name ]
1212- print (f"\n { game_name } :" )
1213- print (f"Log-likelihood: { result ['log_likelihood' ]:.2f} " )
1205+ logger . info (f"\n { game_name } :" )
1206+ logger . info (f"Log-likelihood: { result ['log_likelihood' ]:.2f} " )
12141207 has_sigma = "elo_std" in result
12151208 if has_sigma :
1216- print (f"\n { 'Player' :<30s} { 'BT Strength' :>12s} { 'Elo' :>8s} { '±1σ' :>8s} " )
1217- print ("-" * 62 )
1209+ logger . info (f"\n { 'Player' :<30s} { 'BT Strength' :>12s} { 'Elo' :>8s} { '±1σ' :>8s} " )
1210+ logger . info ("-" * 62 )
12181211 else :
1219- print (f"\n { 'Player' :<30s} { 'BT Strength' :>12s} { 'Elo' :>8s} " )
1220- print ("-" * 52 )
1212+ logger . info (f"\n { 'Player' :<30s} { 'BT Strength' :>12s} { 'Elo' :>8s} " )
1213+ logger . info ("-" * 52 )
12211214
12221215 # Sort by strength descending
12231216 indices = np .argsort (result ["strengths" ])[::- 1 ]
@@ -1228,9 +1221,9 @@ def print_results(results: dict[str, dict]) -> None:
12281221 sigma = result .get ("elo_std" )
12291222 if sigma is not None :
12301223 s = sigma [idx ]
1231- print (f" { player :<30s} { strength :12.3f} { elo :8.0f} { s :8.0f} " )
1224+ logger . info (f" { player :<30s} { strength :12.3f} { elo :8.0f} { s :8.0f} " )
12321225 else :
1233- print (f" { player :<30s} { strength :12.3f} { elo :8.0f} " )
1226+ logger . info (f" { player :<30s} { strength :12.3f} { elo :8.0f} " )
12341227
12351228
12361229def write_latex_table (results : dict [str , dict ], output_dir : Path ) -> None :
@@ -1370,8 +1363,15 @@ def write_latex_table(results: dict[str, dict], output_dir: Path) -> None:
13701363 )
13711364 results [game_name ] = fitter .fit ()
13721365
1373- print (f"\n Regularization λ = { args .regularization } " )
1374- print (f"Elo conversion: R = { ELO_BASE } + ({ ELO_SLOPE } /ln(10)) * s" )
1366+ # Add file handler to logger to save all output
1367+ if args .output_dir :
1368+ args .output_dir .mkdir (parents = True , exist_ok = True )
1369+ add_file_handler (logger , args .output_dir / "elo_results.log" )
1370+
1371+ # Print ELO results
1372+ logger .info (f"\n Regularization λ = { args .regularization } " )
1373+ logger .info (f"Elo conversion: R = { ELO_BASE } + ({ ELO_SLOPE } /ln(10)) * s" )
1374+
13751375 print_results (results )
13761376
13771377 plotter = BradleyTerryFitterPlots (results , builder .win_matrix )
@@ -1383,7 +1383,7 @@ def write_latex_table(results: dict[str, dict], output_dir: Path) -> None:
13831383 for bootstrap_type in ["nonparametric" , "parametric" ]:
13841384 BootStrapRankStability (
13851385 builder ,
1386- n_bootstrap = 200 ,
1386+ n_bootstrap = 1000 ,
13871387 game = "ALL" ,
13881388 regularization = args .regularization ,
13891389 topks = None ,
0 commit comments