diff --git a/CHANGELOG.md b/CHANGELOG.md index 471e03e..ee49e91 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.25.0 - 2023-06-04 + +- Add Rating, RatingSystem, RatingPeriodSystem, TeamRatingSystem, and MultiTeamRatingSystem traits + ## v0.24.0 - 2023-01-01 - Renamed `match_quality_teams` to `match_quality_two_teams` for consistency diff --git a/Cargo.toml b/Cargo.toml index db136bf..1b44fbc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "skillratings" -version = "0.24.0" +version = "0.25.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 c00ec69..f2833d0 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ Alternatively, you can add the following to your `Cargo.toml` file manually: ```toml [dependencies] -skillratings = "0.24" +skillratings = "0.25" ``` ### Serde support @@ -56,7 +56,7 @@ By editing `Cargo.toml` manually: ```toml [dependencies] -skillratings = {version = "0.24", features = ["serde"]} +skillratings = {version = "0.25", features = ["serde"]} ``` ## Usage and Examples diff --git a/src/dwz.rs b/src/dwz.rs index 6625994..97850ac 100644 --- a/src/dwz.rs +++ b/src/dwz.rs @@ -51,7 +51,7 @@ use std::{collections::HashMap, error::Error, fmt::Display}; #[cfg(feature = "serde")] use serde::{Deserialize, Serialize}; -use crate::{elo::EloRating, Outcomes}; +use crate::{elo::EloRating, Outcomes, Rating, RatingPeriodSystem, RatingSystem}; #[derive(Copy, Clone, Debug, PartialEq)] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] @@ -89,6 +89,22 @@ impl Default for DWZRating { } } +impl Rating for DWZRating { + fn rating(&self) -> f64 { + self.rating + } + fn uncertainty(&self) -> Option { + None + } + fn new(rating: Option, _uncertainty: Option) -> Self { + Self { + rating: rating.unwrap_or(1000.0), + index: 1, + age: 26, + } + } +} + impl From<(f64, usize, usize)> for DWZRating { fn from((r, i, a): (f64, usize, usize)) -> Self { Self { @@ -144,6 +160,44 @@ impl Display for GetFirstDWZError { impl Error for GetFirstDWZError {} +/// Struct to calculate ratings and expected score for [`DWZRating`] +pub struct DWZ {} + +impl RatingSystem for DWZ { + type RATING = DWZRating; + type CONFIG = (); + + fn new(_config: Self::CONFIG) -> Self { + Self {} + } + + fn rate( + &self, + player_one: &DWZRating, + player_two: &DWZRating, + outcome: &Outcomes, + ) -> (DWZRating, DWZRating) { + dwz(player_one, player_two, outcome) + } + + fn expected_score(&self, player_one: &DWZRating, player_two: &DWZRating) -> (f64, f64) { + expected_score(player_one, player_two) + } +} + +impl RatingPeriodSystem for DWZ { + type RATING = DWZRating; + type CONFIG = (); + + fn new(_config: Self::CONFIG) -> Self { + Self {} + } + + fn rate(&self, player: &DWZRating, results: &[(DWZRating, Outcomes)]) -> DWZRating { + dwz_rating_period(player, results) + } +} + #[must_use] /// Calculates new [`DWZRating`] of two players based on their old rating, index, age and outcome of the game. /// @@ -903,7 +957,7 @@ mod tests { assert_eq!(player_one, player_two); assert_eq!(player_one, player_one.clone()); - assert!(!format!("{:?}", player_one).is_empty()); + assert!(!format!("{player_one:?}").is_empty()); assert_eq!( DWZRating::from((1400.0, 20)), @@ -921,4 +975,35 @@ mod tests { GetFirstDWZError::NotEnoughGames.clone() ); } + + #[test] + fn test_traits() { + let player_one: DWZRating = Rating::new(Some(240.0), Some(90.0)); + let player_two: DWZRating = Rating::new(Some(240.0), Some(90.0)); + + let rating_system: DWZ = RatingSystem::new(()); + + assert!((player_one.rating() - 240.0).abs() < f64::EPSILON); + assert_eq!(player_one.uncertainty(), None); + + let (new_player_one, new_player_two) = + RatingSystem::rate(&rating_system, &player_one, &player_two, &Outcomes::WIN); + + let (exp1, exp2) = RatingSystem::expected_score(&rating_system, &player_one, &player_two); + + assert!((new_player_one.rating - 306.666_666_666_666_7).abs() < f64::EPSILON); + assert!((new_player_two.rating - 237.350_993_377_483_43).abs() < f64::EPSILON); + assert!((exp1 - 0.5).abs() < f64::EPSILON); + assert!((exp2 - 0.5).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)); + + let rating_period: DWZ = RatingPeriodSystem::new(()); + + let new_player_one = + RatingPeriodSystem::rate(&rating_period, &player_one, &[(player_two, Outcomes::WIN)]); + + assert!((new_player_one.rating - 306.666_666_666_666_7).abs() < f64::EPSILON); + } } diff --git a/src/egf.rs b/src/egf.rs index f53022f..280079d 100644 --- a/src/egf.rs +++ b/src/egf.rs @@ -55,7 +55,7 @@ #[cfg(feature = "serde")] use serde::{Deserialize, Serialize}; -use crate::Outcomes; +use crate::{Outcomes, Rating, RatingPeriodSystem, RatingSystem}; #[derive(Copy, Clone, Debug, PartialEq)] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] @@ -86,6 +86,20 @@ impl Default for EGFRating { } } +impl Rating for EGFRating { + fn rating(&self) -> f64 { + self.rating + } + fn uncertainty(&self) -> Option { + None + } + fn new(rating: Option, _uncertainty: Option) -> Self { + Self { + rating: rating.unwrap_or(0.0), + } + } +} + impl From for EGFRating { fn from(r: f64) -> Self { Self { rating: r } @@ -124,6 +138,50 @@ impl Default for EGFConfig { } } +/// Struct to calculate ratings and expected score for [`EGFRating`] +pub struct EGF { + config: EGFConfig, +} + +impl RatingSystem for EGF { + type RATING = EGFRating; + type CONFIG = EGFConfig; + + fn new(config: Self::CONFIG) -> Self { + Self { config } + } + + fn rate( + &self, + player_one: &EGFRating, + player_two: &EGFRating, + outcome: &Outcomes, + ) -> (EGFRating, EGFRating) { + egf(player_one, player_two, outcome, &self.config) + } + + fn expected_score(&self, player_one: &EGFRating, player_two: &EGFRating) -> (f64, f64) { + expected_score(player_one, player_two, &self.config) + } +} + +impl RatingPeriodSystem for EGF { + type RATING = EGFRating; + type CONFIG = EGFConfig; + + fn new(config: Self::CONFIG) -> Self { + Self { config } + } + + fn rate(&self, player: &EGFRating, results: &[(EGFRating, Outcomes)]) -> EGFRating { + // Need to add a config to the results. + let new_results: Vec<(EGFRating, Outcomes, EGFConfig)> = + results.iter().map(|r| (r.0, r.1, self.config)).collect(); + + egf_rating_period(player, &new_results[..]) + } +} + #[must_use] /// Calculates the [`EGFRating`]s of two players based on their old ratings and the outcome of the game. /// @@ -414,9 +472,40 @@ mod tests { assert_eq!(player_one, player_one.clone()); assert!((config.handicap - config.clone().handicap).abs() < f64::EPSILON); - assert!(!format!("{:?}", player_one).is_empty()); - assert!(!format!("{:?}", config).is_empty()); + assert!(!format!("{player_one:?}").is_empty()); + assert!(!format!("{config:?}").is_empty()); assert_eq!(player_one, EGFRating::from(0.0)); } + + #[test] + fn test_traits() { + let player_one: EGFRating = Rating::new(Some(240.0), Some(90.0)); + let player_two: EGFRating = Rating::new(Some(240.0), Some(90.0)); + + let rating_system: EGF = RatingSystem::new(EGFConfig::new()); + + assert!((player_one.rating() - 240.0).abs() < f64::EPSILON); + assert_eq!(player_one.uncertainty(), None); + + let (new_player_one, new_player_two) = + RatingSystem::rate(&rating_system, &player_one, &player_two, &Outcomes::WIN); + + let (exp1, exp2) = RatingSystem::expected_score(&rating_system, &player_one, &player_two); + + assert!((new_player_one.rating - 284.457_578_792_560_87).abs() < f64::EPSILON); + assert!((new_player_two.rating - 205.842_421_207_441_73).abs() < f64::EPSILON); + assert!((exp1 - 0.5).abs() < f64::EPSILON); + assert!((exp2 - 0.5).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)); + + let rating_period: EGF = RatingPeriodSystem::new(EGFConfig::new()); + + let new_player_one = + RatingPeriodSystem::rate(&rating_period, &player_one, &[(player_two, Outcomes::WIN)]); + + assert!((new_player_one.rating - 284.457_578_792_560_87).abs() < f64::EPSILON); + } } diff --git a/src/elo.rs b/src/elo.rs index 0313e7e..8ba462f 100644 --- a/src/elo.rs +++ b/src/elo.rs @@ -46,7 +46,10 @@ #[cfg(feature = "serde")] use serde::{Deserialize, Serialize}; -use crate::{dwz::DWZRating, fifa::FifaRating, ingo::IngoRating, uscf::USCFRating, Outcomes}; +use crate::{ + dwz::DWZRating, fifa::FifaRating, ingo::IngoRating, uscf::USCFRating, Outcomes, Rating, + RatingPeriodSystem, RatingSystem, +}; /// The Elo rating of a player. /// @@ -72,6 +75,20 @@ impl Default for EloRating { } } +impl Rating for EloRating { + fn rating(&self) -> f64 { + self.rating + } + fn uncertainty(&self) -> Option { + None + } + fn new(rating: Option, _uncertainty: Option) -> Self { + Self { + rating: rating.unwrap_or(1000.0), + } + } +} + impl From for EloRating { fn from(r: f64) -> Self { Self { rating: r } @@ -81,7 +98,7 @@ impl From for EloRating { impl From for EloRating { fn from(i: IngoRating) -> Self { Self { - rating: 2840.0 - 8.0 * i.rating, + rating: 8.0f64.mul_add(-i.rating, 2840.0), } } } @@ -137,6 +154,46 @@ impl Default for EloConfig { } } +/// Struct to calculate ratings and expected score for [`EloRating`] +pub struct Elo { + config: EloConfig, +} + +impl RatingSystem for Elo { + type RATING = EloRating; + type CONFIG = EloConfig; + + fn new(config: Self::CONFIG) -> Self { + Self { config } + } + + fn rate( + &self, + player_one: &EloRating, + player_two: &EloRating, + outcome: &Outcomes, + ) -> (EloRating, EloRating) { + elo(player_one, player_two, outcome, &self.config) + } + + fn expected_score(&self, player_one: &EloRating, player_two: &EloRating) -> (f64, f64) { + expected_score(player_one, player_two) + } +} + +impl RatingPeriodSystem for Elo { + type RATING = EloRating; + type CONFIG = EloConfig; + + fn new(config: Self::CONFIG) -> Self { + Self { config } + } + + fn rate(&self, player: &EloRating, results: &[(EloRating, Outcomes)]) -> EloRating { + elo_rating_period(player, results, &self.config) + } +} + /// Calculates the [`EloRating`]s of two players based on their old ratings and the outcome of the game. /// /// Takes in two players as [`EloRating`]s, an [`Outcome`](Outcomes) and an [`EloConfig`]. @@ -367,9 +424,40 @@ mod tests { assert_eq!(player_one, player_one.clone()); assert!((config.k - config.clone().k).abs() < f64::EPSILON); - assert!(!format!("{:?}", player_one).is_empty()); - assert!(!format!("{:?}", config).is_empty()); + assert!(!format!("{player_one:?}").is_empty()); + assert!(!format!("{config:?}").is_empty()); assert_eq!(player_one, EloRating::from(1000.0)); } + + #[test] + fn test_traits() { + let player_one: EloRating = Rating::new(Some(240.0), Some(90.0)); + let player_two: EloRating = Rating::new(Some(240.0), Some(90.0)); + + let rating_system: Elo = RatingSystem::new(EloConfig::new()); + + assert!((player_one.rating() - 240.0).abs() < f64::EPSILON); + assert_eq!(player_one.uncertainty(), None); + + let (new_player_one, new_player_two) = + RatingSystem::rate(&rating_system, &player_one, &player_two, &Outcomes::WIN); + + let (exp1, exp2) = RatingSystem::expected_score(&rating_system, &player_one, &player_two); + + assert!((new_player_one.rating - 256.0).abs() < f64::EPSILON); + assert!((new_player_two.rating - 224.0).abs() < f64::EPSILON); + assert!((exp1 - 0.5).abs() < f64::EPSILON); + assert!((exp2 - 0.5).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)); + + let rating_period: Elo = RatingPeriodSystem::new(EloConfig::new()); + + let new_player_one = + RatingPeriodSystem::rate(&rating_period, &player_one, &[(player_two, Outcomes::WIN)]); + + assert!((new_player_one.rating - 256.0).abs() < f64::EPSILON); + } } diff --git a/src/fifa.rs b/src/fifa.rs index 51697cc..8224f46 100644 --- a/src/fifa.rs +++ b/src/fifa.rs @@ -52,7 +52,7 @@ #[cfg(feature = "serde")] use serde::{Deserialize, Serialize}; -use crate::{elo::EloRating, Outcomes}; +use crate::{elo::EloRating, Outcomes, Rating, RatingPeriodSystem, RatingSystem}; /// The Fifa rating of a team. /// @@ -78,6 +78,20 @@ impl Default for FifaRating { } } +impl Rating for FifaRating { + fn rating(&self) -> f64 { + self.rating + } + fn uncertainty(&self) -> Option { + None + } + fn new(rating: Option, _uncertainty: Option) -> Self { + Self { + rating: rating.unwrap_or(1000.0), + } + } +} + impl From for FifaRating { fn from(r: f64) -> Self { Self { rating: r } @@ -154,6 +168,50 @@ impl Default for FifaConfig { } } +/// Struct to calculate ratings and expected score for [`FifaRating`] +pub struct Fifa { + config: FifaConfig, +} + +impl RatingSystem for Fifa { + type RATING = FifaRating; + type CONFIG = FifaConfig; + + fn new(config: Self::CONFIG) -> Self { + Self { config } + } + + fn rate( + &self, + player_one: &FifaRating, + player_two: &FifaRating, + outcome: &Outcomes, + ) -> (FifaRating, FifaRating) { + fifa(player_one, player_two, outcome, &self.config) + } + + fn expected_score(&self, player_one: &FifaRating, player_two: &FifaRating) -> (f64, f64) { + expected_score(player_one, player_two) + } +} + +impl RatingPeriodSystem for Fifa { + type RATING = FifaRating; + type CONFIG = FifaConfig; + + fn new(config: Self::CONFIG) -> Self { + Self { config } + } + + fn rate(&self, player: &FifaRating, results: &[(FifaRating, Outcomes)]) -> FifaRating { + // Need to add a config to the results. + let new_results: Vec<(FifaRating, Outcomes, FifaConfig)> = + results.iter().map(|r| (r.0, r.1, self.config)).collect(); + + fifa_rating_period(player, &new_results[..]) + } +} + #[must_use] /// Calculates the [`FifaRating`]s of two teams based on their old ratings and the outcome of the game. /// @@ -466,9 +524,40 @@ mod tests { assert_eq!(player_one, player_one.clone()); assert!((config.importance - config.clone().importance).abs() < f64::EPSILON); - assert!(!format!("{:?}", player_one).is_empty()); - assert!(!format!("{:?}", config).is_empty()); + assert!(!format!("{player_one:?}").is_empty()); + assert!(!format!("{config:?}").is_empty()); assert_eq!(player_one, FifaRating::from(1000.)); } + + #[test] + fn test_traits() { + let player_one: FifaRating = Rating::new(Some(240.0), Some(90.0)); + let player_two: FifaRating = Rating::new(Some(240.0), Some(90.0)); + + let rating_system: Fifa = RatingSystem::new(FifaConfig::new()); + + assert!((player_one.rating() - 240.0).abs() < f64::EPSILON); + assert_eq!(player_one.uncertainty(), None); + + let (new_player_one, new_player_two) = + RatingSystem::rate(&rating_system, &player_one, &player_two, &Outcomes::WIN); + + let (exp1, exp2) = RatingSystem::expected_score(&rating_system, &player_one, &player_two); + + assert!((new_player_one.rating - 245.0).abs() < f64::EPSILON); + assert!((new_player_two.rating - 235.0).abs() < f64::EPSILON); + assert!((exp1 - 0.5).abs() < f64::EPSILON); + assert!((exp2 - 0.5).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)); + + let rating_period: Fifa = RatingPeriodSystem::new(FifaConfig::new()); + + let new_player_one = + RatingPeriodSystem::rate(&rating_period, &player_one, &[(player_two, Outcomes::WIN)]); + + assert!((new_player_one.rating - 245.0).abs() < f64::EPSILON); + } } diff --git a/src/glicko.rs b/src/glicko.rs index b871884..7dab82b 100644 --- a/src/glicko.rs +++ b/src/glicko.rs @@ -1,5 +1,5 @@ //! The Glicko algorithm, developed by Mark Glickman as an improvement on Elo. -//! It is still being used in some games in favour Glicko-2, such as Pokémon Showdown and Quake Live. +//! It is still being used in some games in favour Glicko-2, such as Pokémon Showdown, Chess.com and Quake Live. //! //! If you are looking for the updated Glicko-2 rating system, please see [`Glicko-2`](crate::glicko2). //! @@ -53,6 +53,7 @@ use serde::{Deserialize, Serialize}; use crate::{ glicko2::Glicko2Rating, glicko_boost::GlickoBoostRating, sticko::StickoRating, Outcomes, + Rating, RatingPeriodSystem, RatingSystem, }; use std::f64::consts::PI; @@ -88,6 +89,21 @@ impl Default for GlickoRating { } } +impl Rating for GlickoRating { + fn rating(&self) -> f64 { + self.rating + } + fn uncertainty(&self) -> Option { + Some(self.deviation) + } + fn new(rating: Option, uncertainty: Option) -> Self { + Self { + rating: rating.unwrap_or(1500.0), + deviation: uncertainty.unwrap_or(350.0), + } + } +} + impl From<(f64, f64)> for GlickoRating { fn from((r, d): (f64, f64)) -> Self { Self { @@ -149,6 +165,46 @@ impl Default for GlickoConfig { } } +/// Struct to calculate ratings and expected score for [`GlickoRating`] +pub struct Glicko { + config: GlickoConfig, +} + +impl RatingSystem for Glicko { + type RATING = GlickoRating; + type CONFIG = GlickoConfig; + + fn new(config: Self::CONFIG) -> Self { + Self { config } + } + + fn rate( + &self, + player_one: &GlickoRating, + player_two: &GlickoRating, + outcome: &Outcomes, + ) -> (GlickoRating, GlickoRating) { + glicko(player_one, player_two, outcome, &self.config) + } + + fn expected_score(&self, player_one: &GlickoRating, player_two: &GlickoRating) -> (f64, f64) { + expected_score(player_one, player_two) + } +} + +impl RatingPeriodSystem for Glicko { + type RATING = GlickoRating; + type CONFIG = GlickoConfig; + + fn new(config: Self::CONFIG) -> Self { + Self { config } + } + + fn rate(&self, player: &GlickoRating, results: &[(GlickoRating, Outcomes)]) -> GlickoRating { + glicko_rating_period(player, results, &self.config) + } +} + #[must_use] /// Calculates the [`GlickoRating`]s of two players based on their old ratings, deviations, and the outcome of the game. /// @@ -441,8 +497,7 @@ pub fn decay_deviation(player: &GlickoRating, config: &GlickoConfig) -> GlickoRa /// ``` pub fn confidence_interval(player: &GlickoRating) -> (f64, f64) { ( - // Seems like there is no mul_sub function. - player.rating - 1.96 * player.deviation, + 1.96f64.mul_add(-player.deviation, player.rating), 1.96f64.mul_add(player.deviation, player.rating), ) } @@ -684,9 +739,40 @@ mod tests { assert_eq!(player_one, player_one.clone()); assert!((config.c - config.clone().c).abs() < f64::EPSILON); - assert!(!format!("{:?}", player_one).is_empty()); - assert!(!format!("{:?}", config).is_empty()); + assert!(!format!("{player_one:?}").is_empty()); + assert!(!format!("{config:?}").is_empty()); assert_eq!(player_one, GlickoRating::from((1500.0, 350.0))); } + + #[test] + fn test_traits() { + let player_one: GlickoRating = Rating::new(Some(240.0), Some(90.0)); + let player_two: GlickoRating = Rating::new(Some(240.0), Some(90.0)); + + let rating_system: Glicko = RatingSystem::new(GlickoConfig::new()); + + assert!((player_one.rating() - 240.0).abs() < f64::EPSILON); + assert_eq!(player_one.uncertainty(), Some(90.0)); + + let (new_player_one, new_player_two) = + RatingSystem::rate(&rating_system, &player_one, &player_two, &Outcomes::WIN); + + let (exp1, exp2) = RatingSystem::expected_score(&rating_system, &player_one, &player_two); + + assert!((new_player_one.rating - 270.633_674_957_731_9).abs() < f64::EPSILON); + assert!((new_player_two.rating - 209.366_325_042_268_1).abs() < f64::EPSILON); + assert!((exp1 - 0.5).abs() < f64::EPSILON); + assert!((exp2 - 0.5).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)); + + let rating_period: Glicko = RatingPeriodSystem::new(GlickoConfig::new()); + + let new_player_one = + RatingPeriodSystem::rate(&rating_period, &player_one, &[(player_two, Outcomes::WIN)]); + + assert!((new_player_one.rating - 270.633_674_957_731_9).abs() < f64::EPSILON); + } } diff --git a/src/glicko2.rs b/src/glicko2.rs index e0afcfd..303e02c 100644 --- a/src/glicko2.rs +++ b/src/glicko2.rs @@ -1,5 +1,5 @@ //! The Glicko-2 algorithm, an improvement on Glicko and widely used in online games, -//! like Counter Strike: Global Offensive, Team Fortress 2, Splatoon 2 and most online chess platforms. +//! like Counter Strike: Global Offensive, Team Fortress 2, Splatoon 2 or Lichess. //! //! If you are looking for the regular Glicko rating system, please see [`Glicko`](crate::glicko). //! @@ -55,7 +55,8 @@ use serde::{Deserialize, Serialize}; use crate::{ - glicko::GlickoRating, glicko_boost::GlickoBoostRating, sticko::StickoRating, Outcomes, + glicko::GlickoRating, glicko_boost::GlickoBoostRating, sticko::StickoRating, Outcomes, Rating, + RatingPeriodSystem, RatingSystem, }; use std::f64::consts::PI; @@ -95,6 +96,22 @@ impl Default for Glicko2Rating { } } +impl Rating for Glicko2Rating { + fn rating(&self) -> f64 { + self.rating + } + fn uncertainty(&self) -> Option { + Some(self.deviation) + } + fn new(rating: Option, uncertainty: Option) -> Self { + Self { + rating: rating.unwrap_or(1500.0), + deviation: uncertainty.unwrap_or(350.0), + volatility: 0.06, + } + } +} + impl From<(f64, f64, f64)> for Glicko2Rating { fn from((r, d, v): (f64, f64, f64)) -> Self { Self { @@ -167,6 +184,46 @@ impl Default for Glicko2Config { } } +/// Struct to calculate ratings and expected score for [`Glicko2Rating`] +pub struct Glicko2 { + config: Glicko2Config, +} + +impl RatingSystem for Glicko2 { + type RATING = Glicko2Rating; + type CONFIG = Glicko2Config; + + fn new(config: Self::CONFIG) -> Self { + Self { config } + } + + fn rate( + &self, + player_one: &Glicko2Rating, + player_two: &Glicko2Rating, + outcome: &Outcomes, + ) -> (Glicko2Rating, Glicko2Rating) { + glicko2(player_one, player_two, outcome, &self.config) + } + + fn expected_score(&self, player_one: &Glicko2Rating, player_two: &Glicko2Rating) -> (f64, f64) { + expected_score(player_one, player_two) + } +} + +impl RatingPeriodSystem for Glicko2 { + type RATING = Glicko2Rating; + type CONFIG = Glicko2Config; + + fn new(config: Self::CONFIG) -> Self { + Self { config } + } + + fn rate(&self, player: &Glicko2Rating, results: &[(Glicko2Rating, Outcomes)]) -> Glicko2Rating { + glicko2_rating_period(player, results, &self.config) + } +} + /// Calculates the [`Glicko2Rating`]s of two players based on their old ratings, deviations, volatilities, and the outcome of the game. /// /// For the original version, please see [`Glicko`](crate::glicko). @@ -494,8 +551,7 @@ pub fn decay_deviation(player: &Glicko2Rating) -> Glicko2Rating { /// ``` pub fn confidence_interval(player: &Glicko2Rating) -> (f64, f64) { ( - // Seems like there is no mul_sub function. - player.rating - 1.96 * player.deviation, + 1.96f64.mul_add(-player.deviation, player.rating), 1.96f64.mul_add(player.deviation, player.rating), ) } @@ -546,9 +602,9 @@ fn new_volatility( let mut b = if delta_squared > deviation_squared + v { (delta_squared - deviation_squared - v).ln() } else { - let mut k = 1.0; + let mut k: f64 = 1.0; while f_value( - a - k * tau, + k.mul_add(-tau, a), delta_squared, deviation_squared, v, @@ -558,7 +614,7 @@ fn new_volatility( { k += 1.0; } - a - k * tau + k.mul_add(-tau, a) }; let mut fa = f_value(a, delta_squared, deviation_squared, v, old_volatility, tau); @@ -782,8 +838,8 @@ mod tests { let (exp_one, exp_two) = expected_score(&player_one, &player_two); - assert!((exp_one * 100.0 - 50.0).abs() < f64::EPSILON); - assert!((exp_two * 100.0 - 50.0).abs() < f64::EPSILON); + assert!(exp_one.mul_add(100.0, -50.0).abs() < f64::EPSILON); + assert!(exp_two.mul_add(100.0, -50.0).abs() < f64::EPSILON); let player_three = Glicko2Rating { rating: 2000.0, @@ -966,9 +1022,40 @@ mod tests { assert_eq!(player_one, player_one.clone()); assert!((config.tau - config.clone().tau).abs() < f64::EPSILON); - assert!(!format!("{:?}", player_one).is_empty()); - assert!(!format!("{:?}", config).is_empty()); + assert!(!format!("{player_one:?}").is_empty()); + assert!(!format!("{config:?}").is_empty()); assert_eq!(player_one, Glicko2Rating::from((1500.0, 350.0, 0.06))); } + + #[test] + fn test_traits() { + let player_one: Glicko2Rating = Rating::new(Some(240.0), Some(90.0)); + let player_two: Glicko2Rating = Rating::new(Some(240.0), Some(90.0)); + + let rating_system: Glicko2 = RatingSystem::new(Glicko2Config::new()); + + assert!((player_one.rating() - 240.0).abs() < f64::EPSILON); + assert_eq!(player_one.uncertainty(), Some(90.0)); + + let (new_player_one, new_player_two) = + RatingSystem::rate(&rating_system, &player_one, &player_two, &Outcomes::WIN); + + let (exp1, exp2) = RatingSystem::expected_score(&rating_system, &player_one, &player_two); + + assert!((new_player_one.rating - 261.373_963_260_869_7).abs() < f64::EPSILON); + assert!((new_player_two.rating - 218.626_036_739_130_34).abs() < f64::EPSILON); + assert!((exp1 - 0.5).abs() < f64::EPSILON); + assert!((exp2 - 0.5).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)); + + let rating_period: Glicko2 = RatingPeriodSystem::new(Glicko2Config::new()); + + let new_player_one = + RatingPeriodSystem::rate(&rating_period, &player_one, &[(player_two, Outcomes::WIN)]); + + assert!((new_player_one.rating - 261.373_963_260_869_7).abs() < f64::EPSILON); + } } diff --git a/src/glicko_boost.rs b/src/glicko_boost.rs index c81dd7b..af7de78 100644 --- a/src/glicko_boost.rs +++ b/src/glicko_boost.rs @@ -72,7 +72,10 @@ use std::f64::consts::PI; #[cfg(feature = "serde")] use serde::{Deserialize, Serialize}; -use crate::{glicko::GlickoRating, glicko2::Glicko2Rating, sticko::StickoRating, Outcomes}; +use crate::{ + glicko::GlickoRating, glicko2::Glicko2Rating, sticko::StickoRating, Outcomes, Rating, + RatingPeriodSystem, RatingSystem, +}; #[derive(Copy, Clone, Debug, PartialEq)] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] @@ -106,6 +109,21 @@ impl Default for GlickoBoostRating { } } +impl Rating for GlickoBoostRating { + fn rating(&self) -> f64 { + self.rating + } + fn uncertainty(&self) -> Option { + Some(self.deviation) + } + fn new(rating: Option, uncertainty: Option) -> Self { + Self { + rating: rating.unwrap_or(1500.0), + deviation: uncertainty.unwrap_or(350.0), + } + } +} + impl From<(f64, f64)> for GlickoBoostRating { fn from((r, d): (f64, f64)) -> Self { Self { @@ -202,6 +220,59 @@ impl Default for GlickoBoostConfig { } } +/// Struct to calculate ratings and expected score for [`GlickoBoost`] +pub struct GlickoBoost { + config: GlickoBoostConfig, +} + +impl RatingSystem for GlickoBoost { + type RATING = GlickoBoostRating; + type CONFIG = GlickoBoostConfig; + + fn new(config: Self::CONFIG) -> Self { + Self { config } + } + + fn rate( + &self, + player_one: &GlickoBoostRating, + player_two: &GlickoBoostRating, + outcome: &Outcomes, + ) -> (GlickoBoostRating, GlickoBoostRating) { + glicko_boost(player_one, player_two, outcome, &self.config) + } + + fn expected_score( + &self, + player_one: &GlickoBoostRating, + player_two: &GlickoBoostRating, + ) -> (f64, f64) { + expected_score(player_one, player_two, &self.config) + } +} + +impl RatingPeriodSystem for GlickoBoost { + type RATING = GlickoBoostRating; + type CONFIG = GlickoBoostConfig; + + fn new(config: Self::CONFIG) -> Self { + Self { config } + } + + fn rate( + &self, + player: &GlickoBoostRating, + results: &[(GlickoBoostRating, Outcomes)], + ) -> GlickoBoostRating { + // Need to add a colour indicator to the results, we use white everytime. + // The advantage of playing white is set to 0 by default, anyways. + let new_results: Vec<(GlickoBoostRating, Outcomes, bool)> = + results.iter().map(|r| (r.0, r.1, true)).collect(); + + glicko_boost_rating_period(player, &new_results[..], &self.config) + } +} + #[must_use] /// Calculates the [`GlickoBoostRating`]s of two players based on their old ratings, deviations, and the outcome of the game. /// @@ -624,7 +695,7 @@ pub fn decay_deviation( /// ``` pub fn confidence_interval(player: &GlickoBoostRating) -> (f64, f64) { ( - player.rating - 1.96 * player.deviation, + 1.96f64.mul_add(-player.deviation, player.rating), 1.96f64.mul_add(player.deviation, player.rating), ) } @@ -1024,9 +1095,41 @@ mod tests { assert_eq!(player_one, player_one.clone()); assert!((config.eta - config.clone().eta).abs() < f64::EPSILON); - assert!(!format!("{:?}", player_one).is_empty()); - assert!(!format!("{:?}", config).is_empty()); + assert!(!format!("{player_one:?}").is_empty()); + assert!(!format!("{config:?}").is_empty()); assert_eq!(player_one, GlickoBoostRating::from((1500.0, 350.0))); } + + #[test] + fn test_traits() { + let player_one: GlickoBoostRating = Rating::new(Some(240.0), Some(90.0)); + let player_two: GlickoBoostRating = Rating::new(Some(240.0), Some(90.0)); + + let rating_system: GlickoBoost = RatingSystem::new(GlickoBoostConfig::new()); + + assert!((player_one.rating() - 240.0).abs() < f64::EPSILON); + assert_eq!(player_one.uncertainty(), Some(90.0)); + + let (new_player_one, new_player_two) = + RatingSystem::rate(&rating_system, &player_one, &player_two, &Outcomes::WIN); + + let (exp1, exp2) = RatingSystem::expected_score(&rating_system, &player_one, &player_two); + + assert!((new_player_one.rating - 259.366_898_204_792_6).abs() < f64::EPSILON); + assert!((new_player_two.rating - 220.633_101_795_207_38).abs() < f64::EPSILON); + + 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 player_one: GlickoBoostRating = Rating::new(Some(240.0), Some(90.0)); + let player_two: GlickoBoostRating = Rating::new(Some(240.0), Some(90.0)); + + let rating_period: GlickoBoost = RatingPeriodSystem::new(GlickoBoostConfig::new()); + + let new_player_one = + RatingPeriodSystem::rate(&rating_period, &player_one, &[(player_two, Outcomes::WIN)]); + + assert!((new_player_one.rating - 259.366_898_204_792_6).abs() < f64::EPSILON); + } } diff --git a/src/ingo.rs b/src/ingo.rs index 086938e..ed64230 100644 --- a/src/ingo.rs +++ b/src/ingo.rs @@ -45,7 +45,7 @@ #[cfg(feature = "serde")] use serde::{Deserialize, Serialize}; -use crate::{elo::EloRating, Outcomes}; +use crate::{elo::EloRating, Outcomes, Rating, RatingPeriodSystem, RatingSystem}; #[derive(Copy, Clone, Debug, PartialEq)] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] @@ -81,6 +81,21 @@ impl Default for IngoRating { } } +impl Rating for IngoRating { + fn rating(&self) -> f64 { + self.rating + } + fn uncertainty(&self) -> Option { + None + } + fn new(rating: Option, _uncertainty: Option) -> Self { + Self { + rating: rating.unwrap_or(230.0), + age: 26, + } + } +} + impl From<(f64, usize)> for IngoRating { fn from((r, a): (f64, usize)) -> Self { Self { rating: r, age: a } @@ -103,6 +118,45 @@ impl From for IngoRating { } } +/// Struct to calculate ratings and expected score for [`IngoRating`] +pub struct Ingo {} + +impl RatingSystem for Ingo { + type RATING = IngoRating; + // No need for a config here. + type CONFIG = (); + + fn new(_config: Self::CONFIG) -> Self { + Self {} + } + + fn rate( + &self, + player_one: &IngoRating, + player_two: &IngoRating, + outcome: &Outcomes, + ) -> (IngoRating, IngoRating) { + ingo(player_one, player_two, outcome) + } + + fn expected_score(&self, player_one: &IngoRating, player_two: &IngoRating) -> (f64, f64) { + expected_score(player_one, player_two) + } +} + +impl RatingPeriodSystem for Ingo { + type RATING = IngoRating; + type CONFIG = (); + + fn new(_config: Self::CONFIG) -> Self { + Self {} + } + + fn rate(&self, player: &IngoRating, results: &[(IngoRating, Outcomes)]) -> IngoRating { + ingo_rating_period(player, results) + } +} + #[must_use] /// Calculates the [`IngoRating`]s of two players based on their ratings, and the outcome of the game. /// @@ -279,7 +333,7 @@ pub fn expected_score(player_one: &IngoRating, player_two: &IngoRating) -> (f64, } fn performance(average_rating: f64, score: f64) -> f64 { - average_rating - (100.0 * score - 50.0) + average_rating - 100.0f64.mul_add(score, -50.0) } /// Similar to the DWZ algorithm, we use the age of the player to get the development coefficient. @@ -423,8 +477,39 @@ mod tests { assert_eq!(player_one, player_one.clone()); - assert!(!format!("{:?}", player_one).is_empty()); + assert!(!format!("{player_one:?}").is_empty()); assert_eq!(IngoRating::from((222.0, 26)), IngoRating::from(222.0)); } + + #[test] + fn test_traits() { + let player_one: IngoRating = Rating::new(Some(240.0), Some(90.0)); + let player_two: IngoRating = Rating::new(Some(240.0), Some(90.0)); + + let rating_system: Ingo = RatingSystem::new(()); + + assert!((player_one.rating() - 240.0).abs() < f64::EPSILON); + assert_eq!(player_one.uncertainty(), None); + + let (new_player_one, new_player_two) = + RatingSystem::rate(&rating_system, &player_one, &player_two, &Outcomes::WIN); + + let (exp1, exp2) = RatingSystem::expected_score(&rating_system, &player_one, &player_two); + + assert!((new_player_one.rating - 237.619_047_619_047_62).abs() < f64::EPSILON); + assert!((new_player_two.rating - 242.380_952_380_952_38).abs() < f64::EPSILON); + assert!((exp1 - 0.5).abs() < f64::EPSILON); + assert!((exp2 - 0.5).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)); + + let rating_period: Ingo = RatingPeriodSystem::new(()); + + let new_player_one = + RatingPeriodSystem::rate(&rating_period, &player_one, &[(player_two, Outcomes::WIN)]); + + assert!((new_player_one.rating - 237.619_047_619_047_62).abs() < f64::EPSILON); + } } diff --git a/src/lib.rs b/src/lib.rs index e96085d..9736ce7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -47,7 +47,7 @@ //! //! ```toml //! [dependencies] -//! skillratings = "0.24" +//! skillratings = "0.25" //! ``` //! //! ## Serde support @@ -64,7 +64,7 @@ //! //! ```toml //! [dependencies] -//! skillratings = {version = "0.24", features = ["serde"]} +//! skillratings = {version = "0.25", features = ["serde"]} //! ``` //! //! # Usage and Examples @@ -283,6 +283,8 @@ //! assert_eq!(new_player.rating.round(), 1362.0); //! ``` +#[cfg(feature = "serde")] +use serde::de::DeserializeOwned; #[cfg(feature = "serde")] use serde::{Deserialize, Serialize}; @@ -373,6 +375,98 @@ impl From for usize { } } +/// Measure of player's skill. +pub trait Rating { + /// A single value for player's skill + fn rating(&self) -> f64; + /// A value for the uncertainty of a players rating. + /// If the algorithm does not include an uncertainty value, this will return `None`. + fn uncertainty(&self) -> Option; + /// Initialise a `Rating` with provided score and uncertainty, if `None` use default. + /// If the algorithm does not include an uncertainty value it will get dismissed. + fn new(rating: Option, uncertainty: Option) -> Self; +} + +/// Rating system for 1v1 matches. +pub trait RatingSystem { + #[cfg(feature = "serde")] + type RATING: Rating + Copy + std::fmt::Debug + DeserializeOwned + Serialize; + #[cfg(not(feature = "serde"))] + /// Rating type rating system. + type RATING: Rating + Copy + std::fmt::Debug; + /// Config type for rating system. + 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. + fn rate( + &self, + player_one: &Self::RATING, + player_two: &Self::RATING, + outcome: &Outcomes, + ) -> (Self::RATING, Self::RATING); + /// Calculate expected outcome of two players. Returns probability of player winning from 0.0 to 1.0. + fn expected_score(&self, player_one: &Self::RATING, player_two: &Self::RATING) -> (f64, f64); +} + +/// Rating system for rating periods. +pub trait RatingPeriodSystem { + #[cfg(feature = "serde")] + type RATING: Rating + Copy + std::fmt::Debug + DeserializeOwned + Serialize; + #[cfg(not(feature = "serde"))] + /// Rating type rating system. + type RATING: Rating + Copy + std::fmt::Debug; + /// Config type for rating system. + 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. + fn rate(&self, player: &Self::RATING, results: &[(Self::RATING, Outcomes)]) -> Self::RATING; + // TODO: Add expected_score functions for rating periods? +} + +/// Rating system for two teams. +pub trait TeamRatingSystem { + #[cfg(feature = "serde")] + type RATING: Rating + Copy + std::fmt::Debug + DeserializeOwned + Serialize; + #[cfg(not(feature = "serde"))] + /// Rating type rating system. + type RATING: Rating + Copy + std::fmt::Debug; + /// Config type for rating system. + 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 teams based on provided ratings and outcome. + fn rate( + &self, + team_one: &[Self::RATING], + team_two: &[Self::RATING], + outcome: &Outcomes, + ) -> (Vec, Vec); + /// Calculate expected outcome of two teams. Returns probability of team winning from 0.0 to 1.0. + fn expected_score(&self, team_one: &[Self::RATING], team_two: &[Self::RATING]) -> (f64, f64); +} + +/// Rating system for more than two teams. +pub trait MultiTeamRatingSystem { + #[cfg(feature = "serde")] + type RATING: Rating + Copy + std::fmt::Debug + DeserializeOwned + Serialize; + #[cfg(not(feature = "serde"))] + /// Rating type rating system + type RATING: Rating + Copy + std::fmt::Debug; + /// Config type for rating system. + 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 multiple teams based on provided ratings and outcome. + fn rate( + &self, + teams_and_ranks: &[(&[Self::RATING], MultiTeamOutcome)], + ) -> Vec>; + /// Calculate expected outcome of multiple teams. Returns probability of team winning from 0.0 to 1.0. + fn expected_score(&self, teams: &[&[Self::RATING]]) -> Vec; +} + #[cfg(test)] mod tests { use super::*; @@ -398,11 +492,11 @@ mod tests { let outcome = Outcomes::WIN; assert_eq!(outcome, outcome.clone()); - assert!(!format!("{:?}", outcome).is_empty()); + assert!(!format!("{outcome:?}").is_empty()); let multi_team_outcome = MultiTeamOutcome::new(1); assert_eq!(multi_team_outcome, multi_team_outcome.clone()); - assert!(!format!("{:?}", multi_team_outcome).is_empty()); + assert!(!format!("{multi_team_outcome:?}").is_empty()); assert!(MultiTeamOutcome::new(1) < MultiTeamOutcome::new(2)); } } diff --git a/src/sticko.rs b/src/sticko.rs index e5f586f..7ab1cff 100644 --- a/src/sticko.rs +++ b/src/sticko.rs @@ -70,6 +70,7 @@ use serde::{Deserialize, Serialize}; use crate::{ glicko::GlickoRating, glicko2::Glicko2Rating, glicko_boost::GlickoBoostRating, Outcomes, + Rating, RatingPeriodSystem, RatingSystem, }; #[derive(Copy, Clone, Debug, PartialEq)] @@ -104,6 +105,21 @@ impl Default for StickoRating { } } +impl Rating for StickoRating { + fn rating(&self) -> f64 { + self.rating + } + fn uncertainty(&self) -> Option { + Some(self.deviation) + } + fn new(rating: Option, uncertainty: Option) -> Self { + Self { + rating: rating.unwrap_or(1500.0), + deviation: uncertainty.unwrap_or(350.0), + } + } +} + impl From<(f64, f64)> for StickoRating { fn from((r, d): (f64, f64)) -> Self { Self { @@ -204,6 +220,51 @@ impl Default for StickoConfig { } } +/// Struct to calculate ratings and expected score for [`StickoRating`] +pub struct Sticko { + config: StickoConfig, +} + +impl RatingSystem for Sticko { + type RATING = StickoRating; + type CONFIG = StickoConfig; + + fn new(config: Self::CONFIG) -> Self { + Self { config } + } + + fn rate( + &self, + player_one: &StickoRating, + player_two: &StickoRating, + outcome: &Outcomes, + ) -> (StickoRating, StickoRating) { + sticko(player_one, player_two, outcome, &self.config) + } + + fn expected_score(&self, player_one: &StickoRating, player_two: &StickoRating) -> (f64, f64) { + expected_score(player_one, player_two, &self.config) + } +} + +impl RatingPeriodSystem for Sticko { + type RATING = StickoRating; + type CONFIG = StickoConfig; + + fn new(config: Self::CONFIG) -> Self { + Self { config } + } + + fn rate(&self, player: &StickoRating, results: &[(StickoRating, Outcomes)]) -> StickoRating { + // Need to add a colour indicator to the results, we use white everytime. + // The advantage of playing white is set to 0 by default, anyways. + let new_results: Vec<(StickoRating, Outcomes, bool)> = + results.iter().map(|r| (r.0, r.1, true)).collect(); + + sticko_rating_period(player, &new_results[..], &self.config) + } +} + #[must_use] /// Calculates the [`StickoRating`]s of two players based on their old ratings, deviations, and the outcome of the game. /// @@ -572,7 +633,7 @@ pub fn decay_deviation(player: &StickoRating, config: &StickoConfig) -> StickoRa /// ``` pub fn confidence_interval(player: &StickoRating) -> (f64, f64) { ( - player.rating - 1.96 * player.deviation, + 1.96f64.mul_add(-player.deviation, player.rating), 1.96f64.mul_add(player.deviation, player.rating), ) } @@ -891,9 +952,40 @@ mod tests { assert_eq!(player_one, player_one.clone()); assert!((config.c - config.clone().c).abs() < f64::EPSILON); - assert!(!format!("{:?}", player_one).is_empty()); - assert!(!format!("{:?}", config).is_empty()); + assert!(!format!("{player_one:?}").is_empty()); + assert!(!format!("{config:?}").is_empty()); assert_eq!(player_one, StickoRating::from((1500.0, 350.0))); } + + #[test] + fn test_traits() { + let player_one: StickoRating = Rating::new(Some(240.0), Some(90.0)); + let player_two: StickoRating = Rating::new(Some(240.0), Some(90.0)); + + let rating_system: Sticko = RatingSystem::new(StickoConfig::new()); + + assert!((player_one.rating() - 240.0).abs() < f64::EPSILON); + assert_eq!(player_one.uncertainty(), Some(90.0)); + + let (new_player_one, new_player_two) = + RatingSystem::rate(&rating_system, &player_one, &player_two, &Outcomes::WIN); + + let (exp1, exp2) = RatingSystem::expected_score(&rating_system, &player_one, &player_two); + + assert!((new_player_one.rating - 261.352_796_989_360_1).abs() < f64::EPSILON); + assert!((new_player_two.rating - 218.647_203_010_639_9).abs() < f64::EPSILON); + assert!((exp1 - 0.5).abs() < f64::EPSILON); + assert!((exp2 - 0.5).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)); + + let rating_period: Sticko = RatingPeriodSystem::new(StickoConfig::new()); + + let new_player_one = + RatingPeriodSystem::rate(&rating_period, &player_one, &[(player_two, Outcomes::WIN)]); + + assert!((new_player_one.rating - 261.352_796_989_360_1).abs() < f64::EPSILON); + } } diff --git a/src/trueskill/factor_graph.rs b/src/trueskill/factor_graph.rs new file mode 100644 index 0000000..adc3ca3 --- /dev/null +++ b/src/trueskill/factor_graph.rs @@ -0,0 +1,276 @@ +use std::cell::RefCell; +use std::collections::HashMap; +use std::rc::Rc; + +use super::gaussian::Gaussian; + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Variable { + pub gaussian: Gaussian, + messages: HashMap, +} + +impl Variable { + pub fn new() -> Self { + Self { + gaussian: Gaussian::default(), + messages: HashMap::new(), + } + } + + fn set(&mut self, val: Gaussian) -> f64 { + let delta = self.delta(val); + + self.gaussian.pi = val.pi; + self.gaussian.tau = val.tau; + + delta + } + + fn update_message(&mut self, factor_id: u32, message: Gaussian) -> f64 { + let old_message = self.messages[&factor_id]; + let v = self.messages.entry(factor_id).or_default(); + *v = message; + + self.set(self.gaussian / old_message * message) + } + + fn update_value(&mut self, factor_id: u32, val: Gaussian) -> f64 { + let old_message = self.messages[&factor_id]; + let v = self.messages.entry(factor_id).or_default(); + *v = val * old_message / self.gaussian; + + self.set(val) + } + + fn delta(&self, other: Gaussian) -> f64 { + let pi_delta = (self.gaussian.pi - other.pi).abs(); + if pi_delta.is_infinite() { + return 0.0; + } + + (self.gaussian.tau - other.tau).abs().max(pi_delta.sqrt()) + } +} + +pub struct PriorFactor { + id: u32, + pub variable: Rc>, + val: Gaussian, + dynamic: f64, +} + +impl PriorFactor { + pub fn new(id: u32, variable: Rc>, val: Gaussian, dynamic: f64) -> Self { + variable.borrow_mut().messages.entry(id).or_default(); + + Self { + id, + variable, + val, + dynamic, + } + } + + pub fn down(&mut self) -> f64 { + let sigma = self.val.sigma().hypot(self.dynamic); + let value = Gaussian::with_mu_sigma(self.val.mu(), sigma); + self.variable.borrow_mut().update_value(self.id, value) + } +} + +pub struct LikelihoodFactor { + id: u32, + mean: Rc>, + value: Rc>, + variance: f64, +} + +impl LikelihoodFactor { + pub fn new( + id: u32, + mean: Rc>, + value: Rc>, + variance: f64, + ) -> Self { + mean.borrow_mut().messages.entry(id).or_default(); + value.borrow_mut().messages.entry(id).or_default(); + + Self { + id, + mean, + value, + variance, + } + } + + pub fn down(&mut self) -> f64 { + let msg = { + let mean = self.mean.borrow(); + mean.gaussian / mean.messages[&self.id] + }; + let a = self.calc_a(msg); + self.value + .borrow_mut() + .update_message(self.id, Gaussian::with_pi_tau(a * msg.pi, a * msg.tau)) + } + + pub fn up(&mut self) -> f64 { + let msg = { + let value = self.value.borrow(); + value.gaussian / value.messages[&self.id] + }; + let a = self.calc_a(msg); + self.mean + .borrow_mut() + .update_message(self.id, Gaussian::with_pi_tau(a * msg.pi, a * msg.tau)) + } + + fn calc_a(&self, gaussian: Gaussian) -> f64 { + self.variance.mul_add(gaussian.pi, 1.0).recip() + } +} + +pub struct SumFactor { + id: u32, + sum: Rc>, + terms: Vec>>, + coeffs: Vec, +} + +impl SumFactor { + pub fn new( + id: u32, + sum: Rc>, + terms: Vec>>, + coeffs: Vec, + ) -> Self { + sum.borrow_mut().messages.entry(id).or_default(); + for term in &terms { + term.borrow_mut().messages.entry(id).or_default(); + } + + Self { + id, + sum, + terms, + coeffs, + } + } + + pub fn down(&mut self) -> f64 { + let msgs: Vec = self + .terms + .iter() + .map(|term| term.borrow().messages[&self.id]) + .collect(); + self.update(&self.sum, &self.terms, &msgs, &self.coeffs) + } + + pub fn up(&mut self, index: usize) -> f64 { + let coeff = self.coeffs[index]; + let mut coeffs = Vec::new(); + for (x, c) in self.coeffs.iter().enumerate() { + if coeff == 0.0 { + coeffs.push(0.0); + } else if x == index { + coeffs.push(coeff.recip()); + } else { + coeffs.push(-(*c) / coeff); + } + } + + let mut vals = self.terms.clone(); + vals[index] = self.sum.clone(); + let msgs: Vec = vals + .iter() + .map(|val| val.borrow().messages[&self.id]) + .collect(); + + self.update(&self.terms[index], &vals, &msgs, &coeffs) + } + + #[inline] + pub fn terms_len(&self) -> usize { + self.terms.len() + } + + fn update( + &self, + var: &Rc>, + vals: &[Rc>], + msgs: &[Gaussian], + coeffs: &[f64], + ) -> f64 { + let mut pi_inv = 0.0_f64; + let mut mu = 0.0; + + for ((val, msg), coeff) in vals.iter().zip(msgs).zip(coeffs) { + let div = val.borrow().gaussian / *msg; + mu += coeff * div.mu(); + if pi_inv.is_infinite() { + continue; + } + + if div.pi == 0.0 { + pi_inv = f64::INFINITY; + } else { + pi_inv += coeff.powi(2) / div.pi; + } + } + + let pi = pi_inv.recip(); + let tau = pi * mu; + + var.borrow_mut() + .update_message(self.id, Gaussian::with_pi_tau(pi, tau)) + } +} + +pub struct TruncateFactor { + id: u32, + variable: Rc>, + v_func: Box f64>, + w_func: Box f64>, + draw_margin: f64, +} + +impl TruncateFactor { + pub fn new( + id: u32, + variable: Rc>, + v_func: Box f64>, + w_func: Box f64>, + draw_margin: f64, + ) -> Self { + variable.borrow_mut().messages.entry(id).or_default(); + + Self { + id, + variable, + v_func, + w_func, + draw_margin, + } + } + + pub fn up(&mut self) -> f64 { + let div = { + let variable = self.variable.borrow(); + variable.gaussian / variable.messages[&self.id] + }; + let pi_sqrt = div.pi.sqrt(); + let arg_1 = div.tau / pi_sqrt; + let arg_2 = self.draw_margin * pi_sqrt; + let v = (self.v_func)(arg_1, arg_2); + let w = (self.w_func)(arg_1, arg_2); + let denom = 1.0 - w; + + let pi = div.pi / denom; + let tau = pi_sqrt.mul_add(v, div.tau) / denom; + + self.variable + .borrow_mut() + .update_value(self.id, Gaussian::with_pi_tau(pi, tau)) + } +} diff --git a/src/trueskill/gaussian.rs b/src/trueskill/gaussian.rs new file mode 100644 index 0000000..8bfb7e5 --- /dev/null +++ b/src/trueskill/gaussian.rs @@ -0,0 +1,65 @@ +use std::cmp::Ordering; +use std::ops::{Div, Mul}; + +#[derive(Copy, Clone, Debug, Default, PartialEq)] +pub struct Gaussian { + pub pi: f64, + pub tau: f64, +} + +impl Gaussian { + pub fn with_mu_sigma(mu: f64, sigma: f64) -> Self { + assert_ne!(sigma, 0.0, "sigma^2 needs to be greater than 0"); + + let pi = sigma.powi(-2); + Self { pi, tau: pi * mu } + } + + pub const fn with_pi_tau(pi: f64, tau: f64) -> Self { + Self { pi, tau } + } + + pub fn mu(&self) -> f64 { + if self.pi == 0.0 { + return 0.0; + } + + self.tau / self.pi + } + + pub fn sigma(&self) -> f64 { + if self.pi == 0.0 { + return f64::INFINITY; + } + + self.pi.recip().sqrt() + } +} + +impl Mul for Gaussian { + type Output = Self; + + fn mul(self, rhs: Self) -> Self::Output { + Self { + pi: self.pi + rhs.pi, + tau: self.tau + rhs.tau, + } + } +} + +impl Div for Gaussian { + type Output = Self; + + fn div(self, rhs: Self) -> Self::Output { + Self { + pi: self.pi - rhs.pi, + tau: self.tau - rhs.tau, + } + } +} + +impl PartialOrd for Gaussian { + fn partial_cmp(&self, other: &Self) -> Option { + self.mu().partial_cmp(&other.mu()) + } +} diff --git a/src/trueskill/matrix.rs b/src/trueskill/matrix.rs new file mode 100644 index 0000000..d6ef084 --- /dev/null +++ b/src/trueskill/matrix.rs @@ -0,0 +1,238 @@ +use super::TrueSkillRating; + +// This Matrix could have been imported, but we implement it ourselves, since we only have to use some basic things here. +#[derive(Clone, Debug)] +pub struct Matrix { + data: Vec, + rows: usize, + cols: usize, +} + +impl Matrix { + pub fn set(&mut self, row: usize, col: usize, val: f64) { + self.data[row * self.cols + col] = val; + } + + pub fn get(&self, row: usize, col: usize) -> f64 { + self.data[row * self.cols + col] + } + + pub fn new(rows: usize, cols: usize) -> Self { + Self { + data: vec![0.0; rows * cols], + rows, + cols, + } + } + + pub fn new_from_data(data: &[f64], rows: usize, cols: usize) -> Self { + Self { + data: data.to_vec(), + rows, + cols, + } + } + + pub fn new_diagonal(data: &[f64]) -> Self { + let mut matrix = Self::new(data.len(), data.len()); + + for (i, val) in data.iter().enumerate() { + matrix.set(i, i, *val); + } + + matrix + } + + pub fn create_rotated_a_matrix(teams: &[&[TrueSkillRating]]) -> Self { + let total_players = teams.iter().map(|team| team.len()).sum::(); + + let mut player_assignments: Vec = vec![]; + + let mut total_previous_players = 0; + + let team_assignments_list_count = teams.len(); + + for current_column in 0..team_assignments_list_count - 1 { + let current_team = teams[current_column]; + + player_assignments.append(&mut vec![0.0; total_previous_players]); + + for _current_player in current_team { + player_assignments.push(1.0); // TODO: Replace 1.0 by partial play weighting + total_previous_players += 1; + } + + let mut rows_remaining = total_players - total_previous_players; + let next_team = teams[current_column + 1]; + + for _next_player in next_team { + player_assignments.push(-1.0 * 1.0); // TODO: Replace 1.0 by partial play weighting + rows_remaining -= 1; + } + + player_assignments.append(&mut vec![0.0; rows_remaining]); + } + + Self::new_from_data( + &player_assignments, + team_assignments_list_count - 1, + total_players, + ) + } + + pub fn transpose(&self) -> Self { + let mut matrix = Self::new(self.cols, self.rows); + + for i in 0..self.rows { + for j in 0..self.cols { + matrix.set(j, i, self.get(i, j)); + } + } + + matrix + } + + #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)] + pub fn determinant(&self) -> f64 { + assert_eq!(self.rows, self.cols, "Matrix must be square"); + + if self.rows == 1 { + return self.get(0, 0); + } + + let mut sum = 0.0; + + for i in 0..self.rows { + sum += self.get(0, i) * self.minor(0, i).determinant() * (-1.0_f64).powi(i as i32); + } + + sum + } + + pub fn minor(&self, row: usize, col: usize) -> Self { + let mut matrix = Self::new(self.rows - 1, self.cols - 1); + + for i in 0..self.rows { + for j in 0..self.cols { + if i != row && j != col { + matrix.set( + if i > row { i - 1 } else { i }, + if j > col { j - 1 } else { j }, + self.get(i, j), + ); + } + } + } + + matrix + } + + #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)] + pub fn adjugate(&self) -> Self { + let mut matrix = Self::new(self.rows, self.cols); + + for i in 0..self.rows { + for j in 0..self.cols { + matrix.set( + i, + j, + self.minor(j, i).determinant() * (-1.0_f64).powi((i + j) as i32), + ); + } + } + + matrix + } + + pub fn inverse(&self) -> Self { + let det = self.determinant(); + + // Avoiding 1/0 + assert!((det != 0.0), "Matrix is not invertible"); + + self.adjugate() * det.recip() + } +} + +impl std::ops::Mul for Matrix { + type Output = Self; + + fn mul(self, rhs: Self) -> Self::Output { + if self.cols == rhs.rows { + let mut matrix = Self::new(self.rows, rhs.cols); + + for i in 0..self.rows { + for j in 0..rhs.cols { + let mut sum = 0.0; + + for k in 0..self.cols { + sum += self.get(i, k) * rhs.get(k, j); + } + + matrix.set(i, j, sum); + } + } + + matrix + } else if self.rows == rhs.cols { + let mut matrix = Self::new(self.cols, rhs.rows); + + for i in 0..self.cols { + for j in 0..rhs.rows { + let mut sum = 0.0; + + for k in 0..self.rows { + sum += self.get(k, i) * rhs.get(j, k); + } + + matrix.set(i, j, sum); + } + } + + matrix + } else { + panic!("Cannot multiply matrices with incompatible dimensions"); + } + } +} + +impl std::ops::Mul for Matrix { + type Output = Self; + + fn mul(self, rhs: f64) -> Self::Output { + let mut matrix = Self::new(self.rows, self.cols); + + for i in 0..self.rows { + for j in 0..self.cols { + matrix.set(i, j, self.get(i, j) * rhs); + } + } + + matrix + } +} + +impl std::ops::Add for Matrix { + type Output = Self; + + fn add(self, rhs: Self) -> Self::Output { + assert_eq!( + self.rows, rhs.rows, + "Cannot add matrices with different row counts" + ); + assert_eq!( + self.cols, rhs.cols, + "Cannot add matrices with different column counts" + ); + + let mut matrix = Self::new(self.rows, self.cols); + + for i in 0..self.rows { + for j in 0..self.cols { + matrix.set(i, j, self.get(i, j) + rhs.get(i, j)); + } + } + + matrix + } +} diff --git a/src/trueskill.rs b/src/trueskill/mod.rs similarity index 58% rename from src/trueskill.rs rename to src/trueskill/mod.rs index dbc60ca..719e003 100644 --- a/src/trueskill.rs +++ b/src/trueskill/mod.rs @@ -62,17 +62,32 @@ //! //! # More Information //! - [Wikipedia Article](https://en.wikipedia.org/wiki/TrueSkill) -//! - [TrueSkill Ranking System](https://www.microsoft.com/en-us/research/project/trueskill-ranking-system/) //! - [Original Paper (PDF)](https://proceedings.neurips.cc/paper/2006/file/f44ee263952e65b3610b8ba51229d1f9-Paper.pdf) //! - [The math behind TrueSkill (PDF)](http://www.moserware.com/assets/computing-your-skill/The%20Math%20Behind%20TrueSkill.pdf) //! - [Moserware: Computing Your Skill](http://www.moserware.com/2010/03/computing-your-skill.html) +//! - [TrueSkill Calculator](https://trueskill-calculator.vercel.app/) +mod factor_graph; +mod gaussian; +mod matrix; + +use std::cell::RefCell; use std::f64::consts::{FRAC_1_SQRT_2, PI, SQRT_2}; +use std::rc::Rc; +use factor_graph::{LikelihoodFactor, PriorFactor, SumFactor, TruncateFactor, Variable}; +use gaussian::Gaussian; +use matrix::Matrix; #[cfg(feature = "serde")] use serde::{Deserialize, Serialize}; use crate::{weng_lin::WengLinRating, Outcomes}; +use crate::{ + MultiTeamOutcome, MultiTeamRatingSystem, Rating, RatingPeriodSystem, RatingSystem, + TeamRatingSystem, +}; + +const MIN_DELTA: f64 = 0.0001; #[derive(Copy, Clone, Debug, PartialEq)] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] @@ -104,6 +119,21 @@ impl Default for TrueSkillRating { } } +impl Rating for TrueSkillRating { + fn rating(&self) -> f64 { + self.rating + } + fn uncertainty(&self) -> Option { + Some(self.uncertainty) + } + fn new(rating: Option, uncertainty: Option) -> Self { + Self { + rating: rating.unwrap_or(25.0), + uncertainty: uncertainty.unwrap_or(25.0 / 3.0), + } + } +} + impl From<(f64, f64)> for TrueSkillRating { fn from((r, u): (f64, f64)) -> Self { Self { @@ -163,6 +193,96 @@ impl Default for TrueSkillConfig { } } +/// Struct to calculate ratings and expected score for [`TrueSkillRating`] +pub struct TrueSkill { + config: TrueSkillConfig, +} + +impl RatingSystem for TrueSkill { + type RATING = TrueSkillRating; + type CONFIG = TrueSkillConfig; + + fn new(config: Self::CONFIG) -> Self { + Self { config } + } + + fn rate( + &self, + player_one: &TrueSkillRating, + player_two: &TrueSkillRating, + outcome: &Outcomes, + ) -> (TrueSkillRating, TrueSkillRating) { + trueskill(player_one, player_two, outcome, &self.config) + } + + fn expected_score( + &self, + player_one: &TrueSkillRating, + player_two: &TrueSkillRating, + ) -> (f64, f64) { + expected_score(player_one, player_two, &self.config) + } +} + +impl RatingPeriodSystem for TrueSkill { + type RATING = TrueSkillRating; + type CONFIG = TrueSkillConfig; + + fn new(config: Self::CONFIG) -> Self { + Self { config } + } + + fn rate( + &self, + player: &TrueSkillRating, + results: &[(TrueSkillRating, Outcomes)], + ) -> TrueSkillRating { + trueskill_rating_period(player, results, &self.config) + } +} + +impl TeamRatingSystem for TrueSkill { + type RATING = TrueSkillRating; + type CONFIG = TrueSkillConfig; + + fn new(config: Self::CONFIG) -> Self { + Self { config } + } + + fn rate( + &self, + team_one: &[TrueSkillRating], + team_two: &[TrueSkillRating], + outcome: &Outcomes, + ) -> (Vec, Vec) { + trueskill_two_teams(team_one, team_two, outcome, &self.config) + } + + fn expected_score(&self, team_one: &[Self::RATING], team_two: &[Self::RATING]) -> (f64, f64) { + expected_score_two_teams(team_one, team_two, &self.config) + } +} + +impl MultiTeamRatingSystem for TrueSkill { + type RATING = TrueSkillRating; + type CONFIG = TrueSkillConfig; + + fn new(config: Self::CONFIG) -> Self { + Self { config } + } + + fn rate( + &self, + teams_and_ranks: &[(&[Self::RATING], MultiTeamOutcome)], + ) -> Vec> { + trueskill_multi_team(teams_and_ranks, &self.config) + } + + fn expected_score(&self, teams: &[&[Self::RATING]]) -> Vec { + expected_score_multi_team(teams, &self.config) + } +} + #[must_use] /// Calculates the [`TrueSkillRating`]s of two players based on their old ratings, uncertainties, and the outcome of the game. /// @@ -523,13 +643,211 @@ pub fn trueskill_two_teams( (new_team_one, new_team_two) } +#[must_use] +/// Calculates the [`TrueSkillRating`] of multiple teams based on their ratings, uncertainties, and the outcome of the game. +/// +/// Takes in a slice, which contains tuples of teams, which are just slices of [`TrueSkillRating`]s, +/// as well the rank of the team as an [`MultiTeamOutcome`] and a [`TrueSkillConfig`]. +/// +/// Ties are represented by several teams having the same rank. +/// +/// Similar to [`trueskill_two_teams`]. +/// +/// **Caution regarding usage of TrueSkill**: +/// Microsoft permits only Xbox Live games or non-commercial projects to use TrueSkill(TM). +/// If your project is commercial, you should use another rating system included here. +/// +/// # Examples +/// ``` +/// use skillratings::{ +/// trueskill::{trueskill_multi_team, TrueSkillConfig, TrueSkillRating}, +/// MultiTeamOutcome, +/// }; +/// +/// let team_one = vec![ +/// TrueSkillRating::new(), +/// TrueSkillRating { +/// rating: 30.0, +/// uncertainty: 1.2, +/// }, +/// TrueSkillRating { +/// rating: 21.0, +/// uncertainty: 6.5, +/// }, +/// ]; +/// +/// let team_two = vec![ +/// TrueSkillRating::default(), +/// TrueSkillRating { +/// rating: 41.0, +/// uncertainty: 1.4, +/// }, +/// TrueSkillRating { +/// rating: 19.2, +/// uncertainty: 4.3, +/// }, +/// ]; +/// +/// let team_three = vec![ +/// TrueSkillRating::default(), +/// TrueSkillRating { +/// rating: 29.4, +/// uncertainty: 1.6, +/// }, +/// TrueSkillRating { +/// rating: 17.2, +/// uncertainty: 2.1, +/// }, +/// ]; +/// +/// let teams_and_ranks = vec![ +/// (&team_one[..], MultiTeamOutcome::new(2)), // Team 1 takes the second place. +/// (&team_two[..], MultiTeamOutcome::new(1)), // Team 2 takes the first place. +/// (&team_three[..], MultiTeamOutcome::new(3)), // Team 3 takes the third place. +/// ]; +/// +/// let new_teams = trueskill_multi_team(&teams_and_ranks, &TrueSkillConfig::new()); +/// +/// assert_eq!(new_teams.len(), 3); +/// +/// let new_one = &new_teams[0]; +/// let new_two = &new_teams[1]; +/// let new_three = &new_teams[2]; +/// +/// assert!((new_one[0].rating - 25.622_306_739_859_763).abs() < f64::EPSILON); +/// assert!((new_one[1].rating - 30.012_965_086_723_046).abs() < f64::EPSILON); +/// assert!((new_one[2].rating - 21.378635787625903).abs() < f64::EPSILON); +/// +/// assert!((new_two[0].rating - 28.246_057_397_676_047).abs() < f64::EPSILON); +/// assert!((new_two[1].rating - 41.091_932_136_518_125).abs() < f64::EPSILON); +/// assert!((new_two[2].rating - 20.064_520_412_174_183).abs() < f64::EPSILON); +/// +/// assert!((new_three[0].rating - 21.131_635_862_464_19).abs() < f64::EPSILON); +/// assert!((new_three[1].rating - 29.257_024_085_611_56).abs() < f64::EPSILON); +/// assert!((new_three[2].rating - 16.953_981_169_279_245).abs() < f64::EPSILON); +/// ``` +pub fn trueskill_multi_team( + teams_and_ranks: &[(&[TrueSkillRating], MultiTeamOutcome)], + config: &TrueSkillConfig, +) -> Vec> { + if teams_and_ranks.is_empty() { + return Vec::new(); + } + + // Just returning the original teams if a team is empty. + for (team, _) in teams_and_ranks { + if team.is_empty() { + return teams_and_ranks + .iter() + .map(|(team, _)| team.to_vec()) + .collect(); + } + } + + let mut sorted_teams_and_ranks_with_pos = Vec::new(); + for (pos, (team, outcome)) in teams_and_ranks.iter().enumerate() { + sorted_teams_and_ranks_with_pos.push((pos, (*team, *outcome))); + } + sorted_teams_and_ranks_with_pos.sort_by_key(|v| v.1 .1); + + let teams_and_ranks: Vec<(&[TrueSkillRating], MultiTeamOutcome)> = + sorted_teams_and_ranks_with_pos + .iter() + .map(|v| v.1) + .collect(); + + let mut flattened_ratings = Vec::new(); + for (team, _) in &teams_and_ranks { + for player in *team { + flattened_ratings.push(*player); + } + } + + let rating_vars = { + let mut v = Vec::with_capacity(flattened_ratings.len()); + for _ in 0..flattened_ratings.len() { + v.push(Rc::new(RefCell::new(Variable::new()))); + } + + v + }; + let perf_vars = { + let mut v = Vec::with_capacity(flattened_ratings.len()); + for _ in 0..flattened_ratings.len() { + v.push(Rc::new(RefCell::new(Variable::new()))); + } + + v + }; + let team_perf_vars = { + let mut v = Vec::with_capacity(teams_and_ranks.len()); + for _ in 0..teams_and_ranks.len() { + v.push(Rc::new(RefCell::new(Variable::new()))); + } + + v + }; + let team_diff_vars = { + let mut v = Vec::with_capacity(teams_and_ranks.len() - 1); + for _ in 0..(teams_and_ranks.len() - 1) { + v.push(Rc::new(RefCell::new(Variable::new()))); + } + + v + }; + let team_sizes = team_sizes(&teams_and_ranks); + + let rating_layer = run_schedule( + &rating_vars, + &perf_vars, + &team_perf_vars, + &team_diff_vars, + &team_sizes, + &teams_and_ranks, + &flattened_ratings, + config.default_dynamics, + config.beta, + config.draw_probability, + MIN_DELTA, + ); + + let mut transformed_groups = Vec::new(); + let mut iter_team_sizes = vec![0]; + iter_team_sizes.extend_from_slice(&team_sizes[..(team_sizes.len() - 1)]); + + for (start, end) in iter_team_sizes.into_iter().zip(&team_sizes) { + let mut group = Vec::new(); + for f in &rating_layer[start..*end] { + let gaussian = f.variable.borrow().gaussian; + let mu = gaussian.mu(); + let sigma = gaussian.sigma(); + + group.push(TrueSkillRating { + rating: mu, + uncertainty: sigma, + }); + } + + transformed_groups.push(group); + } + + let mut unsorted_with_pos = sorted_teams_and_ranks_with_pos + .iter() + .map(|v| v.0) + .zip(transformed_groups) + .collect::>(); + unsorted_with_pos.sort_by_key(|v| v.0); + + unsorted_with_pos.into_iter().map(|v| v.1).collect() +} + #[must_use] /// Gets the quality of the match, which is equal to the probability that the match will end in a draw. /// The higher the Value, the better the quality of the match. /// /// Takes in two players as [`TrueSkillRating`]s and returns the probability of a draw occurring as an [`f64`] between 1.0 and 0.0. /// -/// Similar to [`match_quality_two_teams`]. +/// Similar to [`match_quality_two_teams`] and [`match_quality_multi_team`]. /// /// # Examples /// ``` @@ -576,7 +894,7 @@ pub fn match_quality( /// /// Takes in two teams as a Slice of [`TrueSkillRating`]s and returns the probability of a draw occurring as an [`f64`] between 1.0 and 0.0. /// -/// Similar to [`match_quality`]. +/// Similar to [`match_quality`] and [`match_quality_multi_team`]. /// /// # Examples /// ``` @@ -635,6 +953,103 @@ pub fn match_quality_two_teams( a * b } +#[must_use] +/// Gets the quality of the match, which is equal to the probability that the match will end in a draw. +/// The higher the Value, the better the quality of the match. +/// +/// Takes in multiple teams as a Slices of [`TrueSkillRating`]s, a [`TrueSkillConfig`] +/// and returns the probability of a draw occurring as an [`f64`] between 1.0 and 0.0. +/// +/// Similar to [`match_quality`] and [`match_quality_two_teams`]. +/// +/// # Examples +/// ``` +/// use skillratings::trueskill::{match_quality_multi_team, TrueSkillConfig, TrueSkillRating}; +/// +/// let team_one = vec![ +/// TrueSkillRating { +/// rating: 20.0, +/// uncertainty: 2.0, +/// }, +/// TrueSkillRating { +/// rating: 25.0, +/// uncertainty: 2.0, +/// }, +/// ]; +/// let team_two = vec![ +/// TrueSkillRating { +/// rating: 35.0, +/// uncertainty: 2.0, +/// }, +/// TrueSkillRating { +/// rating: 20.0, +/// uncertainty: 3.0, +/// }, +/// ]; +/// let team_three = vec![ +/// TrueSkillRating { +/// rating: 20.0, +/// uncertainty: 2.0, +/// }, +/// TrueSkillRating { +/// rating: 22.0, +/// uncertainty: 1.0, +/// }, +/// ]; +/// +/// let quality = match_quality_multi_team( +/// &[&team_one, &team_two, &team_three], +/// &TrueSkillConfig::new(), +/// ); +/// +/// // There is a ~28.6% chance of a draw occurring. +/// assert_eq!(quality, 0.285_578_468_347_742_1); +/// ``` +pub fn match_quality_multi_team(teams: &[&[TrueSkillRating]], config: &TrueSkillConfig) -> f64 { + if teams.is_empty() { + return 0.0; + } + + for team in teams { + if team.is_empty() { + return 0.0; + } + } + + let total_players = teams.iter().map(|t| t.len()).sum::(); + + let team_uncertainties_sq_flatten = teams + .iter() + .flat_map(|team| { + team.iter() + .map(|p| p.uncertainty.powi(2)) + .collect::>() + }) + .collect::>(); + let team_ratings_flatten = teams + .iter() + .flat_map(|team| team.iter().map(|p| p.rating).collect::>()) + .collect::>(); + + let mean_matrix = Matrix::new_from_data(&team_ratings_flatten, total_players, 1); + let variance_matrix = Matrix::new_diagonal(&team_uncertainties_sq_flatten); + + let rotated_a_matrix = Matrix::create_rotated_a_matrix(teams); + let a_matrix = rotated_a_matrix.transpose(); + + let a_ta = rotated_a_matrix.clone() * a_matrix.clone() * config.beta.powi(2); + let atsa = rotated_a_matrix.clone() * variance_matrix * a_matrix.clone(); + let start = a_matrix * mean_matrix.transpose(); + let middle = a_ta.clone() + atsa; + + let end = rotated_a_matrix * mean_matrix; + + let e_arg = (start * middle.inverse() * end * -0.5).determinant(); + let s_arg = a_ta.determinant() / middle.determinant(); + + e_arg.exp() * s_arg.sqrt() +} + #[must_use] /// Calculates the expected outcome of two players based on TrueSkill. /// @@ -642,7 +1057,7 @@ pub fn match_quality_two_teams( /// 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. /// -/// Similar to [`expected_score_two_teams`]. +/// Similar to [`expected_score_two_teams`] and [`expected_score_multi_team`]. /// /// To see the actual chances of a draw occurring, please use [`match_quality`]. /// @@ -697,7 +1112,7 @@ pub fn expected_score( /// 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. /// -/// Similar to [`expected_score`]. +/// Similar to [`expected_score`] and [`expected_score_multi_team`]. /// /// To see the actual chances of a draw occurring, please use [`match_quality_two_teams`]. /// @@ -760,6 +1175,116 @@ pub fn expected_score_two_teams( (exp_one, exp_two) } +#[must_use] +/// Calculates the expected outcome of multiple teams based on TrueSkill. +/// +/// Takes in multiple teams as Slices of [`TrueSkillRating`]s, a [`TrueSkillConfig`] +/// and returns the probability of victory for each team 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 `1 / Number of Teams` mean a draw is likely to occur. +/// +/// Similar to [`expected_score`] and [`expected_score_multi_team`]. +/// +/// To see the actual chances of a draw occurring, please use [`match_quality_multi_team`]. +/// +/// # Examples +/// ``` +/// use skillratings::trueskill::{expected_score_multi_team, TrueSkillConfig, TrueSkillRating}; +/// +/// let team_one = vec![ +/// TrueSkillRating { +/// rating: 38.0, +/// uncertainty: 3.0, +/// }, +/// TrueSkillRating { +/// rating: 38.0, +/// uncertainty: 3.0, +/// }, +/// ]; +/// +/// let team_two = vec![ +/// TrueSkillRating { +/// rating: 44.0, +/// uncertainty: 3.0, +/// }, +/// TrueSkillRating { +/// rating: 44.0, +/// uncertainty: 3.0, +/// }, +/// ]; +/// +/// let team_three = vec![ +/// TrueSkillRating { +/// rating: 50.0, +/// uncertainty: 3.0, +/// }, +/// TrueSkillRating { +/// rating: 50.0, +/// uncertainty: 3.0, +/// }, +/// ]; +/// +/// let exp = expected_score_multi_team( +/// &[&team_one, &team_two, &team_three], +/// &TrueSkillConfig::new(), +/// ); +/// +/// assert!((exp.iter().sum::() - 1.0).abs() < f64::EPSILON); +/// +/// // Team one has a 6% chance of winning, Team two a 33% and Team three a 61% chance. +/// assert!((exp[0] * 100.0 - 6.0).round().abs() < f64::EPSILON); +/// assert!((exp[1] * 100.0 - 33.0).round().abs() < f64::EPSILON); +/// assert!((exp[2] * 100.0 - 61.0).round().abs() < f64::EPSILON); +/// ``` +pub fn expected_score_multi_team( + teams: &[&[TrueSkillRating]], + config: &TrueSkillConfig, +) -> Vec { + let player_count = teams.iter().map(|t| t.len()).sum::() as f64; + + let mut win_probabilities = Vec::with_capacity(teams.len()); + let mut total_probability = 0.0; + + for (i, team_one) in teams.iter().enumerate() { + // We are calculating the probability of team_one winning against all other teams. + // We do this for every team, sum up the probabilities + // and then divide by the total probability to get the probability of winning for each team. + let mut current_team_probabilities = Vec::with_capacity(teams.len() - 1); + let team_one_ratings = team_one.iter().map(|p| p.rating).sum::(); + let team_one_uncertainties = team_one.iter().map(|p| p.uncertainty.powi(2)).sum::(); + + for (j, team_two) in teams.iter().enumerate() { + if i == j { + continue; + } + + let team_two_ratings = team_two.iter().map(|p| p.rating).sum::(); + let team_two_uncertainties = + team_two.iter().map(|p| p.uncertainty.powi(2)).sum::(); + + let delta = team_one_ratings - team_two_ratings; + let denom = (team_two_uncertainties + + player_count.mul_add(config.beta.powi(2), team_one_uncertainties)) + .sqrt(); + + let result = cdf(delta / denom, 0.0, 1.0); + + current_team_probabilities.push(result); + total_probability += result; + } + + win_probabilities.push(current_team_probabilities); + } + + let mut expected_scores = Vec::new(); + + for probability in win_probabilities { + expected_scores.push(probability.iter().sum::() / total_probability); + } + + expected_scores +} + #[must_use] /// Gets the conservatively estimated rank of a player using their rating and deviation. /// @@ -787,11 +1312,11 @@ pub fn expected_score_two_teams( /// assert!((older_rank.round() - 37.0).abs() < f64::EPSILON); /// ``` pub fn get_rank(player: &TrueSkillRating) -> f64 { - player.rating - (player.uncertainty * 3.0) + player.uncertainty.mul_add(-3.0, player.rating) } fn draw_margin(draw_probability: f64, beta: f64, total_players: f64) -> f64 { - inverse_cdf(0.5 * (draw_probability + 1.0), 0.0, 1.0) * total_players.sqrt() * beta + inverse_cdf((draw_probability + 1.0) / 2.0, 0.0, 1.0) * total_players.sqrt() * beta } fn v_non_draw(difference: f64, draw_margin: f64, c: f64) -> f64 { @@ -866,7 +1391,7 @@ fn w_draw(difference: f64, draw_margin: f64, c: f64) -> f64 { v.mul_add( v, - ((draw_c - diff_c_abs) * p1 - (-draw_c - diff_c_abs) * p2) / norm, + (draw_c - diff_c_abs).mul_add(p1, -(-draw_c - diff_c_abs) * p2) / norm, ) } @@ -887,7 +1412,7 @@ fn new_uncertainty(uncertainty: f64, c: f64, w: f64, default_dynamics: f64) -> f let variance = uncertainty.mul_add(uncertainty, default_dynamics.powi(2)); let dev_multiplier = variance / c.powi(2); - (variance * (1.0 - w * dev_multiplier)).sqrt() + (variance * w.mul_add(-dev_multiplier, 1.0)).sqrt() } // The following functions could have been imported from some math crate, @@ -924,7 +1449,7 @@ fn erfc(x: f64) -> f64 { ), 1.000_023_68, ), - -z * z - 1.265_512_23, + (-z).mul_add(z, -1.265_512_23), ) .exp(); @@ -957,7 +1482,7 @@ fn inverse_erfc(y: f64) -> f64 { for _ in 0..2 { let err = erfc(x) - y; - x += err / (1.128_379_167_095_512_57 * (-(x.powi(2))).exp() - x * err); + x += err / 1.128_379_167_095_512_57f64.mul_add((-(x.powi(2))).exp(), -x * err); } if zero_point { @@ -975,7 +1500,7 @@ fn cdf(x: f64, mu: f64, sigma: f64) -> f64 { /// The inverse of the cumulative distribution function. fn inverse_cdf(x: f64, mu: f64, sigma: f64) -> f64 { - mu - sigma * SQRT_2 * inverse_erfc(2.0 * x) + (sigma * SQRT_2).mul_add(-inverse_erfc(2.0 * x), mu) } /// The probability density function. @@ -983,6 +1508,278 @@ fn pdf(x: f64, mu: f64, sigma: f64) -> f64 { ((2.0 * PI).sqrt() * sigma.abs()).recip() * (-(((x - mu) / sigma.abs()).powi(2) / 2.0)).exp() } +#[allow(clippy::too_many_arguments)] +fn run_schedule( + rating_vars: &[Rc>], + perf_vars: &[Rc>], + team_perf_vars: &[Rc>], + team_diff_vars: &[Rc>], + team_sizes: &[usize], + sorted_teams_and_ranks: &[(&[TrueSkillRating], MultiTeamOutcome)], + flattened_ratings: &[TrueSkillRating], + tau: f64, + beta: f64, + draw_probability: f64, + min_delta: f64, +) -> Vec { + assert!(!(min_delta <= 0.0), "min_delta must be greater than 0"); + + let mut id = 0; + + let mut rating_layer = build_rating_layer(rating_vars, flattened_ratings, tau, id); + id += >::try_into(rating_layer.len()).unwrap(); + let mut perf_layer = build_perf_layer(rating_vars, perf_vars, beta, id); + id += >::try_into(perf_layer.len()).unwrap(); + let mut team_perf_layer = build_team_perf_layer(team_perf_vars, perf_vars, team_sizes, id); + id += >::try_into(team_perf_layer.len()).unwrap(); + + for factor in &mut rating_layer { + factor.down(); + } + for factor in &mut perf_layer { + factor.down(); + } + for factor in &mut team_perf_layer { + factor.down(); + } + + let mut team_diff_layer = build_team_diff_layer(team_diff_vars, team_perf_vars, id); + let team_diff_len = team_diff_layer.len(); + id += >::try_into(team_diff_len).unwrap(); + let mut trunc_layer = build_trunc_layer( + team_diff_vars, + sorted_teams_and_ranks, + draw_probability, + beta, + id, + ); + id += >::try_into(trunc_layer.len()).unwrap(); + + let mut delta: f64 = 0.0; + for _ in 0..10 { + if team_diff_len == 1 { + team_diff_layer[0].down(); + delta = trunc_layer[0].up(); + } else { + delta = 0.0; + for x in 0..(team_diff_len - 1) { + team_diff_layer[x].down(); + delta = delta.max(trunc_layer[x].up()); + team_diff_layer[x].up(1); + } + for x in (1..team_diff_len).rev() { + team_diff_layer[x].down(); + delta = delta.max(trunc_layer[x].up()); + team_diff_layer[x].up(0); + } + } + if delta <= min_delta { + break; + } + } + + team_diff_layer[0].up(0); + team_diff_layer[team_diff_len - 1].up(1); + for f in &mut team_perf_layer { + for x in 0..f.terms_len() { + f.up(x); + } + } + for f in &mut perf_layer { + f.up(); + } + + rating_layer +} + +fn build_rating_layer( + rating_vars: &[Rc>], + flattened_ratings: &[TrueSkillRating], + tau: f64, + starting_id: u32, +) -> Vec { + let mut v = Vec::with_capacity(rating_vars.len()); + let mut i = starting_id; + for (var, rating) in rating_vars.iter().zip(flattened_ratings) { + v.push(PriorFactor::new( + i, + Rc::clone(var), + Gaussian::with_mu_sigma(rating.rating, rating.uncertainty), + tau, + )); + i += 1; + } + + v +} + +fn build_perf_layer( + rating_vars: &[Rc>], + perf_vars: &[Rc>], + beta: f64, + starting_id: u32, +) -> Vec { + let beta_sq = beta.powi(2); + let mut v = Vec::with_capacity(rating_vars.len()); + let mut i = starting_id; + for (rating_var, perf_var) in rating_vars.iter().zip(perf_vars) { + v.push(LikelihoodFactor::new( + i, + Rc::clone(rating_var), + Rc::clone(perf_var), + beta_sq, + )); + i += 1; + } + + v +} + +fn build_team_perf_layer( + team_perf_vars: &[Rc>], + perf_vars: &[Rc>], + team_sizes: &[usize], + starting_id: u32, +) -> Vec { + let mut v = Vec::with_capacity(team_perf_vars.len()); + let mut i = starting_id; + for (team, team_perf_var) in team_perf_vars.iter().enumerate() { + let start = if team > 0 { team_sizes[team - 1] } else { 0 }; + + let end = team_sizes[team]; + let child_perf_vars = perf_vars[start..end].to_vec(); + let coeffs = vec![1.0; child_perf_vars.len()]; + + v.push(SumFactor::new( + i, + Rc::clone(team_perf_var), + child_perf_vars, + coeffs, + )); + i += 1; + } + + v +} + +fn build_team_diff_layer( + team_diff_vars: &[Rc>], + team_perf_vars: &[Rc>], + starting_id: u32, +) -> Vec { + let mut v = Vec::with_capacity(team_diff_vars.len()); + let mut i = starting_id; + for (team, team_diff_var) in team_diff_vars.iter().enumerate() { + v.push(SumFactor::new( + i, + Rc::clone(team_diff_var), + team_perf_vars[team..(team + 2)].to_vec(), + vec![1.0, -1.0], + )); + i += 1; + } + + v +} + +fn build_trunc_layer( + team_diff_vars: &[Rc>], + sorted_teams_and_ranks: &[(&[TrueSkillRating], MultiTeamOutcome)], + draw_probability: f64, + beta: f64, + starting_id: u32, +) -> Vec { + fn v_w(diff: f64, draw_margin: f64) -> f64 { + let x = diff - draw_margin; + let denom = cdf(x, 0.0, 1.0); + + if denom == 0.0 { + -x + } else { + pdf(x, 0.0, 1.0) / denom + } + } + + fn v_d(diff: f64, draw_margin: f64) -> f64 { + let abs_diff = diff.abs(); + let a = draw_margin - abs_diff; + let b = -draw_margin - abs_diff; + let denom = cdf(a, 0.0, 1.0) - cdf(b, 0.0, 1.0); + let numer = pdf(b, 0.0, 1.0) - pdf(a, 0.0, 1.0); + + let lhs = if denom == 0.0 { a } else { numer / denom }; + let rhs = if diff < 0.0 { -1.0 } else { 1.0 }; + + lhs * rhs + } + + fn w_w(diff: f64, draw_margin: f64) -> f64 { + let x = diff - draw_margin; + let v = v_w(diff, draw_margin); + let w = v * (v + x); + if 0.0 < w && w < 1.0 { + return w; + } + + panic!("floating point error"); + } + + fn w_d(diff: f64, draw_margin: f64) -> f64 { + let abs_diff = diff.abs(); + let a = draw_margin - abs_diff; + let b = -draw_margin - abs_diff; + let denom = cdf(a, 0.0, 1.0) - cdf(b, 0.0, 1.0); + + assert!(!(denom == 0.0), "floating point error"); + + let v = v_d(abs_diff, draw_margin); + v.mul_add(v, (a * pdf(a, 0.0, 1.0) - b * pdf(b, 0.0, 1.0)) / denom) + } + + let mut v = Vec::with_capacity(team_diff_vars.len()); + let mut i = starting_id; + for (x, team_diff_var) in team_diff_vars.iter().enumerate() { + let size = sorted_teams_and_ranks[x..(x + 2)] + .iter() + .map(|v| v.0.len() as f64) + .sum(); + let draw_margin = draw_margin(draw_probability, beta, size); + let v_func: Box f64>; + let w_func: Box f64>; + if sorted_teams_and_ranks[x].1 == sorted_teams_and_ranks[x + 1].1 { + v_func = Box::new(v_d); + w_func = Box::new(w_d); + } else { + v_func = Box::new(v_w); + w_func = Box::new(w_w); + }; + + v.push(TruncateFactor::new( + i, + Rc::clone(team_diff_var), + v_func, + w_func, + draw_margin, + )); + i += 1; + } + + v +} + +fn team_sizes(teams_and_ranks: &[(&[TrueSkillRating], MultiTeamOutcome)]) -> Vec { + let mut team_sizes = Vec::new(); + for (team, _) in teams_and_ranks { + if team_sizes.is_empty() { + team_sizes.push(team.len()); + } else { + team_sizes.push(team.len() + team_sizes[team_sizes.len() - 1]); + } + } + + team_sizes +} + #[cfg(test)] mod tests { use super::*; @@ -1235,7 +2032,7 @@ mod tests { assert!((team_two[0].rating - 23.356_662_026_148_804).abs() < f64::EPSILON); assert!((team_two[1].rating - 29.075_310_476_318_872).abs() < f64::EPSILON); - assert!((team_one[0].uncertainty - 6.555_663_733_192_404).abs() < f64::EPSILON); + assert!((team_one[0].uncertainty - 6.555_663_733_192_403).abs() < f64::EPSILON); assert!((team_one[1].uncertainty - 5.417_723_612_401_869).abs() < f64::EPSILON); assert!((team_two[0].uncertainty - 3.832_975_356_683_128).abs() < f64::EPSILON); assert!((team_two[1].uncertainty - 2.930_957_525_591_959_5).abs() < f64::EPSILON); @@ -1374,8 +2171,8 @@ mod tests { let (exp1, exp2) = expected_score(&player_one, &player_two, &TrueSkillConfig::new()); - assert!((exp1 * 100.0 - 50.0).round().abs() < f64::EPSILON); - assert!((exp2 * 100.0 - 50.0).round().abs() < f64::EPSILON); + assert!(exp1.mul_add(100.0, -50.0).round().abs() < f64::EPSILON); + assert!(exp2.mul_add(100.0, -50.0).round().abs() < f64::EPSILON); let better_player = TrueSkillRating { rating: 44.0, @@ -1389,10 +2186,118 @@ mod tests { let (exp1, exp2) = expected_score(&better_player, &worse_player, &TrueSkillConfig::default()); - assert!((exp1 * 100.0 - 80.0).round().abs() < f64::EPSILON); - assert!((exp2 * 100.0 - 20.0).round().abs() < f64::EPSILON); + assert!(exp1.mul_add(100.0, -80.0).round().abs() < f64::EPSILON); + assert!(exp2.mul_add(100.0, -20.0).round().abs() < f64::EPSILON); assert!((exp1.mul_add(100.0, exp2 * 100.0).round() - 100.0).abs() < f64::EPSILON); + + // Testing if the other functions give the same result. + let team_one = [TrueSkillRating::from((44.0, 3.0))]; + let team_two = [TrueSkillRating::from((38.0, 3.0))]; + + let (e0, e1) = expected_score_two_teams(&team_one, &team_two, &TrueSkillConfig::new()); + let e = expected_score_multi_team(&[&team_one, &team_two], &TrueSkillConfig::new()); + + assert!((e0 - e[0]).abs() < f64::EPSILON); + assert!((e1 - e[1]).abs() < f64::EPSILON); + assert!((exp1 - e[0]).abs() < f64::EPSILON); + assert!((exp2 - e[1]).abs() < f64::EPSILON); + } + + #[test] + fn test_match_quality_multi_team() { + let team_one = vec![TrueSkillRating::new(); 2]; + let team_two = vec![TrueSkillRating::from((30.0, 3.0)); 2]; + let team_three = vec![TrueSkillRating::from((40.0, 2.0)); 2]; + + let exp = match_quality_multi_team( + &[&team_one, &team_two, &team_three], + &TrueSkillConfig::new(), + ); + + // Double checked this with the most popular python implementation. + assert!((exp - 0.017_538_349_223_941_27).abs() < f64::EPSILON); + + let exp = match_quality_multi_team(&[], &TrueSkillConfig::default()); + + assert!(exp < f64::EPSILON); + + let exp = match_quality_multi_team(&[&team_one, &[]], &TrueSkillConfig::default()); + + assert!(exp < f64::EPSILON); + } + + #[test] + fn test_multi_team_expected() { + let team_one = vec![ + TrueSkillRating { + rating: 38.0, + uncertainty: 3.0, + }, + TrueSkillRating { + rating: 38.0, + uncertainty: 3.0, + }, + ]; + + let team_two = vec![ + TrueSkillRating { + rating: 44.0, + uncertainty: 3.0, + }, + TrueSkillRating { + rating: 44.0, + uncertainty: 3.0, + }, + ]; + + let team_three = vec![ + TrueSkillRating { + rating: 50.0, + uncertainty: 3.0, + }, + TrueSkillRating { + rating: 50.0, + uncertainty: 3.0, + }, + ]; + + let exp = expected_score_multi_team( + &[&team_one, &team_two, &team_three], + &TrueSkillConfig::new(), + ); + + assert!((exp.iter().sum::() - 1.0).abs() < f64::EPSILON); + + assert_eq!( + exp, + vec![ + 0.058_904_655_169_257_615, + 0.333_333_333_333_333_3, + 0.607_762_011_497_409 + ] + ); + + let team_one = vec![TrueSkillRating::new(); 10]; + let team_two = vec![TrueSkillRating::new(); 10]; + let team_three = vec![TrueSkillRating::new(); 10]; + let team_four = vec![TrueSkillRating::new(); 10]; + + let exp = expected_score_multi_team( + &[&team_one, &team_two, &team_three, &team_four], + &TrueSkillConfig::new(), + ); + + assert!((exp.iter().sum::() - 1.0).abs() < f64::EPSILON); + assert_eq!( + exp, + vec![ + 0.249_999_999_999_999_97, + 0.249_999_999_999_999_97, + 0.249_999_999_999_999_97, + 0.249_999_999_999_999_97 + ] + ); } #[test] @@ -1489,6 +2394,26 @@ mod tests { assert!(v3 == NEG_INFINITY); } + #[test] + fn test_matrix_panics() { + use std::panic::catch_unwind; + + let result = catch_unwind(|| Matrix::new(2, 3).determinant()); + assert!(result.is_err()); + + let result = catch_unwind(|| Matrix::new(2, 2).inverse()); + assert!(result.is_err()); + + let result = catch_unwind(|| Matrix::new(2, 2) * Matrix::new(3, 3)); + assert!(result.is_err()); + + let result = catch_unwind(|| Matrix::new(3, 2) + Matrix::new(2, 2)); + assert!(result.is_err()); + + let result = catch_unwind(|| Matrix::new(2, 2) + Matrix::new(2, 3)); + assert!(result.is_err()); + } + #[test] #[allow(clippy::clone_on_copy)] fn test_misc_stuff() { @@ -1498,9 +2423,130 @@ mod tests { assert_eq!(player_one, player_one.clone()); assert!((config.beta - config.clone().beta).abs() < f64::EPSILON); - assert!(!format!("{:?}", player_one).is_empty()); - assert!(!format!("{:?}", config).is_empty()); + assert!(!format!("{player_one:?}").is_empty()); + assert!(!format!("{config:?}").is_empty()); + + assert!(!format!("{:?}", Matrix::new(2, 3)).is_empty()); assert_eq!(player_one, TrueSkillRating::from((25.0, 25.0 / 3.0))); } + + #[test] + fn test_traits() { + let player_one: TrueSkillRating = Rating::new(Some(24.0), Some(2.0)); + let player_two: TrueSkillRating = Rating::new(Some(24.0), Some(2.0)); + + let rating_system: TrueSkill = RatingSystem::new(TrueSkillConfig::new()); + + assert!((player_one.rating() - 24.0).abs() < f64::EPSILON); + assert_eq!(player_one.uncertainty(), Some(2.0)); + + let (new_player_one, new_player_two) = + RatingSystem::rate(&rating_system, &player_one, &player_two, &Outcomes::WIN); + + let (exp1, exp2) = RatingSystem::expected_score(&rating_system, &player_one, &player_two); + + assert!((new_player_one.rating - 24.534_185_520_312_818).abs() < f64::EPSILON); + assert!((new_player_two.rating - 23.465_814_479_687_182).abs() < f64::EPSILON); + assert!((exp1 + exp2 - 1.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)); + + let rating_period: TrueSkill = RatingPeriodSystem::new(TrueSkillConfig::new()); + + let new_player_one = + RatingPeriodSystem::rate(&rating_period, &player_one, &[(player_two, Outcomes::WIN)]); + + assert!((new_player_one.rating - 24.534_185_520_312_818).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)); + + let team_rating: TrueSkill = TeamRatingSystem::new(TrueSkillConfig::new()); + + let (new_team_one, new_team_two) = + TeamRatingSystem::rate(&team_rating, &[player_one], &[player_two], &Outcomes::WIN); + + assert!((new_team_one[0].rating - 24.534_185_520_312_818).abs() < f64::EPSILON); + assert!((new_team_two[0].rating - 23.465_814_479_687_182).abs() < f64::EPSILON); + + let (exp1, exp2) = + TeamRatingSystem::expected_score(&rating_system, &[player_one], &[player_two]); + + assert!((exp1 + exp2 - 1.0).abs() < f64::EPSILON); + } + + #[test] + /// This example is taken from the Python TrueSkill package: + /// https://github.com/sublee/trueskill/blob/3ff78b26c8374b30cc0d6bae32cb45c9bee46a1d/trueskilltest.py#L311 + fn test_trueskill_multi_team() { + let t1p1 = TrueSkillRating { + rating: 40.0, + uncertainty: 4.0, + }; + let t1p2 = TrueSkillRating { + rating: 45.0, + uncertainty: 3.0, + }; + + let t2p1 = TrueSkillRating { + rating: 20.0, + uncertainty: 7.0, + }; + let t2p2 = TrueSkillRating { + rating: 19.0, + uncertainty: 6.0, + }; + let t2p3 = TrueSkillRating { + rating: 30.0, + uncertainty: 9.0, + }; + let t2p4 = TrueSkillRating { + rating: 10.0, + uncertainty: 4.0, + }; + + let t3p1 = TrueSkillRating { + rating: 50.0, + uncertainty: 5.0, + }; + let t3p2 = TrueSkillRating { + rating: 30.0, + uncertainty: 2.0, + }; + + let t1 = [t1p1, t1p2]; + let t2 = [t2p1, t2p2, t2p3, t2p4]; + let t3 = [t3p1, t3p2]; + + let teams_and_ranks = [ + (&t1[..], MultiTeamOutcome::new(0)), + (&t2[..], MultiTeamOutcome::new(1)), + (&t3[..], MultiTeamOutcome::new(1)), + ]; + let results = trueskill_multi_team(&teams_and_ranks, &TrueSkillConfig::new()); + + assert!((results[0][0].rating - 40.876_849_177_315_655).abs() < f64::EPSILON); + assert!((results[0][1].rating - 45.493_394_092_398_44).abs() < f64::EPSILON); + + assert!((results[1][0].rating - 19.608_650_920_845_236).abs() < f64::EPSILON); + assert!((results[1][1].rating - 18.712_463_514_890_54).abs() < f64::EPSILON); + assert!((results[1][2].rating - 29.353_112_227_810_637).abs() < f64::EPSILON); + assert!((results[1][3].rating - 9.872_175_198_037_164).abs() < f64::EPSILON); + + assert!((results[2][0].rating - 48.829_832_201_455_32).abs() < f64::EPSILON); + assert!((results[2][1].rating - 29.812_500_188_903_005).abs() < f64::EPSILON); + + assert!((results[0][0].uncertainty - 3.839_527_589_355_37).abs() < f64::EPSILON); + assert!((results[0][1].uncertainty - 2.933_671_613_522_051).abs() < f64::EPSILON); + + assert!((results[1][0].uncertainty - 6.396_044_310_523_897).abs() < f64::EPSILON); + assert!((results[1][1].uncertainty - 5.624_556_429_622_889).abs() < f64::EPSILON); + assert!((results[1][2].uncertainty - 7.673_456_361_986_594).abs() < f64::EPSILON); + assert!((results[1][3].uncertainty - 3.891_408_425_994_520_3).abs() < f64::EPSILON); + + assert!((results[2][0].uncertainty - 4.590_018_525_151_38).abs() < f64::EPSILON); + assert!((results[2][1].uncertainty - 1.976_314_792_712_798_2).abs() < f64::EPSILON); + } } diff --git a/src/uscf.rs b/src/uscf.rs index b2bc4ee..48e7b4d 100644 --- a/src/uscf.rs +++ b/src/uscf.rs @@ -64,7 +64,7 @@ #[cfg(feature = "serde")] use serde::{Deserialize, Serialize}; -use crate::{elo::EloRating, Outcomes}; +use crate::{elo::EloRating, Outcomes, Rating, RatingPeriodSystem, RatingSystem}; #[derive(Copy, Clone, Debug, PartialEq)] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] @@ -109,6 +109,21 @@ impl Default for USCFRating { } } +impl Rating for USCFRating { + fn rating(&self) -> f64 { + self.rating + } + fn uncertainty(&self) -> Option { + None + } + fn new(rating: Option, _uncertainty: Option) -> Self { + Self { + rating: rating.unwrap_or(1300.0), + games: 0, + } + } +} + impl From<(f64, usize)> for USCFRating { fn from((r, g): (f64, usize)) -> Self { Self { @@ -162,6 +177,46 @@ impl Default for USCFConfig { } } +/// Struct to calculate ratings and expected score for [`USCFRating`] +pub struct USCF { + config: USCFConfig, +} + +impl RatingSystem for USCF { + type RATING = USCFRating; + type CONFIG = USCFConfig; + + fn new(config: Self::CONFIG) -> Self { + Self { config } + } + + fn rate( + &self, + player_one: &USCFRating, + player_two: &USCFRating, + outcome: &Outcomes, + ) -> (USCFRating, USCFRating) { + uscf(player_one, player_two, outcome, &self.config) + } + + fn expected_score(&self, player_one: &USCFRating, player_two: &USCFRating) -> (f64, f64) { + expected_score(player_one, player_two) + } +} + +impl RatingPeriodSystem for USCF { + type RATING = USCFRating; + type CONFIG = USCFConfig; + + fn new(config: Self::CONFIG) -> Self { + Self { config } + } + + fn rate(&self, player: &USCFRating, results: &[(USCFRating, Outcomes)]) -> USCFRating { + uscf_rating_period(player, results, &self.config) + } +} + #[must_use] /// Calculates the [`USCFRating`]s of two players based on their old ratings, deviations, and the outcome of the game. /// @@ -743,9 +798,40 @@ mod tests { assert_eq!(player_one, player_one.clone()); assert!((config.t - config.clone().t).abs() < f64::EPSILON); - assert!(!format!("{:?}", player_one).is_empty()); - assert!(!format!("{:?}", config).is_empty()); + assert!(!format!("{player_one:?}").is_empty()); + assert!(!format!("{config:?}").is_empty()); assert_eq!(player_one, USCFRating::from((1300.0, 0))); } + + #[test] + fn test_traits() { + let player_one: USCFRating = Rating::new(Some(240.0), Some(90.0)); + let player_two: USCFRating = Rating::new(Some(240.0), Some(90.0)); + + let rating_system: USCF = RatingSystem::new(USCFConfig::new()); + + assert!((player_one.rating() - 240.0).abs() < f64::EPSILON); + assert_eq!(player_one.uncertainty(), None); + + let (new_player_one, new_player_two) = + RatingSystem::rate(&rating_system, &player_one, &player_two, &Outcomes::WIN); + + let (exp1, exp2) = RatingSystem::expected_score(&rating_system, &player_one, &player_two); + + assert!((new_player_one.rating - 640.0).abs() < f64::EPSILON); + assert!((new_player_two.rating - 100.0).abs() < f64::EPSILON); + assert!((exp1 - 0.5).abs() < f64::EPSILON); + assert!((exp2 - 0.5).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)); + + let rating_period: USCF = RatingPeriodSystem::new(USCFConfig::new()); + + let new_player_one = + RatingPeriodSystem::rate(&rating_period, &player_one, &[(player_two, Outcomes::WIN)]); + + assert!((new_player_one.rating - 640.0).abs() < f64::EPSILON); + } } diff --git a/src/weng_lin.rs b/src/weng_lin.rs index 7b9749c..f2f3889 100644 --- a/src/weng_lin.rs +++ b/src/weng_lin.rs @@ -60,7 +60,10 @@ #[cfg(feature = "serde")] use serde::{Deserialize, Serialize}; -use crate::{trueskill::TrueSkillRating, MultiTeamOutcome, Outcomes}; +use crate::{ + trueskill::TrueSkillRating, MultiTeamOutcome, MultiTeamRatingSystem, Outcomes, Rating, + RatingPeriodSystem, RatingSystem, TeamRatingSystem, +}; use std::cmp::Ordering; #[derive(Copy, Clone, Debug, PartialEq)] @@ -95,6 +98,21 @@ impl Default for WengLinRating { } } +impl Rating for WengLinRating { + fn rating(&self) -> f64 { + self.rating + } + fn uncertainty(&self) -> Option { + Some(self.uncertainty) + } + fn new(rating: Option, uncertainty: Option) -> Self { + Self { + rating: rating.unwrap_or(25.0), + uncertainty: uncertainty.unwrap_or(25.0 / 3.0), + } + } +} + impl From<(f64, f64)> for WengLinRating { fn from((r, u): (f64, f64)) -> Self { Self { @@ -148,6 +166,88 @@ impl Default for WengLinConfig { } } +/// Struct to calculate ratings and expected score for [`WengLinRating`] +pub struct WengLin { + config: WengLinConfig, +} + +impl RatingSystem for WengLin { + type RATING = WengLinRating; + type CONFIG = WengLinConfig; + + fn new(config: Self::CONFIG) -> Self { + Self { config } + } + + fn rate( + &self, + player_one: &WengLinRating, + player_two: &WengLinRating, + outcome: &Outcomes, + ) -> (WengLinRating, WengLinRating) { + weng_lin(player_one, player_two, outcome, &self.config) + } + + fn expected_score(&self, player_one: &WengLinRating, player_two: &WengLinRating) -> (f64, f64) { + expected_score(player_one, player_two, &self.config) + } +} + +impl RatingPeriodSystem for WengLin { + type RATING = WengLinRating; + type CONFIG = WengLinConfig; + + fn new(config: Self::CONFIG) -> Self { + Self { config } + } + + fn rate(&self, player: &WengLinRating, results: &[(WengLinRating, Outcomes)]) -> WengLinRating { + weng_lin_rating_period(player, results, &self.config) + } +} + +impl TeamRatingSystem for WengLin { + type RATING = WengLinRating; + type CONFIG = WengLinConfig; + + fn new(config: Self::CONFIG) -> Self { + Self { config } + } + + fn rate( + &self, + team_one: &[WengLinRating], + team_two: &[WengLinRating], + outcome: &Outcomes, + ) -> (Vec, Vec) { + weng_lin_two_teams(team_one, team_two, outcome, &self.config) + } + + fn expected_score(&self, team_one: &[Self::RATING], team_two: &[Self::RATING]) -> (f64, f64) { + expected_score_two_teams(team_one, team_two, &self.config) + } +} + +impl MultiTeamRatingSystem for WengLin { + type RATING = WengLinRating; + type CONFIG = WengLinConfig; + + fn new(config: Self::CONFIG) -> Self { + Self { config } + } + + fn rate( + &self, + teams_and_ranks: &[(&[Self::RATING], MultiTeamOutcome)], + ) -> Vec> { + weng_lin_multi_team(teams_and_ranks, &self.config) + } + + fn expected_score(&self, teams: &[&[Self::RATING]]) -> Vec { + expected_score_multi_team(teams, &self.config) + } +} + #[must_use] /// Calculates the [`WengLinRating`]s of two players based on their old ratings, uncertainties, and the outcome of the game. /// @@ -620,7 +720,7 @@ pub fn weng_lin_multi_team( /// 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. /// -/// Similar to [`expected_score_teams`]. +/// Similar to [`expected_score_two_teams`] and [`expected_score_multi_team`]. /// /// # Examples /// ``` @@ -876,8 +976,8 @@ fn new_uncertainty_teams( uncertainty_tolerance: f64, large_delta: f64, ) -> f64 { - let new_player_uncertainty_sq = (1.0 - - ((player_uncertainty_sq / team_uncertainty_sq) * large_delta)) + let new_player_uncertainty_sq = (player_uncertainty_sq / team_uncertainty_sq) + .mul_add(-large_delta, 1.0) .max(uncertainty_tolerance); (player_uncertainty_sq * new_player_uncertainty_sq).sqrt() } @@ -1322,9 +1422,77 @@ mod tests { assert_eq!(player_one, player_one.clone()); assert!((config.beta - config.clone().beta).abs() < f64::EPSILON); - assert!(!format!("{:?}", player_one).is_empty()); - assert!(!format!("{:?}", config).is_empty()); + assert!(!format!("{player_one:?}").is_empty()); + assert!(!format!("{config:?}").is_empty()); assert_eq!(player_one, WengLinRating::from((25.0, 25.0 / 3.0))); } + + #[test] + fn test_traits() { + let player_one: WengLinRating = Rating::new(Some(24.0), Some(2.0)); + let player_two: WengLinRating = Rating::new(Some(24.0), Some(2.0)); + + let rating_system: WengLin = RatingSystem::new(WengLinConfig::new()); + + assert!((player_one.rating() - 24.0).abs() < f64::EPSILON); + assert_eq!(player_one.uncertainty(), Some(2.0)); + + let (new_player_one, new_player_two) = + RatingSystem::rate(&rating_system, &player_one, &player_two, &Outcomes::WIN); + + let (exp1, exp2) = RatingSystem::expected_score(&rating_system, &player_one, &player_two); + + assert!((new_player_one.rating - 24.305_987_072_319_287).abs() < f64::EPSILON); + assert!((new_player_two.rating - 23.694_012_927_680_713).abs() < f64::EPSILON); + + assert!((exp1 + exp2 - 1.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)); + + let rating_period: WengLin = RatingPeriodSystem::new(WengLinConfig::new()); + + let new_player_one = + RatingPeriodSystem::rate(&rating_period, &player_one, &[(player_two, Outcomes::WIN)]); + + assert!((new_player_one.rating - 24.305_987_072_319_287).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)); + + let team_rating: WengLin = TeamRatingSystem::new(WengLinConfig::new()); + + let (new_team_one, new_team_two) = + TeamRatingSystem::rate(&team_rating, &[player_one], &[player_two], &Outcomes::WIN); + + assert!((new_team_one[0].rating - 24.305_987_072_319_287).abs() < f64::EPSILON); + assert!((new_team_two[0].rating - 23.694_012_927_680_713).abs() < f64::EPSILON); + + let (exp1, exp2) = + TeamRatingSystem::expected_score(&rating_system, &[player_one], &[player_two]); + + assert!((exp1 + exp2 - 1.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)); + + let multi_team_rating: WengLin = MultiTeamRatingSystem::new(WengLinConfig::new()); + + let new_teams = MultiTeamRatingSystem::rate( + &multi_team_rating, + &[ + (&[player_one], MultiTeamOutcome::new(1)), + (&[player_two], MultiTeamOutcome::new(2)), + ], + ); + + assert!((new_teams[0][0].rating - 24.305_987_072_319_287).abs() < f64::EPSILON); + assert!((new_teams[1][0].rating - 23.694_012_927_680_713).abs() < f64::EPSILON); + + let exp = + MultiTeamRatingSystem::expected_score(&rating_system, &[&[player_one], &[player_two]]); + + assert!((exp[0] + exp[1] - 1.0).abs() < f64::EPSILON); + } }