From cfa3a7f8e99e81c9f0698c41b9f551f8c986c511 Mon Sep 17 00:00:00 2001 From: atomflunder <80397293+atomflunder@users.noreply.github.com> Date: Fri, 26 Aug 2022 17:35:53 +0200 Subject: [PATCH] Add Weng-Lin calculations --- CHANGELOG.md | 5 + Cargo.toml | 6 +- README.md | 38 ++- src/config.rs | 33 +++ src/dwz.rs | 6 - src/elo.rs | 4 - src/glicko.rs | 4 - src/glicko2.rs | 4 - src/ingo.rs | 3 - src/lib.rs | 12 +- src/rating.rs | 33 ++- src/trueskill.rs | 39 ++- src/weng_lin.rs | 682 +++++++++++++++++++++++++++++++++++++++++++++++ 13 files changed, 816 insertions(+), 53 deletions(-) create mode 100644 src/weng_lin.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 0288bd8..c185c78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ This is a broad overview of the changes that have been made over the lifespan of this library. +## v0.12.0 - 2022-08-26 + +- Add Weng-Lin (A Bayesian Approximation Method for Online Ranking) calculations +- Return original teams when a team is empty in `trueskill::trueskill_teams` + ## v0.11.0 - 2022-08-26 - Add `new` and `default` implementations for `DWZRating` diff --git a/Cargo.toml b/Cargo.toml index bebee5e..b5dc815 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,12 +1,12 @@ [package] name = "skillratings" -version = "0.11.0" +version = "0.12.0" edition = "2021" -description = "Calculate a player's skill rating using Elo, DWZ, Ingo, TrueSkill, Glicko and Glicko-2 algorithms." +description = "Calculate a player's skill rating using algorithms like Elo, Glicko, Glicko-2, TrueSkill and many more." readme= "README.md" repository = "https://github.com/atomflunder/skillratings" license = "MIT" -keywords = ["elo", "glicko-2", "glicko", "dwz", "trueskill"] +keywords = ["elo", "glicko", "glicko-2", "trueskill", "rating"] categories = ["game-development", "algorithms", "mathematics"] # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/README.md b/README.md index 5d0c547..ed78dee 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ Currently supported algorithms: - [Glicko](#glicko-rating-system) - [Glicko-2](#glicko-2-rating-system) - [TrueSkill](#trueskill-rating-system) +- [Weng-Lin (Bayesian Approxmation Method)](#weng-lin-rating-system) - [DWZ (Deutsche Wertungszahl)](#dwz-deutsche-wertungszahl-rating-system) - [Ingo](#ingo-rating-system) @@ -24,7 +25,7 @@ Add the following to your `Cargo.toml` file: ```toml [dependencies] -skillratings = "0.11.0" +skillratings = "0.12.0" ``` ## Basic Usage @@ -171,6 +172,41 @@ assert!(((p2.rating * 100.0).round() - 2983.0).abs() < f64::EPSILON); assert!(((p2.uncertainty * 100.0).round() - 120.0).abs() < f64::EPSILON); ``` +## Weng-Lin rating system + +(A Bayesian Approximation Method for Online Ranking) + +- [Documentation](https://docs.rs/skillratings/latest/skillratings/weng_lin/index.html) +- [Original Paper (PDF)](https://jmlr.csail.mit.edu/papers/volume12/weng11a/weng11a.pdf) + +```rust +use skillratings::{ + rating::WengLinRating, weng_lin::weng_lin, outcomes::Outcomes, config::WengLinConfig +}; + +let player_one = WengLinRating { + rating: 42.0, + uncertainty: 1.3, +}; +let player_two = WengLinRating { + rating: 25.0, + uncertainty: 8.333, +}; + +// The config allows you to change certain adjustable values in the algorithms. +let config = WengLinConfig::new(); + +// The outcome is from the perspective of player one. +let outcome = Outcomes::WIN; + +let (player_one, player_two) = weng_lin(player_one, player_two, outcome, &config); + +assert!(((player_one.rating * 100.0).round() - 4203.0).abs() < f64::EPSILON); +assert!(((player_one.uncertainty * 100.0).round() - 130.0).abs() < f64::EPSILON); +assert!(((player_two.rating * 100.0).round() - 2391.0).abs() < f64::EPSILON); +assert!(((player_two.uncertainty * 100.0).round() - 803.0).abs() < f64::EPSILON); +``` + ### DWZ (Deutsche Wertungszahl) rating system - [Documentation](https://docs.rs/skillratings/latest/skillratings/dwz/index.html) diff --git a/src/config.rs b/src/config.rs index e409cbf..680f237 100644 --- a/src/config.rs +++ b/src/config.rs @@ -114,3 +114,36 @@ impl Default for TrueSkillConfig { Self::new() } } + +/// Constants used in the Weng calculations. +pub struct WengLinConfig { + /// The skill-class width, aka the number of difference in rating points + /// needed to have an 80% win probability against another player. + /// By default set to 25 / 6 ≈ `4.167`. + /// If your game is more reliant on pure skill, decrease this value, + /// if there are more random factors, increase it. + pub beta: f64, + /// The lower ceiling of the sigma value, in the uncertainty calculations. + /// The lower this value, the lower the possible uncertainty values. + /// By default set to 0.000_001. + /// Do not set this to a negative value. + pub uncertainty_tolerance: f64, +} + +impl WengLinConfig { + #[must_use] + /// Initialize a new `WengConfig` with a beta value of 25 / 6 ≈ `4.167` + /// and an uncertainty tolerance of `0.000_001`. + pub fn new() -> Self { + Self { + beta: 25.0 / 6.0, + uncertainty_tolerance: 0.000_001, + } + } +} + +impl Default for WengLinConfig { + fn default() -> Self { + Self::new() + } +} diff --git a/src/dwz.rs b/src/dwz.rs index 30d93ae..e5fff62 100644 --- a/src/dwz.rs +++ b/src/dwz.rs @@ -58,9 +58,6 @@ use crate::{outcomes::Outcomes, rating::DWZRating}; /// assert!((player_two_new.rating.round() - 1464.0).abs() < f64::EPSILON); /// assert_eq!(player_two_new.index, 13); /// ``` -/// -/// # More -/// [Wikipedia Article on DWZ](https://en.wikipedia.org/wiki/Deutsche_Wertungszahl) pub fn dwz( player_one: DWZRating, player_two: DWZRating, @@ -285,9 +282,6 @@ pub fn expected_score(player_one: DWZRating, player_two: DWZRating) -> (f64, f64 /// assert!((player.rating - 1491.0).abs() < f64::EPSILON); /// assert_eq!(player.index, 1); /// ``` -/// -/// # More -/// [Probability Table](https://www.schachbund.de/wertungsordnung-anhang-2-tabellen/articles/wertungsordnung-anhang-21-wahrscheinlichkeitstabelle.html) pub fn get_first_dwz(player_age: usize, results: &Vec<(DWZRating, Outcomes)>) -> Option { if results.len() < 5 { return None; diff --git a/src/elo.rs b/src/elo.rs index 24119db..950642d 100644 --- a/src/elo.rs +++ b/src/elo.rs @@ -36,10 +36,6 @@ use crate::{config::EloConfig, outcomes::Outcomes, rating::EloRating}; /// assert!((player_one_new.rating - 1016.0).abs() < f64::EPSILON); /// assert!((player_two_new.rating - 984.0).abs() < f64::EPSILON); /// ``` -/// -/// # More -/// [Wikipedia Article on the Elo system](https://en.wikipedia.org/wiki/Elo_rating_system) -/// [Elo Calculator](https://www.omnicalculator.com/sports/elo) #[must_use] pub fn elo( player_one: EloRating, diff --git a/src/glicko.rs b/src/glicko.rs index c7cc21e..ee1c934 100644 --- a/src/glicko.rs +++ b/src/glicko.rs @@ -56,10 +56,6 @@ use std::f64::consts::PI; /// assert!((player_two_new.rating.round() - 1338.0).abs() < f64::EPSILON); /// assert!((player_two_new.deviation.round() - 290.0).abs() < f64::EPSILON); /// ``` -/// -/// # More -/// [Wikipedia Article on the Glicko system](https://en.wikipedia.org/wiki/Glicko_rating_system). -/// [Example of the Glicko system](http://www.glicko.net/glicko/glicko.pdf). pub fn glicko( player_one: GlickoRating, player_two: GlickoRating, diff --git a/src/glicko2.rs b/src/glicko2.rs index 7668f6a..9188678 100644 --- a/src/glicko2.rs +++ b/src/glicko2.rs @@ -59,10 +59,6 @@ use std::f64::consts::PI; /// assert!((player_two_new.deviation.round() - 290.0).abs() < f64::EPSILON); /// assert!((player_two_new.volatility - 0.05999578094735206).abs() < f64::EPSILON); /// ``` -/// -/// # More -/// [Wikipedia Article on the Glicko-2 system](https://en.wikipedia.org/wiki/Glicko-2). -/// [Example of the Glicko-2 system](http://www.glicko.net/glicko/glicko2.pdf). #[must_use] pub fn glicko2( player_one: Glicko2Rating, diff --git a/src/ingo.rs b/src/ingo.rs index c4699fd..5b9ff8b 100644 --- a/src/ingo.rs +++ b/src/ingo.rs @@ -46,9 +46,6 @@ use crate::{outcomes::Outcomes, rating::IngoRating}; /// assert!((p1.rating.round() - 129.0).abs() < f64::EPSILON); /// assert!((p2.rating.round() - 161.0).abs() < f64::EPSILON); /// ``` -/// -/// # More: -/// [Wikipedia Article on the Ingo system (in german, no english version available)](https://de.wikipedia.org/wiki/Ingo-Zahl). pub fn ingo( player_one: IngoRating, player_two: IngoRating, diff --git a/src/lib.rs b/src/lib.rs index 528213e..2cf5d08 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -12,11 +12,12 @@ //! //! Currently we support these skill rating systems: //! **[`Elo`](crate::elo)**, -//! **[`DWZ`](crate::dwz)**, -//! **[`Ingo`](crate::ingo)**, +//! **[`Glicko`](crate::glicko)**, +//! **[`Glicko-2`](crate::glicko2)**, //! **[`TrueSkill`](crate::trueskill)**, -//! **[`Glicko`](crate::glicko)** -//! and **[`Glicko-2`](crate::glicko2)**. +//! **[`Weng-Lin`](crate::weng_lin)**, +//! **[`DWZ (Deutsche Wertungszahl)`](crate::dwz)**, +//! and **[`Ingo`](crate::ingo)**. //! //! You can use this crate to calculate results for two players instantly, //! or for one player in a rating period with the algorithms mentioned above. @@ -28,7 +29,7 @@ //! Add the following to your `Cargo.toml` file: //! ```toml //! [dependencies] -//! skillratings = "0.11.0" +//! skillratings = "0.12.0" //! ``` //! //! # Examples and Usage @@ -44,3 +45,4 @@ pub mod ingo; pub mod outcomes; pub mod rating; pub mod trueskill; +pub mod weng_lin; diff --git a/src/rating.rs b/src/rating.rs index bc21a94..5846040 100644 --- a/src/rating.rs +++ b/src/rating.rs @@ -156,7 +156,7 @@ pub struct TrueSkillRating { impl TrueSkillRating { #[must_use] - /// Initialize a new `TrueSkillRating` with a rating of 25.0, and an uncertainty of 25/3 ≈ 8.33. + /// Initialize a new TrueSkillRating with a rating of 25.0, and an uncertainty of 25/3 ≈ 8.33. pub fn new() -> Self { Self { rating: 25.0, @@ -212,3 +212,34 @@ impl From for IngoRating { } } } + +#[derive(Copy, Clone, Debug, PartialEq)] +/// The Weng rating of a player. +/// +/// Similar to [`TrueSkillRating`]. +/// +/// The default rating is 25.0. +/// The default uncertainty is 25/3 ≈ 8.33. +pub struct WengLinRating { + /// The rating value (mu) of the WengRating, by default 25.0. + pub rating: f64, + /// The uncertainty value (sigma) of the WengRating, by default 25/3 ≈ 8.33. + pub uncertainty: f64, +} + +impl WengLinRating { + #[must_use] + /// Initialize a new WengRating with a rating of 25.0, and an uncertainty of 25/3 ≈ 8.33. + pub fn new() -> Self { + Self { + rating: 25.0, + uncertainty: 25.0 / 3.0, + } + } +} + +impl Default for WengLinRating { + fn default() -> Self { + Self::new() + } +} diff --git a/src/trueskill.rs b/src/trueskill.rs index 476cd99..99b2603 100644 --- a/src/trueskill.rs +++ b/src/trueskill.rs @@ -1,6 +1,7 @@ //! The TrueSkill rating algorithm, developed by Microsoft for Halo 3. -//! Used in the Halo games, the Forza Games, Tom Clancy's: Rainbow Six Siege, and most Xbox Live games. -//! Unlike the other rating algorithms, TrueSkill supports teams. +//! Used in the Halo games, the Forza Games, Tom Clancy's: Rainbow Six Siege, and most Xbox Live games. +//! +//! Developed specifically for online games with multiple teams and multiple players. //! //! **Caution:** TrueSkill is patented. If you have a commercial project, it is recommended to use another algorithm included here. //! @@ -65,11 +66,6 @@ use crate::{config::TrueSkillConfig, outcomes::Outcomes, rating::TrueSkillRating /// assert!(((player_two.rating * 100.0).round() - 4960.0).abs() < f64::EPSILON); /// assert!(((player_two.uncertainty * 100.0).round() - 121.0).abs() < f64::EPSILON); /// ``` -/// -/// # More: -/// [Wikipedia Article about TrueSkill](https://en.wikipedia.org/wiki/TrueSkill). -/// [TrueSkill: A Bayesian Skill Rating System (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). pub fn trueskill( player_one: TrueSkillRating, player_two: TrueSkillRating, @@ -191,10 +187,6 @@ pub fn trueskill( /// assert!(((player.rating * 100.0).round() - 3277.0).abs() < f64::EPSILON); /// assert!(((player.uncertainty * 100.0).round() - 566.0).abs() < f64::EPSILON); /// ``` -/// # More: -/// [Wikipedia Article about TrueSkill](https://en.wikipedia.org/wiki/TrueSkill). -/// [TrueSkill: A Bayesian Skill Rating System (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). pub fn trueskill_rating_period( player: TrueSkillRating, results: &Vec<(TrueSkillRating, Outcomes)>, @@ -301,17 +293,16 @@ pub fn trueskill_rating_period( /// assert!((team_two[0].rating - 27.574_109_105_332_1).abs() < f64::EPSILON); /// assert!((team_two[1].rating - 36.210_764_756_738_115).abs() < f64::EPSILON); /// ``` -/// -/// # More: -/// [Wikipedia Article about TrueSkill](https://en.wikipedia.org/wiki/TrueSkill). -/// [TrueSkill: A Bayesian Skill Rating System (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). pub fn trueskill_teams( team_one: Vec, team_two: Vec, outcome: Outcomes, config: &TrueSkillConfig, ) -> (Vec, Vec) { + if team_one.is_empty() || team_two.is_empty() { + return (team_one, team_two); + } + let total_players = (team_one.len() + team_two.len()) as f64; let draw_margin = draw_margin(config.draw_probability, config.beta, total_players); @@ -524,9 +515,6 @@ pub fn match_quality_teams( /// /// assert!((exp1.mul_add(100.0, exp2 * 100.0).round() - 100.0).abs() < f64::EPSILON); /// ``` -/// -/// # More -/// pub fn expected_score( player_one: TrueSkillRating, player_two: TrueSkillRating, @@ -606,9 +594,6 @@ pub fn expected_score( /// assert!(((exp1 * 100.0).round() - 12.0).abs() < f64::EPSILON); /// assert!(((exp2 * 100.0).round() - 88.0).abs() < f64::EPSILON); /// ``` -/// -/// # More -/// pub fn expected_score_teams( team_one: Vec, team_two: Vec, @@ -1060,6 +1045,7 @@ mod tests { } #[test] + #[allow(clippy::cognitive_complexity)] /// This test is taken from: /// fn test_teams() { @@ -1149,6 +1135,15 @@ mod tests { 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); + + let (team_one, _) = trueskill_teams( + vec![player_one], + vec![], + Outcomes::WIN, + &TrueSkillConfig::new(), + ); + + assert_eq!(team_one[0], player_one); } #[test] diff --git a/src/weng_lin.rs b/src/weng_lin.rs new file mode 100644 index 0000000..4867b62 --- /dev/null +++ b/src/weng_lin.rs @@ -0,0 +1,682 @@ +//! A bayesian approximation method for online ranking. Similar to TrueSkill, but based on a logistical distribution. +//! Used in games such as Rocket League. +//! +//! Developed by Ruby C. Weng and Chih-Jen Lin. +//! Unlike with the other algorithms, there does not seem to exist a *short* name everyone agrees upon, +//! so we are just calling it Weng-Lin, for short, after the researchers. +//! But the proper name would be `A Bayesian Approximation Method for Online Ranking`. +//! +//! Developed specifically for online games with multiple teams and multiple players, +//! this algorithm aims to be simpler and faster than TrueSkill while yielding similar accuracy. +//! +//! While TrueSkill is based upon a Gaussian distibution, this algorithm is based upon a logistical distribution, the Bradley-Terry model. +//! +//! # More Information +//! - [Original Paper (PDF)](https://jmlr.csail.mit.edu/papers/volume12/weng11a/weng11a.pdf) +//! - [Bradley-Terry model Wikipedia](https://en.wikipedia.org/wiki/Bradley–Terry_model) +//! - [Approximate Bayesian computaion Wikipedia](https://en.wikipedia.org/wiki/Approximate_Bayesian_computation) +//! - [Logistic distribution Wikipedia](https://en.wikipedia.org/wiki/Logistic_distribution) + +use crate::{config::WengLinConfig, outcomes::Outcomes, rating::WengLinRating}; + +#[must_use] +#[allow(clippy::needless_pass_by_value)] +/// Calculates the [`WengLinRating`]s of two players based on their old ratings, uncertainties, and the outcome of the game. +/// +/// Takes in two players as [`WengLinRating`]s, an [`Outcome`](Outcomes), and a [`WengLinConfig`]. +/// +/// The outcome of the match is in the perspective of `player_one`. +/// This means [`Outcomes::WIN`] is a win for `player_one` and [`Outcomes::LOSS`] is a win for `player_two`. +/// +/// Similar to [`weng_lin_rating_period`] and [`weng_lin_teams`]. +/// +/// # Examples +/// ``` +/// use skillratings::{ +/// rating::WengLinRating, weng_lin::weng_lin, outcomes::Outcomes, config::WengLinConfig +/// }; +/// +/// let player_one = WengLinRating { +/// rating: 42.0, +/// uncertainty: 1.3, +/// }; +/// let player_two = WengLinRating::new(); +/// +/// let (player_one, player_two) = weng_lin(player_one, player_two, Outcomes::WIN, &WengLinConfig::new()); +/// +/// assert!(((player_one.rating * 100.0).round() - 4203.0).abs() < f64::EPSILON); +/// assert!(((player_one.uncertainty * 100.0).round() - 130.0).abs() < f64::EPSILON); +/// assert!(((player_two.rating * 100.0).round() - 2391.0).abs() < f64::EPSILON); +/// assert!(((player_two.uncertainty * 100.0).round() - 803.0).abs() < f64::EPSILON); +/// ``` +pub fn weng_lin( + player_one: WengLinRating, + player_two: WengLinRating, + outcome: Outcomes, + config: &WengLinConfig, +) -> (WengLinRating, WengLinRating) { + let c = 2.0f64 + .mul_add( + config.beta.powi(2), + player_one + .uncertainty + .mul_add(player_one.uncertainty, player_two.uncertainty.powi(2)), + ) + .sqrt(); + + let (p1, p2) = expected_score(player_one, player_two, config); + + let outcome1 = match outcome { + Outcomes::WIN => 1.0, + Outcomes::DRAW => 0.5, + Outcomes::LOSS => 0.0, + }; + + let outcome2 = match outcome { + Outcomes::WIN => 0.0, + Outcomes::DRAW => 0.5, + Outcomes::LOSS => 1.0, + }; + + let delta1 = (player_one.uncertainty.powi(2) / c) * (outcome1 - p1); + // You could also set gamma to 1/k, with k being the amount of teams in a match. + let gamma1 = player_one.uncertainty / c; + let eta1 = gamma1 * (player_one.uncertainty / c).powi(2) * p1 * p2; + + let delta2 = (player_two.uncertainty.powi(2) / c) * (outcome2 - p2); + let gamma2 = player_two.uncertainty / c; + let eta2 = gamma2 * (player_two.uncertainty / c).powi(2) * p2 * p1; + + let new_rating1 = delta1 + player_one.rating; + let sigma1 = (1.0 - 1.0 * eta1).max(config.uncertainty_tolerance); + let new_uncertainty1 = (player_one.uncertainty.powi(2) * sigma1).sqrt(); + + let new_rating2 = delta2 + player_two.rating; + let sigma2 = (1.0 - 1.0 * eta2).max(config.uncertainty_tolerance); + let new_uncertainty2 = (player_two.uncertainty.powi(2) * sigma2).sqrt(); + + ( + WengLinRating { + rating: new_rating1, + uncertainty: new_uncertainty1, + }, + WengLinRating { + rating: new_rating2, + uncertainty: new_uncertainty2, + }, + ) +} + +#[must_use] +/// Calculates a [`WengLinRating`] in a non-traditional way using a rating period, +/// for compatibility with the other algorithms. +/// +/// Takes in a player as an [`WengLinRating`] and their results as a Vec of tuples containing the opponent as an [`WengLinRating`], +/// the outcome of the game as an [`Outcome`](Outcomes) and a [`WengLinConfig`]. +/// +/// The outcome of the match is in the perspective of the player. +/// This means [`Outcomes::WIN`] is a win for the player and [`Outcomes::LOSS`] is a win for the opponent. +/// +/// Similar to [`weng_lin`] or [`weng_lin_teams`]. +/// +/// # Examples +/// ``` +/// use skillratings::{ +/// rating::WengLinRating, weng_lin::weng_lin_rating_period, outcomes::Outcomes, config::WengLinConfig +/// }; +/// +/// let player = WengLinRating::new(); +/// +/// let opponent_one = WengLinRating::new(); +/// let opponent_two = WengLinRating { +/// rating: 12.0, +/// uncertainty: 4.2, +/// }; +/// +/// let player = weng_lin_rating_period( +/// player, +/// &vec![ +/// (opponent_one, Outcomes::WIN), +/// (opponent_two, Outcomes::DRAW), +/// ], +/// &WengLinConfig::new(), +/// ); +/// +/// assert!(((player.rating * 100.0).round() - 2578.0).abs() < f64::EPSILON); +/// assert!(((player.uncertainty * 100.0).round() - 780.0).abs() < f64::EPSILON); +/// ``` +pub fn weng_lin_rating_period( + player: WengLinRating, + results: &Vec<(WengLinRating, Outcomes)>, + config: &WengLinConfig, +) -> WengLinRating { + let mut player = player; + + for (opponent, result) in results { + let c = 2.0f64 + .mul_add( + config.beta.powi(2), + player + .uncertainty + .mul_add(player.uncertainty, opponent.uncertainty.powi(2)), + ) + .sqrt(); + + let (p1, p2) = expected_score(player, *opponent, config); + + let outcome = match result { + Outcomes::WIN => 1.0, + Outcomes::DRAW => 0.5, + Outcomes::LOSS => 0.0, + }; + + let delta = (player.uncertainty.powi(2) / c) * (outcome - p1); + let gamma = player.uncertainty / c; + let eta = gamma * (player.uncertainty / c).powi(2) * p1 * p2; + + let new_rating = delta + player.rating; + let sigma = (1.0 - 1.0 * eta).max(config.uncertainty_tolerance); + let new_uncertainty = (player.uncertainty.powi(2) * sigma).sqrt(); + + player = WengLinRating { + rating: new_rating, + uncertainty: new_uncertainty, + } + } + + player +} + +#[must_use] +/// Calculates the [`WengLinRating`] of two teams based on their ratings, uncertainties, and the outcome of the game. +/// +/// Takes in two teams as a Vec of [`WengLinRating`]s, the outcome of the game as an [`Outcome`](Outcomes) and a [`WengLinConfig`]. +/// +/// The outcome of the match is in the perspective of `team_one`. +/// This means [`Outcomes::WIN`] is a win for `team_one` and [`Outcomes::LOSS`] is a win for `team_two`. +/// +/// Similar to [`weng_lin`]. +/// +/// # Examples +/// ``` +/// use skillratings::{ +/// rating::WengLinRating, weng_lin::weng_lin_teams, outcomes::Outcomes, config::WengLinConfig +/// }; +/// +/// let team_one = vec![ +/// WengLinRating::new(), +/// WengLinRating { +/// rating: 30.0, +/// uncertainty: 1.2, +/// }, +/// WengLinRating { +/// rating: 21.0, +/// uncertainty: 6.5, +/// }, +/// ]; +/// +/// let team_two = vec![ +/// WengLinRating::default(), +/// WengLinRating { +/// rating: 41.0, +/// uncertainty: 1.4, +/// }, +/// WengLinRating { +/// rating: 19.2, +/// uncertainty: 4.3, +/// }, +/// ]; +/// +/// let (team_one, team_two) = +/// weng_lin_teams(team_one, team_two, Outcomes::WIN, &WengLinConfig::new()); +/// +/// assert!(((team_one[0].rating * 100.0).round() - 2790.0).abs() < f64::EPSILON); +/// assert!(((team_one[1].rating * 100.0).round() - 3006.0).abs() < f64::EPSILON); +/// assert!(((team_one[2].rating * 100.0).round() - 2277.0).abs() < f64::EPSILON); +/// +/// assert!(((team_two[0].rating * 100.0).round() - 2210.0).abs() < f64::EPSILON); +/// assert!(((team_two[1].rating * 100.0).round() - 4092.0).abs() < f64::EPSILON); +/// assert!(((team_two[2].rating * 100.0).round() - 1843.0).abs() < f64::EPSILON); +/// ``` +pub fn weng_lin_teams( + team_one: Vec, + team_two: Vec, + outcome: Outcomes, + config: &WengLinConfig, +) -> (Vec, Vec) { + if team_one.is_empty() || team_two.is_empty() { + return (team_one, team_two); + } + let team_one_uncertainties = team_one.iter().map(|p| p.uncertainty.powi(2)).sum::(); + let team_two_uncertainties = team_two.iter().map(|p| p.uncertainty.powi(2)).sum::(); + + let c = 2.0f64 + .mul_add( + config.beta.powi(2), + team_one_uncertainties + team_two_uncertainties, + ) + .sqrt(); + + let (p1, p2) = expected_score_teams(team_one.clone(), team_two.clone(), config); + + let outcome1 = match outcome { + Outcomes::WIN => 1.0, + Outcomes::DRAW => 0.5, + Outcomes::LOSS => 0.0, + }; + + let outcome2 = match outcome { + Outcomes::WIN => 0.0, + Outcomes::DRAW => 0.5, + Outcomes::LOSS => 1.0, + }; + + let delta1 = (team_one_uncertainties / c) * (outcome1 - p1); + let gamma1 = team_one_uncertainties.sqrt() / c; + let eta1 = gamma1 * (team_one_uncertainties.sqrt() / c).powi(2) * p1 * p2; + + let delta2 = (team_two_uncertainties / c) * (outcome2 - p2); + let gamma2 = team_two_uncertainties.sqrt() / c; + let eta2 = gamma2 * (team_two_uncertainties.sqrt() / c).powi(2) * p2 * p1; + + let mut new_team_one = Vec::new(); + let mut new_team_two = Vec::new(); + + for player in team_one { + let new_rating = + (player.uncertainty.powi(2) / team_one_uncertainties).mul_add(delta1, player.rating); + let sigma = (1.0 - (player.uncertainty.powi(2) / team_one_uncertainties) * eta1) + .max(config.uncertainty_tolerance); + + let new_uncertainty = (player.uncertainty.powi(2) * sigma).sqrt(); + + new_team_one.push(WengLinRating { + rating: new_rating, + uncertainty: new_uncertainty, + }); + } + + for player in team_two { + let new_rating = + (player.uncertainty.powi(2) / team_two_uncertainties).mul_add(delta2, player.rating); + let sigma = (1.0 - (player.uncertainty.powi(2) / team_two_uncertainties) * eta2) + .max(config.uncertainty_tolerance); + + let new_uncertainty = (player.uncertainty.powi(2) * sigma).sqrt(); + + new_team_two.push(WengLinRating { + rating: new_rating, + uncertainty: new_uncertainty, + }); + } + + (new_team_one, new_team_two) +} + +#[must_use] +/// Calculates the expected outcome of two players based on the Bradley-Terry model. +/// +/// Takes in two players as [`WengLinRating`]s and a [`WengLinConfig`], +/// and returns the probability of victory for each player as an [`f64`] between 1.0 and 0.0. +/// +/// 1.0 means a certain victory for the player, 0.0 means certain loss. +/// Values near 0.5 mean a draw is likely to occur. +/// +/// Similar to [`expected_score_teams`]. +/// +/// # Examples +/// ``` +/// use skillratings::{ +/// rating::WengLinRating, weng_lin::expected_score, config::WengLinConfig +/// }; +/// +/// let p1 = WengLinRating { +/// rating: 42.0, +/// uncertainty: 2.1, +/// }; +/// let p2 = WengLinRating { +/// rating: 31.0, +/// uncertainty: 1.2, +/// }; +/// +/// let (exp1, exp2) = expected_score(p1, p2, &WengLinConfig::new()); +/// +/// assert!((exp1 + exp2 - 1.0).abs() < f64::EPSILON); +/// +/// assert!(((exp1 * 100.0).round() - 85.0).abs() < f64::EPSILON); +/// ``` +pub fn expected_score( + player_one: WengLinRating, + player_two: WengLinRating, + config: &WengLinConfig, +) -> (f64, f64) { + let c = 2.0f64 + .mul_add( + config.beta.powi(2), + player_one + .uncertainty + .mul_add(player_one.uncertainty, player_two.uncertainty.powi(2)), + ) + .sqrt(); + + let e1 = (player_one.rating / c).exp(); + let e2 = (player_two.rating / c).exp(); + + (e1 / (e1 + e2), e2 / (e1 + e2)) +} + +#[must_use] +#[allow(clippy::needless_pass_by_value)] +/// Calculates the expected outcome of two teams based on the Bradley-Terry model. +/// +/// Takes in two teams as Vec of [`WengLinRating`]s and a [`WengLinConfig`], +/// and returns the probability of victory for each player as an [`f64`] between 1.0 and 0.0. +/// +/// 1.0 means a certain victory for the player, 0.0 means certain loss. +/// Values near 0.5 mean a draw is likely to occur. +/// +/// Similar to [`expected_score`]. +/// +/// # Examples +/// ``` +/// use skillratings::{ +/// rating::WengLinRating, weng_lin::expected_score_teams, config::WengLinConfig +/// }; +/// +/// let team_one = vec![ +/// WengLinRating { +/// rating: 42.0, +/// uncertainty: 2.1, +/// }, +/// WengLinRating::new(), +/// WengLinRating { +/// rating: 12.0, +/// uncertainty: 3.2, +/// }, +/// +/// ]; +/// let team_two = vec![ +/// WengLinRating { +/// rating: 31.0, +/// uncertainty: 1.2, +/// }, +/// WengLinRating::new(), +/// WengLinRating { +/// rating: 41.0, +/// uncertainty: 1.2, +/// }, +/// ]; +/// +/// let (exp1, exp2) = expected_score_teams(team_one, team_two, &WengLinConfig::new()); +/// +/// assert!((exp1 + exp2 - 1.0).abs() < f64::EPSILON); +/// +/// assert!(((exp1 * 100.0).round() - 21.0).abs() < f64::EPSILON); +/// ``` +pub fn expected_score_teams( + team_one: Vec, + team_two: Vec, + config: &WengLinConfig, +) -> (f64, f64) { + let team_one_ratings = team_one.iter().map(|p| p.rating).sum::(); + let team_two_ratings = team_two.iter().map(|p| p.rating).sum::(); + + let team_one_uncertainties = team_one.iter().map(|p| p.uncertainty.powi(2)).sum::(); + let team_two_uncertainties = team_two.iter().map(|p| p.uncertainty.powi(2)).sum::(); + + let c = 2.0f64 + .mul_add( + config.beta.powi(2), + team_one_uncertainties + team_two_uncertainties, + ) + .sqrt(); + + let e1 = (team_one_ratings / c).exp(); + let e2 = (team_two_ratings / c).exp(); + + (e1 / (e1 + e2), e2 / (e1 + e2)) +} + +mod tests { + #[allow(unused_imports)] + use super::*; + + #[test] + fn test_weng() { + let p1 = WengLinRating::new(); + let p2 = WengLinRating::new(); + + let (t1, t2) = weng_lin(p1, p2, Outcomes::WIN, &WengLinConfig::new()); + + assert!((t1.rating - 27.635_231_383_473_65).abs() < f64::EPSILON); + assert!((t1.uncertainty - 8.065_506_316_323_548).abs() < f64::EPSILON); + assert!((t2.rating - 22.364_768_616_526_35).abs() < f64::EPSILON); + assert!((t2.uncertainty - 8.065_506_316_323_548).abs() < f64::EPSILON); + + let p1 = WengLinRating { + rating: 42.0, + uncertainty: 1.3, + }; + let p2 = WengLinRating::new(); + + let (t1, t2) = weng_lin(p1, p2, Outcomes::WIN, &WengLinConfig::new()); + + assert!((t1.rating - 42.026_412_401_802_894).abs() < f64::EPSILON); + assert!((t1.uncertainty - 1.299_823_053_277_078_3).abs() < f64::EPSILON); + assert!((t2.rating - 23.914_677_769_440_46).abs() < f64::EPSILON); + assert!((t2.uncertainty - 8.029_022_445_649_298).abs() < f64::EPSILON); + + let (t1, t2) = weng_lin(p1, p2, Outcomes::LOSS, &WengLinConfig::new()); + + assert!((t1.rating - 41.862_153_998_286_94).abs() < f64::EPSILON); + assert!((t1.uncertainty - 1.299_823_053_277_078_3).abs() < f64::EPSILON); + assert!((t2.rating - 30.664_283_436_598_35).abs() < f64::EPSILON); + assert!((t2.uncertainty - 8.029_022_445_649_298).abs() < f64::EPSILON); + + let (t1, t2) = weng_lin(p1, p2, Outcomes::DRAW, &WengLinConfig::new()); + + assert!((t1.rating - 41.944_283_200_044_92).abs() < f64::EPSILON); + assert!((t1.uncertainty - 1.299_823_053_277_078_3).abs() < f64::EPSILON); + assert!((t2.rating - 27.289_480_603_019_403).abs() < f64::EPSILON); + assert!((t2.uncertainty - 8.029_022_445_649_298).abs() < f64::EPSILON); + } + + #[test] + #[allow(clippy::cognitive_complexity)] + fn test_weng_teams() { + let t1 = vec![ + WengLinRating::new(), + WengLinRating { + rating: 30.0, + uncertainty: 1.2, + }, + WengLinRating { + rating: 21.0, + uncertainty: 6.5, + }, + ]; + + let t2 = vec![ + WengLinRating::default(), + WengLinRating { + rating: 41.0, + uncertainty: 1.4, + }, + WengLinRating { + rating: 19.2, + uncertainty: 4.3, + }, + ]; + + let (nt1, nt2) = + weng_lin_teams(t1.clone(), t2.clone(), Outcomes::WIN, &WengLinConfig::new()); + + assert!((nt1[0].rating - 27.904_443_970_057_24).abs() < f64::EPSILON); + assert!((nt1[1].rating - 30.060_226_550_163_108).abs() < f64::EPSILON); + assert!((nt1[2].rating - 22.767_063_711_382_825).abs() < f64::EPSILON); + + assert!((nt2[0].rating - 22.095_556_029_942_76).abs() < f64::EPSILON); + assert!((nt2[1].rating - 40.918_024_973_389_1).abs() < f64::EPSILON); + assert!((nt2[2].rating - 18.426_674_366_308_44).abs() < f64::EPSILON); + + assert!((nt1[0].uncertainty - 8.138_803_466_450_47).abs() < f64::EPSILON); + assert!((nt1[1].uncertainty - 1.199_425_779_255_630_7).abs() < f64::EPSILON); + assert!((nt1[2].uncertainty - 6.408_113_466_768_933).abs() < f64::EPSILON); + + assert!((nt2[0].uncertainty - 8.160_155_338_979_159).abs() < f64::EPSILON); + assert!((nt2[1].uncertainty - 1.399_187_149_975_365_4).abs() < f64::EPSILON); + assert!((nt2[2].uncertainty - 4.276_389_807_576_043).abs() < f64::EPSILON); + + let (nt1, nt2) = weng_lin_teams( + t1.clone(), + t2.clone(), + Outcomes::DRAW, + &WengLinConfig::new(), + ); + + assert!((nt1[0].rating - 25.652_558_832_338_293).abs() < f64::EPSILON); + assert!((nt1[1].rating - 30.013_531_459_947_366).abs() < f64::EPSILON); + assert!((nt1[2].rating - 21.397_016_793_594_62).abs() < f64::EPSILON); + + assert!((nt2[0].rating - 24.347_441_167_661_707).abs() < f64::EPSILON); + assert!((nt2[1].rating - 40.981_582_179_516_08).abs() < f64::EPSILON); + assert!((nt2[2].rating - 19.026_252_295_536_935).abs() < f64::EPSILON); + + // The uncertainties do not change. + assert!((nt1[0].uncertainty - 8.138_803_466_450_47).abs() < f64::EPSILON); + + let (nt1, nt2) = weng_lin_teams(t1, t2, Outcomes::LOSS, &WengLinConfig::default()); + + assert!((nt1[0].rating - 23.400_673_694_619_35).abs() < f64::EPSILON); + assert!((nt1[1].rating - 29.966_836_369_731_627).abs() < f64::EPSILON); + assert!((nt1[2].rating - 20.026_969_875_806_41).abs() < f64::EPSILON); + + assert!((nt2[0].rating - 26.599_326_305_380_65).abs() < f64::EPSILON); + assert!((nt2[1].rating - 41.045_139_385_643_06).abs() < f64::EPSILON); + assert!((nt2[2].rating - 19.625_830_224_765_43).abs() < f64::EPSILON); + } + + #[test] + fn test_empty_team() { + let t1 = vec![WengLinRating::new()]; + let t2 = Vec::new(); + + let (nt1, nt2) = weng_lin_teams( + t1.clone(), + t2.clone(), + Outcomes::DRAW, + &WengLinConfig::new(), + ); + + assert_eq!(t1, nt1); + assert_eq!(t2, nt2); + } + + #[test] + fn test_expected() { + let p1 = WengLinRating::new(); + let p2 = WengLinRating::new(); + + let (exp1, exp2) = expected_score(p1, p2, &WengLinConfig::new()); + + assert!((exp1 - exp2).abs() < f64::EPSILON); + + let p1 = WengLinRating { + rating: 42.0, + uncertainty: 2.1, + }; + let p2 = WengLinRating { + rating: 31.0, + uncertainty: 1.2, + }; + + let (exp1, exp2) = expected_score(p1, p2, &WengLinConfig::new()); + + assert!((exp1 + exp2 - 1.0).abs() < f64::EPSILON); + + assert!((exp1 - 0.849_021_123_412_260_5).abs() < f64::EPSILON); + assert!((exp2 - 0.150_978_876_587_739_42).abs() < f64::EPSILON); + } + + #[test] + fn test_expected_teams() { + let p1 = vec![WengLinRating::new()]; + let p2 = vec![WengLinRating::new()]; + + let (exp1, exp2) = expected_score_teams(p1, p2, &WengLinConfig::new()); + + assert!((exp1 - exp2).abs() < f64::EPSILON); + + let mut p1 = vec![WengLinRating { + rating: 42.0, + uncertainty: 2.1, + }]; + let mut p2 = vec![WengLinRating { + rating: 31.0, + uncertainty: 1.2, + }]; + + let (exp1, exp2) = expected_score_teams(p1.clone(), p2.clone(), &WengLinConfig::new()); + + assert!((exp1 + exp2 - 1.0).abs() < f64::EPSILON); + + assert!((exp1 - 0.849_021_123_412_260_5).abs() < f64::EPSILON); + assert!((exp2 - 0.150_978_876_587_739_42).abs() < f64::EPSILON); + + p1.push(WengLinRating::new()); + p1.push(WengLinRating { + rating: 12.0, + uncertainty: 3.2, + }); + + p2.push(WengLinRating { + rating: 41.0, + uncertainty: 1.2, + }); + + let (exp1, exp2) = expected_score_teams(p1.clone(), p2.clone(), &WengLinConfig::new()); + + assert!((exp1 + exp2 - 1.0).abs() < f64::EPSILON); + + // Even if they are better, team two is a player down. + assert!((exp1 - 0.653_518_078_332_893_4).abs() < f64::EPSILON); + + p2.push(WengLinRating::new()); + + let (exp1, _) = expected_score_teams(p1, p2, &WengLinConfig::new()); + + assert!((exp1 - 0.213_836_440_502_453_18).abs() < f64::EPSILON); + } + + #[test] + fn test_rating_period() { + let player = WengLinRating::new(); + + let opponent_one = WengLinRating::new(); + let opponent_two = WengLinRating { + rating: 12.0, + uncertainty: 4.2, + }; + + let (normal_player, _) = + weng_lin(player, opponent_one, Outcomes::WIN, &WengLinConfig::new()); + let (normal_player, _) = weng_lin( + normal_player, + opponent_two, + Outcomes::DRAW, + &WengLinConfig::new(), + ); + + let rating_player = weng_lin_rating_period( + player, + &vec![ + (opponent_one, Outcomes::WIN), + (opponent_two, Outcomes::DRAW), + ], + &WengLinConfig::new(), + ); + + assert!((normal_player.rating - rating_player.rating).abs() < f64::EPSILON); + assert!((normal_player.uncertainty - rating_player.uncertainty).abs() < f64::EPSILON); + } +}