Skip to content

Commit 3a17996

Browse files
committed
Feat(plots): Add round score/win rate distribution plots
1 parent 465fbc6 commit 3a17996

2 files changed

Lines changed: 634 additions & 0 deletions

File tree

Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
#!/usr/bin/env python3
2+
import argparse
3+
import json
4+
from pathlib import Path
5+
6+
import matplotlib.pyplot as plt
7+
import numpy as np
8+
from tqdm import tqdm
9+
10+
from codeclash.analysis.viz.utils import MODEL_TO_DISPLAY_NAME
11+
from codeclash.constants import LOCAL_LOG_DIR
12+
from codeclash.games import BattleCodeGame, DummyGame
13+
from codeclash.utils.log import get_logger
14+
15+
logger = get_logger("round_score_distribution")
16+
17+
18+
def get_normalized_scores(metadata_path: Path) -> tuple[str | None, dict[str, list[float]], dict[str, list[float]]]:
19+
"""Get normalized scores for all rounds where all players had valid submissions.
20+
21+
Returns a tuple of (game_name, game_to_scores, model_to_scores).
22+
"""
23+
metadata = json.loads(metadata_path.read_text())
24+
25+
try:
26+
players = metadata["config"]["players"]
27+
game_name = metadata["config"]["game"]["name"]
28+
except KeyError:
29+
return None, {}, {}
30+
31+
if len(players) != 2:
32+
return None, {}, {}
33+
34+
if game_name in {DummyGame.name, BattleCodeGame.name}:
35+
return None, {}, {}
36+
37+
# Map player names to models
38+
player_to_model = {}
39+
for player in players:
40+
player_name = player["name"]
41+
model = player["config"]["model"]["model_name"].strip("@").split("/")[-1]
42+
player_to_model[player_name] = model
43+
44+
player_names = list(player_to_model.keys())
45+
46+
# Collect scores
47+
all_normalized_scores = []
48+
model_scores = {model: [] for model in player_to_model.values()}
49+
50+
for idx, stats in metadata["round_stats"].items():
51+
if idx == "0":
52+
continue
53+
54+
# Check if all players have valid submissions
55+
player_stats = stats.get("player_stats", {})
56+
if not all(ps.get("valid_submit", False) for ps in player_stats.values()):
57+
continue
58+
59+
# Get scores
60+
scores = stats.get("scores", {})
61+
player_scores = {player: scores.get(player) for player in player_names}
62+
63+
if not all(s is not None for s in player_scores.values()):
64+
continue
65+
66+
total_score = sum(player_scores.values())
67+
68+
if total_score == 0:
69+
continue
70+
71+
# Normalize and add to lists
72+
for player, score in player_scores.items():
73+
normalized_score = score / total_score
74+
all_normalized_scores.append(normalized_score)
75+
model = player_to_model[player]
76+
model_scores[model].append(normalized_score)
77+
78+
# Organize by game
79+
game_to_scores = {game_name: all_normalized_scores} if all_normalized_scores else {}
80+
81+
# Filter out empty model scores
82+
model_to_scores = {model: scores for model, scores in model_scores.items() if scores}
83+
84+
return game_name, game_to_scores, model_to_scores
85+
86+
87+
def plot_stratified(
88+
data_by_category: dict[str, list[float]], output_path: Path, *, title: str, by_model: bool = False
89+
) -> None:
90+
"""Plot normalized scores stratified by category (game or model)."""
91+
all_scores = [s for scores in data_by_category.values() for s in scores]
92+
93+
# Determine category order
94+
if by_model:
95+
category_names = sorted(data_by_category.keys(), key=lambda m: MODEL_TO_DISPLAY_NAME.get(m, m))
96+
else:
97+
category_names = sorted(data_by_category.keys())
98+
99+
# Create subplots: 1 for all + 1 per category
100+
n_plots = 1 + len(category_names)
101+
n_cols = 2
102+
n_rows = (n_plots + n_cols - 1) // n_cols
103+
104+
fig, axes = plt.subplots(n_rows, n_cols, figsize=(14, 5 * n_rows))
105+
axes = axes.flatten() if n_plots > 1 else [axes]
106+
107+
bins = np.linspace(0, 1, 51)
108+
109+
# Plot all combined
110+
ax = axes[0]
111+
ax.hist(all_scores, bins=bins, edgecolor="black", alpha=0.7)
112+
ax.set_xlabel("Normalized Score", fontsize=10, fontweight="bold")
113+
ax.set_ylabel("Frequency", fontsize=10, fontweight="bold")
114+
ax.set_title("All Models" if by_model else "All Games", fontsize=12, fontweight="bold")
115+
ax.set_xlim(0, 1)
116+
ax.grid(True, alpha=0.3, axis="y")
117+
118+
mean_score = np.mean(all_scores)
119+
median_score = np.median(all_scores)
120+
stats_text = f"Mean: {mean_score:.3f}\nMedian: {median_score:.3f}\nN: {len(all_scores)}"
121+
ax.text(
122+
0.98,
123+
0.98,
124+
stats_text,
125+
transform=ax.transAxes,
126+
verticalalignment="top",
127+
horizontalalignment="right",
128+
fontsize=9,
129+
bbox=dict(boxstyle="round", facecolor="wheat", alpha=0.8),
130+
)
131+
132+
# Plot per category
133+
for idx, category_name in enumerate(category_names, start=1):
134+
ax = axes[idx]
135+
scores = data_by_category[category_name]
136+
137+
ax.hist(scores, bins=bins, edgecolor="black", alpha=0.7)
138+
ax.set_xlabel("Normalized Score", fontsize=10, fontweight="bold")
139+
ax.set_ylabel("Frequency", fontsize=10, fontweight="bold")
140+
display_name = MODEL_TO_DISPLAY_NAME.get(category_name, category_name) if by_model else category_name
141+
ax.set_title(display_name, fontsize=12, fontweight="bold")
142+
ax.set_xlim(0, 1)
143+
ax.grid(True, alpha=0.3, axis="y")
144+
145+
mean_score = np.mean(scores)
146+
median_score = np.median(scores)
147+
stats_text = f"Mean: {mean_score:.3f}\nMedian: {median_score:.3f}\nN: {len(scores)}"
148+
ax.text(
149+
0.98,
150+
0.98,
151+
stats_text,
152+
transform=ax.transAxes,
153+
verticalalignment="top",
154+
horizontalalignment="right",
155+
fontsize=9,
156+
bbox=dict(boxstyle="round", facecolor="wheat", alpha=0.8),
157+
)
158+
159+
# Hide unused subplots
160+
for idx in range(n_plots, len(axes)):
161+
axes[idx].set_visible(False)
162+
163+
plt.suptitle(title, fontsize=14, fontweight="bold")
164+
plt.tight_layout(rect=[0, 0, 1, 0.97])
165+
166+
plt.savefig(output_path, dpi=300, bbox_inches="tight")
167+
logger.info(f"Saved plot to {output_path}")
168+
169+
plt.close()
170+
171+
172+
def main(log_dir: Path, output_by_game: Path, output_by_model: Path) -> None:
173+
"""Calculate normalized scores and plot histograms stratified by game and by model."""
174+
logger.info(f"Processing tournaments from {log_dir}")
175+
176+
scores_by_game = {}
177+
scores_by_model = {}
178+
179+
for metadata_path in tqdm(list(log_dir.rglob("metadata.json"))):
180+
try:
181+
game_name, game_scores, model_scores = get_normalized_scores(metadata_path)
182+
if game_name:
183+
# Collect by game
184+
for game, scores in game_scores.items():
185+
if game not in scores_by_game:
186+
scores_by_game[game] = []
187+
scores_by_game[game].extend(scores)
188+
189+
# Collect by model
190+
for model, scores in model_scores.items():
191+
if model not in scores_by_model:
192+
scores_by_model[model] = []
193+
scores_by_model[model].extend(scores)
194+
except Exception as e:
195+
logger.error(f"Error processing {metadata_path}: {e}", exc_info=True)
196+
continue
197+
198+
if not scores_by_game:
199+
logger.warning("No scores collected")
200+
return
201+
202+
all_scores = [s for scores in scores_by_game.values() for s in scores]
203+
logger.info(
204+
f"Collected {len(all_scores)} normalized scores across {len(scores_by_game)} games and {len(scores_by_model)} models"
205+
)
206+
207+
# Plot by game
208+
plot_stratified(
209+
scores_by_game,
210+
output_by_game,
211+
title="Distribution of Normalized Player Scores by Game (Valid Rounds Only)",
212+
by_model=False,
213+
)
214+
215+
# Plot by model
216+
plot_stratified(
217+
scores_by_model,
218+
output_by_model,
219+
title="Distribution of Normalized Player Scores by Model (Valid Rounds Only)",
220+
by_model=True,
221+
)
222+
223+
224+
if __name__ == "__main__":
225+
parser = argparse.ArgumentParser(description="Plot distribution of normalized player scores (valid rounds only)")
226+
parser.add_argument("-d", "--log_dir", type=Path, default=LOCAL_LOG_DIR, help="Path to log directory")
227+
parser.add_argument(
228+
"--output_by_game",
229+
type=Path,
230+
default=Path("round_score_distribution_by_game.pdf"),
231+
help="Output path for by-game plot",
232+
)
233+
parser.add_argument(
234+
"--output_by_model",
235+
type=Path,
236+
default=Path("round_score_distribution_by_model.pdf"),
237+
help="Output path for by-model plot",
238+
)
239+
args = parser.parse_args()
240+
241+
main(args.log_dir, args.output_by_game, args.output_by_model)

0 commit comments

Comments
 (0)