Compare commits
2
Commits
60fc3e9d05
...
cc601c06eb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc601c06eb | ||
|
|
86e1521f8a |
+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
|
||||
|
||||
+21
-1
@@ -615,10 +615,18 @@ impl<T: Time> TimeSlice<T> {
|
||||
/// configured prior. The sweep runs on a scratch copy, so the real slice
|
||||
/// is untouched — which is what makes the filtered estimates independent
|
||||
/// of whether `History::converge` has run.
|
||||
/// One forward-only step for this slice.
|
||||
///
|
||||
/// `targets` restricts only the *evidence sum*, to events in which at
|
||||
/// least one target competitor appears; an empty set means no restriction.
|
||||
/// The forward messages are always built from every event in the slice —
|
||||
/// restricting those instead would answer a different question (a history
|
||||
/// in which the other events never happened), not a held-out one.
|
||||
pub(crate) fn filtered_step<D: Drift<T>>(
|
||||
&self,
|
||||
incoming: &HashMap<Index, Gaussian>,
|
||||
competitors: &CompetitorStore<T, D>,
|
||||
targets: &std::collections::HashSet<Index>,
|
||||
) -> FilteredStep {
|
||||
let mut scratch = TimeSlice {
|
||||
events: self.events.clone(),
|
||||
@@ -674,7 +682,19 @@ impl<T: Time> TimeSlice<T> {
|
||||
scratch.iterate_to_convergence(competitors);
|
||||
|
||||
FilteredStep {
|
||||
log_evidence: scratch.events.iter().map(|event| event.log_evidence).sum(),
|
||||
log_evidence: scratch
|
||||
.events
|
||||
.iter()
|
||||
.filter(|event| {
|
||||
targets.is_empty()
|
||||
|| event
|
||||
.teams
|
||||
.iter()
|
||||
.flat_map(|team| &team.items)
|
||||
.any(|item| targets.contains(&item.competitor))
|
||||
})
|
||||
.map(|event| event.log_evidence)
|
||||
.sum(),
|
||||
posteriors: scratch
|
||||
.skills
|
||||
.iter()
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
//! The evidence accessors span two independent axes — smoothed vs forward-only,
|
||||
//! all-keys vs key-restricted — and all four corners must exist and differ.
|
||||
//!
|
||||
//! `filtered_log_evidence_for` was the missing corner: the one a per-competitor
|
||||
//! prequential score needs.
|
||||
|
||||
use trueskill_tt::{
|
||||
ConstantDrift, Event, History, InferenceError, Member, NullObserver, Outcome, Team,
|
||||
};
|
||||
|
||||
type H = History<i64, ConstantDrift, NullObserver, &'static str>;
|
||||
|
||||
/// Two disjoint cohorts, so a key restriction is guaranteed to leave events out.
|
||||
fn two_cohorts() -> H {
|
||||
let mut h = H::default();
|
||||
let mut events = Vec::new();
|
||||
for t in 1..=6 {
|
||||
for (x, y) in [("a", "b"), ("c", "d")] {
|
||||
events.push(Event {
|
||||
time: t,
|
||||
teams: [
|
||||
Team::with_members([Member::new(x)]),
|
||||
Team::with_members([Member::new(y)]),
|
||||
]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
outcome: Outcome::winner(0, 2),
|
||||
});
|
||||
}
|
||||
}
|
||||
h.add_events(events).expect("fixture ingests");
|
||||
h.converge().expect("fixture converges");
|
||||
h
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_four_corners_are_distinct_quantities() {
|
||||
let h = two_cohorts();
|
||||
|
||||
let smoothed_all = h.log_evidence();
|
||||
let smoothed_ab = h.log_evidence_for(&[&"a", &"b"]).unwrap();
|
||||
let filtered_all = h.filtered_log_evidence();
|
||||
let filtered_ab = h.filtered_log_evidence_for(&[&"a", &"b"]).unwrap();
|
||||
|
||||
for (name, v) in [
|
||||
("smoothed_all", smoothed_all),
|
||||
("smoothed_ab", smoothed_ab),
|
||||
("filtered_all", filtered_all),
|
||||
("filtered_ab", filtered_ab),
|
||||
] {
|
||||
assert!(
|
||||
v.is_finite() && v <= 0.0,
|
||||
"{name} = {v} is not a log probability"
|
||||
);
|
||||
}
|
||||
|
||||
// Restricting to one cohort must drop the other cohort's events. Half the
|
||||
// events, and the two cohorts are symmetric, so it lands near half.
|
||||
assert!(
|
||||
smoothed_ab > smoothed_all,
|
||||
"restricting must drop evidence terms: {smoothed_ab} vs {smoothed_all}"
|
||||
);
|
||||
assert!(filtered_ab > filtered_all);
|
||||
|
||||
// The forward-only corner is a genuinely different quantity from the
|
||||
// smoothed one, not an alias for it.
|
||||
assert!(
|
||||
(filtered_ab - smoothed_ab).abs() > 1e-9,
|
||||
"filtered and smoothed restricted evidence coincide ({filtered_ab} vs {smoothed_ab}); \
|
||||
one of them is not computing what it claims"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restricting_to_both_cohorts_recovers_the_unrestricted_value() {
|
||||
let h = two_cohorts();
|
||||
|
||||
// Control on the filter itself: naming every competitor must restrict
|
||||
// nothing, so this catches a filter that drops events it should keep.
|
||||
let all_named = h
|
||||
.filtered_log_evidence_for(&[&"a", &"b", &"c", &"d"])
|
||||
.unwrap();
|
||||
assert!(
|
||||
(all_named - h.filtered_log_evidence()).abs() < 1e-12,
|
||||
"naming everyone changed the answer: {all_named} vs {}",
|
||||
h.filtered_log_evidence()
|
||||
);
|
||||
}
|
||||
|
||||
/// The restriction selects *events*, not competitors: naming one member of a
|
||||
/// pair that only ever plays each other selects the same events as naming both.
|
||||
#[test]
|
||||
fn naming_either_member_of_a_pair_selects_the_same_events() {
|
||||
let h = two_cohorts();
|
||||
|
||||
let ab = h.filtered_log_evidence_for(&[&"a"]).unwrap();
|
||||
let ab_pair = h.filtered_log_evidence_for(&[&"a", &"b"]).unwrap();
|
||||
assert!(
|
||||
(ab - ab_pair).abs() < 1e-12,
|
||||
"a and b only ever play each other, so naming either or both selects \
|
||||
the same events: {ab} vs {ab_pair}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_key_is_an_error_here_too() {
|
||||
let h = two_cohorts();
|
||||
|
||||
let err = h
|
||||
.filtered_log_evidence_for(&[&"typo"])
|
||||
.expect_err("unknown key");
|
||||
assert!(matches!(err, InferenceError::UnknownKey { .. }), "{err:?}");
|
||||
|
||||
// Control: the same call on a known key succeeds.
|
||||
h.filtered_log_evidence_for(&[&"a"]).expect("a is known");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_skills_agrees_with_current_skill() {
|
||||
let h = two_cohorts();
|
||||
|
||||
let all = h.current_skills();
|
||||
assert_eq!(all.len(), 4, "four competitors played");
|
||||
|
||||
for key in ["a", "b", "c", "d"] {
|
||||
let one = h.current_skill(key).expect("played");
|
||||
let from_map = all[key];
|
||||
assert_eq!(
|
||||
(one.mu(), one.sigma()),
|
||||
(from_map.mu(), from_map.sigma()),
|
||||
"current_skills disagrees with current_skill for {key}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_skills_omits_a_registered_but_unplayed_competitor() {
|
||||
let mut h = two_cohorts();
|
||||
h.register(Member::new("e")).expect("e is new");
|
||||
|
||||
let all = h.current_skills();
|
||||
assert!(
|
||||
!all.contains_key("e"),
|
||||
"a competitor with no appearances has no posterior to report"
|
||||
);
|
||||
assert!(
|
||||
h.current_skill("e").is_none(),
|
||||
"control: the singular agrees"
|
||||
);
|
||||
assert_eq!(all.len(), 4);
|
||||
}
|
||||
Reference in New Issue
Block a user