@@ -277,16 +277,16 @@ def print_matrix(self) -> None:
277277class BradleyTerryFitter :
278278 def __init__ (
279279 self ,
280- win_matrix : dict [str , dict [ tuple [str , str ], list [float ] ]],
280+ matchups : dict [tuple [str , str ], list [float ]],
281281 * ,
282282 regularization : float = 0.01 ,
283283 compute_uncertainties : bool = True ,
284284 ):
285- self .win_matrix = win_matrix
285+ self .matchups = matchups
286286 self .regularization = regularization
287287 self .compute_uncertainties = compute_uncertainties
288- self .results : dict [ str , dict ] = {}
289- """game name -> {players: list[str], strengths: np.ndarray, log_likelihood: float}"""
288+ self .result : dict | None = None
289+ """{players: list[str], strengths: np.ndarray, log_likelihood: float}"""
290290
291291 def _sigmoid (self , x : np .ndarray ) -> np .ndarray :
292292 return 1 / (1 + np .exp (- x ))
@@ -353,15 +353,15 @@ def _constrained_covariance(self, H: np.ndarray) -> np.ndarray:
353353 Hr_inv = np .linalg .pinv (Hr )
354354 return Z @ Hr_inv @ Z .T
355355
356- def _fit_game (self , game_name : str , matchups : dict [ tuple [ str , str ], list [ float ]] ) -> dict :
357- """Fit Bradley-Terry model for a single game ."""
358- players = sorted ({p for pair in matchups .keys () for p in pair })
356+ def fit (self ) -> dict :
357+ """Fit Bradley-Terry model."""
358+ players = sorted ({p for pair in self . matchups .keys () for p in pair })
359359 n_players = len (players )
360360 player_to_idx = {p : i for i , p in enumerate (players )}
361361
362362 pairs = []
363363 wins = []
364- for (p1 , p2 ), (w1 , w2 ) in matchups .items ():
364+ for (p1 , p2 ), (w1 , w2 ) in self . matchups .items ():
365365 i , j = player_to_idx [p1 ], player_to_idx [p2 ]
366366 pairs .append ((i , j ))
367367 wins .append ([w1 , w2 ])
@@ -390,9 +390,6 @@ def _fit_game(self, game_name: str, matchups: dict[tuple[str, str], list[float]]
390390 options = {"ftol" : 1e-9 , "maxiter" : 1000 },
391391 )
392392
393- if not result .success :
394- logger .warning (f"Optimization failed for { game_name } : { result .message } " )
395-
396393 strengths = result .x
397394 out = {
398395 "players" : players ,
@@ -406,46 +403,41 @@ def _fit_game(self, game_name: str, matchups: dict[tuple[str, str], list[float]]
406403 elo_std = scale * np .sqrt (np .clip (np .diag (cov ), 0.0 , np .inf ))
407404 out ["covariance" ] = cov
408405 out ["elo_std" ] = elo_std
406+ self .result = out
409407 return out
410408
411- def fit_all (self ) -> None :
412- """Fit Bradley-Terry model for all games."""
413- for game_name , matchups in self .win_matrix .items ():
414- logger .info (f"Fitting Bradley-Terry model for { game_name } " )
415- self .results [game_name ] = self ._fit_game (game_name , matchups )
416-
417- def print_results (self ) -> None :
418- """Print fitted strengths and Elo ratings."""
419- print (f"Regularization λ = { self .regularization } " )
420- print (f"Elo conversion: R = { ELO_BASE } + ({ ELO_SLOPE } /ln(10)) * s" )
421- for game_name , result in sorted (self .results .items ()):
422- print (f"\n { game_name } :" )
423- print (f"Log-likelihood: { result ['log_likelihood' ]:.2f} " )
424- has_sigma = "elo_std" in result
425- if has_sigma :
426- print (f"\n { 'Player' :<30s} { 'BT Strength' :>12s} { 'Elo' :>8s} { '±1σ' :>8s} " )
427- print ("-" * 62 )
428- else :
429- print (f"\n { 'Player' :<30s} { 'BT Strength' :>12s} { 'Elo' :>8s} " )
430- print ("-" * 52 )
431-
432- # Sort by strength descending
433- indices = np .argsort (result ["strengths" ])[::- 1 ]
434- for idx in indices :
435- player = result ["players" ][idx ]
436- strength = result ["strengths" ][idx ]
437- elo = self .bt_to_elo (strength )
438- sigma = result .get ("elo_std" )
439- if sigma is not None :
440- s = sigma [idx ]
441- print (f" { player :<30s} { strength :12.3f} { elo :8.0f} { s :8.0f} " )
442- else :
443- print (f" { player :<30s} { strength :12.3f} { elo :8.0f} " )
444-
445409
446410class BradleyTerryFitterPlots :
447- def __init__ (self , fitter : BradleyTerryFitter ):
448- self .fitter = fitter
411+ def __init__ (self , results : dict [str , dict ], win_matrix : dict [str , dict [tuple [str , str ], list [float ]]]):
412+ self .results = results
413+ self .win_matrix = win_matrix
414+
415+ @staticmethod
416+ def bt_to_elo (strength : float ) -> float :
417+ """Convert Bradley-Terry strength to Elo rating.
418+
419+ Formula: R_i = R_0 + (β/ln(10)) * s_i
420+ where β = 400 (ELO_SLOPE), R_0 = 1200 (ELO_BASE)
421+ """
422+ return ELO_BASE + (ELO_SLOPE / np .log (10 )) * strength
423+
424+ @staticmethod
425+ def _sigmoid (x : np .ndarray ) -> np .ndarray :
426+ return 1 / (1 + np .exp (- x ))
427+
428+ def _negative_log_likelihood (
429+ self , strengths : np .ndarray , pairs : list , wins : np .ndarray , regularization : float
430+ ) -> float :
431+ """Negative log-likelihood for Bradley-Terry model with L2 regularization."""
432+ assert len (wins ) == len (pairs )
433+ ll = 0.0
434+ for k , (i , j ) in enumerate (pairs ):
435+ diff = strengths [i ] - strengths [j ]
436+ w_ij , w_ji = wins [k ]
437+ ll += w_ij * np .log (self ._sigmoid (diff ) + 1e-10 )
438+ ll += w_ji * np .log (self ._sigmoid (- diff ) + 1e-10 )
439+ regularization_term = regularization * np .sum (strengths ** 2 )
440+ return - ll + regularization_term
449441
450442 def create_elo_plots (self , output_dir : Path ) -> None :
451443 """Create combined horizontal bar chart showing Elo ratings for all games.
@@ -458,14 +450,14 @@ def create_elo_plots(self, output_dir: Path) -> None:
458450 output_dir .mkdir (parents = True , exist_ok = True )
459451
460452 # Get player ordering from "ALL" game
461- if "ALL" not in self .fitter . results :
453+ if "ALL" not in self .results :
462454 logger .warning ("No 'ALL' game found in results, skipping Elo plots" )
463455 return
464456
465- all_result = self .fitter . results ["ALL" ]
457+ all_result = self .results ["ALL" ]
466458 all_players = all_result ["players" ]
467459 all_strengths = all_result ["strengths" ]
468- all_elos = np .array ([self .fitter . bt_to_elo (s ) for s in all_strengths ])
460+ all_elos = np .array ([self .bt_to_elo (s ) for s in all_strengths ])
469461
470462 # Sort by ALL game Elo descending
471463 all_indices = np .argsort (all_elos )[::- 1 ]
@@ -475,21 +467,21 @@ def create_elo_plots(self, output_dir: Path) -> None:
475467 player_to_pos = {p : i for i , p in enumerate (player_order )}
476468
477469 # Create subplots for each game
478- games = sorted (self .fitter . results .keys ())
470+ games = sorted (self .results .keys ())
479471 n_games = len (games )
480472
481473 fig , axes = plt .subplots (1 , n_games , figsize = (5 * n_games , max (8 , len (player_order ) * 0.5 )), sharey = True )
482474 if n_games == 1 :
483475 axes = [axes ]
484476
485477 for ax , game_name in zip (axes , games ):
486- result = self .fitter . results [game_name ]
478+ result = self .results [game_name ]
487479 players = result ["players" ]
488480 strengths = result ["strengths" ]
489481 sigma = result .get ("elo_std" )
490482
491483 # Convert to Elo ratings
492- elos = {p : self .fitter . bt_to_elo (s ) for p , s in zip (players , strengths )}
484+ elos = {p : self .bt_to_elo (s ) for p , s in zip (players , strengths )}
493485
494486 # Create arrays aligned with player_order
495487 y_positions = []
@@ -547,22 +539,23 @@ def create_elo_plots(self, output_dir: Path) -> None:
547539 plt .close ()
548540 logger .info (f"Saved combined Elo plot: { output_path } " )
549541
550- def create_validation_plots (self , output_dir : Path ) -> None :
542+ def create_validation_plots (self , output_dir : Path , regularization : float = 0.01 ) -> None :
551543 """Create validation plots showing log-likelihood profiles for each player.
552544
553545 Args:
554546 output_dir: Directory to save PDF plots
547+ regularization: L2 regularization strength used in fitting
555548 """
556549 output_dir .mkdir (parents = True , exist_ok = True )
557550
558- for game_name , result in self .fitter . results .items ():
551+ for game_name , result in self .results .items ():
559552 players = result ["players" ]
560553 strengths = result ["strengths" ]
561554 n_players = len (players )
562555
563556 # Rebuild pairs and wins for this game
564557 player_to_idx = {p : i for i , p in enumerate (players )}
565- matchups = self .fitter . win_matrix [game_name ]
558+ matchups = self .win_matrix [game_name ]
566559 pairs = []
567560 wins = []
568561 for (p1 , p2 ), (w1 , w2 ) in matchups .items ():
@@ -592,7 +585,7 @@ def create_validation_plots(self, output_dir: Path) -> None:
592585 test_strengths [idx ] = s
593586 # Re-normalize to maintain sum=0 constraint
594587 test_strengths -= test_strengths .mean ()
595- neg_ll = self .fitter . _negative_log_likelihood (test_strengths , pairs , wins )
588+ neg_ll = self ._negative_log_likelihood (test_strengths , pairs , wins , regularization )
596589 neg_lls .append (neg_ll )
597590
598591 neg_lls = np .array (neg_lls )
@@ -633,6 +626,39 @@ def create_validation_plots(self, output_dir: Path) -> None:
633626 logger .info (f"Saved validation plot: { output_path } " )
634627
635628
629+ def print_results (results : dict [str , dict ]) -> None :
630+ """Print fitted strengths and Elo ratings for all games.
631+
632+ Args:
633+ results: Dictionary mapping game name to fit results
634+ regularization: L2 regularization strength used in fitting
635+ """
636+ for game_name in sorted (results .keys ()):
637+ result = results [game_name ]
638+ print (f"\n { game_name } :" )
639+ print (f"Log-likelihood: { result ['log_likelihood' ]:.2f} " )
640+ has_sigma = "elo_std" in result
641+ if has_sigma :
642+ print (f"\n { 'Player' :<30s} { 'BT Strength' :>12s} { 'Elo' :>8s} { '±1σ' :>8s} " )
643+ print ("-" * 62 )
644+ else :
645+ print (f"\n { 'Player' :<30s} { 'BT Strength' :>12s} { 'Elo' :>8s} " )
646+ print ("-" * 52 )
647+
648+ # Sort by strength descending
649+ indices = np .argsort (result ["strengths" ])[::- 1 ]
650+ for idx in indices :
651+ player = result ["players" ][idx ]
652+ strength = result ["strengths" ][idx ]
653+ elo = BradleyTerryFitter .bt_to_elo (strength )
654+ sigma = result .get ("elo_std" )
655+ if sigma is not None :
656+ s = sigma [idx ]
657+ print (f" { player :<30s} { strength :12.3f} { elo :8.0f} { s :8.0f} " )
658+ else :
659+ print (f" { player :<30s} { strength :12.3f} { elo :8.0f} " )
660+
661+
636662if __name__ == "__main__" :
637663 parser = argparse .ArgumentParser (description = "Build win matrix and fit Bradley-Terry model" )
638664 parser .add_argument ("-d" , "--log_dir" , type = Path , default = LOCAL_LOG_DIR )
@@ -685,20 +711,28 @@ def create_validation_plots(self, output_dir: Path) -> None:
685711 if args .print_matrix :
686712 builder .print_matrix ()
687713
688- compute_uncertainties = (
714+ uncertainties_supported = (
689715 args .score_type == "per_tournament_boolean_drop_draws" and args .all_normalization_scheme == "none"
690716 )
691- fitter = BradleyTerryFitter (
692- builder .win_matrix ,
693- regularization = args .regularization ,
694- compute_uncertainties = compute_uncertainties ,
695- )
696- fitter .fit_all ()
697- fitter .print_results ()
717+
718+ # Fit Bradley-Terry model for each game
719+ results = {}
720+ for game_name , matchups in builder .win_matrix .items ():
721+ logger .info (f"Fitting Bradley-Terry model for { game_name } " )
722+ fitter = BradleyTerryFitter (
723+ matchups ,
724+ regularization = args .regularization ,
725+ compute_uncertainties = uncertainties_supported ,
726+ )
727+ results [game_name ] = fitter .fit ()
728+
729+ print (f"\n Regularization λ = { args .regularization } " )
730+ print (f"Elo conversion: R = { ELO_BASE } + ({ ELO_SLOPE } /ln(10)) * s" )
731+ print_results (results )
698732
699733 if args .validation_plots or args .elo_plot :
700- plotter = BradleyTerryFitterPlots (fitter )
734+ plotter = BradleyTerryFitterPlots (results , builder . win_matrix )
701735 if args .validation_plots :
702- plotter .create_validation_plots (args .validation_dir )
736+ plotter .create_validation_plots (args .validation_dir , regularization = args . regularization )
703737 if args .elo_plot :
704738 plotter .create_elo_plots (args .elo_plot_dir )
0 commit comments