From 50e11cfbfad5c64dbc9aefff8a58938002236c50 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Thu, 27 Aug 2026 16:30:19 +0200 Subject: [PATCH] feat: add filtered learning curves learning_curve returns post-convergence posteriors, so every point is smoothed: the estimate at a given date incorporates rounds played years later. On ustat's data that starts six players' curves already spread apart at sigma 0.9-1.6 against a prior of 6.0, barely moving thereafter. filtered_learning_curve plots the same competitor on forward-only information, so everyone starts at the prior and fans out. It could not be reconstructed from the public API before: a caller could only refit over events[0..k] for every k, which is O(n^2) fits for something one forward pass already computes. --- src/history.rs | 50 +++++++++++++++++++++++++++++++++--- tests/filtered.rs | 65 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+), 3 deletions(-) diff --git a/src/history.rs b/src/history.rs index 3a7cb1b..92e59e1 100644 --- a/src/history.rs +++ b/src/history.rs @@ -307,9 +307,6 @@ impl, O: Observer, K: Eq + Hash + Clone> History HashMap> { #[cfg(feature = "rayon")] { @@ -380,6 +377,53 @@ impl, O: Observer, K: Eq + Hash + Clone> History HashMap> { + let mut data: HashMap> = HashMap::new(); + + for (time, step) in self.filtered_pass() { + for (agent, posterior) in step.posteriors { + if let Some(key) = self.keys.key(agent).cloned() { + data.entry(key).or_default().push((time, posterior)); + } + } + } + + data + } + + /// Filtered learning curve for a single key: (time, posterior) pairs in + /// time order. + /// + /// Runs the same full pass as `filtered_learning_curves` and keeps one + /// key, so asking for several keys individually costs a pass each — use + /// the plural form for that. + pub fn filtered_learning_curve(&self, key: &Q) -> Vec<(T, Gaussian)> + where + K: Borrow, + Q: Hash + Eq + ?Sized, + { + let Some(idx) = self.keys.get(key) else { + return Vec::new(); + }; + + self.filtered_pass() + .into_iter() + .filter_map(|(time, step)| { + step.posteriors + .iter() + .find(|(agent, _)| *agent == idx) + .map(|&(_, posterior)| (time, posterior)) + }) + .collect() + } + pub(crate) fn log_evidence_internal(&mut self, forward: bool, targets: &[Index]) -> f64 { #[cfg(feature = "rayon")] { diff --git a/tests/filtered.rs b/tests/filtered.rs index 3642ac2..ca5ff22 100644 --- a/tests/filtered.rs +++ b/tests/filtered.rs @@ -50,3 +50,68 @@ fn filtered_evidence_sits_between_coin_flip_and_batch() { scores each game on strictly less information than smoothing does" ); } + +#[test] +fn filtered_first_point_is_less_certain_than_smoothed() { + let mut history = repeated_winner(12); + + history.converge().unwrap(); + + let smoothed = history.learning_curve("a"); + let filtered = history.filtered_learning_curve("a"); + + assert_eq!( + smoothed.len(), + filtered.len(), + "both curves must cover the same time points" + ); + + let (smoothed_time, first_smoothed) = smoothed[0]; + let (filtered_time, first_filtered) = filtered[0]; + + assert_eq!(smoothed_time, filtered_time); + + assert!( + first_filtered.sigma() > first_smoothed.sigma(), + "filtered sigma {} at the first point is not above smoothed {}; the smoother \ + collapses uncertainty before the first round is drawn, which is the whole \ + reason this method exists", + first_filtered.sigma(), + first_smoothed.sigma() + ); + + assert!( + first_filtered.sigma() < trueskill_tt::SIGMA, + "filtered sigma {} at the first point is not below the prior {}; one game was \ + played, so some uncertainty must have been resolved", + first_filtered.sigma(), + trueskill_tt::SIGMA + ); + + for pair in filtered.windows(2) { + assert!( + pair[1].1.mu() > pair[0].1.mu(), + "filtered mu must climb at every step for a competitor who wins every \ + game: t={} mu={} then t={} mu={}", + pair[0].0, + pair[0].1.mu(), + pair[1].0, + pair[1].1.mu() + ); + } +} + +#[test] +fn filtered_curves_plural_agrees_with_singular() { + let mut history = repeated_winner(4); + + history.converge().unwrap(); + + let curves = history.filtered_learning_curves(); + + assert_eq!( + curves["b"], + history.filtered_learning_curve("b"), + "the plural form must agree with the singular for the same key" + ); +}