Coverage for src/duelboard/analyzers.py: 95%
76 statements
« prev ^ index » next coverage.py v7.10.3, created at 2025-08-14 19:18 +0900
« prev ^ index » next coverage.py v7.10.3, created at 2025-08-14 19:18 +0900
1"""Analysis tools for Elo ratings and battle data."""
3from collections import defaultdict
5import pandas as pd
7from .models import Battle, BattleOutcome
8from .types import BattleData, BattleStatistics, PlayerStats, RatingsDict, WinRateMatrix
11class WinRatePredictor:
12 """Predict win rates based on Elo ratings."""
14 def __init__(self, scale: float = 400, base: float = 10) -> None:
15 """Initialize the predictor.
17 Args:
18 scale: Scale parameter for Elo calculation
19 base: Base for exponential calculation
20 """
21 self.scale = scale
22 self.base = base
24 def predict_win_probability(
25 self,
26 rating_a: float,
27 rating_b: float,
28 ) -> float:
29 """Predict win probability for player A against player B.
31 Args:
32 rating_a: Elo rating of player A
33 rating_b: Elo rating of player B
35 Returns:
36 Probability that player A wins (0.0 to 1.0)
37 """
38 return 1 / (1 + self.base ** ((rating_b - rating_a) / self.scale))
40 def create_win_rate_matrix(self, ratings: RatingsDict) -> pd.DataFrame:
41 """Create win rate prediction matrix for all player pairs.
43 Args:
44 ratings: Dictionary of player ratings
46 Returns:
47 DataFrame with win rates where index is player A, columns is player B
48 """
49 players = sorted(ratings.keys())
50 win_rates: WinRateMatrix = {}
52 for player_a in players:
53 win_rates[player_a] = {}
54 for player_b in players:
55 if player_a == player_b:
56 win_rates[player_a][player_b] = float("nan")
57 else:
58 prob = self.predict_win_probability(
59 ratings[player_a].rating,
60 ratings[player_b].rating,
61 )
62 win_rates[player_a][player_b] = prob
64 df = pd.DataFrame(win_rates, index=players)
65 df.index.name = "player_a"
66 df.columns.name = "player_b"
67 return df.T # Transpose so rows beat columns
70class PairwiseAnalyzer:
71 """Analyze pairwise statistics from battle data."""
73 def compute_pairwise_win_fraction(
74 self,
75 battles: list[Battle] | pd.DataFrame,
76 ) -> pd.DataFrame:
77 """Compute pairwise win fractions from battle data.
79 Args:
80 battles: List of Battle objects or DataFrame with battles
82 Returns:
83 DataFrame with win fractions where rows beat columns
84 """
85 if isinstance(battles, list):
86 # Convert battles to DataFrame
87 battle_data = [
88 BattleData(
89 player_a=battle.player_a,
90 player_b=battle.player_b,
91 winner=battle.outcome.value,
92 )
93 for battle in battles
94 if battle.outcome not in (BattleOutcome.TIE, BattleOutcome.TIE_BOTHBAD)
95 ]
96 df = pd.DataFrame(battle_data)
97 else:
98 df = battles[~battles["winner"].str.contains("tie", case=False, na=False)].copy()
100 if df.empty:
101 return pd.DataFrame()
103 # Count wins as model A
104 a_win_pivot = pd.pivot_table(
105 df[df["winner"] == "player_a"],
106 index="player_a",
107 columns="player_b",
108 aggfunc=len,
109 fill_value=0,
110 )
112 # Count wins as model B
113 b_win_pivot = pd.pivot_table(
114 df[df["winner"] == "player_b"],
115 index="player_a",
116 columns="player_b",
117 aggfunc=len,
118 fill_value=0,
119 )
121 # Count total battles
122 battle_pivot = pd.pivot_table(
123 df,
124 index="player_a",
125 columns="player_b",
126 aggfunc=len,
127 fill_value=0,
128 )
130 # Calculate win fractions
131 total_wins = a_win_pivot + b_win_pivot.T
132 total_battles = battle_pivot + battle_pivot.T
134 # Avoid division by zero
135 win_fractions = total_wins.div(total_battles).fillna(0)
137 # Ensure all players are represented
138 all_players = sorted(set(df["player_a"].tolist() + df["player_b"].tolist()))
139 return win_fractions.reindex(all_players, columns=all_players, fill_value=0)
141 def compute_battle_statistics(
142 self,
143 battles: list[Battle] | pd.DataFrame,
144 ) -> pd.DataFrame:
145 """Compute basic battle statistics for each player.
147 Args:
148 battles: List of Battle objects or DataFrame with battles
150 Returns:
151 DataFrame with battle statistics per player
152 """
153 if isinstance(battles, list):
154 battle_data = [
155 BattleData(
156 player_a=battle.player_a,
157 player_b=battle.player_b,
158 winner=battle.outcome.value,
159 )
160 for battle in battles
161 ]
162 df = pd.DataFrame(battle_data)
163 else:
164 df = battles.copy()
166 stats: defaultdict[str, PlayerStats] = defaultdict(
167 lambda: PlayerStats(battles=0, wins=0, losses=0, ties=0),
168 )
170 for _, row in df.iterrows():
171 player_a = row["player_a"]
172 player_b = row["player_b"]
173 winner = row["winner"]
175 # Update battle counts
176 stats[player_a]["battles"] += 1
177 stats[player_b]["battles"] += 1
179 # Update win/loss/tie counts
180 if "tie" in winner.lower():
181 stats[player_a]["ties"] += 1
182 stats[player_b]["ties"] += 1
183 elif winner == "player_a":
184 stats[player_a]["wins"] += 1
185 stats[player_b]["losses"] += 1
186 elif winner == "player_b":
187 stats[player_b]["wins"] += 1
188 stats[player_a]["losses"] += 1
190 # Convert to DataFrame
191 result_data: list[BattleStatistics] = []
192 for player, stat_dict in stats.items():
193 win_rate = stat_dict["wins"] / stat_dict["battles"] if stat_dict["battles"] > 0 else 0.0
194 result_data.append(
195 BattleStatistics(
196 player=player,
197 battles=stat_dict["battles"],
198 wins=stat_dict["wins"],
199 losses=stat_dict["losses"],
200 ties=stat_dict["ties"],
201 win_rate=win_rate,
202 ),
203 )
205 result_df = pd.DataFrame(result_data)
206 return result_df.sort_values("win_rate", ascending=False).reset_index(drop=True)
208 def visualize_battle_count_matrix(
209 self,
210 battles: list[Battle] | pd.DataFrame,
211 ) -> pd.DataFrame:
212 """Create battle count matrix for visualization.
214 Args:
215 battles: List of Battle objects or DataFrame with battles
217 Returns:
218 Symmetric DataFrame with battle counts
219 """
220 if isinstance(battles, list):
221 battle_data = [
222 {"player_a": battle.player_a, "player_b": battle.player_b}
223 for battle in battles
224 ]
225 df = pd.DataFrame(battle_data)
226 else:
227 df = battles[["player_a", "player_b"]].copy()
229 # Count battles
230 battle_counts = pd.pivot_table(
231 df,
232 index="player_a",
233 columns="player_b",
234 aggfunc=len,
235 fill_value=0,
236 )
238 # Make symmetric
239 symmetric_counts = battle_counts + battle_counts.T
241 # Ensure all players are represented
242 all_players = sorted(set(df["player_a"].tolist() + df["player_b"].tolist()))
243 return symmetric_counts.reindex(
244 all_players,
245 columns=all_players,
246 fill_value=0,
247 )