Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 14 additions & 7 deletions src/elo_mmr_py/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@
from dataclasses import dataclass
from typing import Dict, List, Optional

from elo_mmr_py.elo_mmr_python_bindings import Contest, PlayerEvent
from elo_mmr_py.elo_mmr_python_bindings import (
Contest,
ContestRatingParams,
PlayerEvent,
)
from elo_mmr_py.elo_mmr_python_bindings import rate as rust_rate


Expand All @@ -16,15 +20,14 @@ class Player:

def rate(
contests: List[Contest],
system: str = 'mmr',
mu_noob: float = 1500.,
sig_noob: float = 350.,
system: str = "mmr",
mu_noob: float = 1500.0,
sig_noob: float = 350.0,
load_checkpoint: Optional[str] = None,
save_checkpoint: Optional[str] = None,
) -> Dict[str, Player]:
"""
Rates players based on their performance in contests.

Args:
system (str): The name of the rating system to use (e.g., "mmr", "glicko"). Default is "mmr".
contests (List[PyContest]): A list of contest objects, each representing a single contest.
Expand All @@ -34,14 +37,18 @@ def rate(
If None, the rating system starts without prior state.
save_checkpoint (Optional[str]): The path to a file where the rating system state will be saved
after processing all contests. If None, the state is not saved.

Returns:
Dict[str, Player]: A dictionary mapping player names to their ratings.
"""

# Filter out contests with zero weight, as they can cause issues with the rating system.
filtered_contests = [
c for c in contests if c.rating_params and c.rating_params.weight > 0
]

rate_result = rust_rate(
system,
contests,
filtered_contests,
mu_noob=mu_noob,
sig_noob=sig_noob,
load_checkpoint=load_checkpoint,
Expand Down
61 changes: 58 additions & 3 deletions src/rust/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,39 @@ use pyo3::prelude::*;
use pyo3::wrap_pyfunction;
use multi_skill::systems::{PlayerEvent, get_rating_system_by_name};
use multi_skill::experiment_config::{Experiment, ExperimentResults};
use multi_skill::data_processing::{Dataset, Contest, ContestDataset, BoxedDataset, read_json, ContestRatingParams};
use multi_skill::data_processing::{
Contest, ContestDataset, ContestRatingParams, Dataset, BoxedDataset, read_json,
};

#[pyclass(name = "ContestRatingParams")]
#[derive(Clone, Copy)]
struct PyContestRatingParams {
inner: ContestRatingParams,
}

#[pymethods]
impl PyContestRatingParams {
#[new]
#[args(weight = "1.0", perf_ceiling = "f64::INFINITY")]
fn new(weight: f64, perf_ceiling: f64) -> PyResult<Self> {
Ok(PyContestRatingParams {
inner: ContestRatingParams {
weight,
perf_ceiling,
},
})
}

#[getter]
fn weight(&self) -> PyResult<f64> {
Ok(self.inner.weight)
}

#[getter]
fn perf_ceiling(&self) -> PyResult<f64> {
Ok(self.inner.perf_ceiling)
}
}

/// A Contest object represents a competition.
///
Expand All @@ -13,6 +45,8 @@ use multi_skill::data_processing::{Dataset, Contest, ContestDataset, BoxedDatase
/// name (Optional[str]): The name of the contest. Defaults to "".
/// time_seconds (Optional[int]): The duration of the contest in seconds. Defaults to 0.
/// url (Optional[str]): The URL of the contest. Defaults to None.
/// rating_params (Optional[PyContestRatingParams]): The rating parameters for the contest.
/// Defaults to ContestRatingParams(weight=1.0, perf_ceiling=inf).
#[pyclass(name="Contest")]
#[derive(Clone)]
struct PyContest {
Expand All @@ -22,14 +56,27 @@ struct PyContest {
#[pymethods]
impl PyContest {
#[new]
#[args(standings, name = "None", time_seconds = "None", url = "None")]
#[args(
standings,
name = "None",
time_seconds = "None",
url = "None",
rating_params = "None"
)]
fn new(
standings: Vec<(String, usize, usize)>,
name: Option<String>,
time_seconds: Option<u64>,
url: Option<String>,
rating_params: Option<PyContestRatingParams>,
) -> PyResult<Self> {
let rating_params = ContestRatingParams::default();
let rating_params = match rating_params {
Some(p) => p.inner,
None => ContestRatingParams {
weight: 1.0,
perf_ceiling: f64::INFINITY,
},
};
let name = name.unwrap_or("".to_string());
let time_seconds = time_seconds.unwrap_or(0);
Ok(PyContest {
Expand Down Expand Up @@ -62,6 +109,13 @@ impl PyContest {
fn url(&self) -> PyResult<Option<&str>> {
Ok(self.inner.url.as_deref())
}

#[getter]
fn rating_params(&self) -> PyResult<PyContestRatingParams> {
Ok(PyContestRatingParams {
inner: self.inner.rating_params,
})
}
}

struct ContestVec(Vec<Contest>);
Expand Down Expand Up @@ -215,5 +269,6 @@ fn elo_mmr_python_bindings(_py: Python, m: &PyModule) -> PyResult<()> {
m.add_class::<PyPlayerEvent>()?;
m.add_class::<PyRateResult>()?;
m.add_wrapped(wrap_pyfunction!(rate))?;
m.add_class::<PyContestRatingParams>()?;
Ok(())
}
78 changes: 77 additions & 1 deletion tests/test_elo_mmr_py.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
#!/usr/bin/env python3

import tempfile
import math

from elo_mmr_py import Contest, rate
from elo_mmr_py import Contest, ContestRatingParams, rate


def test_create_contest_with_standings():
Expand Down Expand Up @@ -33,6 +34,20 @@ def test_create_contest_with_url():
assert contest.url == url


def test_create_contest_with_rating_params():
rating_params = ContestRatingParams(weight=0.5, perf_ceiling=2000.0)
contest = Contest(standings=[], rating_params=rating_params)
assert isinstance(contest, Contest)
assert contest.rating_params.weight == 0.5
assert contest.rating_params.perf_ceiling == 2000.0


def test_default_rating_params():
contest = Contest(standings=[])
assert contest.rating_params.weight == 1.0
assert math.isinf(contest.rating_params.perf_ceiling)


def test_rate_contests():
contests = [
Contest(standings=[('player_1', 0, 0), ('player_2', 1, 1), ('player_3', 2, 2)]),
Expand All @@ -51,6 +66,67 @@ def test_rate_contests():
assert ranking == ['player_4', 'player_1', 'player_2', 'player_3']


def test_rate_contest_with_zero_weight():
contests = [
Contest(standings=[('player_1', 0, 0), ('player_2', 1, 1)]),
]
players_before = rate(contests)

# this contest should have no effect on ratings
contests_zero_weight = [
Contest(
standings=[('player_1', 0, 0), ('player_3', 1, 1)],
rating_params=ContestRatingParams(weight=0.0)
),
]
players_after = rate(contests + contests_zero_weight)

assert players_before['player_1'].rating == players_after['player_1'].rating
assert 'player_3' not in players_after


def test_rating_change_proportional_to_weight():
# Initial ratings are mu=1500, sig=350
mu_noob = 1500.0

# Test with weight=1.0
contests_full_weight = [
Contest(
standings=[('player_1', 0, 0), ('player_2', 1, 1)],
rating_params=ContestRatingParams(weight=1.0)
),
]
players_full_weight = rate(contests_full_weight, mu_noob=mu_noob)
rating_change_full_weight = players_full_weight['player_1'].rating - mu_noob

# Test with weight=0.5
contests_half_weight = [
Contest(
standings=[('player_1', 0, 0), ('player_2', 1, 1)],
rating_params=ContestRatingParams(weight=0.5)
),
]
players_half_weight = rate(contests_half_weight, mu_noob=mu_noob)
rating_change_half_weight = players_half_weight['player_1'].rating - mu_noob

# Test with weight=0.0
contests_zero_weight = [
Contest(
standings=[('player_1', 0, 0), ('player_2', 1, 1)],
rating_params=ContestRatingParams(weight=0.0)
),
]
players_zero_weight = rate(contests_zero_weight, mu_noob=mu_noob)

# A lower weight should result in a smaller rating change.
assert rating_change_half_weight > 0
assert rating_change_full_weight > rating_change_half_weight

# A zero weight should result in no rating change, and no players returned
assert 'player_1' not in players_zero_weight
assert len(players_zero_weight) == 0


def test_save_and_load_checkpoints():
contests = [
Contest(standings=[('player_1', 0, 0), ('player_2', 1, 1), ('player_3', 2, 2)]),
Expand Down