Skip to content

Commit 43b3849

Browse files
committed
Change(metrics): Do proper parameteric bootstrap
1 parent 814de78 commit 43b3849

1 file changed

Lines changed: 81 additions & 81 deletions

File tree

codeclash/analysis/metrics/elo2.py

Lines changed: 81 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
from codeclash.analysis.significance import calculate_p_value
1616
from codeclash.analysis.viz.utils import MODEL_TO_DISPLAY_NAME
1717
from 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

2020
logger = 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

494485
class 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}\nBT 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("\nRank 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("\nRank 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

12361229
def 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"\nRegularization λ = {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"\nRegularization λ = {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

Comments
 (0)