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.
This commit is contained in:
2026-08-27 16:30:19 +02:00
parent d4af048914
commit 50e11cfbfa
2 changed files with 112 additions and 3 deletions
+47 -3
View File
@@ -307,9 +307,6 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
}
/// Learning curves for all competitors, keyed by their user-facing key.
///
/// Note: `key(idx)` is O(n) per lookup; this method is therefore O(n²)
/// in the number of competitors. Acceptable for T2; T3 may optimize.
pub fn learning_curves(&self) -> HashMap<K, Vec<(T, Gaussian)>> {
#[cfg(feature = "rayon")]
{
@@ -380,6 +377,53 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
.collect()
}
/// Filtered learning curves for all competitors, keyed by user-facing key.
///
/// Each point is the posterior using only events up to and including that
/// time — "what we knew then". Contrast `learning_curves`, whose points
/// are smoothed and so incorporate rounds played later.
///
/// Runs a full forward pass per call and caches nothing.
pub fn filtered_learning_curves(&self) -> HashMap<K, Vec<(T, Gaussian)>> {
let mut data: HashMap<K, Vec<(T, Gaussian)>> = 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<Q>(&self, key: &Q) -> Vec<(T, Gaussian)>
where
K: Borrow<Q>,
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")]
{