|
1 | 1 | #!/usr/bin/env python3 |
2 | 2 | import argparse |
3 | 3 | import json |
| 4 | +from collections import Counter |
4 | 5 | from dataclasses import dataclass |
5 | 6 | from pathlib import Path |
6 | 7 |
|
@@ -125,11 +126,12 @@ class SkipTournamentException(Exception): |
125 | 126 |
|
126 | 127 |
|
127 | 128 | class ELOCalculator: |
128 | | - def __init__(self, *, k_factor: float, starting_elo: float, weighting_function: str, alpha: float): |
| 129 | + def __init__(self, *, k_factor: float, starting_elo: float, weighting_function: str, alpha: float, unit: str): |
129 | 130 | self._k_factor = k_factor |
130 | 131 | self._starting_elo = starting_elo |
131 | 132 | self._weighting_function = weighting_function |
132 | 133 | self._alpha = alpha |
| 134 | + self._unit = unit |
133 | 135 | self._player_profiles = {} |
134 | 136 |
|
135 | 137 | @property |
@@ -161,6 +163,49 @@ def _analyze_round(self, stats: dict, player2profile: dict, *, total_rounds: int |
161 | 163 | weighted_k_factor = self._k_factor * round_weight |
162 | 164 | update_profiles(prof_and_score, weighted_k_factor) |
163 | 165 |
|
| 166 | + def _score_per_round(self, metadata: dict, player2profile: dict, total_rounds: int) -> None: |
| 167 | + for idx, stats in metadata["round_stats"].items(): |
| 168 | + idx = int(idx) |
| 169 | + if idx == 0: |
| 170 | + # Skip initial round |
| 171 | + continue |
| 172 | + assert idx == stats["round_num"], (idx, stats["round_num"]) |
| 173 | + self._analyze_round(stats, player2profile, total_rounds=total_rounds) |
| 174 | + |
| 175 | + def _score_per_tournament(self, metadata: dict, player2profile: dict, total_rounds: int) -> None: |
| 176 | + # Update score = number of rounds won per tournament (ties count as 0.5 each) |
| 177 | + round_stats = metadata["round_stats"] |
| 178 | + winners = [] |
| 179 | + ties = 0 |
| 180 | + for k, v in round_stats.items(): |
| 181 | + try: |
| 182 | + if int(k) == 0: |
| 183 | + continue |
| 184 | + except Exception: |
| 185 | + pass |
| 186 | + w = v.get("winner") |
| 187 | + if w == RESULT_TIE: |
| 188 | + ties += 1 |
| 189 | + elif w is not None: |
| 190 | + winners.append(w) |
| 191 | + |
| 192 | + win_counts = Counter(winners) |
| 193 | + |
| 194 | + # Ensure both players are considered (even if they have zero wins) |
| 195 | + players = list(player2profile.keys()) |
| 196 | + for p in players: |
| 197 | + player2profile[p].rounds_played += 1 # Each player played all rounds |
| 198 | + wins_per_player = {p: win_counts.get(p, 0) + 0.5 * ties for p in players} |
| 199 | + |
| 200 | + wins_total = sum(wins_per_player.values()) |
| 201 | + if wins_total == 0: |
| 202 | + raise SkipTournamentException |
| 203 | + |
| 204 | + prof_and_score = [(player2profile[p], wins_per_player[p] / wins_total) for p in players] |
| 205 | + |
| 206 | + weighted_k_factor = self._k_factor * (1 / total_rounds) if total_rounds > 0 else self._k_factor |
| 207 | + update_profiles(prof_and_score, weighted_k_factor) |
| 208 | + |
164 | 209 | def _analyze_tournament(self, tournament_log_folder: Path) -> None: |
165 | 210 | """Update all profiles based on the results of one tournament""" |
166 | 211 | with open(tournament_log_folder / "metadata.json") as f: |
@@ -190,15 +235,10 @@ def _analyze_tournament(self, tournament_log_folder: Path) -> None: |
190 | 235 | # Determine total rounds for weighting calculation |
191 | 236 | total_rounds = len([k for k in metadata["round_stats"].keys() if k != "0"]) |
192 | 237 |
|
193 | | - for idx, stats in metadata["round_stats"].items(): |
194 | | - idx = int(idx) |
195 | | - if idx == 0: |
196 | | - # Skip initial round |
197 | | - continue |
198 | | - |
199 | | - assert idx == stats["round_num"], (idx, stats["round_num"]) |
200 | | - |
201 | | - self._analyze_round(stats, player2profile, total_rounds=total_rounds) |
| 238 | + if self._unit == "round": |
| 239 | + self._score_per_round(metadata, player2profile, total_rounds=total_rounds) |
| 240 | + elif self._unit == "tournament": |
| 241 | + self._score_per_tournament(metadata, player2profile, total_rounds=total_rounds) |
202 | 242 |
|
203 | 243 | def analyze(self, log_dir: Path) -> None: |
204 | 244 | print(f"Calculating weighted ELO ratings from logs in {log_dir} ...") |
@@ -320,12 +360,20 @@ def print_results(self) -> None: |
320 | 360 | default=2.0, |
321 | 361 | help="Alpha parameter for exponential weighting (Default: 2.0, ignored for linear weighting)", |
322 | 362 | ) |
| 363 | + parser.add_argument( |
| 364 | + "-u", |
| 365 | + "--unit", |
| 366 | + choices=["round", "tournament"], |
| 367 | + default="round", |
| 368 | + help="Calculate Elo ratings on a per-round or per-tournament basis (Default: round)", |
| 369 | + ) |
323 | 370 | args = parser.parse_args() |
324 | 371 | calculator = ELOCalculator( |
325 | 372 | k_factor=args.k_factor, |
326 | 373 | starting_elo=args.starting_elo, |
327 | 374 | weighting_function=args.weighting_function, |
328 | 375 | alpha=args.alpha, |
| 376 | + unit=args.unit, |
329 | 377 | ) |
330 | 378 | calculator.analyze(args.log_dir) |
331 | 379 | calculator.print_results() |
0 commit comments