Skip to content

Commit 67e3f62

Browse files
committed
Ref(metrics): Simplify calculation class
1 parent 697ef66 commit 67e3f62

1 file changed

Lines changed: 101 additions & 67 deletions

File tree

codeclash/analysis/metrics/elo2.py

Lines changed: 101 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -277,16 +277,16 @@ def print_matrix(self) -> None:
277277
class 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

446410
class 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+
636662
if __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"\nRegularization λ = {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

Comments
 (0)