Coverage for src/duelboard/calculators/base.py: 96%
55 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"""Base Elo rating calculator using online linear update algorithm."""
3from collections import defaultdict
5import pandas as pd
7from duelboard.models import Battle, BattleOutcome, EloRating
8from duelboard.types import RatingsDict
11class EloCalculator:
12 """Base Elo rating calculator using online linear update algorithm."""
14 def __init__(
15 self,
16 k_factor: float = 4,
17 scale: float = 400,
18 base: float = 10,
19 initial_rating: float = 1000,
20 ) -> None:
21 """Initialize the Elo calculator.
23 Args:
24 k_factor: K-factor for Elo updates (lower = more stable)
25 scale: Scale parameter for Elo calculation
26 base: Base for exponential calculation
27 initial_rating: Initial rating for new players
28 """
29 self.k_factor = k_factor
30 self.scale = scale
31 self.base = base
32 self.initial_rating = initial_rating
34 def calculate(self, battles: list[Battle] | pd.DataFrame) -> RatingsDict:
35 """Calculate Elo ratings from a list of battles.
37 Args:
38 battles: List of Battle objects or DataFrame with battles
40 Returns:
41 Dictionary mapping player names to EloRating objects
42 """
43 if isinstance(battles, pd.DataFrame):
44 battles = self._dataframe_to_battles(battles)
46 ratings = defaultdict(lambda: self.initial_rating)
47 battle_counts = defaultdict(int)
49 for battle in battles:
50 player_a = battle.player_a
51 player_b = battle.player_b
52 outcome = battle.outcome
54 rating_a = ratings[player_a]
55 rating_b = ratings[player_b]
57 expected_a = self._calculate_expected_score(rating_a, rating_b)
58 expected_b = 1 - expected_a
60 actual_a = self._outcome_to_score(outcome, is_player_a=True)
61 actual_b = 1 - actual_a
63 new_rating_a = rating_a + self.k_factor * (actual_a - expected_a)
64 new_rating_b = rating_b + self.k_factor * (actual_b - expected_b)
66 ratings[player_a] = new_rating_a
67 ratings[player_b] = new_rating_b
69 battle_counts[player_a] += 1
70 battle_counts[player_b] += 1
72 return {
73 player: EloRating(
74 player=player,
75 rating=rating,
76 battles=battle_counts[player],
77 )
78 for player, rating in ratings.items()
79 }
81 def _calculate_expected_score(self, rating_a: float, rating_b: float) -> float:
82 """Calculate expected score for player A."""
83 return 1 / (1 + self.base ** ((rating_b - rating_a) / self.scale))
85 def _outcome_to_score(self, outcome: BattleOutcome, *, is_player_a: bool) -> float:
86 """Convert battle outcome to score for player A or B."""
87 if outcome in (BattleOutcome.TIE, BattleOutcome.TIE_BOTHBAD):
88 return 0.5
90 if is_player_a:
91 return 1.0 if outcome == BattleOutcome.WIN_A else 0.0
92 return 1.0 if outcome == BattleOutcome.WIN_B else 0.0
94 def _dataframe_to_battles(self, df: pd.DataFrame) -> list[Battle]:
95 """Convert DataFrame to list of Battle objects."""
96 battles = []
97 for _, row in df.iterrows():
98 # Try different column name formats for compatibility
99 player_a = row.get("player_a", row.get("player_a"))
100 player_b = row.get("player_b", row.get("player_b"))
101 winner = row.get("winner", row.get("result"))
103 battle = Battle(
104 player_a=player_a,
105 player_b=player_b,
106 outcome=BattleOutcome(winner),
107 metadata=row.to_dict() if hasattr(row, "to_dict") else {},
108 )
109 battles.append(battle)
110 return battles
112 def predict_win_probability(
113 self,
114 player_a: str,
115 player_b: str,
116 ratings: RatingsDict,
117 ) -> float:
118 """Predict win probability for player A against player B.
120 Args:
121 player_a: Name of player A
122 player_b: Name of player B
123 ratings: Dictionary of current ratings
125 Returns:
126 Probability that player A wins
127 """
128 rating_a = ratings[player_a].rating
129 rating_b = ratings[player_b].rating
130 return self._calculate_expected_score(rating_a, rating_b)
132 def get_leaderboard(self, ratings: RatingsDict) -> list[EloRating]:
133 """Get sorted leaderboard from ratings.
135 Args:
136 ratings: Dictionary of ratings
138 Returns:
139 List of EloRating objects sorted by rating (descending)
140 """
141 return sorted(ratings.values(), key=lambda x: x.rating, reverse=True)