multi_skill/systems/
elo_mmr.rs

1//! Elo-R system details: https://arxiv.org/abs/2101.00400
2use super::util::{
3    solve_newton, standard_normal_cdf, standard_normal_pdf, Player, Rating, RatingSystem, TanhTerm,
4};
5use core::ops::Range;
6use rayon::prelude::*;
7use std::cmp::Ordering;
8use std::sync::atomic::{AtomicUsize, Ordering::Relaxed};
9use superslice::Ext;
10
11trait Term {
12    fn eval(&self, x: f64, order: Ordering, split_ties: bool) -> (f64, f64);
13    // my_rank is assumed to be a non-empty, sorted slice.
14    // This function is a computational bottleneck, so it's important to optimize.
15    fn evals(&self, x: f64, ranks: &[usize], my_rank: usize, split_ties: bool) -> (f64, f64) {
16        if ranks.len() == 1 {
17            // The unit-length case is very common, so we optimize it.
18            let (value, deriv) = self.eval(x, ranks[0].cmp(&my_rank), split_ties);
19            return (value, deriv);
20        }
21        let Range { start, end } = ranks.equal_range(&my_rank);
22        let equal = end - start;
23        let greater = ranks.len() - end;
24        let mut value = 0.;
25        let mut deriv = 0.;
26        if start > 0 {
27            let (v, p) = self.eval(x, Ordering::Less, split_ties);
28            value += start as f64 * v;
29            deriv += start as f64 * p;
30        }
31        if equal > 0 {
32            let (v, p) = self.eval(x, Ordering::Equal, split_ties);
33            value += equal as f64 * v;
34            deriv += equal as f64 * p;
35        }
36        if greater > 0 {
37            let (v, p) = self.eval(x, Ordering::Greater, split_ties);
38            value += greater as f64 * v;
39            deriv += greater as f64 * p;
40        }
41        (value, deriv)
42    }
43}
44
45impl Term for Rating {
46    fn eval(&self, x: f64, order: Ordering, split_ties: bool) -> (f64, f64) {
47        let z = (x - self.mu) / self.sig;
48        let pdf = standard_normal_pdf(z) / self.sig;
49        let pdf_prime = -z * pdf / self.sig;
50
51        match order {
52            Ordering::Less => {
53                // -cdf(-z) is a numerically stable version of cdf(z)-1
54                let cdf_m1 = -standard_normal_cdf(-z);
55                let val = pdf / cdf_m1;
56                (val, pdf_prime / cdf_m1 - val * val)
57            }
58            Ordering::Greater => {
59                let cdf = standard_normal_cdf(z);
60                let val = pdf / cdf;
61                (val, pdf_prime / cdf - val * val)
62            }
63            Ordering::Equal => {
64                if split_ties {
65                    let cdf = standard_normal_cdf(z);
66                    let cdf_m1 = cdf - 1.;
67                    let val0 = pdf / cdf;
68                    let val1 = pdf / cdf_m1;
69                    (
70                        0.5 * (val0 + val1),
71                        0.5 * (pdf_prime * (1. / cdf + 1. / cdf_m1) - val0 * val0 - val1 * val1),
72                    )
73                } else {
74                    let pdf_pp = -(pdf / self.sig + z * pdf_prime) / self.sig;
75                    let val = pdf_prime / pdf;
76                    (val, pdf_pp / pdf - val * val)
77                }
78            }
79        }
80    }
81}
82
83impl Term for TanhTerm {
84    fn eval(&self, x: f64, order: Ordering, split_ties: bool) -> (f64, f64) {
85        let z = (x - self.mu) * self.w_arg;
86        let val = -z.tanh() * self.w_out;
87        let val_prime = -z.cosh().powi(-2) * self.w_arg * self.w_out;
88
89        match order {
90            Ordering::Less => (val - self.w_out, val_prime),
91            Ordering::Greater => (val + self.w_out, val_prime),
92            Ordering::Equal => {
93                if split_ties {
94                    (val, val_prime)
95                } else {
96                    (2. * val, 2. * val_prime)
97                }
98            }
99        }
100    }
101}
102
103fn bucket(a: f64, width: f64) -> i32 {
104    (a / width).round() as i32
105}
106
107fn same_bucket(a: f64, b: f64, width: f64) -> bool {
108    bucket(a, width) == bucket(b, width)
109}
110
111fn cmp_by_bucket(a: f64, b: f64, width: f64) -> Ordering {
112    bucket(a, width).cmp(&bucket(b, width))
113}
114
115#[derive(Debug)]
116pub enum EloMMRVariant {
117    Gaussian,
118    Logistic(f64),
119}
120
121#[derive(Debug)]
122pub struct EloMMR {
123    // squared variation in individual performances, when the contest_weight is 1
124    pub beta: f64,
125    // each contest participation adds an amount of drift such that, in the absence of
126    // much time passing, the limiting skill uncertainty's square approaches this value
127    pub sig_limit: f64,
128    // additional variance per second, from a drift that's continuous in time
129    pub drift_per_sec: f64,
130    // whether to count ties as half a win plus half a loss
131    pub split_ties: bool,
132    // maximum number of opponents and recent events to use, as a compute-saving approximation
133    pub subsample_size: usize,
134    // width of mu and sigma to group subsamples by
135    pub subsample_bucket: f64,
136    // whether to use a Gaussian or logistic performance model
137    pub variant: EloMMRVariant,
138}
139
140impl Default for EloMMR {
141    fn default() -> Self {
142        Self::from_limit(200., 80., false, false, EloMMRVariant::Logistic(1.))
143    }
144}
145
146impl EloMMR {
147    pub fn default_fast() -> Self {
148        Self::from_limit(200., 80., false, true, EloMMRVariant::Logistic(1.))
149    }
150
151    pub fn default_gaussian() -> Self {
152        Self::from_limit(200., 80., false, false, EloMMRVariant::Gaussian)
153    }
154
155    pub fn default_gaussian_fast() -> Self {
156        Self::from_limit(200., 80., false, true, EloMMRVariant::Gaussian)
157    }
158
159    // sig_perf must exceed sig_limit, the limiting uncertainty for a player with long history
160    // the ratio (sig_limit / sig_perf) effectively determines the rating update weight
161    pub fn from_limit(
162        beta: f64,
163        sig_limit: f64,
164        split_ties: bool,
165        fast: bool,
166        variant: EloMMRVariant,
167    ) -> Self {
168        assert!(sig_limit > 0.);
169        assert!(beta > sig_limit);
170        let subsample_size = if fast { 100 } else { usize::MAX };
171        let subsample_bucket = if fast { 2. } else { 1e-5 };
172        Self {
173            beta,
174            sig_limit,
175            drift_per_sec: 0.,
176            split_ties,
177            subsample_size,
178            subsample_bucket,
179            variant,
180        }
181    }
182
183    fn sig_perf_and_drift(&self, contest_weight: f64) -> (f64, f64) {
184        let excess_beta_sq =
185            (self.beta * self.beta - self.sig_limit * self.sig_limit) / contest_weight;
186        let sig_perf = (self.sig_limit * self.sig_limit + excess_beta_sq).sqrt();
187        let discrete_drift = self.sig_limit.powi(4) / excess_beta_sq;
188        (sig_perf, discrete_drift)
189    }
190
191    fn subsample(
192        terms: &[(Rating, Vec<usize>)],
193        rating: f64,
194        num_samples: usize,
195        subsample_bucket: f64,
196    ) -> impl Iterator<Item = usize> + Clone {
197        // TODO: ensure the player still includes themself exactly once
198        let mut beg = terms
199            .binary_search_by(|term| {
200                cmp_by_bucket(term.0.mu, rating, subsample_bucket).then(std::cmp::Ordering::Greater)
201            })
202            .unwrap_err();
203        let mut end = beg + 1;
204
205        let expand = (num_samples.saturating_sub(end - beg) + 1) / 2;
206        beg = beg.saturating_sub(expand);
207        end = terms.len().min(end + expand);
208
209        let expand = num_samples.saturating_sub(end - beg);
210        beg = beg.saturating_sub(expand);
211        end = terms.len().min(end + expand);
212
213        beg..end
214        //.filter(move |&i| i != player_i)
215        //.chain(Some(player_i))
216    }
217}
218
219impl RatingSystem for EloMMR {
220    fn round_update(&self, contest_weight: f64, mut standings: Vec<(&mut Player, usize, usize)>) {
221        let (sig_perf, discrete_drift) = self.sig_perf_and_drift(contest_weight);
222
223        // Update ratings due to waiting period between contests,
224        // then use it to create Gaussian terms for the Q-function.
225        // The rank must also be stored in order to determine if it's a win, loss, or tie
226        // term. filter_map can exclude the least useful terms from subsampling.
227        let mut base_terms: Vec<(Rating, usize)> = standings
228            .par_iter_mut()
229            .map(|(player, lo, _)| {
230                let continuous_drift = self.drift_per_sec * player.update_time as f64;
231                let sig_drift = (discrete_drift + continuous_drift).sqrt();
232                match self.variant {
233                    // if transfer_speed is infinite or the prior is Gaussian, the logistic
234                    // weights become zero so this special-case optimization clears them out
235                    EloMMRVariant::Logistic(transfer_speed) if transfer_speed < f64::INFINITY => {
236                        player.add_noise_best(sig_drift, transfer_speed)
237                    }
238                    _ => player.add_noise_and_collapse(sig_drift),
239                }
240                (player.approx_posterior.with_noise(sig_perf), *lo)
241            })
242            .collect();
243
244        // Sort terms by rating to allow for subsampling within a range or ratings.
245        base_terms.sort_unstable_by(|a, b| {
246            cmp_by_bucket(a.0.mu, b.0.mu, self.subsample_bucket)
247                .then_with(|| cmp_by_bucket(a.0.sig, b.0.sig, self.subsample_bucket))
248                .then_with(|| a.1.cmp(&b.1))
249        });
250        let mut normal_terms: Vec<(Rating, Vec<usize>)> = vec![];
251        for (term, lo) in base_terms {
252            if let Some((last_term, ranks)) = normal_terms.last_mut() {
253                if same_bucket(last_term.mu, term.mu, self.subsample_bucket)
254                    && same_bucket(last_term.sig, term.sig, self.subsample_bucket)
255                {
256                    let len = ranks.len() as f64;
257                    last_term.mu = (len * last_term.mu + term.mu) / (len + 1.);
258                    last_term.sig = (len * last_term.sig + term.sig) / (len + 1.);
259                    ranks.push(lo);
260                    continue;
261                }
262            }
263            normal_terms.push((term, vec![lo]));
264        }
265
266        // Create the equivalent logistic terms.
267        let tanh_terms: Vec<(TanhTerm, Vec<usize>)> = normal_terms
268            .iter()
269            .map(|(rating, ranks)| ((*rating).into(), ranks.clone()))
270            .collect();
271
272        // Store the maximum subsample we've seen so far, to avoid logging excessive warnings
273        let idx_len_max = AtomicUsize::new(9999);
274
275        // The computational bottleneck: update ratings based on contest performance
276        standings.into_par_iter().for_each(|(player, my_rank, _)| {
277            let player_mu = player.approx_posterior.mu;
278            let idx_subsample =
279                Self::subsample(&normal_terms, player_mu, self.subsample_size, self.subsample_bucket);
280            // Log a warning if the subsample size is very large
281            let idx_len_upper_bound = idx_subsample.size_hint().1.unwrap_or(usize::MAX);
282            if idx_len_max.fetch_max(idx_len_upper_bound, Relaxed) < idx_len_upper_bound {
283                eprintln!(
284                    "WARNING: subsampling {} opponents might be slow; consider decreasing subsample_size.",
285                    idx_len_upper_bound
286                );
287            }
288            let bounds = (-6000.0, 9000.0);
289
290            match self.variant {
291                EloMMRVariant::Gaussian => {
292                    let idx_subsample = idx_subsample
293                        .map(|i| &normal_terms[i]);
294                    let f = |x| {
295                        idx_subsample
296                            .clone()
297                            .map(|(rating, ranks)| {
298                                rating.evals(x, &ranks, my_rank, self.split_ties)
299                            })
300                            .fold((0., 0.), |(s, sp), (v, vp)| (s + v, sp + vp))
301                    };
302                    let mu_perf = solve_newton(bounds, f);
303                    player.update_rating_with_normal(Rating {
304                        mu: mu_perf,
305                        sig: sig_perf,
306                    });
307                }
308                EloMMRVariant::Logistic(_) => {
309                    let idx_subsample = idx_subsample
310                        .map(|i| &tanh_terms[i]);
311                    let f = |x| {
312                        idx_subsample
313                            .clone()
314                            .map(|(rating, ranks)| {
315                                rating.evals(x, &ranks, my_rank, self.split_ties)
316                            })
317                            .fold((0., 0.), |(s, sp), (v, vp)| (s + v, sp + vp))
318                    };
319                    let mu_perf = solve_newton(bounds, f);
320                    player.update_rating_with_logistic(
321                        Rating {
322                            mu: mu_perf,
323                            sig: sig_perf,
324                        },
325                        self.subsample_size,
326                    );
327                }
328            };
329        });
330    }
331}