Skip to content

Commit f3dc50d

Browse files
committed
Feat(viz): Recover after loss streak
1 parent 6941b31 commit f3dc50d

1 file changed

Lines changed: 290 additions & 0 deletions

File tree

Lines changed: 290 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,290 @@
1+
#!/usr/bin/env python3
2+
"""Plot comeback and falldown probabilities after win/loss streaks."""
3+
4+
import argparse
5+
import json
6+
import logging
7+
from collections import defaultdict
8+
from pathlib import Path
9+
10+
import matplotlib.pyplot as plt
11+
import pandas as pd
12+
from matplotlib.font_manager import FontProperties
13+
from matplotlib.ticker import AutoMinorLocator, NullLocator
14+
from tqdm import tqdm
15+
16+
from codeclash import REPO_DIR
17+
from codeclash.analysis.viz.utils import ASSETS_DIR, FONT_BOLD, MARKERS, MODEL_TO_COLOR, MODEL_TO_DISPLAY_NAME
18+
19+
logger = logging.getLogger(__name__)
20+
21+
22+
def load_tournament_data(log_dir: Path) -> pd.DataFrame:
23+
"""Load tournament data and extract round winners."""
24+
data = []
25+
for metadata_file in tqdm(list(log_dir.rglob("metadata.json")), desc="Processing tournaments"):
26+
try:
27+
metadata = json.load(open(metadata_file))
28+
p2m = {
29+
x["name"]: x["config"]["model"]["model_name"].strip("@").partition("/")[2]
30+
or x["config"]["model"]["model_name"].strip("@")
31+
for x in metadata["config"]["players"]
32+
}
33+
34+
if len(p2m) != 2:
35+
continue
36+
37+
models_in_tournament = list(p2m.values())
38+
if len(set(models_in_tournament)) < 2:
39+
continue
40+
41+
round_stats = metadata.get("round_stats", {})
42+
round_ids = [r for r in round_stats.keys() if r != "0"]
43+
if len(round_ids) != 15:
44+
continue
45+
46+
player_names = list(p2m.keys())
47+
model_a = p2m[player_names[0]]
48+
model_b = p2m[player_names[1]]
49+
50+
tournament_data = {
51+
"model_a": model_a,
52+
"model_b": model_b,
53+
"tournament_path": str(metadata_file.parent),
54+
}
55+
56+
for round_id in sorted(round_stats.keys(), key=int):
57+
if round_id == "0":
58+
continue
59+
60+
round_data = round_stats[round_id]
61+
winner = round_data.get("winner")
62+
63+
if winner in p2m:
64+
winner_model = p2m[winner]
65+
tournament_data[f"round_{round_id}_winner"] = winner_model
66+
else:
67+
tournament_data[f"round_{round_id}_winner"] = None
68+
69+
data.append(tournament_data)
70+
except Exception:
71+
logger.warning("Failed to process tournament metadata file %s", metadata_file, exc_info=True)
72+
continue
73+
74+
return pd.DataFrame(data)
75+
76+
77+
def calculate_streak_probabilities(df: pd.DataFrame) -> tuple[pd.DataFrame, pd.DataFrame]:
78+
"""Calculate comeback and falldown probabilities after i consecutive losses/wins."""
79+
model_comeback_stats = defaultdict(lambda: defaultdict(lambda: {"opportunities": 0, "successes": 0}))
80+
model_falldown_stats = defaultdict(lambda: defaultdict(lambda: {"opportunities": 0, "successes": 0}))
81+
82+
for _, row in df.iterrows():
83+
model_a = row["model_a"]
84+
model_b = row["model_b"]
85+
86+
for round_num in range(2, 16):
87+
winner_current = row[f"round_{round_num}_winner"]
88+
89+
# Check for model_a
90+
consecutive_losses_a = 0
91+
for lookback in range(1, round_num):
92+
check_round = round_num - lookback
93+
if row[f"round_{check_round}_winner"] == model_b:
94+
consecutive_losses_a += 1
95+
else:
96+
break
97+
98+
if consecutive_losses_a > 0:
99+
for i in range(1, consecutive_losses_a + 1):
100+
model_comeback_stats[model_a][i]["opportunities"] += 1
101+
if winner_current == model_a:
102+
model_comeback_stats[model_a][i]["successes"] += 1
103+
104+
consecutive_wins_a = 0
105+
for lookback in range(1, round_num):
106+
check_round = round_num - lookback
107+
if row[f"round_{check_round}_winner"] == model_a:
108+
consecutive_wins_a += 1
109+
else:
110+
break
111+
112+
if consecutive_wins_a > 0:
113+
for i in range(1, consecutive_wins_a + 1):
114+
model_falldown_stats[model_a][i]["opportunities"] += 1
115+
if winner_current == model_b:
116+
model_falldown_stats[model_a][i]["successes"] += 1
117+
118+
# Check for model_b
119+
consecutive_losses_b = 0
120+
for lookback in range(1, round_num):
121+
check_round = round_num - lookback
122+
if row[f"round_{check_round}_winner"] == model_a:
123+
consecutive_losses_b += 1
124+
else:
125+
break
126+
127+
if consecutive_losses_b > 0:
128+
for i in range(1, consecutive_losses_b + 1):
129+
model_comeback_stats[model_b][i]["opportunities"] += 1
130+
if winner_current == model_b:
131+
model_comeback_stats[model_b][i]["successes"] += 1
132+
133+
consecutive_wins_b = 0
134+
for lookback in range(1, round_num):
135+
check_round = round_num - lookback
136+
if row[f"round_{check_round}_winner"] == model_b:
137+
consecutive_wins_b += 1
138+
else:
139+
break
140+
141+
if consecutive_wins_b > 0:
142+
for i in range(1, consecutive_wins_b + 1):
143+
model_falldown_stats[model_b][i]["opportunities"] += 1
144+
if winner_current == model_a:
145+
model_falldown_stats[model_b][i]["successes"] += 1
146+
147+
# Create dataframes
148+
comeback_prob_data = []
149+
for model in model_comeback_stats:
150+
row_data = {"model": model}
151+
for i in range(1, 15):
152+
if model_comeback_stats[model][i]["opportunities"] > 0:
153+
prob = model_comeback_stats[model][i]["successes"] / model_comeback_stats[model][i]["opportunities"]
154+
row_data[f"comeback_prob_after_{i}_losses"] = prob
155+
else:
156+
row_data[f"comeback_prob_after_{i}_losses"] = None
157+
comeback_prob_data.append(row_data)
158+
159+
comeback_prob_df = pd.DataFrame(comeback_prob_data).set_index("model")
160+
161+
falldown_prob_data = []
162+
for model in model_falldown_stats:
163+
row_data = {"model": model}
164+
for i in range(1, 15):
165+
if model_falldown_stats[model][i]["opportunities"] > 0:
166+
prob = model_falldown_stats[model][i]["successes"] / model_falldown_stats[model][i]["opportunities"]
167+
row_data[f"falldown_prob_after_{i}_wins"] = prob
168+
else:
169+
row_data[f"falldown_prob_after_{i}_wins"] = None
170+
falldown_prob_data.append(row_data)
171+
172+
falldown_prob_df = pd.DataFrame(falldown_prob_data).set_index("model")
173+
174+
return comeback_prob_df, falldown_prob_df
175+
176+
177+
def plot_comeback_probabilities(comeback_prob_df: pd.DataFrame, output_file: Path) -> None:
178+
"""Plot probability of winning after i consecutive losses."""
179+
fig, ax = plt.subplots(figsize=(6, 6))
180+
label_font = FontProperties(fname=FONT_BOLD.get_file(), size=18)
181+
title_font = FontProperties(fname=FONT_BOLD.get_file(), size=18)
182+
legend_font = FontProperties(fname=FONT_BOLD.get_file(), size=14)
183+
184+
sorted_models = sorted(comeback_prob_df.index)
185+
186+
for idx, model in enumerate(sorted_models):
187+
x_values = []
188+
y_values = []
189+
for i in range(1, 15):
190+
col = f"comeback_prob_after_{i}_losses"
191+
if pd.notna(comeback_prob_df.loc[model, col]):
192+
x_values.append(i)
193+
y_values.append(comeback_prob_df.loc[model, col])
194+
195+
if x_values:
196+
display_name = MODEL_TO_DISPLAY_NAME.get(model, model)
197+
color = MODEL_TO_COLOR.get(model, None)
198+
marker = MARKERS[idx % len(MARKERS)]
199+
ax.plot(
200+
x_values,
201+
y_values,
202+
label=display_name,
203+
alpha=0.7,
204+
color=color,
205+
linewidth=2.5,
206+
marker=marker,
207+
markersize=7,
208+
)
209+
210+
ax.set_xlabel("Number of Consecutive Rounds Lost", fontproperties=label_font)
211+
ax.set_ylabel("Probability of winning next round", fontproperties=label_font)
212+
ax.set_title("Comeback probability after loss streak", fontproperties=title_font)
213+
ax.legend(loc="upper right", prop=legend_font)
214+
ax.grid(True, alpha=0.3)
215+
ax.yaxis.set_minor_locator(AutoMinorLocator())
216+
ax.xaxis.set_minor_locator(NullLocator())
217+
for label in ax.get_xticklabels() + ax.get_yticklabels():
218+
label.set_fontproperties(FontProperties(fname=FONT_BOLD.get_file(), size=14))
219+
plt.tight_layout()
220+
plt.savefig(output_file, bbox_inches="tight")
221+
print(f"Saved comeback probability plot to {output_file}")
222+
223+
224+
def plot_falldown_probabilities(falldown_prob_df: pd.DataFrame, output_file: Path) -> None:
225+
"""Plot probability of losing after i consecutive wins."""
226+
fig, ax = plt.subplots(figsize=(6, 6))
227+
label_font = FontProperties(fname=FONT_BOLD.get_file(), size=18)
228+
title_font = FontProperties(fname=FONT_BOLD.get_file(), size=18)
229+
legend_font = FontProperties(fname=FONT_BOLD.get_file(), size=14)
230+
231+
sorted_models = sorted(falldown_prob_df.index)
232+
233+
for idx, model in enumerate(sorted_models):
234+
x_values = []
235+
y_values = []
236+
for i in range(1, 15):
237+
col = f"falldown_prob_after_{i}_wins"
238+
if pd.notna(falldown_prob_df.loc[model, col]):
239+
x_values.append(i)
240+
y_values.append(falldown_prob_df.loc[model, col])
241+
242+
if x_values:
243+
display_name = MODEL_TO_DISPLAY_NAME.get(model, model)
244+
color = MODEL_TO_COLOR.get(model, None)
245+
marker = MARKERS[idx % len(MARKERS)]
246+
ax.plot(
247+
x_values,
248+
y_values,
249+
label=display_name,
250+
alpha=0.7,
251+
color=color,
252+
linewidth=2.5,
253+
marker=marker,
254+
markersize=7,
255+
)
256+
257+
ax.set_xlabel("Number of Consecutive Rounds Won", fontproperties=label_font)
258+
ax.set_ylabel("Probability of losing next round", fontproperties=label_font)
259+
ax.set_title("Falldown probability after win streak", fontproperties=title_font)
260+
ax.legend(loc="upper right", prop=legend_font)
261+
ax.grid(True, alpha=0.3)
262+
ax.yaxis.set_minor_locator(AutoMinorLocator())
263+
ax.xaxis.set_minor_locator(NullLocator())
264+
for label in ax.get_xticklabels() + ax.get_yticklabels():
265+
label.set_fontproperties(FontProperties(fname=FONT_BOLD.get_file(), size=14))
266+
plt.tight_layout()
267+
plt.savefig(output_file, bbox_inches="tight")
268+
print(f"Saved falldown probability plot to {output_file}")
269+
270+
271+
def main(log_dir: Path | None = None) -> None:
272+
"""Main function to generate comeback and falldown plots."""
273+
if log_dir is None:
274+
log_dir = REPO_DIR / "logs"
275+
276+
print(f"Loading tournament data from {log_dir}")
277+
df = load_tournament_data(log_dir)
278+
print(f"Loaded {len(df)} tournaments")
279+
280+
comeback_prob_df, falldown_prob_df = calculate_streak_probabilities(df)
281+
282+
plot_comeback_probabilities(comeback_prob_df, ASSETS_DIR / "comeback_probabilities.pdf")
283+
plot_falldown_probabilities(falldown_prob_df, ASSETS_DIR / "falldown_probabilities.pdf")
284+
285+
286+
if __name__ == "__main__":
287+
parser = argparse.ArgumentParser(description="Plot comeback and falldown probabilities after win/loss streaks")
288+
parser.add_argument("--log-dir", type=Path, help="Path to logs directory")
289+
args = parser.parse_args()
290+
main(log_dir=args.log_dir)

0 commit comments

Comments
 (0)