feat: complete the evidence matrix and add current_skills
Three of four corners of the evidence matrix existed. The missing one was forward-only *and* key-restricted — which is exactly what per-competitor prequential scoring needs, the intersection of the two workloads `log_evidence_for` and `filtered_log_evidence` are each documented for. `filtered_log_evidence_for` fills it. It is not `log_evidence_internal(true, targets)`: that path selects `skill.forward` as the prior, which stops being a filtering quantity once `iteration` has run a backward sweep. It goes through `filtered_pass` like its unrestricted sibling, with the restriction applied to which events are *scored*, never to which are *run* — so it is a held-out score under the real history, not a score under a counterfactual one where nobody else played. Key resolution for both `*_for` accessors now shares `resolve_targets`, so they cannot drift apart on how an unknown key is reported. `current_skills` is the plural of `current_skill`. Building a leaderboard previously meant materialising every competitor's full smoothed curve via `learning_curves` and reading the last point of each. Tests carry controls in both directions: naming every competitor must recover the unrestricted value (catching a filter that drops too much), and the restricted forward-only value must differ from the restricted smoothed one (catching an alias). Refs #70. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
This commit is contained in:
+89
-9
@@ -1,6 +1,6 @@
|
||||
use std::{
|
||||
borrow::Borrow,
|
||||
collections::{BTreeMap, HashMap},
|
||||
collections::{BTreeMap, HashMap, HashSet},
|
||||
hash::Hash,
|
||||
marker::PhantomData,
|
||||
};
|
||||
@@ -744,6 +744,35 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
||||
.find_map(|ts| ts.skills.get(idx).map(|sk| sk.posterior()))
|
||||
}
|
||||
|
||||
/// Every competitor's latest posterior, keyed by user-facing key.
|
||||
///
|
||||
/// The plural of `current_skill`, and the cheap way to build a
|
||||
/// leaderboard: the alternative was materialising every competitor's full
|
||||
/// smoothed curve via `learning_curves` only to read the last point of
|
||||
/// each.
|
||||
///
|
||||
/// A competitor that is registered but has never played has no posterior
|
||||
/// and is absent from the map, exactly as `current_skill` returns `None`
|
||||
/// for them.
|
||||
#[must_use]
|
||||
pub fn current_skills(&self) -> HashMap<K, Gaussian> {
|
||||
let mut latest: HashMap<Index, Gaussian> = HashMap::new();
|
||||
|
||||
// Time order, so a later slice overwrites an earlier one.
|
||||
for slice in &self.time_slices {
|
||||
for (competitor, skill) in slice.skills.iter() {
|
||||
latest.insert(competitor, skill.posterior());
|
||||
}
|
||||
}
|
||||
|
||||
latest
|
||||
.into_iter()
|
||||
.filter_map(|(competitor, posterior)| {
|
||||
self.keys.key(competitor).cloned().map(|k| (k, posterior))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Learning curve for a single key: (time, posterior) pairs in time order.
|
||||
///
|
||||
/// `None` if the history has never seen the key; `Some(vec![])` if it is
|
||||
@@ -778,7 +807,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
||||
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 (time, step) in self.filtered_pass(&HashSet::new()) {
|
||||
for (competitor, posterior) in step.posteriors {
|
||||
if let Some(key) = self.keys.key(competitor).cloned() {
|
||||
data.entry(key).or_default().push((time, posterior));
|
||||
@@ -808,7 +837,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
||||
let idx = self.keys.get(key)?;
|
||||
|
||||
Some(
|
||||
self.filtered_pass()
|
||||
self.filtered_pass(&HashSet::new())
|
||||
.into_iter()
|
||||
.filter_map(|(time, step)| {
|
||||
step.posteriors
|
||||
@@ -879,7 +908,20 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
||||
K: std::borrow::Borrow<Q>,
|
||||
Q: std::hash::Hash + Eq + ?Sized + std::fmt::Debug,
|
||||
{
|
||||
let mut targets: Vec<Index> = Vec::with_capacity(keys.len());
|
||||
let targets: Vec<Index> = self.resolve_targets(keys)?.into_iter().collect();
|
||||
Ok(self.log_evidence_internal(false, &targets))
|
||||
}
|
||||
|
||||
/// Intern a key list, refusing the whole list if any key is unknown.
|
||||
///
|
||||
/// Shared by the two `*_for` evidence accessors so they cannot drift apart
|
||||
/// on how an unknown key is reported.
|
||||
fn resolve_targets<Q>(&self, keys: &[&Q]) -> Result<HashSet<Index>, InferenceError>
|
||||
where
|
||||
K: Borrow<Q>,
|
||||
Q: Hash + Eq + ?Sized + std::fmt::Debug,
|
||||
{
|
||||
let mut targets = HashSet::with_capacity(keys.len());
|
||||
for (member, key) in keys.iter().enumerate() {
|
||||
let idx = self
|
||||
.keys
|
||||
@@ -889,22 +931,25 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
||||
member,
|
||||
key: format!("{key:?}"),
|
||||
})?;
|
||||
targets.push(idx);
|
||||
targets.insert(idx);
|
||||
}
|
||||
Ok(self.log_evidence_internal(false, &targets))
|
||||
Ok(targets)
|
||||
}
|
||||
|
||||
/// Walk the slices in time order carrying forward messages only.
|
||||
///
|
||||
/// This is the forward half of `iteration` with the backward half never
|
||||
/// run. It reads `self` and mutates nothing.
|
||||
fn filtered_pass(&self) -> Vec<(T, FilteredStep)> {
|
||||
///
|
||||
/// `targets` restricts each step's evidence sum; the messages carried
|
||||
/// forward are unaffected. See `TimeSlice::filtered_step`.
|
||||
fn filtered_pass(&self, targets: &HashSet<Index>) -> Vec<(T, FilteredStep)> {
|
||||
let mut messages: HashMap<Index, Gaussian> = HashMap::new();
|
||||
|
||||
let mut pass = Vec::with_capacity(self.time_slices.len());
|
||||
|
||||
for slice in &self.time_slices {
|
||||
let step = slice.filtered_step(&messages, &self.competitors);
|
||||
let step = slice.filtered_step(&messages, &self.competitors, targets);
|
||||
|
||||
for &(competitor, posterior) in &step.posteriors {
|
||||
messages.insert(competitor, posterior);
|
||||
@@ -930,12 +975,47 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
||||
/// not depend on whether `converge` has been called.
|
||||
#[must_use]
|
||||
pub fn filtered_log_evidence(&self) -> f64 {
|
||||
self.filtered_pass()
|
||||
self.filtered_pass(&HashSet::new())
|
||||
.iter()
|
||||
.map(|(_, step)| step.log_evidence)
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// Filtered log-evidence restricted to events involving at least one of
|
||||
/// the given keys.
|
||||
///
|
||||
/// The intersection of the two axes the other three evidence accessors
|
||||
/// span: forward-only *and* key-restricted, which is what per-competitor
|
||||
/// prequential scoring needs. `log_evidence_for` is the smoothed
|
||||
/// counterpart, and its per-event priors carry information from events
|
||||
/// that had not happened yet — so it is not the quantity for scoring a
|
||||
/// competitor's history *as it unfolded*.
|
||||
///
|
||||
/// Restricting filters which events are *scored*, not which are *run*: the
|
||||
/// forward messages still absorb every event, so this is a held-out score
|
||||
/// under the real history, not a score under a counterfactual one where
|
||||
/// nobody else played.
|
||||
///
|
||||
/// Runs a full forward pass per call and caches nothing.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// `UnknownKey` for any key the history has never seen — see
|
||||
/// `log_evidence_for` for why an unknown key must not be skipped.
|
||||
pub fn filtered_log_evidence_for<Q>(&self, keys: &[&Q]) -> Result<f64, InferenceError>
|
||||
where
|
||||
K: Borrow<Q>,
|
||||
Q: Hash + Eq + ?Sized + std::fmt::Debug,
|
||||
{
|
||||
let targets = self.resolve_targets(keys)?;
|
||||
|
||||
Ok(self
|
||||
.filtered_pass(&targets)
|
||||
.iter()
|
||||
.map(|(_, step)| step.log_evidence)
|
||||
.sum())
|
||||
}
|
||||
|
||||
/// The configured observer.
|
||||
///
|
||||
/// `History` takes its observer by value, so this is how a caller inspects
|
||||
|
||||
Reference in New Issue
Block a user