Skip to content

Commit 4eaa773

Browse files
committed
Add p-value calculation
1 parent c56253a commit 4eaa773

4 files changed

Lines changed: 62 additions & 3 deletions

File tree

codeclash/ratings/significance.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
from scipy.stats import binomtest
2+
3+
from codeclash.constants import RESULT_TIE
4+
5+
6+
def calculate_p_value(scores: dict[str, int]) -> float:
7+
"""Calculate the p-value for the statistical significance of the winner.
8+
9+
Input: scores as a dictionary of player names and number of games won.
10+
Special case: 'Tie' (constants.RESULT_TIE) is a tie between all players.
11+
12+
Ties (RESULT_TIE) are excluded by conditioning on decisive games.
13+
14+
Uses an exact one-sided binomial test for the top player's share vs 1/K,
15+
Bonferroni-corrected for choosing the winner post hoc among K players.
16+
Returns 1.0 if there is no unique winner or no decisive games.
17+
"""
18+
player_wins = {p: c for p, c in scores.items() if p != RESULT_TIE}
19+
decisive_games = sum(player_wins.values())
20+
n_players = len(player_wins)
21+
assert n_players > 1, "At least two players are required to calculate significance"
22+
if not player_wins or not decisive_games:
23+
# No winner
24+
return 1.0
25+
top_player_wins = max(player_wins.values())
26+
if sum(c == top_player_wins for c in player_wins.values()) != 1:
27+
# Multiple players have the same number of wins
28+
# Definitely not significant
29+
return 1.0
30+
# Null-hypothesis: The top player wins 1/n_players of the games
31+
p0 = 1.0 / n_players
32+
p_one = binomtest(top_player_wins, decisive_games, p=p0, alternative="greater").pvalue
33+
# Bonferonni correction: Imagine a game with 100 players. And let's assume that there's one
34+
# player that wins 10 games, and everyone else 0 or 1. The p-value would probably look pretty convincing
35+
# However, since we have so many players, it's actually not unlikely that _some_ player will win 10 games
36+
# by chance. So essentially by selecting the winner post-hoc in a multiple-comparison setting, we've inflated
37+
# the significance. Bonferonni correction is a simple way to account for this.
38+
p_bonferonni = p_one * n_players
39+
return min(1.0, p_bonferonni)

codeclash/viewer/app.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
from flask import Flask, jsonify, redirect, render_template, request, url_for
1414

15+
from codeclash.ratings.significance import calculate_p_value
1516
from codeclash.tournaments.utils.git_utils import filter_git_diff, split_git_diff_by_files
1617

1718
# Global variable to store the directory to search for logs
@@ -206,7 +207,7 @@ def process_round_results(round_results: dict[str, Any] | None) -> dict[str, Any
206207
processed["scores"] = scores
207208
processed["sorted_scores"] = list(scores.items())
208209

209-
# Calculate winner percentage
210+
# Calculate winner percentage and p-value
210211
winner = round_results.get("winner")
211212
if winner and scores:
212213
total_games = sum(scores.values())
@@ -218,10 +219,16 @@ def process_round_results(round_results: dict[str, Any] | None) -> dict[str, Any
218219
processed["winner_percentage"] = win_percentage
219220
else:
220221
processed["winner_percentage"] = None # No percentage for ties
222+
223+
# Calculate p-value for statistical significance
224+
p_value = calculate_p_value(scores)
225+
processed["p_value"] = round(p_value, 2)
221226
else:
222227
processed["winner_percentage"] = None
228+
processed["p_value"] = None
223229
else:
224230
processed["winner_percentage"] = None
231+
processed["p_value"] = None
225232

226233
return processed
227234

codeclash/viewer/static/css/style.css

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,14 @@ body {
299299
font-weight: 500;
300300
}
301301

302+
.p-value {
303+
display: inline-block;
304+
margin-left: 0.5rem;
305+
font-size: 0.8rem;
306+
color: var(--text-secondary);
307+
font-weight: 500;
308+
}
309+
302310
.score-breakdown {
303311
display: flex;
304312
flex-wrap: wrap;

codeclash/viewer/templates/index.html

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,9 @@ <h2>📊 Overview</h2>
129129
{% if round_data.results.winner_percentage %}
130130
<span class="winner-percentage">({{ round_data.results.winner_percentage }}%)</span>
131131
{% endif %}
132+
{% if round_data.results.p_value is not none %}
133+
<span class="p-value">(p={{ "%.2f"|format(round_data.results.p_value) }})</span>
134+
{% endif %}
132135
{% else %}
133136
<span class="no-result">-</span>
134137
{% endif %}
@@ -462,13 +465,15 @@ <h3>
462465
{%- set winner_wins = round_data.results.scores.get(winner, 0) -%}
463466
{%- set ties = round_data.results.scores.get('Tie', 0) -%}
464467
{%- set win_percentage = ((winner_wins + 0.5 * ties) / total_games * 100) | round(1) -%}
465-
- Winner: {{ winner }} with {{ win_percentage }}% (
468+
- Winner: {{ winner }} with {{ win_percentage }}%
469+
{%- if round_data.results.p_value is not none %} (p={{ "%.2f"|format(round_data.results.p_value) }}){% endif %} (
466470
{%- for player, score in round_data.results.scores.items() -%}
467471
{{- score -}}
468472
{%- if not loop.last -%}-{%- endif -%}
469473
{%- endfor -%})
470474
{%- else -%}
471-
- Winner: Tie (
475+
- Winner: Tie
476+
{%- if round_data.results.p_value is not none %} (p={{ "%.2f"|format(round_data.results.p_value) }}){% endif %} (
472477
{%- for player, score in round_data.results.scores.items() -%}
473478
{{- score -}}
474479
{%- if not loop.last -%}-{%- endif -%}

0 commit comments

Comments
 (0)