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

1"""Analysis tools for Elo ratings and battle data.""" 

2 

3from collections import defaultdict 

4 

5import pandas as pd 

6 

7from .models import Battle, BattleOutcome 

8from .types import BattleData, BattleStatistics, PlayerStats, RatingsDict, WinRateMatrix 

9 

10 

11class WinRatePredictor: 

12 """Predict win rates based on Elo ratings.""" 

13 

14 def __init__(self, scale: float = 400, base: float = 10) -> None: 

15 """Initialize the predictor. 

16 

17 Args: 

18 scale: Scale parameter for Elo calculation 

19 base: Base for exponential calculation 

20 """ 

21 self.scale = scale 

22 self.base = base 

23 

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. 

30 

31 Args: 

32 rating_a: Elo rating of player A 

33 rating_b: Elo rating of player B 

34 

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)) 

39 

40 def create_win_rate_matrix(self, ratings: RatingsDict) -> pd.DataFrame: 

41 """Create win rate prediction matrix for all player pairs. 

42 

43 Args: 

44 ratings: Dictionary of player ratings 

45 

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 = {} 

51 

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 

63 

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 

68 

69 

70class PairwiseAnalyzer: 

71 """Analyze pairwise statistics from battle data.""" 

72 

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. 

78 

79 Args: 

80 battles: List of Battle objects or DataFrame with battles 

81 

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() 

99 

100 if df.empty: 

101 return pd.DataFrame() 

102 

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 ) 

111 

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 ) 

120 

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 ) 

129 

130 # Calculate win fractions 

131 total_wins = a_win_pivot + b_win_pivot.T 

132 total_battles = battle_pivot + battle_pivot.T 

133 

134 # Avoid division by zero 

135 win_fractions = total_wins.div(total_battles).fillna(0) 

136 

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) 

140 

141 def compute_battle_statistics( 

142 self, 

143 battles: list[Battle] | pd.DataFrame, 

144 ) -> pd.DataFrame: 

145 """Compute basic battle statistics for each player. 

146 

147 Args: 

148 battles: List of Battle objects or DataFrame with battles 

149 

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() 

165 

166 stats: defaultdict[str, PlayerStats] = defaultdict( 

167 lambda: PlayerStats(battles=0, wins=0, losses=0, ties=0), 

168 ) 

169 

170 for _, row in df.iterrows(): 

171 player_a = row["player_a"] 

172 player_b = row["player_b"] 

173 winner = row["winner"] 

174 

175 # Update battle counts 

176 stats[player_a]["battles"] += 1 

177 stats[player_b]["battles"] += 1 

178 

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 

189 

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 ) 

204 

205 result_df = pd.DataFrame(result_data) 

206 return result_df.sort_values("win_rate", ascending=False).reset_index(drop=True) 

207 

208 def visualize_battle_count_matrix( 

209 self, 

210 battles: list[Battle] | pd.DataFrame, 

211 ) -> pd.DataFrame: 

212 """Create battle count matrix for visualization. 

213 

214 Args: 

215 battles: List of Battle objects or DataFrame with battles 

216 

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() 

228 

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 ) 

237 

238 # Make symmetric 

239 symmetric_counts = battle_counts + battle_counts.T 

240 

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 )