Skip to content

Commit f2debb3

Browse files
committed
Add multiplayer analyses
1 parent 6265383 commit f2debb3

10 files changed

Lines changed: 360 additions & 165 deletions
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
"""
2+
Multi-player tournament analysis tools.
3+
4+
Analyses that compare 2-player vs N-player tournament dynamics.
5+
"""
6+
7+
from codeclash.analysis.multiplayer.win_change_rate import analyze_win_change_rate
8+
from codeclash.analysis.multiplayer.win_share import analyze_winner_share
9+
10+
__all__ = [
11+
"analyze_win_change_rate",
12+
"analyze_winner_share",
13+
]
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Win Change Rate Analysis: Measure how often the winner changes between consecutive rounds.
4+
5+
Shows that multi-player tournaments are more volatile with more frequent lead changes
6+
compared to 2-player tournaments.
7+
"""
8+
9+
import json
10+
from pathlib import Path
11+
12+
import matplotlib.pyplot as plt
13+
import numpy as np
14+
15+
from codeclash.analysis.viz.utils import ASSETS_DIR, FONT_BOLD
16+
from codeclash.constants import LOCAL_LOG_DIR
17+
18+
19+
def calculate_lead_changes(metadata_path: Path) -> dict:
20+
"""Calculate how often the winner changes between consecutive rounds."""
21+
with open(metadata_path) as f:
22+
metadata = json.load(f)
23+
24+
# Extract winners in order
25+
winners = []
26+
for round_id in sorted([int(k) for k in metadata.get("round_stats", {}).keys() if k != "0"]):
27+
stats = metadata["round_stats"][str(round_id)]
28+
winner = stats.get("winner")
29+
if winner:
30+
winners.append(winner)
31+
32+
if len(winners) < 2:
33+
return None
34+
35+
# Count changes
36+
changes = sum(1 for i in range(1, len(winners)) if winners[i] != winners[i - 1])
37+
total_transitions = len(winners) - 1
38+
change_rate = (changes / total_transitions * 100) if total_transitions > 0 else 0
39+
40+
return {"total_rounds": len(winners), "lead_changes": changes, "change_rate": change_rate}
41+
42+
43+
def analyze_win_change_rate(log_dir: Path, game_pattern: str = "CoreWar.r15.s1000"):
44+
"""Compare lead change rates between 2-player and multi-player tournaments."""
45+
46+
# Collect 2-player data
47+
print("Analyzing 2-player tournaments...")
48+
lead_changes_2p = []
49+
for metadata_path in log_dir.rglob(f"*{game_pattern}.p2.*/metadata.json"):
50+
result = calculate_lead_changes(metadata_path)
51+
if result:
52+
lead_changes_2p.append(result)
53+
54+
# Collect 6-player data
55+
print("Analyzing 6-player tournaments...")
56+
lead_changes_6p = []
57+
for metadata_path in log_dir.rglob(f"*{game_pattern}.p6.*/metadata.json"):
58+
result = calculate_lead_changes(metadata_path)
59+
if result:
60+
lead_changes_6p.append(result)
61+
62+
# Extract change rates
63+
change_rates_2p = [x["change_rate"] for x in lead_changes_2p]
64+
change_rates_6p = [x["change_rate"] for x in lead_changes_6p]
65+
66+
lead_count_2p = [x["lead_changes"] for x in lead_changes_2p]
67+
lead_count_6p = [x["lead_changes"] for x in lead_changes_6p]
68+
69+
# Print statistics
70+
print("\n" + "=" * 70)
71+
print("LEAD CHANGES COMPARISON")
72+
print("=" * 70)
73+
74+
print("\n2-Player Tournaments:")
75+
print(f" Tournaments analyzed: {len(lead_changes_2p)}")
76+
print(f" Average lead changes: {np.mean(lead_count_2p):.1f} per tournament")
77+
print(f" Average change rate: {np.mean(change_rates_2p):.1f}%")
78+
print(f" Median: {np.median(lead_count_2p):.0f} changes")
79+
print(f" Range: {np.min(lead_count_2p):.0f} - {np.max(lead_count_2p):.0f}")
80+
81+
print("\n6-Player Tournaments:")
82+
print(f" Tournaments analyzed: {len(lead_changes_6p)}")
83+
print(f" Average lead changes: {np.mean(lead_count_6p):.1f} per tournament")
84+
print(f" Average change rate: {np.mean(change_rates_6p):.1f}%")
85+
print(f" Median: {np.median(lead_count_6p):.0f} changes")
86+
print(f" Range: {np.min(lead_count_6p):.0f} - {np.max(lead_count_6p):.0f}")
87+
88+
print("\n" + "=" * 70)
89+
print("KEY INSIGHT")
90+
print("=" * 70)
91+
ratio = np.mean(change_rates_6p) / np.mean(change_rates_2p)
92+
print(f"""
93+
Lead change rate:
94+
• 6-Player: {np.mean(change_rates_6p):.1f}% of round transitions
95+
• 2-Player: {np.mean(change_rates_2p):.1f}% of round transitions
96+
97+
💡 Insight: 6-player tournaments are {ratio:.2f}x MORE VOLATILE!
98+
The lead changes {ratio:.2f}x as often in 6-player vs 2-player tournaments.
99+
100+
👉 Bottom line: Multi-player tournaments are much more dynamic and competitive,
101+
with no single player maintaining dominance throughout the tournament.
102+
""")
103+
104+
# Create visualization
105+
fig, axes = plt.subplots(1, 2, figsize=(12, 5))
106+
107+
# Plot 1: Distribution of lead changes
108+
ax1 = axes[0]
109+
ax1.hist(lead_count_6p, bins=15, alpha=0.7, label="6-Player", color="steelblue", edgecolor="black")
110+
ax1.hist(lead_count_2p, bins=15, alpha=0.7, label="2-Player", color="coral", edgecolor="black")
111+
ax1.axvline(
112+
np.mean(lead_count_6p),
113+
color="steelblue",
114+
linestyle="--",
115+
linewidth=2,
116+
label=f"6P Mean: {np.mean(lead_count_6p):.1f}",
117+
)
118+
ax1.axvline(
119+
np.mean(lead_count_2p),
120+
color="coral",
121+
linestyle="--",
122+
linewidth=2,
123+
label=f"2P Mean: {np.mean(lead_count_2p):.1f}",
124+
)
125+
ax1.set_xlabel("Number of Lead Changes per Tournament", fontsize=18, fontproperties=FONT_BOLD)
126+
ax1.set_ylabel("Frequency", fontsize=18, fontproperties=FONT_BOLD)
127+
ax1.tick_params(axis="both", labelsize=14)
128+
ax1.legend(prop=FONT_BOLD, fontsize=12)
129+
ax1.grid(alpha=0.3)
130+
131+
# Plot 2: Change rates comparison
132+
ax2 = axes[1]
133+
bp = ax2.boxplot(
134+
[change_rates_6p, change_rates_2p], tick_labels=["6-Player", "2-Player"], patch_artist=True, widths=0.6
135+
)
136+
bp["boxes"][0].set_facecolor("steelblue")
137+
bp["boxes"][1].set_facecolor("coral")
138+
ax2.set_ylabel("Lead Change Rate (%)", fontsize=18, fontproperties=FONT_BOLD)
139+
ax2.tick_params(axis="both", labelsize=14)
140+
ax2.grid(axis="y", alpha=0.3)
141+
142+
# Add mean markers
143+
for i, data in enumerate([change_rates_6p, change_rates_2p], 1):
144+
ax2.plot(i, np.mean(data), "D", color="darkred", markersize=8, label="Mean" if i == 1 else "")
145+
ax2.legend(prop=FONT_BOLD, fontsize=12)
146+
147+
# Set tick labels with custom font
148+
for label in ax2.get_xticklabels():
149+
label.set_fontproperties(FONT_BOLD)
150+
151+
plt.tight_layout()
152+
output_file = ASSETS_DIR / "win_change_rate_comparison.png"
153+
plt.savefig(output_file, dpi=300, bbox_inches="tight")
154+
print(f"\nVisualization saved to: {output_file}")
155+
plt.close()
156+
157+
return {"2p": lead_changes_2p, "6p": lead_changes_6p}
158+
159+
160+
def main():
161+
analyze_win_change_rate(LOCAL_LOG_DIR)
162+
163+
164+
if __name__ == "__main__":
165+
main()
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Winner's Share Analysis: Compare winner dominance between 2-player and N-player tournaments.
4+
5+
Shows that multi-player tournaments are more competitive with winners capturing a smaller
6+
percentage of total points.
7+
"""
8+
9+
import json
10+
from pathlib import Path
11+
12+
import matplotlib.pyplot as plt
13+
import numpy as np
14+
15+
from codeclash.analysis.viz.utils import ASSETS_DIR, FONT_BOLD
16+
from codeclash.constants import LOCAL_LOG_DIR
17+
18+
19+
def calculate_winner_share(metadata_path: Path, num_players: int) -> list[float]:
20+
"""Calculate winner's share of total points for each round in a tournament."""
21+
with open(metadata_path) as f:
22+
metadata = json.load(f)
23+
24+
winner_shares = []
25+
26+
for round_id, stats in metadata.get("round_stats", {}).items():
27+
if round_id == "0":
28+
continue
29+
30+
scores = stats.get("scores", {})
31+
# Filter out "Tie" key if present
32+
scores = {k: v for k, v in scores.items() if k != "Tie"}
33+
34+
if len(scores) != num_players:
35+
continue
36+
37+
score_values = list(scores.values())
38+
total_score = sum(score_values)
39+
max_score = max(score_values)
40+
41+
if total_score > 0:
42+
winner_share = max_score / total_score * 100
43+
winner_shares.append(winner_share)
44+
45+
return winner_shares
46+
47+
48+
def analyze_winner_share(log_dir: Path, game_pattern: str = "CoreWar.r15.s1000"):
49+
"""Compare winner's share between 2-player and multi-player tournaments."""
50+
51+
# Collect 2-player data
52+
print("Analyzing 2-player tournaments...")
53+
winner_shares_2p = []
54+
for metadata_path in log_dir.rglob(f"*{game_pattern}.p2.*/metadata.json"):
55+
winner_shares_2p.extend(calculate_winner_share(metadata_path, num_players=2))
56+
57+
# Collect 6-player data
58+
print("Analyzing 6-player tournaments...")
59+
winner_shares_6p = []
60+
for metadata_path in log_dir.rglob(f"*{game_pattern}.p6.*/metadata.json"):
61+
winner_shares_6p.extend(calculate_winner_share(metadata_path, num_players=6))
62+
63+
# Print statistics
64+
print("\n" + "=" * 70)
65+
print("WINNER'S SHARE COMPARISON")
66+
print("=" * 70)
67+
68+
print("\n2-Player Tournaments:")
69+
print(f" Mean: {np.mean(winner_shares_2p):.1f}%")
70+
print(f" Median: {np.median(winner_shares_2p):.1f}%")
71+
print(f" Range: {np.min(winner_shares_2p):.1f}% - {np.max(winner_shares_2p):.1f}%")
72+
print(f" Samples: {len(winner_shares_2p)}")
73+
74+
print("\n6-Player Tournaments:")
75+
print(f" Mean: {np.mean(winner_shares_6p):.1f}%")
76+
print(f" Median: {np.median(winner_shares_6p):.1f}%")
77+
print(f" Range: {np.min(winner_shares_6p):.1f}% - {np.max(winner_shares_6p):.1f}%")
78+
print(f" Samples: {len(winner_shares_6p)}")
79+
80+
print("\n" + "=" * 70)
81+
print("KEY INSIGHT")
82+
print("=" * 70)
83+
ratio = np.mean(winner_shares_2p) / np.mean(winner_shares_6p)
84+
print(f"""
85+
In 6-player tournaments, the winner captures only {np.mean(winner_shares_6p):.1f}% of total points
86+
on average, compared to {np.mean(winner_shares_2p):.1f}% in 2-player tournaments.
87+
88+
This means 6-player games are MUCH less dominated by a single winner - victories
89+
are more competitive and less clear-cut ({ratio:.2f}x less dominant).
90+
91+
👉 Bottom line: Multi-player tournaments show MORE COMPETITIVE BALANCE and LESS
92+
WINNER DOMINANCE than 2-player head-to-head matches.
93+
""")
94+
95+
# Create visualization
96+
fig, axes = plt.subplots(1, 2, figsize=(12, 5))
97+
98+
# Plot 1: Histogram comparison
99+
ax1 = axes[0]
100+
ax1.hist(winner_shares_6p, bins=20, alpha=0.7, label="6-Player", color="steelblue", edgecolor="black")
101+
ax1.hist(winner_shares_2p, bins=20, alpha=0.7, label="2-Player", color="coral", edgecolor="black")
102+
ax1.axvline(
103+
np.mean(winner_shares_6p),
104+
color="steelblue",
105+
linestyle="--",
106+
linewidth=2,
107+
label=f"6P Mean: {np.mean(winner_shares_6p):.1f}%",
108+
)
109+
ax1.axvline(
110+
np.mean(winner_shares_2p),
111+
color="coral",
112+
linestyle="--",
113+
linewidth=2,
114+
label=f"2P Mean: {np.mean(winner_shares_2p):.1f}%",
115+
)
116+
ax1.set_xlabel("Winner's Share of Total Points (%)", fontsize=18, fontproperties=FONT_BOLD)
117+
ax1.set_ylabel("Frequency", fontsize=18, fontproperties=FONT_BOLD)
118+
ax1.tick_params(axis="both", labelsize=14)
119+
ax1.legend(prop=FONT_BOLD, fontsize=12)
120+
ax1.grid(alpha=0.3)
121+
122+
# Plot 2: Box plot comparison
123+
ax2 = axes[1]
124+
bp = ax2.boxplot(
125+
[winner_shares_6p, winner_shares_2p], tick_labels=["6-Player", "2-Player"], patch_artist=True, widths=0.6
126+
)
127+
bp["boxes"][0].set_facecolor("steelblue")
128+
bp["boxes"][1].set_facecolor("coral")
129+
ax2.set_ylabel("Winner's Share of Total Points (%)", fontsize=18, fontproperties=FONT_BOLD)
130+
ax2.tick_params(axis="both", labelsize=14)
131+
ax2.grid(axis="y", alpha=0.3)
132+
133+
# Add mean markers
134+
for i, data in enumerate([winner_shares_6p, winner_shares_2p], 1):
135+
ax2.plot(i, np.mean(data), "D", color="darkred", markersize=8, label="Mean" if i == 1 else "")
136+
ax2.legend(prop=FONT_BOLD, fontsize=12)
137+
138+
# Set tick labels with custom font
139+
for label in ax2.get_xticklabels():
140+
label.set_fontproperties(FONT_BOLD)
141+
142+
plt.tight_layout()
143+
output_file = ASSETS_DIR / "winner_share_comparison.png"
144+
plt.savefig(output_file, dpi=300, bbox_inches="tight")
145+
print(f"\nVisualization saved to: {output_file}")
146+
plt.close()
147+
148+
return {"2p": winner_shares_2p, "6p": winner_shares_6p}
149+
150+
151+
def main():
152+
analyze_winner_share(LOCAL_LOG_DIR)
153+
154+
155+
if __name__ == "__main__":
156+
main()

codeclash/analysis/viz/cdf_files_edited_per_round.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ def main():
6565
plt.xlabel("Files Edited per Round", fontproperties=FONT_BOLD, fontsize=18)
6666
# plt.ylabel("Cumulative Probability", fontproperties=FONT_BOLD, fontsize=18)
6767
# plt.title("CDF of Files Edited per Round by Model")
68-
FONT_BOLD.set_size(20)
68+
FONT_BOLD.set_size(18)
6969
plt.legend(prop=FONT_BOLD)
7070
plt.grid(True)
7171
plt.savefig(OUTPUT_FILE, dpi=300, bbox_inches="tight")

codeclash/analysis/viz/heatmap_returncode.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ def main(logs: Path):
9595
print(f"Overall: {overall_rate:.2f}% average malform rate")
9696

9797
# Create heatmap data
98-
models = list(model_to_arena_to_return_codes.keys())
98+
models = sorted(list(model_to_arena_to_return_codes.keys()))
9999
arenas = set()
100100
for arena_data in model_to_arena_to_return_codes.values():
101101
arenas.update(arena_data.keys())

codeclash/analysis/viz/line_chart_model_resiliency.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -322,7 +322,9 @@ def create_comeback_visualization(success_rates, output_path):
322322
def main():
323323
"""Main function to run round-to-round recovery analysis."""
324324
parser = argparse.ArgumentParser(description="Analyze next-round win rates after losses by loss margin")
325-
parser.add_argument("log_dir", help="Path to directory containing tournament logs", type=Path)
325+
parser.add_argument(
326+
"-d", "--log_dir", help="Path to directory containing tournament logs", type=Path, default="logs/"
327+
)
326328
parser.add_argument("--output", "-o", default=OUTPUT_FILE, help="Output path for the visualization")
327329

328330
args = parser.parse_args()

0 commit comments

Comments
 (0)