├── .gitignore ├── LICENCE.txt ├── whr ├── utils.py ├── game.py ├── playerday.py ├── player.py └── whole_history_rating.py ├── README.md └── tests └── whr_test.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.csv 2 | *.pkl 3 | __py* 4 | setup* 5 | build/* 6 | dist/* 7 | whole_history_rating.egg-info/* -------------------------------------------------------------------------------- /LICENCE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2012 Pete Schwamb 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /whr/utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | 4 | class UnstableRatingException(Exception): 5 | pass 6 | 7 | 8 | def test_stability( 9 | v1: list[list[float]], v2: list[list[float]], precision: float = 10e-3 10 | ) -> bool: 11 | """Tests whether two lists of lists of floats are approximately equal within a specified precision. 12 | 13 | This function flattens each list of lists into a single list and compares each corresponding element from the two lists. If the absolute difference between any pair of elements exceeds the given precision, the lists are considered not equal. 14 | 15 | Args: 16 | v1 (list[list[float]]): The first list of lists of floats. 17 | v2 (list[list[float]]): The second list of lists of floats. 18 | precision (float, optional): The precision threshold below which the values are considered equal. Defaults to 0.01. 19 | 20 | Returns: 21 | bool: True if the two lists are considered close enough, i.e., no pair of corresponding elements differs by more than the specified precision. False otherwise. 22 | """ 23 | v1_flattened = [x for y in v1 for x in y] 24 | v2_flattened = [x for y in v2 for x in y] 25 | for x1, x2 in zip(v1_flattened, v2_flattened): 26 | if abs(x2 - x1) > precision: 27 | return False 28 | return True 29 | -------------------------------------------------------------------------------- /whr/game.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | from typing import Any 5 | 6 | from whr import player as P 7 | from whr import playerday as PD 8 | 9 | 10 | class Game: 11 | def __init__( 12 | self, 13 | black: P.Player, 14 | white: P.Player, 15 | winner: str, 16 | time_step: int, 17 | handicap: float = 0, 18 | extras: dict[str, Any] | None = None, 19 | ): 20 | self.day = time_step 21 | self.white_player = white 22 | self.black_player = black 23 | self.winner = winner.upper() 24 | self.handicap = handicap 25 | self.handicap_proc = handicap 26 | self.bpd: PD.PlayerDay | None = None 27 | self.wpd: PD.PlayerDay | None = None 28 | if extras is None: 29 | self.extras = {"komi": 6.5} 30 | else: 31 | self.extras = extras 32 | self.extras.setdefault("komi", 6.5) 33 | 34 | def __str__(self) -> str: 35 | return f"W:{self.white_player.name}(r={self.wpd.r if self.wpd is not None else '?'}) B:{self.black_player.name}(r={self.bpd.r if self.bpd is not None else '?'}) winner = {self.winner}, komi = {self.extras['komi']}, handicap = {self.handicap}" 36 | 37 | def opponents_adjusted_gamma(self, player: P.Player) -> float: 38 | """ 39 | Calculates the adjusted gamma value of a player's opponent. This is based on the opponent's 40 | Elo rating adjusted for the game's handicap. 41 | 42 | Parameters: 43 | player (P.Player): The player for whom to calculate the opponent's adjusted gamma. 44 | 45 | Returns: 46 | float: The adjusted gamma value of the opponent. 47 | 48 | Raises: 49 | AttributeError: If the player days are not set or the player is not part of the game. 50 | """ 51 | if self.bpd is None or self.wpd is None: 52 | raise AttributeError("black player day and white player day must be set") 53 | if player == self.white_player: 54 | opponent_elo = self.bpd.elo + self.handicap 55 | elif player == self.black_player: 56 | opponent_elo = self.wpd.elo - self.handicap 57 | else: 58 | raise ( 59 | AttributeError( 60 | f"No opponent for {player.__str__()}, since they're not in this game: {self.__str__()}." 61 | ) 62 | ) 63 | rval = 10 ** (opponent_elo / 400.0) 64 | if rval == 0 or rval > sys.maxsize: 65 | raise AttributeError("bad adjusted gamma") 66 | return rval 67 | 68 | def opponent(self, player: P.Player) -> P.Player: 69 | """ 70 | Returns the opponent of the specified player in this game. 71 | 72 | Parameters: 73 | player (P.Player): The player whose opponent is to be found. 74 | 75 | Returns: 76 | P.Player: The opponent player. 77 | """ 78 | if player == self.white_player: 79 | return self.black_player 80 | return self.white_player 81 | 82 | def prediction_score(self) -> float: 83 | """ 84 | Calculates the accuracy of the prediction for the game's outcome. 85 | Returns a score based on the actual outcome compared to the predicted probabilities: 86 | - Returns 1.0 if the prediction matches the actual outcome (white or black winning as predicted). 87 | - Returns 0.5 if the win probability is exactly 0.5, indicating uncertainty. 88 | - Returns 0.0 if the prediction does not match the actual outcome. 89 | 90 | Returns: 91 | float: The prediction score of the game. 92 | """ 93 | if self.white_win_probability() == 0.5: 94 | return 0.5 95 | return ( 96 | 1.0 97 | if ( 98 | (self.winner == "W" and self.white_win_probability() > 0.5) 99 | or (self.winner == "B" and self.white_win_probability() < 0.5) 100 | ) 101 | else 0.0 102 | ) 103 | 104 | def white_win_probability(self) -> float: 105 | """ 106 | Calculates the win probability for the white player based on their gamma value and 107 | the adjusted gamma value of their opponent. 108 | 109 | Returns: 110 | float: The win probability for the white player. 111 | 112 | Raises: 113 | AttributeError: If the white player day is not set. 114 | """ 115 | if self.wpd is None: 116 | raise AttributeError("white player day must be set") 117 | 118 | return self.wpd.gamma() / ( 119 | self.wpd.gamma() + self.opponents_adjusted_gamma(self.white_player) 120 | ) 121 | 122 | def black_win_probability(self) -> float: 123 | """ 124 | Calculates the win probability for the black player based on their gamma value and 125 | the adjusted gamma value of their opponent. 126 | 127 | Returns: 128 | float: The win probability for the black player. 129 | 130 | Raises: 131 | AttributeError: If the black player day is not set. 132 | """ 133 | if self.bpd is None: 134 | raise AttributeError("black player day must be set") 135 | return self.bpd.gamma() / ( 136 | self.bpd.gamma() + self.opponents_adjusted_gamma(self.black_player) 137 | ) 138 | -------------------------------------------------------------------------------- /whr/playerday.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import math 4 | import sys 5 | 6 | from whr import player as P 7 | from whr import game as G 8 | 9 | 10 | class PlayerDay: 11 | def __init__(self, player: P.Player, day: int): 12 | self.day = day 13 | self.player = player 14 | self.is_first_day = False 15 | self.won_games = [] 16 | self.lost_games = [] 17 | self._won_game_terms = None 18 | self._lost_game_terms = None 19 | self.uncertainty: float = -1 20 | 21 | def set_gamma(self, value: float) -> None: 22 | """Sets the player's performance rating (gamma) for this day. 23 | 24 | Args: 25 | value (float): The new gamma value. 26 | """ 27 | self.r = math.log(value) 28 | 29 | def gamma(self) -> float: 30 | """Calculates the player's performance rating (gamma) based on their rating. 31 | 32 | Returns: 33 | float: The player's gamma value. 34 | """ 35 | return math.exp(self.r) 36 | 37 | @property 38 | def elo(self) -> float: 39 | """Calculates the ELO rating from the player's gamma value. 40 | 41 | Returns: 42 | float: The ELO rating. 43 | """ 44 | return (self.r * 400) / (math.log(10)) 45 | 46 | @elo.setter 47 | def elo(self, value: float) -> None: 48 | """Sets the player's ELO rating, adjusting their internal rating accordingly. 49 | 50 | Args: 51 | value (float): The new ELO rating. 52 | """ 53 | self.r = value * (math.log(10) / 400) 54 | 55 | def clear_game_terms_cache(self) -> None: 56 | """Clears the cached terms for games won and lost, forcing recalculation.""" 57 | self._won_game_terms = None 58 | self._lost_game_terms = None 59 | 60 | def won_game_terms(self) -> list[list[float]]: 61 | """Calculates terms for games won by the player on this day. 62 | 63 | Returns: 64 | list[list[float]]: A list of terms used for calculations, including the opponent's adjusted gamma. 65 | """ 66 | if self._won_game_terms is None: 67 | self._won_game_terms = [] 68 | for g in self.won_games: 69 | other_gamma = g.opponents_adjusted_gamma(self.player) 70 | if other_gamma == 0 or other_gamma is None or other_gamma > sys.maxsize: 71 | print( 72 | f"other_gamma ({g.opponent(self.player).__str__()}) = {other_gamma}" 73 | ) 74 | self._won_game_terms.append([1.0, 0.0, 1.0, other_gamma]) 75 | if self.is_first_day: 76 | # win against virtual player ranked with gamma = 1.0 77 | self._won_game_terms.append([1.0, 0.0, 1.0, 1.0]) 78 | return self._won_game_terms 79 | 80 | def lost_game_terms(self) -> list[list[float]]: 81 | """Calculates terms for games lost by the player on this day. 82 | 83 | Returns: 84 | list[list[float]]: A list of terms used for calculations, including the opponent's adjusted gamma. 85 | """ 86 | if self._lost_game_terms is None: 87 | self._lost_game_terms = [] 88 | for g in self.lost_games: 89 | other_gamma = g.opponents_adjusted_gamma(self.player) 90 | if other_gamma == 0 or other_gamma is None or other_gamma > sys.maxsize: 91 | print( 92 | f"other_gamma ({g.opponent(self.player).__str__()}) = {other_gamma}" 93 | ) 94 | self._lost_game_terms.append([0.0, other_gamma, 1.0, other_gamma]) 95 | if self.is_first_day: 96 | # win against virtual player ranked with gamma = 1.0 97 | self._lost_game_terms.append([0.0, 1.0, 1.0, 1.0]) 98 | return self._lost_game_terms 99 | 100 | def log_likelihood_second_derivative(self) -> float: 101 | """Calculates the second derivative of the log likelihood of the player's rating. 102 | 103 | Returns: 104 | float: The second derivative of the log likelihood. 105 | """ 106 | result = 0.0 107 | for _, _, c, d in self.won_game_terms() + self.lost_game_terms(): 108 | result += (c * d) / ((c * self.gamma() + d) ** 2.0) 109 | return -1 * self.gamma() * result 110 | 111 | def log_likelihood_derivative(self) -> float: 112 | """Calculates the derivative of the log likelihood of the player's rating. 113 | 114 | Returns: 115 | float: The derivative of the log likelihood. 116 | """ 117 | tally = 0.0 118 | for _, _, c, d in self.won_game_terms() + self.lost_game_terms(): 119 | tally += c / (c * self.gamma() + d) 120 | return len(self.won_game_terms()) - self.gamma() * tally 121 | 122 | def log_likelihood(self) -> float: 123 | """Calculates the log likelihood of the player's rating based on games played. 124 | 125 | Returns: 126 | float: The log likelihood. 127 | """ 128 | tally = 0.0 129 | for a, b, c, d in self.won_game_terms(): 130 | tally += math.log(a * self.gamma()) 131 | tally -= math.log(c * self.gamma() + d) 132 | for a, b, c, d in self.lost_game_terms(): 133 | tally += math.log(b) 134 | tally -= math.log(c * self.gamma() + d) 135 | return tally 136 | 137 | def add_game(self, game: G.Game) -> None: 138 | """Adds a game to this player's record, categorizing it as won or lost. 139 | 140 | Args: 141 | game (G.Game): The game to add. 142 | """ 143 | if (game.winner == "W" and game.white_player == self.player) or ( 144 | game.winner == "B" and game.black_player == self.player 145 | ): 146 | self.won_games.append(game) 147 | else: 148 | self.lost_games.append(game) 149 | 150 | def update_by_1d_newtons_method(self) -> None: 151 | """Updates the player's rating using one-dimensional Newton's method.""" 152 | dlogp = self.log_likelihood_derivative() 153 | d2logp = self.log_likelihood_second_derivative() 154 | dr = dlogp / d2logp 155 | new_r = self.r - dr 156 | self.r = new_r 157 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # Whole History Rating (WHR) Python Implementation 3 | 4 | This Python library is a conversion from the original Ruby implementation of Rémi Coulom's Whole-History Rating (WHR) algorithm, designed to provide a dynamic rating system for games or matches where players' skills are continuously estimated over time. 5 | 6 | The original Ruby code is available here at [goshrine](https://github.com/goshrine/whole_history_rating). 7 | 8 | ## Installation 9 | 10 | To install the library, use the following command: 11 | 12 | ```shell 13 | pip install whole-history-rating 14 | ``` 15 | 16 | ## Usage 17 | 18 | ### Basic Setup 19 | 20 | Start by importing the library and initializing the base WHR object: 21 | 22 | ```python 23 | from whr import whole_history_rating 24 | 25 | whr = whole_history_rating.Base() 26 | ``` 27 | 28 | ### Creating Games 29 | 30 | Add games to the system using `create_game()` method. It takes the names of the black and white players, the winner ('B' for black, 'W' for white), the day number, and an optional handicap (generally less than 500 elo). 31 | 32 | ```python 33 | whr.create_game("shusaku", "shusai", "B", 1, 0) 34 | whr.create_game("shusaku", "shusai", "W", 2, 0) 35 | whr.create_game("shusaku", "shusai", "W", 3, 0) 36 | ``` 37 | 38 | 39 | ### Refining Ratings Towards Stability 40 | 41 | To achieve accurate and stable ratings, the WHR algorithm allows for iterative refinement. This process can be controlled manually or handled automatically to adjust player ratings until they reach a stable state. 42 | 43 | #### Manual Iteration 44 | 45 | For manual control over the iteration process, specify the number of iterations you wish to perform. This approach gives you direct oversight over the refinement steps. 46 | 47 | ```python 48 | whr.iterate(50) 49 | ``` 50 | 51 | This command will perform 50 iterations, incrementally adjusting player ratings towards stability with each step. 52 | 53 | #### Automatic Iteration 54 | 55 | For a more hands-off approach, the algorithm can automatically iterate until the Elo ratings stabilize within a specified precision. Automatic iteration is particularly useful when dealing with large datasets or when seeking to automate the rating process. 56 | 57 | ```python 58 | whr.auto_iterate(time_limit=10, precision=1e-3, batch_size=10) 59 | ``` 60 | 61 | - `time_limit` (optional): Sets a maximum duration (in seconds) for the iteration process. If `None` (the default), the algorithm will run indefinitely until the specified precision is achieved. 62 | - `precision` (optional): Defines the desired level of accuracy for the ratings' stability. The default value is `0.001`, indicating that iteration will stop when changes between iterations are less than or equal to this threshold. 63 | - `batch_size` (optional): Determines the number of iterations to perform before checking for convergence and, if a `time_limit` is set, before evaluating whether the time limit has been reached. The default value is `10`, balancing between frequent convergence checks and computational efficiency. 64 | 65 | This automated process allows the algorithm to efficiently converge to stable ratings, adjusting the number of iterations dynamically based on the complexity of the data and the specified precision and time constraints. 66 | 67 | 68 | ### Viewing Ratings 69 | 70 | Retrieve and view player ratings, which include the day number, elo rating, and uncertainty: 71 | 72 | ```python 73 | # Example output for whr.ratings_for_player("shusaku") 74 | print(whr.ratings_for_player("shusaku")) 75 | # Output: 76 | # [[1, -43, 0.84], 77 | # [2, -45, 0.84], 78 | # [3, -45, 0.84]] 79 | 80 | # Example output for whr.ratings_for_player("shusai") 81 | print(whr.ratings_for_player("shusai")) 82 | # Output: 83 | # [[1, 43, 0.84], 84 | # [2, 45, 0.84], 85 | # [3, 45, 0.84]] 86 | 87 | ``` 88 | 89 | You can also view or retrieve all ratings in order: 90 | 91 | ```python 92 | whr.print_ordered_ratings(current=False) # Set `current=True` for the latest rankings only. 93 | ratings = whr.get_ordered_ratings(current=False, compact=False) # Set `compact=True` for a condensed list. 94 | ``` 95 | 96 | ### Predicting Match Outcomes 97 | 98 | Predict the outcome of future matches, including between non-existent players: 99 | 100 | ```python 101 | # Example of predicting a future match outcome 102 | probability = whr.probability_future_match("shusaku", "shusai", 0) 103 | print(f"Win probability: shusaku: {probability[0]*100}%; shusai: {probability[1]*100}%") 104 | # Output: 105 | # Win probability: shusaku: 37.24%; shusai: 62.76% <== this is printed 106 | # (0.3724317501643667, 0.6275682498356332) 107 | ``` 108 | 109 | 110 | ### Enhanced Batch Loading of Games 111 | 112 | This feature facilitates the batch loading of multiple games simultaneously by accepting a list of strings, where each string encapsulates the details of a single game. To accommodate names with both first and last names and ensure flexibility in data formatting, you can specify a custom separator (e.g., a comma) to delineate the game attributes. 113 | 114 | #### Standard Loading 115 | 116 | Without specifying a separator, the default space (' ') is used to split the game details: 117 | 118 | ```python 119 | whr.load_games([ 120 | "shusaku shusai B 1 0", # Game 1: Shusaku vs. Shusai, Black wins, Day 1, no handicap. 121 | "shusaku shusai W 2 0", # Game 2: Shusaku vs. Shusai, White wins, Day 2, no handicap. 122 | "shusaku shusai W 3 0" # Game 3: Shusaku vs. Shusai, White wins, Day 3, no handicap. 123 | ]) 124 | ``` 125 | 126 | #### Custom Separator for Complex Names 127 | 128 | When game details include names with spaces, such as first and last names, utilize the `separator` parameter to define an alternative delimiter, ensuring the integrity of each data point: 129 | 130 | ```python 131 | whr.load_games([ 132 | "John Doe, Jane Smith, W, 1, 0", # Game 1: John Doe vs. Jane Smith, White wins, Day 1, no handicap. 133 | "Emily Chen, Liam Brown, B, 2, 0" # Game 2: Emily Chen vs. Liam Brown, Black wins, Day 2, no handicap. 134 | ], separator=",") 135 | ``` 136 | 137 | This method allows for a clear and error-free way to load game data, especially when player names or game details include spaces, providing a robust solution for managing diverse datasets. 138 | 139 | 140 | ### Saving and Loading States 141 | 142 | Save the current state to a file and reload it later to avoid recalculating: 143 | 144 | ```python 145 | whr.save_base('path_to_save.whr') 146 | whr2 = whole_history_rating.Base.load_base('path_to_save.whr') 147 | ``` 148 | 149 | ## Optional Configuration 150 | 151 | Adjust the `w2` parameter, which influences the variance of rating change over time, allowing for faster or slower progression. The default is set to 300, but Rémi Coulom used a value of 14 in his paper to achieve his results. 152 | 153 | ```python 154 | whr = whole_history_rating.Base({'w2': 14}) 155 | ``` 156 | 157 | Enable case-insensitive player names to treat "shusaku" and "ShUsAkU" as the same entity: 158 | 159 | ```python 160 | whr = whole_history_rating.Base({'uncased': True}) 161 | ``` 162 | -------------------------------------------------------------------------------- /tests/whr_test.py: -------------------------------------------------------------------------------- 1 | import copy 2 | import sys 3 | import os 4 | import pytest 5 | 6 | sys.path.append(os.path.join(os.path.dirname(__file__), "../")) 7 | from whr import whole_history_rating 8 | from whr import utils 9 | 10 | 11 | def setup_game_with_elo(white_elo, black_elo, handicap): 12 | whr = whole_history_rating.Base() 13 | game = whr.create_game("black", "white", "W", 1, handicap) 14 | game.black_player.days[0].elo = black_elo 15 | game.white_player.days[0].elo = white_elo 16 | return game 17 | 18 | 19 | def test_even_game_between_equal_strength_players_should_have_white_winrate_of_50_percent(): 20 | game = setup_game_with_elo(500, 500, 0) 21 | assert abs(0.5 - game.white_win_probability()) <= 0.0001 22 | 23 | 24 | def test_handicap_should_confer_advantage(): 25 | game = setup_game_with_elo(500, 500, 1) 26 | assert game.black_win_probability() > 0.5 27 | 28 | 29 | def test_higher_rank_should_confer_advantage(): 30 | game = setup_game_with_elo(600, 500, 0) 31 | assert game.white_win_probability() > 0.5 32 | 33 | 34 | def test_winrates_are_equal_for_same_elo_delta(): 35 | game = setup_game_with_elo(100, 200, 0) 36 | game2 = setup_game_with_elo(200, 300, 0) 37 | assert abs(game.white_win_probability() - game2.white_win_probability()) <= 0.0001 38 | 39 | 40 | def test_winrates_for_twice_as_strong_player(): 41 | game = setup_game_with_elo(100, 200, 0) 42 | assert abs(0.359935 - game.white_win_probability()) <= 0.0001 43 | 44 | 45 | def test_winrates_should_be_inversely_proportional_with_unequal_ranks(): 46 | game = setup_game_with_elo(600, 500, 0) 47 | assert ( 48 | abs(game.white_win_probability() - (1 - game.black_win_probability())) <= 0.0001 49 | ) 50 | 51 | 52 | def test_winrates_should_be_inversely_proportional_with_handicap(): 53 | game = setup_game_with_elo(500, 500, 4) 54 | assert ( 55 | abs(game.white_win_probability() - (1 - game.black_win_probability())) <= 0.0001 56 | ) 57 | 58 | 59 | def test_output(): 60 | whr = whole_history_rating.Base() 61 | whr.create_game("shusaku", "shusai", "B", 1, 0) 62 | whr.create_game("shusaku", "shusai", "W", 2, 0) 63 | whr.create_game("shusaku", "shusai", "W", 3, 0) 64 | whr.iterate(50) 65 | assert [ 66 | (1, -43, 0.84), 67 | (2, -45, 0.84), 68 | (3, -45, 0.84), 69 | ] == whr.ratings_for_player("shusaku") 70 | assert [ 71 | (1, 43, 0.84), 72 | (2, 45, 0.84), 73 | (3, 45, 0.84), 74 | ] == whr.ratings_for_player("shusai") 75 | 76 | 77 | def test_output2(): 78 | whr = whole_history_rating.Base() 79 | whr.create_game("shusaku", "shusai", "B", 1, 0) 80 | whr.create_game("shusaku", "shusai", "W", 2, 0) 81 | whr.create_game("shusaku", "shusai", "W", 3, 0) 82 | whr.create_game("shusaku", "shusai", "W", 4, 0) 83 | whr.create_game("shusaku", "shusai", "W", 4, 0) 84 | whr.iterate(50) 85 | assert [ 86 | (1, -92, 0.71), 87 | (2, -94, 0.71), 88 | (3, -95, 0.71), 89 | (4, -96, 0.72), 90 | ] == whr.ratings_for_player("shusaku") 91 | assert [ 92 | (1, 92, 0.71), 93 | (2, 94, 0.71), 94 | (3, 95, 0.71), 95 | (4, 96, 0.72), 96 | ] == whr.ratings_for_player("shusai") 97 | 98 | 99 | def test_unstable_exception_raised_in_certain_cases(): 100 | whr = whole_history_rating.Base() 101 | for _ in range(10): 102 | whr.create_game("anchor", "player", "B", 1, 0) 103 | whr.create_game("anchor", "player", "W", 1, 0) 104 | for _ in range(10): 105 | whr.create_game("anchor", "player", "B", 180, 600) 106 | whr.create_game("anchor", "player", "W", 180, 600) 107 | with pytest.raises(utils.UnstableRatingException): 108 | whr.iterate(10) 109 | 110 | 111 | def test_log_likelihood(): 112 | whr = whole_history_rating.Base() 113 | whr.create_game("shusaku", "shusai", "B", 1, 0) 114 | whr.create_game("shusaku", "shusai", "W", 4, 0) 115 | whr.create_game("shusaku", "shusai", "W", 10, 0) 116 | player = whr.players["shusaku"] 117 | player.days[0].r = 1 118 | player.days[1].r = 2 119 | player.days[2].r = 0 120 | assert abs(-69.65648196168772 - player.log_likelihood()) <= 0.0001 121 | assert abs(-1.9397850625546684 - player.days[0].log_likelihood()) <= 0.0001 122 | assert abs(-2.1269280110429727 - player.days[1].log_likelihood()) <= 0.0001 123 | assert abs(-0.6931471805599453 - player.days[2].log_likelihood()) <= 0.0001 124 | 125 | 126 | def test_creating_games(): 127 | # test creating the base with modified w2 and uncased 128 | whr = whole_history_rating.Base(config={"w2": 14, "uncased": True}) 129 | # test creating one game 130 | assert isinstance( 131 | whr.create_game("shusaku", "shusai", "B", 4, 0), whole_history_rating.Game 132 | ) 133 | # test creating one game with winner uncased (b instead of B) 134 | assert isinstance( 135 | whr.create_game("shusaku", "shusai", "w", 5, 0), whole_history_rating.Game 136 | ) 137 | # test creating one game with cased letters (ShUsAkU instead of shusaku and ShUsAi instead of shusai) 138 | assert isinstance( 139 | whr.create_game("ShUsAkU", "ShUsAi", "W", 6, 0), whole_history_rating.Game 140 | ) 141 | assert list(whr.players.keys()) == ["shusai", "shusaku"] 142 | 143 | 144 | def test_loading_several_games_at_once(capsys): 145 | whr = whole_history_rating.Base() 146 | # test loading several games at once 147 | test_games = [ 148 | "shusaku; shusai; B; 1", 149 | "shusaku;shusai;W;2;0", 150 | " shusaku ; shusai ;W ; 3; {'w2':300}", 151 | "shusaku;nobody;B;3;0;{'w2':300}", 152 | ] 153 | whr.load_games(test_games, separator=";") 154 | assert len(whr.games) == 4 155 | # test auto iterating to get convergence 156 | whr.iterate(20) 157 | # test getting ratings for player shusaku (day, elo, uncertainty) 158 | assert whr.ratings_for_player("shusaku") == [ 159 | (1, 26.0, 0.70), 160 | (2, 25.0, 0.70), 161 | (3, 24.0, 0.70), 162 | ] 163 | # test getting ratings for player shusai, only current elo and uncertainty 164 | assert whr.ratings_for_player("shusai", current=True) == (87.0, 0.84) 165 | # test getting probability of future match between shusaku and nobody2 (which default to 1 win 1 loss) 166 | assert whr.probability_future_match("shusai", "nobody2", 0) == ( 167 | 0.6224906898220315, 168 | 0.3775093101779684, 169 | ) 170 | display = "win probability: shusai:62.25%; nobody2:37.75%\n" 171 | captured = capsys.readouterr() 172 | assert display == captured.out 173 | # test getting log likelihood of base 174 | assert whr.log_likelihood() == 0.7431542354571272 175 | # test printing ordered ratings 176 | whr.print_ordered_ratings() 177 | display = "nobody => [-112.37545390067574]\nshusaku => [25.552142942931102, 24.669738398550702, 24.49953062693439]\nshusai => [84.74972643795506, 86.17200033461006, 86.88207745833284]\n" 178 | captured = capsys.readouterr() 179 | assert display == captured.out 180 | # test printing ordered ratings, only current elo 181 | whr.print_ordered_ratings(current=True) 182 | display = "nobody => -112.37545390067574\nshusaku => 24.49953062693439\nshusai => 86.88207745833284\n" 183 | captured = capsys.readouterr() 184 | assert display == captured.out 185 | # test getting ordered ratings, compact form 186 | assert whr.get_ordered_ratings(compact=True) == [ 187 | [-112.37545390067574], 188 | [25.552142942931102, 24.669738398550702, 24.49953062693439], 189 | [84.74972643795506, 86.17200033461006, 86.88207745833284], 190 | ] 191 | # test getting ordered ratings, only current elo with compact form 192 | assert whr.get_ordered_ratings(compact=True, current=True) == [ 193 | -112.37545390067574, 194 | 24.49953062693439, 195 | 86.88207745833284, 196 | ] 197 | # test saving base 198 | whole_history_rating.Base.save_base(whr, "test_whr.pkl") 199 | # test loading base 200 | whr2 = whole_history_rating.Base.load_base("test_whr.pkl") 201 | # test inspecting the first game 202 | whr_games = [str(x) for x in whr.games] 203 | whr2_games = [str(x) for x in whr2.games] 204 | assert whr_games == whr2_games 205 | 206 | 207 | def test_save_and_load(): 208 | whr = whole_history_rating.Base( 209 | config={"w2": 1000, "uncased": True, "debug": True, "extra_parameter": "hello"} 210 | ) 211 | whole_history_rating.Base.save_base(whr, "test_whr.pkl") 212 | whr2 = whole_history_rating.Base.load_base("test_whr.pkl") 213 | assert whr.config == whr2.config 214 | 215 | 216 | def test_auto_iterate(capsys): 217 | whr = whole_history_rating.Base() 218 | # test loading several games at once 219 | test_games = [ 220 | "shusaku; shusai; B; 1", 221 | "shusaku;shusai;W;2;0", 222 | " shusaku ; shusai ;W ; 3; {'w2':300}", 223 | "shusaku;nobody;B;3;0;{'w2':300}", 224 | ] 225 | whr.load_games(test_games, separator=";") 226 | # test auto iterating to get convergence 227 | whr1 = copy.deepcopy(whr) 228 | whr2 = copy.deepcopy(whr) 229 | whr3 = copy.deepcopy(whr) 230 | whr4 = copy.deepcopy(whr) 231 | whr5 = copy.deepcopy(whr) 232 | iterations1, is_stable1 = whr1.auto_iterate(batch_size=1) 233 | assert iterations1 == 12 234 | assert is_stable1 235 | iterations2, is_stable2 = whr2.auto_iterate() 236 | assert iterations2 == 30 237 | assert is_stable2 238 | iterations3, is_stable3 = whr3.auto_iterate(precision=0.5, batch_size=1) 239 | assert iterations3 == 6 240 | assert is_stable3 241 | iterations4, is_stable4 = whr4.auto_iterate(precision=0.9, batch_size=1) 242 | assert iterations4 == 5 243 | assert is_stable4 244 | iterations5, is_stable5 = whr5.auto_iterate(time_limit=1, batch_size=1) 245 | assert iterations5 == 12 246 | assert is_stable5 247 | -------------------------------------------------------------------------------- /whr/player.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import math 4 | import sys 5 | import bisect 6 | from typing import Any 7 | 8 | import numpy as np 9 | import numpy.typing as npt 10 | 11 | from whr.utils import UnstableRatingException 12 | from whr import playerday as PD 13 | from whr import game as G 14 | 15 | 16 | class Player: 17 | def __init__(self, name: str, config: dict[str, Any]): 18 | self.name = name 19 | self.debug = config["debug"] 20 | self.w2 = (math.sqrt(config["w2"]) * math.log(10) / 400) ** 2 21 | self.days: list[PD.PlayerDay] = [] 22 | 23 | def log_likelihood(self) -> float: 24 | """Computes the log likelihood of the player's ratings over all days. 25 | 26 | Incorporates both the likelihood of the observed game outcomes and the prior based on changes in rating over time. 27 | 28 | Returns: 29 | float: The log likelihood value for the player's ratings. 30 | """ 31 | result = 0.0 32 | sigma2 = self.compute_sigma2() 33 | n = len(self.days) 34 | for i in range(n): 35 | prior = 0 36 | if i < (n - 1): 37 | rd = self.days[i].r - self.days[i + 1].r 38 | prior += (1 / (math.sqrt(2 * math.pi * sigma2[i]))) * math.exp( 39 | -(rd**2) / 2 / sigma2[i] 40 | ) 41 | if i > 0: 42 | rd = self.days[i].r - self.days[i - 1].r 43 | prior += (1 / (math.sqrt(2 * math.pi * sigma2[i - 1]))) * math.exp( 44 | -(rd**2) / (2 * sigma2[i - 1]) 45 | ) 46 | if prior == 0: 47 | result += self.days[i].log_likelihood() 48 | else: 49 | if ( 50 | self.days[i].log_likelihood() >= sys.maxsize 51 | or math.log(prior) >= sys.maxsize 52 | ): 53 | print( 54 | f"Infinity at {self.__str__()}: {self.days[i].log_likelihood()} + {math.log(prior)}: prior = {prior}, days = {self.days}" 55 | ) 56 | sys.exit() 57 | result += self.days[i].log_likelihood() + math.log(prior) 58 | return result 59 | 60 | @staticmethod 61 | def hessian( 62 | days: list[PD.PlayerDay], sigma2: list[float] 63 | ) -> tuple[npt.NDArray[np.float64], npt.NDArray[np.float64]]: 64 | """Computes the Hessian matrix for the log likelihood function. 65 | 66 | Args: 67 | days (list[PD.PlayerDay]): A list of PD.PlayerDay instances for the player. 68 | sigma2 (list[float]): A list of variance values between consecutive days. 69 | 70 | Returns: 71 | tuple[npt.NDArray[np.float64], npt.NDArray[np.float64]]: A tuple containing the diagonal and sub-diagonal elements of the Hessian matrix. 72 | """ 73 | n = len(days) 74 | diagonal = np.zeros((n,)) 75 | sub_diagonal = np.zeros((n - 1,)) 76 | for row in range(n): 77 | prior = 0 78 | if row < (n - 1): 79 | prior += -1 / sigma2[row] 80 | if row > 0: 81 | prior += -1 / sigma2[row - 1] 82 | diagonal[row] = days[row].log_likelihood_second_derivative() + prior - 0.001 83 | for i in range(n - 1): 84 | sub_diagonal[i] = 1 / sigma2[i] 85 | return (diagonal, sub_diagonal) 86 | 87 | def gradient( 88 | self, r: list[float], days: list[PD.PlayerDay], sigma2: list[float] 89 | ) -> list[float]: 90 | """Computes the gradient of the log likelihood function. 91 | 92 | Args: 93 | r (list[float]): A list of rating values for the player on different days. 94 | days (list[PD.PlayerDay]): A list of PD.PlayerDay instances for the player. 95 | sigma2 (list[float]): A list of variance values between consecutive days. 96 | 97 | Returns: 98 | list[float]: A list containing the gradient of the log likelihood function. 99 | """ 100 | g = [] 101 | n = len(days) 102 | for idx, day in enumerate(days): 103 | prior = 0 104 | if idx < (n - 1): 105 | prior += -(r[idx] - r[idx + 1]) / sigma2[idx] 106 | if idx > 0: 107 | prior += -(r[idx] - r[idx - 1]) / sigma2[idx - 1] 108 | if self.debug: 109 | print(f"g[{idx}] = {day.log_likelihood_derivative()} + {prior}") 110 | g.append(day.log_likelihood_derivative() + prior) 111 | return g 112 | 113 | def run_one_newton_iteration(self) -> None: 114 | """Runs a single iteration of Newton's method to update player ratings.""" 115 | for day in self.days: 116 | day.clear_game_terms_cache() 117 | if len(self.days) == 1: 118 | self.days[0].update_by_1d_newtons_method() 119 | elif len(self.days) > 1: 120 | self.update_by_ndim_newton() 121 | 122 | def compute_sigma2(self) -> list[float]: 123 | """Computes the variance values used as the prior for rating changes. 124 | 125 | Returns: 126 | list[float]: A list of variance values between consecutive rating days. 127 | """ 128 | sigma2 = [] 129 | for d1, d2 in zip(*(self.days[i:] for i in range(2))): 130 | sigma2.append(abs(d2.day - d1.day) * self.w2) 131 | return sigma2 132 | 133 | def update_by_ndim_newton(self) -> None: 134 | """Updates the player's ratings using a multidimensional Newton-Raphson method.""" 135 | # r 136 | r = [d.r for d in self.days] 137 | 138 | # sigma squared (used in the prior) 139 | sigma2 = self.compute_sigma2() 140 | 141 | diag, sub_diag = Player.hessian(self.days, sigma2) 142 | g = self.gradient(r, self.days, sigma2) 143 | n = len(r) 144 | a = np.zeros((n,)) 145 | d = np.zeros((n,)) 146 | b = np.zeros((n,)) 147 | d[0] = diag[0] 148 | b[0] = sub_diag[0] if sub_diag.size > 0 else 0 149 | 150 | for i in range(1, n): 151 | a[i] = sub_diag[i - 1] / d[i - 1] 152 | d[i] = diag[i] - a[i] * b[i - 1] 153 | if i < n - 1: 154 | b[i] = sub_diag[i] 155 | 156 | y = np.zeros((n,)) 157 | y[0] = g[0] 158 | for i in range(1, n): 159 | y[i] = g[i] - a[i] * y[i - 1] 160 | 161 | x = np.zeros((n,)) 162 | x[n - 1] = y[n - 1] / d[n - 1] 163 | for i in range(n - 2, -1, -1): 164 | x[i] = (y[i] - b[i] * x[i + 1]) / d[i] 165 | 166 | new_r = [ri - xi for ri, xi in zip(r, x)] 167 | 168 | for r in new_r: 169 | if r > 650: 170 | raise UnstableRatingException("unstable r on player") 171 | 172 | for idx, day in enumerate(self.days): 173 | day.r = day.r - x[idx] 174 | 175 | def covariance(self) -> npt.NDArray[np.float64]: 176 | """Computes the covariance matrix of the player's rating estimations. 177 | 178 | Returns: 179 | The covariance matrix for the player's ratings. 180 | """ 181 | r = [d.r for d in self.days] 182 | 183 | sigma2 = self.compute_sigma2() 184 | diag, sub_diag = Player.hessian(self.days, sigma2) 185 | n = len(r) 186 | 187 | a = np.zeros((n,)) 188 | d = np.zeros((n,)) 189 | b = np.zeros((n,)) 190 | d[0] = diag[0] 191 | b[0] = sub_diag[0] if sub_diag.size > 0 else 0 192 | 193 | for i in range(1, n): 194 | a[i] = sub_diag[i - 1] / d[i - 1] 195 | d[i] = diag[i] - a[i] * b[i - 1] 196 | if i < n - 1: 197 | b[i] = sub_diag[i] 198 | 199 | dp = np.zeros((n,)) 200 | dp[n - 1] = diag[n - 1] 201 | bp = np.zeros((n,)) 202 | bp[n - 1] = sub_diag[n - 2] if sub_diag.size >= 2 else 0 203 | ap = np.zeros((n,)) 204 | for i in range(n - 2, -1, -1): 205 | ap[i] = sub_diag[i] / dp[i + 1] 206 | dp[i] = diag[i] - ap[i] * bp[i + 1] 207 | if i > 0: 208 | bp[i] = sub_diag[i - 1] 209 | 210 | v = np.zeros((n,)) 211 | for i in range(n - 1): 212 | v[i] = dp[i + 1] / (b[i] * bp[i + 1] - d[i] * dp[i + 1]) 213 | v[n - 1] = -1 / d[n - 1] 214 | 215 | mat = np.zeros((n, n)) 216 | for row in range(n): 217 | for col in range(n): 218 | if row == col: 219 | mat[row, col] = v[row] 220 | elif row == col - 1: 221 | mat[row, col] = -1 * a[col] * v[col] 222 | else: 223 | mat[row, col] = 0 224 | 225 | return mat 226 | 227 | def update_uncertainty(self) -> float | None: 228 | """Updates the uncertainty measure for each day based on the covariance matrix. 229 | 230 | If the player has played on multiple days, this method calculates the variance for each day from the covariance matrix and updates each day's uncertainty value accordingly. If the player has not played on any day, a default uncertainty value is returned. 231 | 232 | Returns: 233 | float | None: The default uncertainty value of 5 if the player has no recorded days, otherwise None after updating the uncertainty values for all recorded days. 234 | """ 235 | if len(self.days) > 0: 236 | c = self.covariance() 237 | u = [c[i, i] for i in range(len(self.days))] # u = variance 238 | for i, d in enumerate(self.days): 239 | d.uncertainty = u[i] 240 | return None 241 | return 5 242 | 243 | def add_game(self, game: G.Game) -> None: 244 | """Adds a game to the player's record, updating or creating a new PD.PlayerDay instance as necessary. 245 | 246 | Args: 247 | game (G.Game): The game to add to the player's record. 248 | """ 249 | all_days = [x.day for x in self.days] 250 | if game.day not in all_days: 251 | day_index = bisect.bisect_right(all_days, game.day) 252 | new_pday = PD.PlayerDay(self, game.day) 253 | if len(self.days) == 0: 254 | new_pday.is_first_day = True 255 | new_pday.set_gamma(1) 256 | else: 257 | # still not perfect because gamma of day index can more farther if more games were not added in order 258 | new_pday.set_gamma(self.days[day_index - 1].gamma()) 259 | self.days.insert(day_index, new_pday) 260 | else: 261 | day_index = all_days.index(game.day) 262 | if game.white_player == self: 263 | game.wpd = self.days[day_index] 264 | else: 265 | game.bpd = self.days[day_index] 266 | self.days[day_index].add_game(game) 267 | -------------------------------------------------------------------------------- /whr/whole_history_rating.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import time 4 | import ast 5 | import pickle 6 | from typing import Any 7 | 8 | from whr.utils import test_stability 9 | from whr.player import Player 10 | from whr.game import Game 11 | 12 | 13 | class Base: 14 | def __init__(self, config: dict[str, Any] | None = None): 15 | self.config = config if config is not None else {} 16 | self.config.setdefault("debug", False) 17 | self.config.setdefault("w2", 300.0) 18 | self.config.setdefault("uncased", False) 19 | self.games = [] 20 | self.players = {} 21 | 22 | def print_ordered_ratings(self, current: bool = False) -> None: 23 | """Displays all ratings for each player (for each of their playing days), ordered. 24 | 25 | Args: 26 | current (bool, optional): If True, displays only the latest elo rating. If False, displays all elo ratings for each day played. 27 | """ 28 | players = [x for x in self.players.values() if len(x.days) > 0] 29 | players.sort(key=lambda x: x.days[-1].gamma()) 30 | for p in players: 31 | if len(p.days) > 0: 32 | if current: 33 | print(f"{p.name} => {p.days[-1].elo}") 34 | else: 35 | print(f"{p.name} => {[x.elo for x in p.days]}") 36 | 37 | def get_ordered_ratings( 38 | self, current: bool = False, compact: bool = False 39 | ) -> list[list[float]]: 40 | """Retrieves all ratings for each player (for each of their playing days), ordered. 41 | 42 | Args: 43 | current (bool, optional): If True, retrieves only the latest elo rating estimation. If False, retrieves all elo rating estimations for each day played. 44 | compact (bool, optional): If True, returns only a list of elo ratings. If False, includes the player's name before their elo ratings. 45 | 46 | Returns: 47 | list[list[float]]: A list containing the elo ratings for each player and each of their playing days. 48 | """ 49 | result = [] 50 | players = [x for x in self.players.values() if len(x.days) > 0] 51 | players.sort(key=lambda x: x.days[-1].gamma()) 52 | for p in players: 53 | if len(p.days) > 0: 54 | if current and compact: 55 | result.append(p.days[-1].elo) 56 | elif current: 57 | result.append((p.name, p.days[-1].elo)) 58 | elif compact: 59 | result.append([x.elo for x in p.days]) 60 | else: 61 | result.append((p.name, [x.elo for x in p.days])) 62 | return result 63 | 64 | def log_likelihood(self) -> float: 65 | """Calculates the likelihood of the current state. 66 | 67 | The likelihood increases with more iterations. 68 | 69 | Returns: 70 | float: The likelihood. 71 | """ 72 | score = 0.0 73 | for p in self.players.values(): 74 | if len(p.days) > 0: 75 | score += p.log_likelihood() 76 | return score 77 | 78 | def player_by_name(self, name: str) -> Player: 79 | """Retrieves the player object corresponding to the given name. 80 | 81 | Args: 82 | name (str): The name of the player. 83 | 84 | Returns: 85 | Player: The corresponding player object. 86 | """ 87 | if self.config["uncased"]: 88 | name = name.lower() 89 | if self.players.get(name, None) is None: 90 | self.players[name] = Player(name, self.config) 91 | return self.players[name] 92 | 93 | def ratings_for_player( 94 | self, name, current: bool = False 95 | ) -> list[tuple[int, float, float]] | tuple[float, float]: 96 | """Retrieves all ratings for each day played by the specified player. 97 | 98 | Args: 99 | name (str): The name of the player. 100 | current (bool, optional): If True, retrieves only the latest elo rating and uncertainty. If False, retrieves all elo ratings and uncertainties for each day played. 101 | 102 | Returns: 103 | list[tuple[int, float, float]] | tuple[float, float]: For each day, includes the time step, the elo rating, and the uncertainty if current is False, else just return the elo and uncertainty of the last day 104 | """ 105 | if self.config["uncased"]: 106 | name = name.lower() 107 | player = self.player_by_name(name) 108 | if current: 109 | return ( 110 | round(player.days[-1].elo), 111 | round(player.days[-1].uncertainty, 2), 112 | ) 113 | return [(d.day, round(d.elo), round(d.uncertainty, 2)) for d in player.days] 114 | 115 | def _setup_game( 116 | self, 117 | black: str, 118 | white: str, 119 | winner: str, 120 | time_step: int, 121 | handicap: float, 122 | extras: dict[str, Any] | None = None, 123 | ) -> Game: 124 | if extras is None: 125 | extras = {} 126 | if black == white: 127 | raise AttributeError("Invalid game (black player == white player)") 128 | white_player = self.player_by_name(white) 129 | black_player = self.player_by_name(black) 130 | game = Game(black_player, white_player, winner, time_step, handicap, extras) 131 | return game 132 | 133 | def create_game( 134 | self, 135 | black: str, 136 | white: str, 137 | winner: str, 138 | time_step: int, 139 | handicap: float, 140 | extras: dict[str, Any] | None = None, 141 | ) -> Game: 142 | """Creates a new game to be added to the base. 143 | 144 | Args: 145 | black (str): The name of the black player. 146 | white (str): The name of the white player. 147 | winner (str): "B" if black won, "W" if white won. 148 | time_step (int): The day of the match from the origin. 149 | handicap (float): The handicap (in elo points). 150 | extras (dict[str, Any] | None, optional): Extra parameters. 151 | 152 | Returns: 153 | Game: The newly added game. 154 | """ 155 | if extras is None: 156 | extras = {} 157 | if self.config["uncased"]: 158 | black = black.lower() 159 | white = white.lower() 160 | game = self._setup_game(black, white, winner, time_step, handicap, extras) 161 | return self._add_game(game) 162 | 163 | def _add_game(self, game: Game) -> Game: 164 | game.white_player.add_game(game) 165 | game.black_player.add_game(game) 166 | if game.bpd is None: 167 | print("Bad game") 168 | self.games.append(game) 169 | return game 170 | 171 | def iterate(self, count: int) -> None: 172 | """Performs a specified number of iterations of the algorithm. 173 | 174 | Args: 175 | count (int): The number of iterations to perform. 176 | """ 177 | for _ in range(count): 178 | self._run_one_iteration() 179 | for player in self.players.values(): 180 | player.update_uncertainty() 181 | 182 | def auto_iterate( 183 | self, 184 | time_limit: int | None = None, 185 | precision: float = 1e-3, 186 | batch_size: int = 10, 187 | ) -> tuple[int, bool]: 188 | """Automatically iterates until the algorithm converges or reaches the time limit. 189 | 190 | Args: 191 | time_limit (int | None, optional): The maximum time, in seconds, after which no more iterations will be launched. If None, no timeout is set 192 | precision (float, optional): The desired precision of stability. 193 | batch_size (int, optional): The number of iterations to perform at each step, with precision and timeout checks after each batch. 194 | 195 | Returns: 196 | tuple[int, bool]: The number of iterations performed and a boolean indicating whether stability was reached. 197 | """ 198 | start = time.time() 199 | a = None 200 | i = 0 201 | while True: 202 | self.iterate(batch_size) 203 | i += batch_size 204 | b = self.get_ordered_ratings(compact=True) 205 | if a is not None and test_stability(a, b, precision): 206 | return i, True 207 | if time_limit is not None and time.time() - start > time_limit: 208 | return i, False 209 | a = b 210 | 211 | def probability_future_match( 212 | self, name1: str, name2: str, handicap: float = 0 213 | ) -> tuple[float, float]: 214 | """Calculates the winning probability for a hypothetical match between two players. 215 | 216 | Args: 217 | name1 (str): The name of the first player. 218 | name2 (str): The name of the second player. 219 | handicap (float, optional): The handicap (in elo points). 220 | 221 | Returns: 222 | tuple[float, float]: The winning probabilities for name1 and name2, respectively, as percentages rounded to the second decimal. 223 | 224 | Raises: 225 | AttributeError: Raised if name1 and name2 are equal 226 | """ 227 | # Avoid self-played games (no info) 228 | if self.config["uncased"]: 229 | name1 = name1.lower() 230 | name2 = name2.lower() 231 | if name1 == name2: 232 | raise AttributeError("Invalid game (black == white)") 233 | player1 = self.player_by_name(name1) 234 | player2 = self.player_by_name(name2) 235 | bpd_gamma = 1 236 | bpd_elo = 0 237 | wpd_gamma = 1 238 | wpd_elo = 0 239 | if len(player1.days) > 0: 240 | bpd = player1.days[-1] 241 | bpd_gamma = bpd.gamma() 242 | bpd_elo = bpd.elo 243 | if len(player2.days) != 0: 244 | wpd = player2.days[-1] 245 | wpd_gamma = wpd.gamma() 246 | wpd_elo = wpd.elo 247 | player1_proba = bpd_gamma / (bpd_gamma + 10 ** ((wpd_elo - handicap) / 400.0)) 248 | player2_proba = wpd_gamma / (wpd_gamma + 10 ** ((bpd_elo + handicap) / 400.0)) 249 | print( 250 | f"win probability: {name1}:{player1_proba*100:.2f}%; {name2}:{player2_proba*100:.2f}%" 251 | ) 252 | return player1_proba, player2_proba 253 | 254 | def _run_one_iteration(self) -> None: 255 | """Runs one iteration of the WHR algorithm.""" 256 | for player in self.players.values(): 257 | player.run_one_newton_iteration() 258 | 259 | def load_games(self, games: list[str], separator: str = " ") -> None: 260 | """Loads all games at once. 261 | 262 | Each game string must follow the format: "black_name white_name winner time_step handicap extras", 263 | where handicap and extras are optional. Handicap defaults to 0 if not provided, and extras must be a valid dictionary. 264 | 265 | Args: 266 | games (list[str]): A list of strings representing games. 267 | separator (str, optional): The separator used between elements of a game, defaulting to a space. 268 | 269 | Raises: 270 | ValueError: If any game string does not comply with the expected format or if parsing fails. 271 | """ 272 | for line in games: 273 | parts = [part.strip() for part in line.split(separator)] 274 | if len(parts) < 4 or len(parts) > 6: 275 | raise ValueError(f"Invalid game format: '{line}'") 276 | 277 | black, white, winner, time_step, *rest = parts 278 | handicap = 0 279 | extras = {} 280 | 281 | if len(rest) == 1: 282 | try: 283 | handicap = int(rest[0]) 284 | except ValueError: 285 | try: 286 | extras = ast.literal_eval(rest[0]) 287 | if not isinstance(extras, dict): 288 | raise ValueError() 289 | except (ValueError, SyntaxError): 290 | raise ValueError( 291 | f"Invalid handicap or extra value in: '{line}'" 292 | ) 293 | 294 | if len(rest) == 2: 295 | try: 296 | handicap = int(rest[0]) 297 | except ValueError: 298 | raise ValueError(f"Invalid handicap value in: '{line}'") 299 | try: 300 | extras = ast.literal_eval(rest[1]) 301 | if not isinstance(extras, dict): 302 | raise ValueError() 303 | except (ValueError, SyntaxError): 304 | raise ValueError(f"Invalid extras dictionary in: '{line}'") 305 | 306 | if self.config["uncased"]: 307 | black, white = black.lower(), white.lower() 308 | 309 | self.create_game(black, white, winner, int(time_step), handicap, extras) 310 | 311 | def save_base(self, path: str) -> None: 312 | """Saves the current state of the base to a specified path. 313 | 314 | Args: 315 | path (str): The path where the base will be saved. 316 | """ 317 | try: 318 | pickle.dump([self.players, self.games, self.config], open(path, "wb")) 319 | except pickle.PicklingError: 320 | pickle.dump( 321 | [ 322 | self.players, 323 | self.games, 324 | { 325 | k: v 326 | for k, v in self.config.items() 327 | if k in ["w2", "debug", "uncased"] 328 | }, 329 | ], 330 | open(path, "wb"), 331 | ) 332 | print( 333 | "WARNING: some elements in self.config you configured can't be pickled, only 'w2', 'debug' and 'uncased' parameters will be saved for self.config" 334 | ) 335 | 336 | @staticmethod 337 | def load_base(path: str) -> Base: 338 | """Loads a saved base from a specified path. 339 | 340 | Args: 341 | path (str): The path to the saved base. 342 | 343 | Returns: 344 | Base: The loaded base. 345 | """ 346 | players, games, config = pickle.load(open(path, "rb")) 347 | result = Base() 348 | result.config, result.games, result.players = config, games, players 349 | return result 350 | --------------------------------------------------------------------------------