From f1b89f1989f748f16a5e653f6d482f66699a912d Mon Sep 17 00:00:00 2001 From: atomflunder <80397293+atomflunder@users.noreply.github.com> Date: Sun, 8 Oct 2023 21:46:30 +0200 Subject: [PATCH] Add expected_score_rating_period functions --- CHANGELOG.md | 4 ++ Cargo.toml | 2 +- README.md | 4 +- src/dwz.rs | 51 ++++++++++++++++++++++++ src/egf.rs | 65 +++++++++++++++++++++++++++++++ src/elo.rs | 39 +++++++++++++++++++ src/fifa.rs | 39 +++++++++++++++++++ src/glicko.rs | 53 +++++++++++++++++++++++++ src/glicko2.rs | 65 +++++++++++++++++++++++++++++++ src/glicko_boost.rs | 95 +++++++++++++++++++++++++++++++++++++++++++++ src/ingo.rs | 48 +++++++++++++++++++++++ src/lib.rs | 19 +++++---- src/sticko.rs | 85 ++++++++++++++++++++++++++++++++++++++++ src/trueskill.rs | 72 +++++++++++++++++++++++++++++++++- src/uscf.rs | 48 +++++++++++++++++++++++ src/weng_lin.rs | 54 ++++++++++++++++++++++++++ 16 files changed, 731 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ad1511..3edd29f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ This is a broad overview of the changes that have been made over the lifespan of this library. +## v0.26.0 - 2023-10-08 + +- Add expected_score_rating_period functions for all rating systems + ## v0.25.1 - 2023-10-05 - Overhaul documentation diff --git a/Cargo.toml b/Cargo.toml index 83f3209..fcf37d4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "skillratings" -version = "0.25.1" +version = "0.26.0" edition = "2021" description = "Calculate a player's skill rating using algorithms like Elo, Glicko, Glicko-2, TrueSkill and many more." readme = "README.md" diff --git a/README.md b/README.md index 0b51572..9f11da7 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ Alternatively, you can add the following to your `Cargo.toml` file manually: ```toml [dependencies] -skillratings = "0.25" +skillratings = "0.26" ``` ### Serde support @@ -71,7 +71,7 @@ By editing `Cargo.toml` manually: ```toml [dependencies] -skillratings = {version = "0.25", features = ["serde"]} +skillratings = {version = "0.26", features = ["serde"]} ``` ## Usage and Examples diff --git a/src/dwz.rs b/src/dwz.rs index 25f167a..04ad45b 100644 --- a/src/dwz.rs +++ b/src/dwz.rs @@ -197,6 +197,10 @@ impl RatingPeriodSystem for DWZ { fn rate(&self, player: &DWZRating, results: &[(DWZRating, Outcomes)]) -> DWZRating { dwz_rating_period(player, results) } + + fn expected_score(&self, player: &Self::RATING, opponents: &[Self::RATING]) -> Vec { + expected_score_rating_period(player, opponents) + } } #[must_use] @@ -394,6 +398,48 @@ pub fn expected_score(player_one: &DWZRating, player_two: &DWZRating) -> (f64, f (exp_one, exp_two) } +#[must_use] +/// Calculates the expected outcome of a player in a rating period or tournament. +/// +/// Takes in a players as [`DWZRating`] and a list of opponents as a slice of [`DWZRating`] +/// and returns the probability of victory for each match as an Vec of [`f64`] between 1.0 and 0.0 from the perspective of the player. +/// 1.0 means a certain victory for the player, 0.0 means certain loss. +/// Values near 0.5 mean a draw is likely to occur. +/// +/// # Examples +/// ``` +/// use skillratings::dwz::{expected_score_rating_period, DWZRating}; +/// +/// let player = DWZRating { +/// rating: 1900.0, +/// index: 42, +/// age: 42, +/// }; +/// +/// let opponent1 = DWZRating { +/// rating: 1930.0, +/// index: 103, +/// age: 39, +/// }; +/// +/// let opponent2 = DWZRating { +/// rating: 1730.0, +/// index: 92, +/// age: 14, +/// }; +/// +/// let exp = expected_score_rating_period(&player, &[opponent1, opponent2]); +/// +/// assert_eq!((exp[0] * 100.0).round(), 46.0); +/// assert_eq!((exp[1] * 100.0).round(), 73.0); +/// ``` +pub fn expected_score_rating_period(player: &DWZRating, opponents: &[DWZRating]) -> Vec { + opponents + .iter() + .map(|o| (1.0 + 10.0_f64.powf(-(400.0_f64.recip()) * (player.rating - o.rating))).recip()) + .collect() +} + /// Gets a proper first [`DWZRating`]. /// /// In the case that you do not have enough opponents to rate a player against, @@ -997,6 +1043,11 @@ mod tests { assert!((exp1 - 0.5).abs() < f64::EPSILON); assert!((exp2 - 0.5).abs() < f64::EPSILON); + let rating_period_system: DWZ = RatingPeriodSystem::new(()); + let exp_rp = + RatingPeriodSystem::expected_score(&rating_period_system, &player_one, &[player_two]); + assert!((exp1 - exp_rp[0]).abs() < f64::EPSILON); + let player_one: DWZRating = Rating::new(Some(240.0), Some(90.0)); let player_two: DWZRating = Rating::new(Some(240.0), Some(90.0)); diff --git a/src/egf.rs b/src/egf.rs index 280079d..1fc89b1 100644 --- a/src/egf.rs +++ b/src/egf.rs @@ -180,6 +180,13 @@ impl RatingPeriodSystem for EGF { egf_rating_period(player, &new_results[..]) } + + fn expected_score(&self, player: &Self::RATING, opponents: &[Self::RATING]) -> Vec { + let new_opponents: Vec<(EGFRating, EGFConfig)> = + opponents.iter().map(|o| (*o, self.config)).collect(); + + expected_score_rating_period(player, &new_opponents) + } } #[must_use] @@ -356,6 +363,54 @@ pub fn expected_score( (exp_one, 1.0 - exp_one) } +#[must_use] +/// Calculates the expected outcome of a player in a rating period or tournament. +/// +/// Takes in a players as [`EGFRating`] and a list of opponents as a slice of Tuples of [`EGFRating`]s and [`EGFConfig`]s +/// and returns the probability of victory for each match as an Vec of [`f64`] between 1.0 and 0.0 from the perspective of the player. +/// 1.0 means a certain victory for the player, 0.0 means certain loss. +/// Values near 0.5 mean a draw is likely to occur. +/// +/// --- +/// +/// 📌 _**Important note:**_ The parameters intentionally work different from other expected_score_rating_period functions here. +/// In most cases the config is not used, however it is required here, because of the handicaps that can change from game-to-game. +/// +/// --- +/// +/// # Examples +/// ``` +/// use skillratings::egf::{expected_score_rating_period, EGFConfig, EGFRating}; +/// +/// let player = EGFRating { rating: 900.0 }; +/// +/// let opponent1 = (EGFRating { rating: 930.0 }, EGFConfig::new()); +/// +/// let opponent2 = (EGFRating { rating: 730.0 }, EGFConfig::new()); +/// +/// let exp = expected_score_rating_period(&player, &[opponent1, opponent2]); +/// +/// assert_eq!((exp[0] * 100.0).round(), 48.0); +/// assert_eq!((exp[1] * 100.0).round(), 62.0); +/// ``` +pub fn expected_score_rating_period( + player: &EGFRating, + opponents: &[(EGFRating, EGFConfig)], +) -> Vec { + opponents + .iter() + .map(|o| { + let (h1, h2) = if o.1.handicap.is_sign_negative() { + (o.1.handicap.abs(), 0.0) + } else { + (0.0, o.1.handicap.abs()) + }; + + (1.0 + (beta(o.0.rating, h2) - beta(player.rating, h1)).exp()).recip() + }) + .collect() +} + fn new_rating(rating: f64, con: f64, score: f64, exp_score: f64, bonus: f64) -> f64 { // The absolute minimum rating is set to be -900. (con.mul_add(score - exp_score, rating) + bonus).max(-900.0) @@ -498,6 +553,16 @@ mod tests { assert!((exp1 - 0.5).abs() < f64::EPSILON); assert!((exp2 - 0.5).abs() < f64::EPSILON); + let rating_period_system: EGF = RatingPeriodSystem::new(EGFConfig::new()); + let exp_rp = + RatingPeriodSystem::expected_score(&rating_period_system, &player_one, &[player_two]); + assert!((exp1 - exp_rp[0]).abs() < f64::EPSILON); + + let rating_period_system2: EGF = RatingPeriodSystem::new(EGFConfig { handicap: -0.0 }); + let exp_rp = + RatingPeriodSystem::expected_score(&rating_period_system2, &player_one, &[player_two]); + assert!((exp1 - exp_rp[0]).abs() < f64::EPSILON); + let player_one: EGFRating = Rating::new(Some(240.0), Some(90.0)); let player_two: EGFRating = Rating::new(Some(240.0), Some(90.0)); diff --git a/src/elo.rs b/src/elo.rs index 531a820..67d4d53 100644 --- a/src/elo.rs +++ b/src/elo.rs @@ -192,6 +192,10 @@ impl RatingPeriodSystem for Elo { fn rate(&self, player: &EloRating, results: &[(EloRating, Outcomes)]) -> EloRating { elo_rating_period(player, results, &self.config) } + + fn expected_score(&self, player: &Self::RATING, opponents: &[Self::RATING]) -> Vec { + expected_score_rating_period(player, opponents) + } } /// Calculates the [`EloRating`]s of two players based on their old ratings and the outcome of the game. @@ -330,6 +334,36 @@ pub fn expected_score(player_one: &EloRating, player_two: &EloRating) -> (f64, f (exp_one, exp_two) } +/// Calculates the expected outcome of a player in a rating period or tournament. +/// +/// Takes in a players as [`EloRating`] and a list of opponents as a slice of [`EloRating`] +/// and returns the probability of victory for each match as an Vec of [`f64`] between 1.0 and 0.0 from the perspective of the player. +/// 1.0 means a certain victory for the player, 0.0 means certain loss. +/// Values near 0.5 mean a draw is likely to occur. +/// +/// # Examples +/// ``` +/// use skillratings::elo::{expected_score_rating_period, EloRating}; +/// +/// let player = EloRating { rating: 1900.0 }; +/// +/// let opponent1 = EloRating { rating: 1930.0 }; +/// +/// let opponent2 = EloRating { rating: 1730.0 }; +/// +/// let exp = expected_score_rating_period(&player, &[opponent1, opponent2]); +/// +/// assert_eq!((exp[0] * 100.0).round(), 46.0); +/// assert_eq!((exp[1] * 100.0).round(), 73.0); +/// ``` +#[must_use] +pub fn expected_score_rating_period(player: &EloRating, opponents: &[EloRating]) -> Vec { + opponents + .iter() + .map(|o| (1.0 + 10_f64.powf((o.rating - player.rating) / 400.0)).recip()) + .collect() +} + #[cfg(test)] mod tests { use super::*; @@ -450,6 +484,11 @@ mod tests { assert!((exp1 - 0.5).abs() < f64::EPSILON); assert!((exp2 - 0.5).abs() < f64::EPSILON); + let rating_period_system: Elo = RatingPeriodSystem::new(EloConfig::new()); + let exp_rp = + RatingPeriodSystem::expected_score(&rating_period_system, &player_one, &[player_two]); + assert!((exp1 - exp_rp[0]).abs() < f64::EPSILON); + let player_one: EloRating = Rating::new(Some(240.0), Some(90.0)); let player_two: EloRating = Rating::new(Some(240.0), Some(90.0)); diff --git a/src/fifa.rs b/src/fifa.rs index 6cf2c64..aae6d76 100644 --- a/src/fifa.rs +++ b/src/fifa.rs @@ -212,6 +212,10 @@ impl RatingPeriodSystem for Fifa { fifa_rating_period(player, &new_results[..]) } + + fn expected_score(&self, player: &Self::RATING, opponents: &[Self::RATING]) -> Vec { + expected_score_rating_period(player, opponents) + } } #[must_use] @@ -414,6 +418,36 @@ pub fn expected_score(player_one: &FifaRating, player_two: &FifaRating) -> (f64, (exp_one, exp_two) } +#[must_use] +/// Calculates the expected outcome of a player in a rating period or tournament. +/// +/// Takes in a players as [`FifaRating`] and a list of opponents as a slice of [`FifaRating`] +/// and returns the probability of victory for each match as an Vec of [`f64`] between 1.0 and 0.0 from the perspective of the player. +/// 1.0 means a certain victory for the player, 0.0 means certain loss. +/// Values near 0.5 mean a draw is likely to occur. +/// +/// # Examples +/// ``` +/// use skillratings::fifa::{expected_score_rating_period, FifaRating}; +/// +/// let player = FifaRating { rating: 1900.0 }; +/// +/// let opponent1 = FifaRating { rating: 1930.0 }; +/// +/// let opponent2 = FifaRating { rating: 1730.0 }; +/// +/// let exp = expected_score_rating_period(&player, &[opponent1, opponent2]); +/// +/// assert_eq!((exp[0] * 100.0).round(), 47.0); +/// assert_eq!((exp[1] * 100.0).round(), 66.0); +/// ``` +pub fn expected_score_rating_period(player: &FifaRating, opponents: &[FifaRating]) -> Vec { + opponents + .iter() + .map(|o| (1.0 + 10_f64.powf(-(player.rating - o.rating) / 600.0)).recip()) + .collect() +} + #[cfg(test)] mod tests { use super::*; @@ -552,6 +586,11 @@ mod tests { assert!((exp1 - 0.5).abs() < f64::EPSILON); assert!((exp2 - 0.5).abs() < f64::EPSILON); + let rating_period_system: Fifa = RatingPeriodSystem::new(FifaConfig::new()); + let exp_rp = + RatingPeriodSystem::expected_score(&rating_period_system, &player_one, &[player_two]); + assert!((exp1 - exp_rp[0]).abs() < f64::EPSILON); + let player_one: FifaRating = Rating::new(Some(240.0), Some(90.0)); let player_two: FifaRating = Rating::new(Some(240.0), Some(90.0)); diff --git a/src/glicko.rs b/src/glicko.rs index 6721cf5..5be7900 100644 --- a/src/glicko.rs +++ b/src/glicko.rs @@ -203,6 +203,10 @@ impl RatingPeriodSystem for Glicko { fn rate(&self, player: &GlickoRating, results: &[(GlickoRating, Outcomes)]) -> GlickoRating { glicko_rating_period(player, results, &self.config) } + + fn expected_score(&self, player: &Self::RATING, opponents: &[Self::RATING]) -> Vec { + expected_score_rating_period(player, opponents) + } } #[must_use] @@ -443,6 +447,50 @@ pub fn expected_score(player_one: &GlickoRating, player_two: &GlickoRating) -> ( (exp_one, exp_two) } +#[must_use] +/// Calculates the expected outcome of a player in a rating period or tournament. +/// +/// Takes in a players as [`GlickoRating`] and a list of opponents as a slice of [`GlickoRating`] +/// and returns the probability of victory for each match as an Vec of [`f64`] between 1.0 and 0.0 from the perspective of the player. +/// 1.0 means a certain victory for the player, 0.0 means certain loss. +/// Values near 0.5 mean a draw is likely to occur. +/// +/// # Examples +/// ``` +/// use skillratings::glicko::{expected_score_rating_period, GlickoRating}; +/// +/// let player = GlickoRating { +/// rating: 1900.0, +/// deviation: 120.0, +/// }; +/// +/// let opponent1 = GlickoRating { +/// rating: 1930.0, +/// deviation: 120.0, +/// }; +/// +/// let opponent2 = GlickoRating { +/// rating: 1730.0, +/// deviation: 120.0, +/// }; +/// +/// let exp = expected_score_rating_period(&player, &[opponent1, opponent2]); +/// +/// assert_eq!((exp[0] * 100.0).round(), 46.0); +/// assert_eq!((exp[1] * 100.0).round(), 70.0); +/// ``` +pub fn expected_score_rating_period(player: &GlickoRating, opponents: &[GlickoRating]) -> Vec { + opponents + .iter() + .map(|o| { + let q = 10_f64.ln() / 400.0; + let g = g_value(q, player.deviation.hypot(o.deviation)); + + (1.0 + 10_f64.powf(-g * (player.rating - o.rating) / 400.0)).recip() + }) + .collect() +} + #[must_use] /// Decays a Rating Deviation Value for a player, if they missed playing in a certain rating period. /// @@ -765,6 +813,11 @@ mod tests { assert!((exp1 - 0.5).abs() < f64::EPSILON); assert!((exp2 - 0.5).abs() < f64::EPSILON); + let rating_period_system: Glicko = RatingPeriodSystem::new(GlickoConfig::new()); + let exp_rp = + RatingPeriodSystem::expected_score(&rating_period_system, &player_one, &[player_two]); + assert!((exp1 - exp_rp[0]).abs() < f64::EPSILON); + let player_one: GlickoRating = Rating::new(Some(240.0), Some(90.0)); let player_two: GlickoRating = Rating::new(Some(240.0), Some(90.0)); diff --git a/src/glicko2.rs b/src/glicko2.rs index d4f3157..f4dfeb3 100644 --- a/src/glicko2.rs +++ b/src/glicko2.rs @@ -224,6 +224,10 @@ impl RatingPeriodSystem for Glicko2 { fn rate(&self, player: &Glicko2Rating, results: &[(Glicko2Rating, Outcomes)]) -> Glicko2Rating { glicko2_rating_period(player, results, &self.config) } + + fn expected_score(&self, player: &Self::RATING, opponents: &[Self::RATING]) -> Vec { + expected_score_rating_period(player, opponents) + } } /// Calculates the [`Glicko2Rating`]s of two players based on their old ratings, deviations, volatilities, and the outcome of the game. @@ -497,6 +501,62 @@ pub fn expected_score(player_one: &Glicko2Rating, player_two: &Glicko2Rating) -> (exp_one, exp_two) } +#[must_use] +/// Calculates the expected outcome of a player in a rating period or tournament. +/// +/// Takes in a players as [`Glicko2Rating`] and a list of opponents as a slice of [`Glicko2Rating`] +/// and returns the probability of victory for each match as an Vec of [`f64`] between 1.0 and 0.0 from the perspective of the player. +/// 1.0 means a certain victory for the player, 0.0 means certain loss. +/// Values near 0.5 mean a draw is likely to occur. +/// +/// # Examples +/// ``` +/// use skillratings::glicko2::{expected_score_rating_period, Glicko2Rating}; +/// +/// let player = Glicko2Rating { +/// rating: 1900.0, +/// deviation: 120.0, +/// volatility: 0.00583, +/// }; +/// +/// let opponent1 = Glicko2Rating { +/// rating: 1930.0, +/// deviation: 120.0, +/// volatility: 0.00583, +/// }; +/// +/// let opponent2 = Glicko2Rating { +/// rating: 1730.0, +/// deviation: 120.0, +/// volatility: 0.00583, +/// }; +/// +/// let exp = expected_score_rating_period(&player, &[opponent1, opponent2]); +/// +/// assert_eq!((exp[0] * 100.0).round(), 46.0); +/// assert_eq!((exp[1] * 100.0).round(), 70.0); +/// ``` +pub fn expected_score_rating_period( + player: &Glicko2Rating, + opponents: &[Glicko2Rating], +) -> Vec { + opponents + .iter() + .map(|o| { + let player_one_rating = (player.rating - 1500.0) / 173.7178; + let player_two_rating = (o.rating - 1500.0) / 173.7178; + + let player_one_deviation = player.deviation / 173.7178; + let player_two_deviation = o.deviation / 173.7178; + + let a1 = g_value(player_two_deviation.hypot(player_one_deviation)) + * (player_one_rating - player_two_rating); + + (1.0 + (-a1).exp()).recip() + }) + .collect() +} + /// Decays a Rating Deviation Value for a player, if they missed playing in a certain rating period. /// /// The length of the rating period and thus the number of missed periods per player is something to decide and track yourself. @@ -1047,6 +1107,11 @@ mod tests { assert!((exp1 - 0.5).abs() < f64::EPSILON); assert!((exp2 - 0.5).abs() < f64::EPSILON); + let rating_period_system: Glicko2 = RatingPeriodSystem::new(Glicko2Config::new()); + let exp_rp = + RatingPeriodSystem::expected_score(&rating_period_system, &player_one, &[player_two]); + assert!((exp1 - exp_rp[0]).abs() < f64::EPSILON); + let player_one: Glicko2Rating = Rating::new(Some(240.0), Some(90.0)); let player_two: Glicko2Rating = Rating::new(Some(240.0), Some(90.0)); diff --git a/src/glicko_boost.rs b/src/glicko_boost.rs index 64bf2c8..7cd87de 100644 --- a/src/glicko_boost.rs +++ b/src/glicko_boost.rs @@ -273,6 +273,13 @@ impl RatingPeriodSystem for GlickoBoost { glicko_boost_rating_period(player, &new_results[..], &self.config) } + + fn expected_score(&self, player: &Self::RATING, opponents: &[Self::RATING]) -> Vec { + let new_opponents: Vec<(GlickoBoostRating, bool)> = + opponents.iter().map(|o| (*o, true)).collect(); + + expected_score_rating_period(player, &new_opponents, &self.config) + } } #[must_use] @@ -620,6 +627,89 @@ pub fn expected_score( (exp_one, exp_two) } +#[must_use] +/// Calculates the expected outcome of a player in a rating period or tournament. +/// +/// Takes in a player as an [`GlickoBoostRating`] and their results as a Slice of tuples containing the opponent as a [`GlickoBoostRating`], +/// and a [`bool`] specifying if the player was playing as player one, and a [`GlickoBoostConfig`]. +/// +/// --- +/// +/// 📌 _**Important note:**_ The parameters intentionally work different from other expected_score_rating_period functions here. +/// An additional config is used, because of the set advantage parameter that describes inherit imbalances, like playing White in Chess +/// or a Football team playing at home. +/// +/// Because of those advantages, we also need a boolean which specifies if the player was playing as the first / advantaged player (e.g. White in Chess). +/// If set to `true` the player was playing with the advantage, if set to `false` the player was with the disadvantage. +/// +/// If the config is set to not have any advantages, the boolean will not matter. +/// +/// --- +/// +/// The outcome of the match is in the perspective of the player. +/// This means [`Outcomes::WIN`] is a win for the player and [`Outcomes::LOSS`] is a win for the opponent. +/// +/// If the player's results are empty, the player's rating deviation will automatically be decayed using [`decay_deviation`]. +/// +/// # Examples +/// ``` +/// use skillratings::{ +/// glicko_boost::{expected_score_rating_period, GlickoBoostConfig, GlickoBoostRating}, +/// Outcomes, +/// }; +/// +/// let player = GlickoBoostRating { +/// rating: 1500.0, +/// deviation: 200.0, +/// }; +/// +/// let opponent1 = GlickoBoostRating { +/// rating: 1400.0, +/// deviation: 30.0, +/// }; +/// +/// let opponent2 = GlickoBoostRating { +/// rating: 1550.0, +/// deviation: 100.0, +/// }; +/// +/// let opponent3 = GlickoBoostRating { +/// rating: 1700.0, +/// deviation: 300.0, +/// }; +/// +/// let results = vec![ +/// // The player was playing as white. +/// (opponent1, true), +/// // The player was playing as black. +/// (opponent2, false), +/// (opponent3, true), +/// ]; +/// +/// let config = GlickoBoostConfig::new(); +/// +/// let exp = expected_score_rating_period(&player, &results, &config); +/// +/// assert_eq!((exp[0] * 100.0).round(), 65.0); +/// assert_eq!((exp[1] * 100.0).round(), 48.0); +/// assert_eq!((exp[2] * 100.0).round(), 34.0); +/// ``` +pub fn expected_score_rating_period( + player: &GlickoBoostRating, + opponents: &[(GlickoBoostRating, bool)], + config: &GlickoBoostConfig, +) -> Vec { + opponents + .iter() + .map(|o| { + let q = 10_f64.ln() / 400.0; + let g = g_value(q, player.deviation.hypot(o.0.deviation)); + + (1.0 + 10_f64.powf(-g * (player.rating + config.eta - o.0.rating) / 400.0)).recip() + }) + .collect() +} + #[must_use] /// Decays a Rating Deviation Value for a player, if they missed playing in a certain rating period. /// @@ -1124,6 +1214,11 @@ mod tests { assert!((exp1 - 0.539_945_539_565_174_9).abs() < f64::EPSILON); assert!((exp2 - 0.460_054_460_434_825_1).abs() < f64::EPSILON); + let rating_period_system: GlickoBoost = RatingPeriodSystem::new(GlickoBoostConfig::new()); + let exp_rp = + RatingPeriodSystem::expected_score(&rating_period_system, &player_one, &[player_two]); + assert!((exp1 - exp_rp[0]).abs() < f64::EPSILON); + let player_one: GlickoBoostRating = Rating::new(Some(240.0), Some(90.0)); let player_two: GlickoBoostRating = Rating::new(Some(240.0), Some(90.0)); diff --git a/src/ingo.rs b/src/ingo.rs index 28a9191..67d4427 100644 --- a/src/ingo.rs +++ b/src/ingo.rs @@ -156,6 +156,10 @@ impl RatingPeriodSystem for Ingo { fn rate(&self, player: &IngoRating, results: &[(IngoRating, Outcomes)]) -> IngoRating { ingo_rating_period(player, results) } + + fn expected_score(&self, player: &Self::RATING, opponents: &[Self::RATING]) -> Vec { + expected_score_rating_period(player, opponents) + } } #[must_use] @@ -333,6 +337,45 @@ pub fn expected_score(player_one: &IngoRating, player_two: &IngoRating) -> (f64, (exp_one, exp_two) } +#[must_use] +/// Calculates the expected outcome of a player in a rating period or tournament. +/// +/// Takes in a players as [`IngoRating`] and a list of opponents as a slice of [`IngoRating`] +/// and returns the probability of victory for each match as an Vec of [`f64`] between 1.0 and 0.0 from the perspective of the player. +/// 1.0 means a certain victory for the player, 0.0 means certain loss. +/// Values near 0.5 mean a draw is likely to occur. +/// +/// # Examples +/// ``` +/// use skillratings::ingo::{expected_score_rating_period, IngoRating}; +/// +/// let player = IngoRating { +/// rating: 190.0, +/// age: 40, +/// }; +/// +/// let opponent1 = IngoRating { +/// rating: 193.0, +/// age: 40, +/// }; +/// +/// let opponent2 = IngoRating { +/// rating: 173.0, +/// age: 40, +/// }; +/// +/// let exp = expected_score_rating_period(&player, &[opponent1, opponent2]); +/// +/// assert_eq!((exp[0] * 100.0).round(), 53.0); +/// assert_eq!((exp[1] * 100.0).round(), 33.0); +/// ``` +pub fn expected_score_rating_period(player: &IngoRating, opponents: &[IngoRating]) -> Vec { + opponents + .iter() + .map(|o| 0.5 + (o.rating - player.rating) / 100.0) + .collect() +} + fn performance(average_rating: f64, score: f64) -> f64 { average_rating - 100.0f64.mul_add(score, -50.0) } @@ -503,6 +546,11 @@ mod tests { assert!((exp1 - 0.5).abs() < f64::EPSILON); assert!((exp2 - 0.5).abs() < f64::EPSILON); + let rating_period_system: Ingo = RatingPeriodSystem::new(()); + let exp_rp = + RatingPeriodSystem::expected_score(&rating_period_system, &player_one, &[player_two]); + assert!((exp1 - exp_rp[0]).abs() < f64::EPSILON); + let player_one: IngoRating = Rating::new(Some(240.0), Some(90.0)); let player_two: IngoRating = Rating::new(Some(240.0), Some(90.0)); diff --git a/src/lib.rs b/src/lib.rs index 1f3e6d0..178d5ed 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -63,7 +63,7 @@ //! //! ```toml //! [dependencies] -//! skillratings = "0.25" +//! skillratings = "0.26" //! ``` //! //! ### Serde support @@ -80,7 +80,7 @@ //! //! ```toml //! [dependencies] -//! skillratings = {version = "0.25", features = ["serde"]} +//! skillratings = {version = "0.26", features = ["serde"]} //! ``` //! //! ## Usage and Examples @@ -332,7 +332,8 @@ //! // When you swap rating systems, make sure to update the config. //! let config = Glicko2Config::new(); //! -//! // We want to rate 1v1 matches here so we are using the `RatingSystem` trait. +//! // For 1v1 matches we are using the `RatingSystem` trait with the provided config. +//! // If no config is available for the rating system, pass in empty brackets. //! // You may also need to use a type annotation here for the compiler. //! let rating_system: Glicko2 = RatingSystem::new(config); //! @@ -461,6 +462,9 @@ impl From for usize { /// /// 📌 _**Important note:**_ Please keep in mind that some rating systems use widely different scales for measuring ratings. /// Please check out the documentation for each rating system for more information, or use `None` to always use default values. +/// +/// Some rating systems might consider other values too (volatility, age, matches played etc.). +/// If that is the case, we will use the default values for those. pub trait Rating { /// A single value for player's skill fn rating(&self) -> f64; @@ -499,7 +503,7 @@ pub trait RatingSystem { /// Rating system for rating periods. /// -/// 📌 _**Important note:**_ The RatingPeriodSystem Trait only implements the `rate` function. +/// 📌 _**Important note:**_ The RatingPeriodSystem Trait only implements the `rate` and `expected_score` functions. /// Some rating systems might also implement additional functions which you can only access by using those directly. pub trait RatingPeriodSystem { #[cfg(feature = "serde")] @@ -511,9 +515,10 @@ pub trait RatingPeriodSystem { type CONFIG; /// Initialise rating system with provided config. If the rating system does not require a config, leave empty brackets. fn new(config: Self::CONFIG) -> Self; - /// Calculate ratings for two players based on provided ratings and outcome. + /// Calculate ratings for a player based on provided list of opponents and outcomes. fn rate(&self, player: &Self::RATING, results: &[(Self::RATING, Outcomes)]) -> Self::RATING; - // TODO: Add expected_score functions for rating periods? + /// Calculate expected scores for a player and a list of opponents. Returns probabilities of the player winning from 0.0 to 1.0. + fn expected_score(&self, player: &Self::RATING, opponents: &[Self::RATING]) -> Vec; } /// Rating system for two teams. @@ -543,7 +548,7 @@ pub trait TeamRatingSystem { /// Rating system for more than two teams. /// -/// 📌 _**Important note:**_ The MultiTeamRatinngSystem Trait only implements the `rate` and `expected_score` functions. +/// 📌 _**Important note:**_ The MultiTeamRatingSystem Trait only implements the `rate` and `expected_score` functions. /// Some rating systems might also implement additional functions which you can only access by using those directly. pub trait MultiTeamRatingSystem { #[cfg(feature = "serde")] diff --git a/src/sticko.rs b/src/sticko.rs index f61e89d..513405e 100644 --- a/src/sticko.rs +++ b/src/sticko.rs @@ -265,6 +265,13 @@ impl RatingPeriodSystem for Sticko { sticko_rating_period(player, &new_results[..], &self.config) } + + fn expected_score(&self, player: &Self::RATING, opponents: &[Self::RATING]) -> Vec { + let new_opponents: Vec<(StickoRating, bool)> = + opponents.iter().map(|o| (*o, true)).collect(); + + expected_score_rating_period(player, &new_opponents, &self.config) + } } #[must_use] @@ -581,6 +588,79 @@ pub fn expected_score( (exp_one, exp_two) } +#[must_use] +/// Calculates the expected outcome of a player in a rating period or tournament. +/// +/// Takes in a players as [`StickoRating`] and a list of opponents as a slice of [`StickoRating`] +/// and returns the probability of victory for each match as an Vec of [`f64`] between 1.0 and 0.0 from the perspective of the player. +/// 1.0 means a certain victory for the player, 0.0 means certain loss. +/// Values near 0.5 mean a draw is likely to occur. +/// +/// --- +/// +/// 📌 _**Important note:**_ The parameters intentionally work different from other expected_score_rating_period functions here. +/// An additional config is used, because of the set advantage parameter that describes inherit imbalances, like playing White in Chess +/// or a Football team playing at home. +/// +/// Because of those advantages, we also need a boolean which specifies if the player was playing as the first / advantaged player (e.g. White in Chess). +/// If set to `true` the player was playing with the advantage, if set to `false` the player was with the disadvantage. +/// +/// If the config is set to not have any advantages, the boolean will not matter. +/// +/// --- +/// +/// +/// # Examples +/// ``` +/// use skillratings::sticko::{expected_score_rating_period, StickoConfig, StickoRating}; +/// +/// let player = StickoRating { +/// rating: 1900.0, +/// deviation: 120.0, +/// }; +/// +/// let opponent1 = StickoRating { +/// rating: 1930.0, +/// deviation: 120.0, +/// }; +/// +/// let opponent2 = StickoRating { +/// rating: 1730.0, +/// deviation: 120.0, +/// }; +/// +/// let results = [ +/// // Playing as White in Chess. +/// (opponent1, true), +/// // Playing as Black in Chess. +/// (opponent2, false), +/// ]; +/// +/// let config = StickoConfig::new(); +/// +/// let exp = expected_score_rating_period(&player, &results, &config); +/// +/// assert_eq!((exp[0] * 100.0).round(), 46.0); +/// assert_eq!((exp[1] * 100.0).round(), 70.0); +/// ``` +pub fn expected_score_rating_period( + player: &StickoRating, + opponents: &[(StickoRating, bool)], + config: &StickoConfig, +) -> Vec { + opponents + .iter() + .map(|o| { + let q = 10_f64.ln() / 400.0; + let g = g_value(q, player.deviation.hypot(o.0.deviation)); + + let gamma = if o.1 { config.gamma } else { -config.gamma }; + + (1.0 + 10_f64.powf(-g * (player.rating + gamma - o.0.rating) / 400.0)).recip() + }) + .collect() +} + #[must_use] /// Decays a Rating Deviation Value for a player, if they missed playing in a certain rating period. /// @@ -980,6 +1060,11 @@ mod tests { assert!((exp1 - 0.5).abs() < f64::EPSILON); assert!((exp2 - 0.5).abs() < f64::EPSILON); + let rating_period_system: Sticko = RatingPeriodSystem::new(StickoConfig::new()); + let exp_rp = + RatingPeriodSystem::expected_score(&rating_period_system, &player_one, &[player_two]); + assert!((exp1 - exp_rp[0]).abs() < f64::EPSILON); + let player_one: StickoRating = Rating::new(Some(240.0), Some(90.0)); let player_two: StickoRating = Rating::new(Some(240.0), Some(90.0)); diff --git a/src/trueskill.rs b/src/trueskill.rs index 25d953c..edd1783 100644 --- a/src/trueskill.rs +++ b/src/trueskill.rs @@ -227,6 +227,10 @@ impl RatingPeriodSystem for TrueSkill { ) -> TrueSkillRating { trueskill_rating_period(player, results, &self.config) } + + fn expected_score(&self, player: &Self::RATING, opponents: &[Self::RATING]) -> Vec { + expected_score_rating_period(player, opponents, &self.config) + } } impl TeamRatingSystem for TrueSkill { @@ -726,7 +730,8 @@ pub fn match_quality_two_teams( #[must_use] /// Calculates the expected outcome of two players based on TrueSkill. /// -/// Takes in two players as [`TrueSkillRating`]s and returns the probability of victory for each player as an [`f64`] between 1.0 and 0.0. +/// Takes in two players as [`TrueSkillRating`]s and a [`TrueSkillConfig`] +/// and returns the probability of victory for each player as an [`f64`] between 1.0 and 0.0. /// 1.0 means a certain victory for the player, 0.0 means certain loss. /// Values near 0.5 mean a draw is likely to occur. /// @@ -781,7 +786,8 @@ pub fn expected_score( #[must_use] /// Calculates the expected outcome of two teams based on TrueSkill. /// -/// Takes in two teams as a Slice of [`TrueSkillRating`]s and returns the probability of victory for each player as an [`f64`] between 1.0 and 0.0. +/// Takes in two teams as a Slice of [`TrueSkillRating`]s and a [`TrueSkillConfig`] +/// and returns the probability of victory for each player as an [`f64`] between 1.0 and 0.0. /// 1.0 means a certain victory for the player, 0.0 means certain loss. /// Values near 0.5 mean a draw is likely to occur. /// @@ -848,6 +854,63 @@ pub fn expected_score_two_teams( (exp_one, exp_two) } +#[must_use] +/// Calculates the expected outcome of a player in a rating period or tournament. +/// +/// Takes in a players as [`TrueSkillRating`], a list of opponents as a slice of [`TrueSkillRating`] and a [`TrueSkillConfig`] +/// and returns the probability of victory for each match as an Vec of [`f64`] between 1.0 and 0.0 from the perspective of the player. +/// 1.0 means a certain victory for the player, 0.0 means certain loss. +/// Values near 0.5 mean a draw is likely to occur. +/// +/// # Examples +/// ``` +/// use skillratings::trueskill::{expected_score_rating_period, TrueSkillConfig, TrueSkillRating}; +/// +/// let player = TrueSkillRating { +/// rating: 19.0, +/// uncertainty: 4.0, +/// }; +/// +/// let opponent1 = TrueSkillRating { +/// rating: 19.3, +/// uncertainty: 4.0, +/// }; +/// +/// let opponent2 = TrueSkillRating { +/// rating: 17.3, +/// uncertainty: 4.0, +/// }; +/// +/// let config = TrueSkillConfig::new(); +/// +/// let exp = expected_score_rating_period(&player, &[opponent1, opponent2], &config); +/// +/// assert_eq!((exp[0] * 100.0).round(), 49.0); +/// assert_eq!((exp[1] * 100.0).round(), 58.0); +/// ``` +pub fn expected_score_rating_period( + player: &TrueSkillRating, + opponents: &[TrueSkillRating], + config: &TrueSkillConfig, +) -> Vec { + opponents + .iter() + .map(|o| { + let delta = player.rating - o.rating; + + let denom = o + .uncertainty + .mul_add( + o.uncertainty, + 2.0f64.mul_add(config.beta.powi(2), player.uncertainty.powi(2)), + ) + .sqrt(); + + cdf(delta / denom, 0.0, 1.0) + }) + .collect() +} + #[must_use] /// Gets the conservatively estimated rank of a player using their rating and deviation. /// @@ -1611,6 +1674,11 @@ mod tests { assert!((new_player_two.rating - 23.465_814_479_687_182).abs() < f64::EPSILON); assert!((exp1 + exp2 - 1.0).abs() < f64::EPSILON); + let rating_period_system: TrueSkill = RatingPeriodSystem::new(TrueSkillConfig::new()); + let exp_rp = + RatingPeriodSystem::expected_score(&rating_period_system, &player_one, &[player_two]); + assert!((exp1 - exp_rp[0]).abs() < f64::EPSILON); + let player_one: TrueSkillRating = Rating::new(Some(24.0), Some(2.0)); let player_two: TrueSkillRating = Rating::new(Some(24.0), Some(2.0)); diff --git a/src/uscf.rs b/src/uscf.rs index cb0f2a7..7054c00 100644 --- a/src/uscf.rs +++ b/src/uscf.rs @@ -217,6 +217,10 @@ impl RatingPeriodSystem for USCF { fn rate(&self, player: &USCFRating, results: &[(USCFRating, Outcomes)]) -> USCFRating { uscf_rating_period(player, results, &self.config) } + + fn expected_score(&self, player: &Self::RATING, opponents: &[Self::RATING]) -> Vec { + expected_score_rating_period(player, opponents) + } } #[must_use] @@ -471,6 +475,45 @@ pub fn expected_score(player_one: &USCFRating, player_two: &USCFRating) -> (f64, (exp_one, exp_two) } +#[must_use] +/// Calculates the expected outcome of a player in a rating period or tournament. +/// +/// Takes in a players as [`USCFRating`] and a list of opponents as a slice of [`USCFRating`] +/// and returns the probability of victory for each match as an Vec of [`f64`] between 1.0 and 0.0 from the perspective of the player. +/// 1.0 means a certain victory for the player, 0.0 means certain loss. +/// Values near 0.5 mean a draw is likely to occur. +/// +/// # Examples +/// ``` +/// use skillratings::uscf::{expected_score_rating_period, USCFRating}; +/// +/// let player = USCFRating { +/// rating: 1900.0, +/// games: 40, +/// }; +/// +/// let opponent1 = USCFRating { +/// rating: 1930.0, +/// games: 40, +/// }; +/// +/// let opponent2 = USCFRating { +/// rating: 1730.0, +/// games: 40, +/// }; +/// +/// let exp = expected_score_rating_period(&player, &[opponent1, opponent2]); +/// +/// assert_eq!((exp[0] * 100.0).round(), 46.0); +/// assert_eq!((exp[1] * 100.0).round(), 73.0); +/// ``` +pub fn expected_score_rating_period(player: &USCFRating, opponents: &[USCFRating]) -> Vec { + opponents + .iter() + .map(|o| e_value(player.rating, o.rating)) + .collect() +} + /// This formula gets applied if a player has not played at least 8 games. fn new_rating_provisional( rating: f64, @@ -826,6 +869,11 @@ mod tests { assert!((exp1 - 0.5).abs() < f64::EPSILON); assert!((exp2 - 0.5).abs() < f64::EPSILON); + let rating_period_system: USCF = RatingPeriodSystem::new(USCFConfig::new()); + let exp_rp = + RatingPeriodSystem::expected_score(&rating_period_system, &player_one, &[player_two]); + assert!((exp1 - exp_rp[0]).abs() < f64::EPSILON); + let player_one: USCFRating = Rating::new(Some(240.0), Some(90.0)); let player_two: USCFRating = Rating::new(Some(240.0), Some(90.0)); diff --git a/src/weng_lin.rs b/src/weng_lin.rs index 1d97f74..9bd8494 100644 --- a/src/weng_lin.rs +++ b/src/weng_lin.rs @@ -206,6 +206,10 @@ impl RatingPeriodSystem for WengLin { fn rate(&self, player: &WengLinRating, results: &[(WengLinRating, Outcomes)]) -> WengLinRating { weng_lin_rating_period(player, results, &self.config) } + + fn expected_score(&self, player: &Self::RATING, opponents: &[Self::RATING]) -> Vec { + expected_score_rating_period(player, opponents, &self.config) + } } impl TeamRatingSystem for WengLin { @@ -918,6 +922,51 @@ pub fn expected_score_multi_team(teams: &[&[WengLinRating]], config: &WengLinCon exps } +#[must_use] +/// Calculates the expected outcome of a player in a rating period or tournament. +/// +/// Takes in a players as [`WengLinRating`], a list of opponents as a slice of [`WengLinRating`] and a [`WengLinConfig`] +/// and returns the probability of victory for each match as an Vec of [`f64`] between 1.0 and 0.0 from the perspective of the player. +/// 1.0 means a certain victory for the player, 0.0 means certain loss. +/// Values near 0.5 mean a draw is likely to occur. +/// +/// # Examples +/// ``` +/// use skillratings::weng_lin::{expected_score_rating_period, WengLinConfig, WengLinRating}; +/// +/// let player = WengLinRating { +/// rating: 19.0, +/// uncertainty: 4.0, +/// }; +/// +/// let opponent1 = WengLinRating { +/// rating: 19.3, +/// uncertainty: 4.0, +/// }; +/// +/// let opponent2 = WengLinRating { +/// rating: 17.3, +/// uncertainty: 4.0, +/// }; +/// +/// let config = WengLinConfig::new(); +/// +/// let exp = expected_score_rating_period(&player, &[opponent1, opponent2], &config); +/// +/// assert_eq!((exp[0] * 100.0).round(), 49.0); +/// assert_eq!((exp[1] * 100.0).round(), 55.0); +/// ``` +pub fn expected_score_rating_period( + player: &WengLinRating, + opponents: &[WengLinRating], + config: &WengLinConfig, +) -> Vec { + opponents + .iter() + .map(|o| expected_score(player, o, config).0) + .collect() +} + fn p_value(rating_one: f64, rating_two: f64, c_value: f64) -> (f64, f64) { let e1 = (rating_one / c_value).exp(); let e2 = (rating_two / c_value).exp(); @@ -1450,6 +1499,11 @@ mod tests { assert!((exp1 + exp2 - 1.0).abs() < f64::EPSILON); + let rating_period_system: WengLin = RatingPeriodSystem::new(WengLinConfig::new()); + let exp_rp = + RatingPeriodSystem::expected_score(&rating_period_system, &player_one, &[player_two]); + assert!((exp1 - exp_rp[0]).abs() < f64::EPSILON); + let player_one: WengLinRating = Rating::new(Some(24.0), Some(2.0)); let player_two: WengLinRating = Rating::new(Some(24.0), Some(2.0));