diff --git a/src/elo_mmr_py/__init__.py b/src/elo_mmr_py/__init__.py index cebf611..c0518f4 100644 --- a/src/elo_mmr_py/__init__.py +++ b/src/elo_mmr_py/__init__.py @@ -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 @@ -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. @@ -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, diff --git a/src/rust/lib.rs b/src/rust/lib.rs index 12a4679..3cab950 100644 --- a/src/rust/lib.rs +++ b/src/rust/lib.rs @@ -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 { + Ok(PyContestRatingParams { + inner: ContestRatingParams { + weight, + perf_ceiling, + }, + }) + } + + #[getter] + fn weight(&self) -> PyResult { + Ok(self.inner.weight) + } + + #[getter] + fn perf_ceiling(&self) -> PyResult { + Ok(self.inner.perf_ceiling) + } +} /// A Contest object represents a competition. /// @@ -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 { @@ -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, time_seconds: Option, url: Option, + rating_params: Option, ) -> PyResult { - 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 { @@ -62,6 +109,13 @@ impl PyContest { fn url(&self) -> PyResult> { Ok(self.inner.url.as_deref()) } + + #[getter] + fn rating_params(&self) -> PyResult { + Ok(PyContestRatingParams { + inner: self.inner.rating_params, + }) + } } struct ContestVec(Vec); @@ -215,5 +269,6 @@ fn elo_mmr_python_bindings(_py: Python, m: &PyModule) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_wrapped(wrap_pyfunction!(rate))?; + m.add_class::()?; Ok(()) } diff --git a/tests/test_elo_mmr_py.py b/tests/test_elo_mmr_py.py index e1b2c24..376e3ce 100644 --- a/tests/test_elo_mmr_py.py +++ b/tests/test_elo_mmr_py.py @@ -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(): @@ -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)]), @@ -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)]),