Skip to content

Commit

Permalink
Enhanced docstring readability for improved code documentation.
Browse files Browse the repository at this point in the history
Update readme.
Refactored `load_games` function.
Removed the multiplication by 100 of the uncertainty coefficient in the player ratings retrieval process.
Replaced the `inspect` method in the `Game` class with the `__str__` method for more intuitive object representation.
  • Loading branch information
pfmonville committed Feb 9, 2024
1 parent b3d1c87 commit 005e799
Show file tree
Hide file tree
Showing 8 changed files with 628 additions and 287 deletions.
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
*.csv
*.pkl
__py*
setup*
build/*
dist/*
whole_history_rating.egg-info/*
187 changes: 134 additions & 53 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,80 +1,161 @@
# whole_history_rating
A python conversion from the ruby implementation of Rémi Coulom's Whole-History Rating (WHR) algorithm.

The original ruby code can be found [here](https://github.com/goshrine/whole_history_rating)
# Whole History Rating (WHR) Python Implementation

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.

Installation
------------
The original Ruby code is available here at [goshrine](https://github.com/goshrine/whole_history_rating).

## Installation

To install the library, use the following command:

```shell
pip install whole-history-rating
```

Usage
-----
## Usage

### Basic Setup

Start by importing the library and initializing the base WHR object:

```py
```python
from whr import whole_history_rating

whr = whole_history_rating.Base()
```

### Creating Games

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

# Base.create_game() arguments: black player name, white player name, winner, day number, handicap
# Handicap should generally be less than 500 elo
```python
whr.create_game("shusaku", "shusai", "B", 1, 0)
whr.create_game("shusaku", "shusai", "W", 2, 0)
whr.create_game("shusaku", "shusai", "W", 3, 0)
```


### Refining Ratings Towards Stability

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.

#### Manual Iteration

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.

# Iterate the WHR algorithm towards convergence with more players/games, more iterations are needed.
```python
whr.iterate(50)
```

This command will perform 50 iterations, incrementally adjusting player ratings towards stability with each step.

#### Automatic Iteration

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.

```python
whr.auto_iterate(time_limit=10, precision=1e-3, batch_size=10)
```

- `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.
- `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.
- `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.

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.


### Viewing Ratings

Retrieve and view player ratings, which include the day number, elo rating, and uncertainty:

```python
# Example output for whr.ratings_for_player("shusaku")
print(whr.ratings_for_player("shusaku"))
# Output:
# [[1, -43, 0.84],
# [2, -45, 0.84],
# [3, -45, 0.84]]

# Example output for whr.ratings_for_player("shusai")
print(whr.ratings_for_player("shusai"))
# Output:
# [[1, 43, 0.84],
# [2, 45, 0.84],
# [3, 45, 0.84]]

```

You can also view or retrieve all ratings in order:

```python
whr.print_ordered_ratings(current=False) # Set `current=True` for the latest rankings only.
ratings = whr.get_ordered_ratings(current=False, compact=False) # Set `compact=True` for a condensed list.
```

### Predicting Match Outcomes

Predict the outcome of future matches, including between non-existent players:

```python
# Example of predicting a future match outcome
probability = whr.probability_future_match("shusaku", "shusai", 0)
print(f"Win probability: shusaku: {probability[0]*100}%; shusai: {probability[1]*100}%")
# Output:
# Win probability: shusaku: 37.24%; shusai: 62.76% <== this is printed
# (0.3724317501643667, 0.6275682498356332)
```


### Enhanced Batch Loading of Games

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.

#### Standard Loading

Without specifying a separator, the default space (' ') is used to split the game details:

```python
whr.load_games([
"shusaku shusai B 1 0", # Game 1: Shusaku vs. Shusai, Black wins, Day 1, no handicap.
"shusaku shusai W 2 0", # Game 2: Shusaku vs. Shusai, White wins, Day 2, no handicap.
"shusaku shusai W 3 0" # Game 3: Shusaku vs. Shusai, White wins, Day 3, no handicap.
])
```

#### Custom Separator for Complex Names

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:

```python
whr.load_games([
"John Doe, Jane Smith, W, 1, 0", # Game 1: John Doe vs. Jane Smith, White wins, Day 1, no handicap.
"Emily Chen, Liam Brown, B, 2, 0" # Game 2: Emily Chen vs. Liam Brown, Black wins, Day 2, no handicap.
], separator=",")
```

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.


### Saving and Loading States

Save the current state to a file and reload it later to avoid recalculating:

# Or let the module iterate until the elo is stable (precision by default 10E-3) with a time limit of 10 seconds by default
whr.auto_iterate(time_limit = 10, precision = 10E-3)

# Results are stored in one triplet for each game: [day_number, elo_rating, uncertainty]
whr.ratings_for_player("shusaku") =>
[[1, -43, 84],
[2, -45, 84],
[3, -45, 84]]
whr.ratings_for_player("shusai") =>
[[1, 43, 84],
[2, 45, 84],
[3, 45, 84]]

# You can print or get all ratings ordered
whr.print_ordered_ratings(current=False) # current to True to only get the last rank estimation
whr.get_ordered_ratings(current=False, compact=False) # compact to True to not have the name before each ranks

# You can get a prediction for a future game between two players (even non existing players)
# Base.probability_future_match() arguments: black player name, white player name, handicap
whr.probability_future_match("shusaku", "shusai",0) =>
win probability: shusaku:37.24%; shusai:62.76%

# You can load several games all together using a file or a list of string representing the game
# all elements in list must be like: "black_name white_name winner time_step handicap extras"
# you can exclude handicap (default=0) and extras (default={})
whr.load_games(["shusaku shusai B 1 0", "shusaku shusai W 2", "shusaku shusai W 3 0"])
whr.load_games(["firstname1 name1, firstname2 name2, W, 1"], separator=",")

# You can save and load a base (you don't have to redo all iterations)
whr.save_base(path)
whr2 = whole_history_rating.Base.load_base(path)
```python
whr.save_base('path_to_save.whr')
whr2 = whole_history_rating.Base.load_base('path_to_save.whr')
```

Optional Configuration
----------------------
## Optional Configuration

One of the meta parameters to WHR is the variance of rating change over one time step, `w2`,
which determines how much a player's rating is likely change in one day. Higher numbers allow for faster progress.
The default value is 300, which is fairly high.
Rémi Coulom in his paper, used w2=14 to get his [results](https://www.remi-coulom.fr/WHR/WHR.pdf)
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.

```py
whr = whole_history_rating.Base({'w2':14})
```python
whr = whole_history_rating.Base({'w2': 14})
```

You can also set the base not case sensitive. "shusaku" and "ShUsAkU" will be the same.
Enable case-insensitive player names to treat "shusaku" and "ShUsAkU" as the same entity:

```py
whr = whole_history_rating.Base({'uncased':True})
```python
whr = whole_history_rating.Base({'uncased': True})
```
85 changes: 70 additions & 15 deletions tests/whr_test.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import copy
import sys
import os
import pytest
Expand Down Expand Up @@ -56,6 +57,24 @@ def test_winrates_should_be_inversely_proportional_with_handicap():


def test_output():
whr = whole_history_rating.Base()
whr.create_game("shusaku", "shusai", "B", 1, 0)
whr.create_game("shusaku", "shusai", "W", 2, 0)
whr.create_game("shusaku", "shusai", "W", 3, 0)
whr.iterate(50)
assert [
(1, -43, 0.84),
(2, -45, 0.84),
(3, -45, 0.84),
] == whr.ratings_for_player("shusaku")
assert [
(1, 43, 0.84),
(2, 45, 0.84),
(3, 45, 0.84),
] == whr.ratings_for_player("shusai")


def test_output2():
whr = whole_history_rating.Base()
whr.create_game("shusaku", "shusai", "B", 1, 0)
whr.create_game("shusaku", "shusai", "W", 2, 0)
Expand All @@ -64,16 +83,16 @@ def test_output():
whr.create_game("shusaku", "shusai", "W", 4, 0)
whr.iterate(50)
assert [
[1, -92, 71],
[2, -94, 71],
[3, -95, 71],
[4, -96, 72],
(1, -92, 0.71),
(2, -94, 0.71),
(3, -95, 0.71),
(4, -96, 0.72),
] == whr.ratings_for_player("shusaku")
assert [
[1, 92, 71],
[2, 94, 71],
[3, 95, 71],
[4, 96, 72],
(1, 92, 0.71),
(2, 94, 0.71),
(3, 95, 0.71),
(4, 96, 0.72),
] == whr.ratings_for_player("shusai")


Expand Down Expand Up @@ -136,22 +155,25 @@ def test_loading_several_games_at_once(capsys):
whr.auto_iterate()
# test getting ratings for player shusaku (day, elo, uncertainty)
assert whr.ratings_for_player("shusaku") == [
[1, 26.0, 70.0],
[2, 25.0, 70.0],
[3, 24.0, 70.0],
(1, 26.0, 0.70),
(2, 25.0, 0.70),
(3, 24.0, 0.70),
]
# test getting ratings for player shusai, only current elo and uncertainty
assert whr.ratings_for_player("shusai", current=True) == (87.0, 84.0)
assert whr.ratings_for_player("shusai", current=True) == (87.0, 0.84)
# test getting probability of future match between shusaku and nobody2 (which default to 1 win 1 loss)
assert whr.probability_future_match("shusai", "nobody2", 0) == (
0.6224906898220315,
0.3775093101779684,
)
display = "win probability: shusai:62.25%; nobody2:37.75%\n"
captured = capsys.readouterr()
assert display == captured.out
# test getting log likelihood of base
assert whr.log_likelihood() == 0.7431542354571272
# test printing ordered ratings
whr.print_ordered_ratings()
display = "win probability: shusai:0.62%; nobody2:0.38%\nnobody => [-112.37545390067574]\nshusaku => [25.552142942931102, 24.669738398550702, 24.49953062693439]\nshusai => [84.74972643795506, 86.17200033461006, 86.88207745833284]\n"
display = "nobody => [-112.37545390067574]\nshusaku => [25.552142942931102, 24.669738398550702, 24.49953062693439]\nshusai => [84.74972643795506, 86.17200033461006, 86.88207745833284]\n"
captured = capsys.readouterr()
assert display == captured.out
# test printing ordered ratings, only current elo
Expand All @@ -176,6 +198,39 @@ def test_loading_several_games_at_once(capsys):
# test loading base
whr2 = whole_history_rating.Base.load_base("test_whr.pkl")
# test inspecting the first game
whr_games = [x.inspect() for x in whr.games]
whr2_games = [x.inspect() for x in whr2.games]
whr_games = [str(x) for x in whr.games]
whr2_games = [str(x) for x in whr2.games]
assert whr_games == whr2_games


def test_auto_iterate(capsys):
whr = whole_history_rating.Base()
# test loading several games at once
test_games = [
"shusaku; shusai; B; 1",
"shusaku;shusai;W;2;0",
" shusaku ; shusai ;W ; 3; {'w2':300}",
"shusaku;nobody;B;3;0;{'w2':300}",
]
whr.load_games(test_games, separator=";")
# test auto iterating to get convergence
whr1 = copy.deepcopy(whr)
whr2 = copy.deepcopy(whr)
whr3 = copy.deepcopy(whr)
whr4 = copy.deepcopy(whr)
whr5 = copy.deepcopy(whr)
iterations1, is_stable1 = whr1.auto_iterate(batch_size=1)
assert iterations1 == 9
assert is_stable1
iterations2, is_stable2 = whr2.auto_iterate()
assert iterations2 == 20
assert is_stable2
iterations3, is_stable3 = whr3.auto_iterate(precision=0.5, batch_size=1)
assert iterations3 == 6
assert is_stable3
iterations4, is_stable4 = whr4.auto_iterate(precision=0.9, batch_size=1)
assert iterations4 == 5
assert is_stable4
iterations5, is_stable5 = whr5.auto_iterate(time_limit=1, batch_size=1)
assert iterations5 == 9
assert is_stable5
Loading

0 comments on commit 005e799

Please sign in to comment.