|
5 | 5 |
|
6 | 6 | from tqdm import tqdm |
7 | 7 |
|
8 | | -from codeclash.constants import FILE_RESULTS, LOCAL_LOG_DIR |
9 | | - |
10 | | -K_FACTOR = 32 # ELO constant, changeable |
| 8 | +from codeclash.constants import LOCAL_LOG_DIR, RESULT_TIE |
11 | 9 |
|
12 | 10 |
|
13 | 11 | @dataclass |
14 | | -class PlayerEloProfile: |
15 | | - player_id: str |
16 | | - game_id: str |
17 | | - rating: float = 1200.0 # Default starting ELO |
18 | | - games_played: int = 0 |
| 12 | +class ModelEloProfile: |
| 13 | + model: str |
| 14 | + arena: str |
| 15 | + rating: float |
| 16 | + rounds_played: int = 0 |
19 | 17 |
|
20 | 18 |
|
21 | 19 | def expected_score(rating_a, rating_b): |
22 | 20 | return 1 / (1 + 10 ** ((rating_b - rating_a) / 400)) |
23 | 21 |
|
24 | 22 |
|
25 | | -def main(log_dir: Path): |
26 | | - # Assuming directory structure is: |
27 | | - # logs/<user_id>/<game_id> |
28 | | - # - players/ |
29 | | - # - rounds/ |
30 | | - # - game.log |
31 | | - # - metadata.json |
| 23 | +def main(log_dir: Path, k_factor: int, starting_elo: int): |
| 24 | + print(f"Calculating ELO ratings from logs in {log_dir} ...") |
| 25 | + print(f"Using K_FACTOR={k_factor}, STARTING_ELO={starting_elo}") |
32 | 26 | player_profiles = {} |
33 | | - for user_folder in log_dir.iterdir(): |
34 | | - print(f"Processing games under user `{user_folder.name}`") |
35 | | - for game_log_folder in tqdm(list(user_folder.iterdir())): |
36 | | - if not game_log_folder.is_dir(): |
37 | | - continue |
38 | | - game_id = game_log_folder.name.split(".")[1] |
39 | | - player_ids = [x.name for x in (game_log_folder / "players").iterdir() if x.is_dir()] |
40 | | - # Initialize profiles |
41 | | - for player in player_ids: |
42 | | - key = f"{game_id}.{player}" |
43 | | - if key not in player_profiles: |
44 | | - player_profiles[key] = PlayerEloProfile(player_id=player, game_id=game_id) |
45 | | - |
46 | | - for round_folder in (game_log_folder / "rounds").iterdir(): |
47 | | - if round_folder.name == "0": |
| 27 | + for game_log_folder in tqdm([x.parent for x in log_dir.rglob("game.log")]): |
| 28 | + arena = game_log_folder.name.split(".")[1] |
| 29 | + metadata = json.load(open(game_log_folder / "metadata.json")) |
| 30 | + try: |
| 31 | + p2m = {x["name"]: x["config"]["model"]["model_name"].strip("@") for x in metadata["config"]["players"]} |
| 32 | + except KeyError: |
| 33 | + print(f"Skipping {game_log_folder} (malformed metadata.json)") |
| 34 | + continue |
| 35 | + |
| 36 | + # Initialize profiles |
| 37 | + for model in p2m.values(): |
| 38 | + key = f"{arena}.{model}" |
| 39 | + if key not in player_profiles: |
| 40 | + player_profiles[key] = ModelEloProfile(model=model, arena=arena, rating=starting_elo) |
| 41 | + |
| 42 | + sims = metadata["game"]["config"]["sims_per_round"] |
| 43 | + if len(p2m) == 2: |
| 44 | + # Only process if there are exactly 2 players |
| 45 | + for idx, stats in metadata["round_stats"].items(): |
| 46 | + if idx == "0": |
48 | 47 | # Skip initial round |
49 | 48 | continue |
50 | | - if not (round_folder / FILE_RESULTS).exists(): |
51 | | - continue |
52 | | - round_results = json.load(open(round_folder / FILE_RESULTS)) |
53 | | - winner = round_results.get("winner") |
54 | | - players = round_results.get("players", player_ids) |
55 | | - # Only process if there are exactly 2 players |
56 | | - if len(players) == 2: |
57 | | - p1_key = f"{game_id}.{players[0]}" |
58 | | - p2_key = f"{game_id}.{players[1]}" |
59 | | - p1 = player_profiles[p1_key] |
60 | | - p2 = player_profiles[p2_key] |
61 | | - p1.games_played += 1 |
62 | | - p2.games_played += 1 |
63 | | - # Determine scores |
64 | | - if winner == players[0]: |
65 | | - s1, s2 = 1, 0 |
66 | | - elif winner == players[1]: |
67 | | - s1, s2 = 0, 1 |
| 49 | + |
| 50 | + prof_and_score = [] |
| 51 | + valid_submits = sum( |
| 52 | + [x["valid_submit"] for x in stats["player_stats"].values() if x.get("valid_submit") is not None] |
| 53 | + ) |
| 54 | + |
| 55 | + for k, v in stats["player_stats"].items(): |
| 56 | + if k != RESULT_TIE: |
| 57 | + if v["score"] is None: |
| 58 | + # Not sure why this happens, but just skip it |
| 59 | + continue |
| 60 | + s = v["score"] * 1.0 / sims |
| 61 | + if valid_submits == 1 and v["valid_submit"]: |
| 62 | + # FOR BACKWARDS COMPATIBILITY: If only one player submitted, give them full point |
| 63 | + s = 1.0 |
| 64 | + prof = player_profiles[f"{arena}.{p2m[k]}"] |
| 65 | + prof.rounds_played += 1 |
| 66 | + prof_and_score.append((prof, s)) |
| 67 | + |
| 68 | + # Update ELO ratings - should only happen once per match |
| 69 | + if len(prof_and_score) == 2: |
| 70 | + p1_prof, p1_raw_score = prof_and_score[0] |
| 71 | + p2_prof, p2_raw_score = prof_and_score[1] |
| 72 | + |
| 73 | + # Normalize scores so they sum to 1.0 (required for proper ELO) |
| 74 | + total_score = p1_raw_score + p2_raw_score |
| 75 | + if total_score > 0: |
| 76 | + p1_score = p1_raw_score / total_score |
| 77 | + p2_score = p2_raw_score / total_score |
68 | 78 | else: |
69 | | - s1, s2 = 0.5, 0.5 # Tie |
70 | | - # Calculate expected scores |
71 | | - e1 = expected_score(p1.rating, p2.rating) |
72 | | - e2 = expected_score(p2.rating, p1.rating) |
73 | | - # Update ratings |
74 | | - p1.rating += K_FACTOR * (s1 - e1) |
75 | | - p2.rating += K_FACTOR * (s2 - e2) |
| 79 | + # If both players scored 0, treat as a tie |
| 80 | + p1_score = p2_score = 0.5 |
| 81 | + |
| 82 | + expected_p1 = expected_score(p1_prof.rating, p2_prof.rating) |
| 83 | + rating_change = k_factor * (p1_score - expected_p1) |
76 | 84 |
|
| 85 | + expected_p2 = expected_score(p2_prof.rating, p1_prof.rating) |
| 86 | + check = k_factor * (p2_score - expected_p2) |
| 87 | + assert abs(check + rating_change) < 1e-6, "ELO rating changes do not sum to zero!" |
| 88 | + |
| 89 | + p1_prof.rating += rating_change |
| 90 | + p2_prof.rating -= rating_change # Zero-sum property |
| 91 | + |
| 92 | + print("=" * 50) |
77 | 93 | print("Player ELO profiles:") |
78 | | - for profile in player_profiles.values(): |
79 | | - print( |
80 | | - f" - {profile.player_id} (Game: {profile.game_id}) - ELO: {profile.rating:.1f} (Games: {profile.games_played})" |
81 | | - ) |
| 94 | + lines = [ |
| 95 | + f" - {profile.model} (Arena: {profile.arena}) - ELO: {profile.rating:.1f} (Games: {profile.rounds_played})" |
| 96 | + for profile in player_profiles.values() |
| 97 | + ] |
| 98 | + print("\n".join(sorted(lines))) |
82 | 99 |
|
83 | 100 | # Weighted average ELO per player across all games |
84 | 101 | weighted_elo = {} |
85 | 102 | total_games = {} |
86 | 103 | for profile in player_profiles.values(): |
87 | | - pid = profile.player_id |
88 | | - weighted_elo[pid] = weighted_elo.get(pid, 0) + profile.rating * profile.games_played |
89 | | - total_games[pid] = total_games.get(pid, 0) + profile.games_played |
| 104 | + mid = profile.model |
| 105 | + weighted_elo[mid] = weighted_elo.get(mid, 0) + profile.rating * profile.rounds_played |
| 106 | + total_games[mid] = total_games.get(mid, 0) + profile.rounds_played |
90 | 107 |
|
91 | 108 | print("\nWeighted average ELO per player (across all games):") |
92 | | - for pid in weighted_elo: |
93 | | - if total_games[pid] > 0: |
94 | | - avg_elo = weighted_elo[pid] / total_games[pid] |
95 | | - else: |
96 | | - avg_elo = 0.0 |
97 | | - print(f" - {pid}: Weighted Avg ELO {avg_elo:.1f} (Total Games: {total_games[pid]})") |
| 109 | + calc_avg_elo = lambda total_elo, games: total_elo / games if games > 0 else 0.0 |
| 110 | + lines = [ |
| 111 | + f" - {pid}: Weighted Avg ELO {calc_avg_elo(weighted_elo[pid], total_games[pid]):.1f} (Games: {total_games[pid]})" |
| 112 | + for pid in weighted_elo |
| 113 | + if total_games[pid] > 0 |
| 114 | + ] |
| 115 | + print("\n".join(sorted(lines))) |
98 | 116 |
|
99 | 117 |
|
100 | 118 | if __name__ == "__main__": |
101 | 119 | parser = argparse.ArgumentParser() |
102 | 120 | parser.add_argument("-d", "--log_dir", type=Path, help="Path to game logs (Default: logs/)", default=LOCAL_LOG_DIR) |
| 121 | + parser.add_argument("-k", "--k_factor", type=int, help="K-Factor for ELO calculation (Default: 32)", default=32) |
| 122 | + parser.add_argument( |
| 123 | + "-s", "--starting_elo", type=int, help="Starting ELO for new players (Default: 1200)", default=1200 |
| 124 | + ) |
103 | 125 | args = parser.parse_args() |
104 | | - main(args.log_dir) |
| 126 | + main(**vars(args)) |
0 commit comments