multi_skill/systems/
util.rs

1use crate::data_processing::Contest;
2use serde::{Deserialize, Serialize};
3use std::cell::{RefCell, RefMut};
4use std::collections::{HashMap, VecDeque};
5
6pub const TANH_MULTIPLIER: f64 = std::f64::consts::PI / 1.7320508075688772;
7pub type PlayersByName = HashMap<String, RefCell<Player>>;
8
9#[derive(Clone, Copy, Debug)]
10pub struct Rating {
11    pub mu: f64,
12    pub sig: f64,
13}
14
15impl Rating {
16    pub fn with_noise(self, sig_noise: f64) -> Self {
17        Self {
18            mu: self.mu,
19            sig: self.sig.hypot(sig_noise),
20        }
21    }
22
23    // TODO: consider making time_decay head towards a limit (mu_noob, sig_moob),
24    //       or alternatively keep mu the same while having sig -> sig_noob.
25    pub fn towards_noise(self, decay: f64, limit: Self) -> Self {
26        let mu_diff = self.mu - limit.mu;
27        let sig_sq_diff = self.sig * self.sig - limit.sig * limit.sig;
28        Self {
29            mu: limit.mu + mu_diff * decay,
30            sig: (limit.sig * limit.sig + sig_sq_diff * decay * decay).sqrt(),
31        }
32    }
33}
34
35#[derive(Clone, Copy, Debug)]
36pub struct TanhTerm {
37    pub mu: f64,
38    pub w_arg: f64,
39    pub w_out: f64,
40}
41
42impl From<Rating> for TanhTerm {
43    fn from(rating: Rating) -> Self {
44        let w = TANH_MULTIPLIER / rating.sig;
45        Self {
46            mu: rating.mu,
47            w_arg: w * 0.5,
48            w_out: w,
49        }
50    }
51}
52
53impl TanhTerm {
54    pub fn get_weight(&self) -> f64 {
55        self.w_arg * self.w_out * 2. / TANH_MULTIPLIER.powi(2)
56    }
57}
58
59#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
60pub struct PlayerEvent {
61    pub contest_index: usize,
62    pub rating_mu: i32,
63    pub rating_sig: i32,
64    pub perf_score: i32,
65    pub place: usize,
66}
67
68pub struct Player {
69    normal_factor: Rating,
70    logistic_factors: VecDeque<TanhTerm>,
71    pub event_history: Vec<PlayerEvent>,
72    pub approx_posterior: Rating,
73    pub update_time: u64,
74    pub delta_time: u64,
75}
76
77impl Player {
78    pub fn with_rating(mu: f64, sig: f64, update_time: u64) -> Self {
79        Player {
80            normal_factor: Rating { mu, sig },
81            logistic_factors: VecDeque::new(),
82            event_history: vec![],
83            approx_posterior: Rating { mu, sig },
84            update_time,
85            delta_time: 0,
86        }
87    }
88
89    pub fn is_newcomer(&self) -> bool {
90        self.event_history.len() <= 1
91    }
92
93    pub fn update_rating(&mut self, rating: Rating, performance_score: f64) {
94        // Assumes that a placeholder history item has been pushed containing contest id and time
95        let last_event = self.event_history.last_mut().unwrap();
96        assert_eq!(last_event.rating_mu, 0);
97        assert_eq!(last_event.rating_sig, 0);
98        assert_eq!(last_event.perf_score, 0);
99
100        self.approx_posterior = rating;
101        last_event.rating_mu = rating.mu.round() as i32;
102        last_event.rating_sig = rating.sig.round() as i32;
103        last_event.perf_score = performance_score.round() as i32;
104    }
105
106    pub fn update_rating_with_normal(&mut self, performance: Rating) {
107        let wn = self.normal_factor.sig.powi(-2);
108        let wp = performance.sig.powi(-2);
109        self.normal_factor.mu = (wn * self.normal_factor.mu + wp * performance.mu) / (wn + wp);
110        self.normal_factor.sig = (wn + wp).recip().sqrt();
111
112        let new_rating = if self.logistic_factors.is_empty() {
113            self.normal_factor
114        } else {
115            self.approximate_posterior(performance.sig)
116        };
117        self.update_rating(new_rating, performance.mu);
118    }
119
120    pub fn update_rating_with_logistic(&mut self, performance: Rating, max_history: usize) {
121        if self.logistic_factors.len() >= max_history {
122            // wl can be chosen so as to preserve total weight or rating; we choose the former.
123            // Either way, the deleted element should be small enough not to matter.
124            let logistic = self.logistic_factors.pop_front().unwrap();
125            let wn = self.normal_factor.sig.powi(-2);
126            let wl = logistic.get_weight();
127            self.normal_factor.mu = (wn * self.normal_factor.mu + wl * logistic.mu) / (wn + wl);
128            self.normal_factor.sig = (wn + wl).recip().sqrt();
129        }
130        self.logistic_factors.push_back(performance.into());
131
132        let new_rating = self.approximate_posterior(performance.sig);
133        self.update_rating(new_rating, performance.mu);
134    }
135
136    // Helper function that assumes the factors have been updated with the latest performance,
137    // but self.approx_posterior has not yet been updated with this performance.
138    fn approximate_posterior(&self, perf_sig: f64) -> Rating {
139        let normal_weight = self.normal_factor.sig.powi(-2);
140        let mu = robust_average(
141            self.logistic_factors.iter().cloned(),
142            -self.normal_factor.mu * normal_weight,
143            normal_weight,
144        );
145        let sig = (self.approx_posterior.sig.powi(-2) + perf_sig.powi(-2))
146            .recip()
147            .sqrt();
148        Rating { mu, sig }
149    }
150
151    // Method #1: the Gaussian/Brownian approximation, in which rating is a Markov state
152    // Equivalent to method #5 with transfer_speed == f64::INFINITY
153    pub fn add_noise_and_collapse(&mut self, sig_noise: f64) {
154        self.approx_posterior = self.approx_posterior.with_noise(sig_noise);
155        self.normal_factor = self.approx_posterior;
156        self.logistic_factors.clear();
157    }
158
159    // Method #2: decrease weights without changing logistic sigmas
160    // Equivalent to method #5 with transfer_speed == 0
161    #[allow(dead_code)]
162    pub fn add_noise_in_front(&mut self, sig_noise: f64) {
163        let decay = 1.0f64.hypot(sig_noise / self.approx_posterior.sig);
164        self.approx_posterior.sig *= decay;
165
166        self.normal_factor.sig *= decay;
167        for rating in &mut self.logistic_factors {
168            rating.w_out /= decay * decay;
169        }
170    }
171
172    // #5: a general method with the nicest properties, parametrized by transfer_speed >= 0
173    // Reduces to method #1 when transfer_speed == f64::INFINITY
174    // Reduces to method #2 when transfer_speed == 0
175    pub fn add_noise_best(&mut self, sig_noise: f64, transfer_speed: f64) {
176        let new_posterior = self.approx_posterior.with_noise(sig_noise);
177
178        let decay = (self.approx_posterior.sig / new_posterior.sig).powi(2);
179        let transfer = decay.powf(transfer_speed);
180        self.approx_posterior = new_posterior;
181
182        let wt_norm_old = self.normal_factor.sig.powi(-2);
183        let wt_from_norm_old = transfer * wt_norm_old;
184        let wt_from_transfers = (1. - transfer)
185            * (wt_norm_old
186                + self
187                    .logistic_factors
188                    .iter()
189                    .map(TanhTerm::get_weight)
190                    .sum::<f64>());
191        let wt_total = wt_from_norm_old + wt_from_transfers;
192
193        self.normal_factor.mu = (wt_from_norm_old * self.normal_factor.mu
194            + wt_from_transfers * self.approx_posterior.mu)
195            / wt_total;
196        self.normal_factor.sig = (decay * wt_total).recip().sqrt();
197        for r in &mut self.logistic_factors {
198            r.w_out *= transfer * decay;
199        }
200    }
201}
202
203#[allow(dead_code)]
204pub fn standard_logistic_pdf(z: f64) -> f64 {
205    0.25 * TANH_MULTIPLIER * (0.5 * TANH_MULTIPLIER * z).cosh().powi(-2)
206}
207
208pub fn standard_logistic_cdf(z: f64) -> f64 {
209    0.5 + 0.5 * (0.5 * TANH_MULTIPLIER * z).tanh()
210}
211
212#[allow(dead_code)]
213pub fn standard_logistic_cdf_inv(prob: f64) -> f64 {
214    (2. * prob - 1.).atanh() * 2. / TANH_MULTIPLIER
215}
216
217pub fn standard_normal_pdf(z: f64) -> f64 {
218    const NORMALIZE: f64 = 0.5 * std::f64::consts::FRAC_2_SQRT_PI / std::f64::consts::SQRT_2;
219    NORMALIZE * (-0.5 * z * z).exp()
220}
221
222pub fn standard_normal_cdf(z: f64) -> f64 {
223    0.5 * statrs::function::erf::erfc(-z / std::f64::consts::SQRT_2)
224    // Less numerically stable: 0.5 + 0.5 * statrs::function::erf::erf(z / std::f64::consts::SQRT_2)
225}
226
227pub fn standard_normal_cdf_inv(prob: f64) -> f64 {
228    -std::f64::consts::SQRT_2 * statrs::function::erf::erfc_inv(2. * prob)
229    // Equivalently: std::f64::consts::SQRT_2 * statrs::function::erf::erf_inv(2. * prob - 1.)
230}
231
232#[allow(dead_code)]
233pub fn solve_bisection((mut lo, mut hi): (f64, f64), f: impl Fn(f64) -> f64) -> f64 {
234    loop {
235        let flo = f(lo);
236        let guess = 0.5 * (lo + hi);
237        if lo >= guess || guess >= hi {
238            return guess;
239        }
240        if f(guess) * flo > 0. {
241            lo = guess;
242        } else {
243            hi = guess;
244        }
245    }
246}
247
248#[allow(dead_code)]
249pub fn solve_illinois((mut lo, mut hi): (f64, f64), f: impl Fn(f64) -> f64) -> f64 {
250    let (mut flo, mut fhi, mut side) = (f(lo), f(hi), 0i8);
251    loop {
252        let guess = (flo * hi - fhi * lo) / (flo - fhi);
253        if lo >= guess || guess >= hi {
254            return 0.5 * (lo + hi);
255        }
256        let fguess = f(guess);
257        if fguess * flo > 0. {
258            lo = guess;
259            flo = fguess;
260            if side == -1 {
261                fhi *= 0.5;
262            }
263            side = -1;
264        } else if fguess * fhi > 0. {
265            hi = guess;
266            fhi = fguess;
267            if side == 1 {
268                flo *= 0.5;
269            }
270            side = 1;
271        } else {
272            return guess;
273        }
274    }
275}
276
277pub fn solve_newton((mut lo, mut hi): (f64, f64), f: impl Fn(f64) -> (f64, f64)) -> f64 {
278    let mut guess = 0.5 * (lo + hi);
279    loop {
280        let (sum, sum_prime) = f(guess);
281        let extrapolate = guess - sum / sum_prime;
282        if extrapolate < guess {
283            hi = guess;
284            guess = extrapolate.max(0.75 * lo + 0.25 * hi).min(hi);
285        } else {
286            lo = guess;
287            guess = extrapolate.max(lo).min(0.25 * lo + 0.75 * hi);
288        }
289        if lo >= guess || guess >= hi {
290            if sum.abs() > 1e-10 {
291                eprintln!(
292                    "WARNING: POSSIBLE FAILURE TO CONVERGE @ {}: s={}, s'={}",
293                    guess, sum, sum_prime
294                );
295            }
296            return guess;
297        }
298    }
299}
300
301// Returns the unique zero of the following strictly increasing function of x:
302// offset + slope * x + sum_i weight_i * tanh((x-mu_i)/sig_i)
303// We must have slope != 0 or |offset| < sum_i weight_i in order for the zero to exist.
304// If offset == slope == 0, we get a robust weighted average of the mu_i's.
305pub fn robust_average(
306    all_ratings: impl Iterator<Item = TanhTerm> + Clone,
307    offset: f64,
308    slope: f64,
309) -> f64 {
310    let bounds = (-6000.0, 9000.0);
311    let f = |x: f64| -> (f64, f64) {
312        all_ratings
313            .clone()
314            .map(|term| {
315                let tanh_z = ((x - term.mu) * term.w_arg).tanh();
316                (
317                    tanh_z * term.w_out,
318                    (1. - tanh_z * tanh_z) * term.w_arg * term.w_out,
319                )
320            })
321            .fold((offset + slope * x, slope), |(s, sp), (v, vp)| {
322                (s + v, sp + vp)
323            })
324    };
325    solve_newton(bounds, f)
326}
327
328pub trait RatingSystem: std::fmt::Debug {
329    fn round_update(&self, contest_weight: f64, standings: Vec<(&mut Player, usize, usize)>);
330}
331
332pub fn outcome_free<T>(standings: &[(T, usize, usize)]) -> bool {
333    standings.is_empty() || standings[0].2 + 1 >= standings.len()
334}
335
336pub fn simulate_contest(
337    players: &mut PlayersByName,
338    contest: &Contest,
339    system: &dyn RatingSystem,
340    mu_newbie: f64,
341    sig_newbie: f64,
342    contest_index: usize,
343) {
344    if outcome_free(&contest.standings) {
345        eprintln!(
346            "WARNING: ignoring contest {} because all players tied",
347            contest_index
348        );
349        return;
350    }
351
352    // If a player is competing for the first time, initialize with a default rating
353    contest.standings.iter().for_each(|&(ref handle, _, _)| {
354        // TODO TEAMS: make an entry for every member of the team, then make the team object
355        //             in teams: PlayersByName with system.make_team(players)
356        players.entry(handle.clone()).or_insert_with(|| {
357            RefCell::new(Player::with_rating(
358                mu_newbie,
359                sig_newbie,
360                contest.time_seconds,
361            ))
362        });
363    });
364
365    // Low-level magic: verify that handles are distinct and store guards so that the cells
366    // can be released later. This setup enables safe parallel processing.
367    let mut guards: Vec<RefMut<Player>> = contest
368        .standings
369        .iter()
370        // TODO TEAMS: if individual, get guard to that, else get guard to its team
371        .map(|&(ref handle, _, _)| players.get(handle).expect("Duplicate handles").borrow_mut())
372        .collect();
373
374    // Update player metadata and get &mut references to all requested players
375    let standings: Vec<(&mut Player, usize, usize)> = guards
376        .iter_mut()
377        .map(std::ops::DerefMut::deref_mut)
378        .zip(contest.standings.iter())
379        .map(|(player, &(_, lo, hi))| {
380            player.event_history.push(PlayerEvent {
381                contest_index,
382                rating_mu: 0,  // will be filled by system.round_update()
383                rating_sig: 0, // will be filled by system.round_update()
384                perf_score: 0, // will be filled by system.round_update()
385                place: lo,
386            });
387            player.delta_time = contest.time_seconds - player.update_time;
388            player.update_time = contest.time_seconds;
389            (player, lo, hi)
390        })
391        .collect();
392
393    system.round_update(contest.weight, standings);
394
395    // TODO TEAMS: each participant uses its team's update using system.infer_from_team(),
396    // making sure to copy the team's event_history metadata as well
397}
398
399pub fn get_participant_ratings(
400    players: &mut PlayersByName,
401    contest_standings: &[(String, usize, usize)],
402    min_history: usize,
403) -> Vec<(Rating, usize, usize)> {
404    let mut standings: Vec<(Rating, usize, usize)> = vec![];
405
406    for &(ref handle, lo, hi) in contest_standings {
407        if let Some(player) = players.get(handle).map(RefCell::borrow) {
408            if player.event_history.len() >= min_history {
409                standings.push((player.approx_posterior, lo, hi));
410            }
411        }
412    }
413
414    // Normalizing the ranks is very annoying, I probably should've just represented
415    // standings as an Vec of Vec of players
416    let (mut last_k, mut last_v) = (usize::MAX, usize::MAX);
417    for (i, (_, lo, _)) in standings.iter_mut().enumerate() {
418        if *lo != last_k {
419            last_k = *lo;
420            last_v = i;
421        }
422        *lo = last_v;
423    }
424    let (mut last_k, mut last_v) = (usize::MAX, usize::MAX);
425    for (i, (_, _, hi)) in standings.iter_mut().enumerate().rev() {
426        if *hi != last_k {
427            last_k = *hi;
428            last_v = i;
429        }
430        *hi = last_v;
431    }
432    standings
433}