feat: add expected_variance_reduction for scored active learning

#49: `expected_information_gain` enumerates discrete outcomes, so a
consumer recording continuous scores cannot ask which matchup to run
next. The issue flagged this as possibly a research question, since
"expected variance reduction under EP may not have a clean closed form
even for Gaussian likelihoods".

It does. Observing a scored event is a rank-one update to the precision
matrix, so Sherman-Morrison gives

    reduction = (c^T L^-1 a)^2 / (v + a^T L^-1 a)

for target functional c and matchup contrast a. Verified against an
actual refit on four candidate matchups: agreement to 1e-9 relative.

Two consequences worth stating.

There is no expectation to take. The expression depends on which matchup
is played but not on how it turns out, because for a Gaussian likelihood
the posterior variance update is data-independent. Pinned by
`the_outcome_does_not_change_the_reduction`, which refits with scores of
(3, 1), (100, -50) and (0, 0) and gets the same answer. The name keeps
the term the active-learning literature uses; no averaging happens.

It is also far cheaper than its ranked counterpart — one linear solve
rather than a full inference pass per possible outcome — because
`c^T L^-1 a` and `a^T L^-1 a` share the same solve.

`target` is deliberately the same linear-functional shape as
`posterior_of`, as the issue proposed, so the two share a concept rather
than inventing two.

The load-bearing test is the refit comparison. An acquisition function
is the archetype of a surface that returns finite, plausible, monotone
numbers while being wrong, and then quietly selects worse matchups
forever; ranking behaviour alone would not catch that.

Closes #49

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
This commit is contained in:
2026-09-08 02:08:32 +02:00
co-authored by Claude Opus 5
parent c866210c65
commit 7cf45db5cf
2 changed files with 362 additions and 41 deletions
+206 -41
View File
@@ -202,6 +202,16 @@ pub(crate) struct CompetitorConfig {
drift_scale: Option<f64>, drift_scale: Option<f64>,
} }
/// A linear functional resolved against one time slice.
struct ResolvedTerms {
/// Coefficients over the slice's own competitors, in its ordering.
contrast: Vec<f64>,
/// Coefficients of competitors the slice has never seen, keyed by their
/// rendering. Independent of everything in the slice by construction.
unseen: HashMap<String, f64>,
mean: f64,
}
impl CompetitorConfig { impl CompetitorConfig {
fn is_empty(self) -> bool { fn is_empty(self) -> bool {
self.prior.is_none() && self.drift_scale.is_none() self.prior.is_none() && self.drift_scale.is_none()
@@ -755,6 +765,67 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
Ok(crate::quality(&group_refs, self.beta)) Ok(crate::quality(&group_refs, self.beta))
} }
/// Resolve `terms` into a contrast over the slice's competitor order, the
/// coefficients of any competitors the slice has never seen, and the mean.
///
/// An unseen competitor shares no event with the slice, so it is
/// independent of everything in it by construction; keeping those
/// coefficients separate is what lets their variance be added rather than
/// solved for.
fn resolve_terms(
&self,
terms: &[(&K, f64)],
slice: &TimeSlice<T>,
row_of: &HashMap<Index, usize>,
width: usize,
) -> Result<ResolvedTerms, InferenceError>
where
K: std::fmt::Debug,
{
let mut contrast = vec![0.0; width];
let mut unseen: HashMap<String, f64> = HashMap::new();
let mut mean = 0.0;
for (member, (key, coefficient)) in terms.iter().enumerate() {
let located = self
.keys
.get(*key)
.and_then(|index| row_of.get(&index).map(|row| (index, *row)));
match located {
Some((index, row)) => {
contrast[row] += coefficient;
mean += coefficient
* slice
.skills
.get(index)
.expect("index came from this slice")
.posterior()
.mu();
}
None => match self.unknown_keys {
crate::UnknownKeys::Prior => {
mean += coefficient * self.mu;
*unseen.entry(format!("{key:?}")).or_insert(0.0) += coefficient;
}
_ => {
return Err(InferenceError::UnknownKey {
team: 0,
member,
key: format!("{key:?}"),
});
}
},
}
}
Ok(ResolvedTerms {
contrast,
unseen,
mean,
})
}
/// Posterior of a linear combination of competitors' skills. /// Posterior of a linear combination of competitors' skills.
/// ///
/// `terms` pairs each competitor with its coefficient, so /// `terms` pairs each competitor with its coefficient, so
@@ -811,57 +882,151 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
row_of.insert(*idx, r); row_of.insert(*idx, r);
} }
let mut contrast = vec![0.0; order.len()]; let ResolvedTerms {
let mut mean = 0.0; contrast,
// A competitor the slice has never seen shares no event with anything unseen,
// in it, so it is independent by construction and its contribution is mean,
// simply additive rather than part of the solve. } = self.resolve_terms(terms, slice, &row_of, order.len())?;
let mut independent_variance = 0.0;
for (member, (key, coefficient)) in terms.iter().enumerate() {
let row = self
.keys
.get(*key)
.and_then(|index| row_of.get(&index).map(|row| (index, *row)));
match row {
Some((index, row)) => {
contrast[row] += coefficient;
mean += coefficient
* slice
.skills
.get(index)
.expect("index came from this slice")
.posterior()
.mu();
}
None => match self.unknown_keys {
crate::UnknownKeys::Prior => {
mean += coefficient * self.mu;
independent_variance += coefficient * coefficient * self.sigma * self.sigma;
}
_ => {
return Err(InferenceError::UnknownKey {
team: 0,
member,
key: format!("{key:?}"),
});
}
},
}
}
let z = let z =
crate::joint::solve_spd(lambda, &contrast).ok_or(InferenceError::JointUnavailable { crate::joint::solve_spd(lambda, &contrast).ok_or(InferenceError::JointUnavailable {
reason: "the precision matrix is not positive-definite, which means \ reason: "the precision matrix is not positive-definite, which means \
a competitor has neither a proper prior nor any evidence", a competitor has neither a proper prior nor any evidence",
})?; })?;
let variance: f64 =
contrast.iter().zip(&z).map(|(c, z)| c * z).sum::<f64>() + independent_variance; let prior_var = self.sigma * self.sigma;
let variance: f64 = contrast.iter().zip(&z).map(|(c, z)| c * z).sum::<f64>()
+ unseen.values().map(|c| c * c * prior_var).sum::<f64>();
Ok(Gaussian::from_mv(mean, variance)) Ok(Gaussian::from_mv(mean, variance))
} }
/// How much observing this matchup would shrink the variance of `target`.
///
/// `target` is a linear functional in the same shape
/// [`History::posterior_of`] takes, so the usual question — "which round
/// would best tell these two competitors apart" — is
/// `target = [(a, 1.0), (b, -1.0)]` scored across candidate matchups.
///
/// This is the scored counterpart to
/// [`expected_information_gain`](crate::expected_information_gain), which
/// enumerates discrete outcomes and cannot be asked about a continuous
/// score. It is also far cheaper: one linear solve rather than a full
/// inference pass per possible outcome.
///
/// # There is no expectation to take
///
/// Observing a scored event is a rank-one update to the precision matrix,
/// and by the Sherman-Morrison identity the resulting variance reduction is
///
/// ```text
/// (c^T L^-1 a)^2 / (v + a^T L^-1 a)
/// ```
///
/// which depends on *which* matchup is played but not on how it turns out.
/// For a Gaussian likelihood the posterior variance is data-independent, so
/// the expectation over outcomes is over a constant. The name keeps the
/// term the active-learning literature uses; no averaging happens.
///
/// Verified against an actual refit to six decimal places for four
/// candidate matchups.
///
/// # Errors
///
/// As [`History::posterior_of`], plus `MismatchedShape` unless exactly two
/// teams are supplied and `EmptyTeam` for an empty one.
pub fn expected_variance_reduction(
&self,
teams: &[&[&K]],
target: &[(&K, f64)],
) -> Result<f64, InferenceError>
where
K: std::fmt::Debug,
{
if teams.len() != 2 {
return Err(InferenceError::MismatchedShape {
kind: "expected_variance_reduction takes exactly 2 teams",
expected: 2,
got: teams.len(),
});
}
let slice = self
.time_slices
.last()
.ok_or(InferenceError::JointUnavailable {
reason: "the history has no events",
})?;
if !slice.all_scored() {
return Err(InferenceError::JointUnavailable {
reason: "the latest slice contains ranked events, whose EP factors \
are not retained after convergence",
});
}
// The candidate matchup, expressed as the same kind of linear
// functional as the target.
let mut matchup: Vec<(&K, f64)> = Vec::new();
let mut noise = self.score_sigma * self.score_sigma;
for (team_idx, team) in teams.iter().enumerate() {
if team.is_empty() {
return Err(InferenceError::EmptyTeam { team: team_idx });
}
let sign = if team_idx == 0 { 1.0 } else { -1.0 };
for key in team.iter() {
matchup.push((*key, sign));
let beta = self
.keys
.get(*key)
.map_or(self.beta, |index| self.agents[index].rating.beta);
noise += beta * beta;
}
}
let (order, lambda) = slice.joint_precision(&self.agents);
let mut row_of = HashMap::with_capacity(order.len());
for (r, idx) in order.iter().enumerate() {
row_of.insert(*idx, r);
}
let target = self.resolve_terms(target, slice, &row_of, order.len())?;
let matchup = self.resolve_terms(&matchup, slice, &row_of, order.len())?;
let (target_contrast, target_unseen) = (target.contrast, target.unseen);
let (matchup_contrast, matchup_unseen) = (matchup.contrast, matchup.unseen);
// One solve: z = L^-1 a serves both inner products, since
// c^T L^-1 a = c^T z and a^T L^-1 a = a^T z.
let z = crate::joint::solve_spd(lambda, &matchup_contrast).ok_or(
InferenceError::JointUnavailable {
reason: "the precision matrix is not positive-definite",
},
)?;
let prior_var = self.sigma * self.sigma;
// Competitors outside the slice are independent, so they contribute
// only where the same key appears in both functionals.
let cross_unseen: f64 = target_unseen
.iter()
.map(|(k, tc)| tc * matchup_unseen.get(k).copied().unwrap_or(0.0) * prior_var)
.sum();
let self_unseen: f64 = matchup_unseen.values().map(|c| c * c * prior_var).sum();
let cross: f64 = target_contrast
.iter()
.zip(&z)
.map(|(c, z)| c * z)
.sum::<f64>()
+ cross_unseen;
let matchup_var: f64 = matchup_contrast
.iter()
.zip(&z)
.map(|(a, z)| a * z)
.sum::<f64>()
+ self_unseen;
Ok(cross * cross / (noise + matchup_var))
}
/// Predictive distribution of the score margin between two teams. /// Predictive distribution of the score margin between two teams.
/// ///
/// Answers "what will the gap be, and how wide is that interval" for a /// Answers "what will the gap be, and how wide is that interval" for a
+156
View File
@@ -0,0 +1,156 @@
//! `expected_variance_reduction`: which matchup best sharpens a given question.
use smallvec::smallvec;
use trueskill_tt::{
ConstantDrift, ConvergenceOptions, Event, History, InferenceError, Member, Outcome, Team,
UnknownKeys,
};
type H = History<i64, ConstantDrift, trueskill_tt::NullObserver, &'static str>;
fn round(a: &'static str, b: &'static str, sa: f64, sb: f64) -> Event<i64, &'static str> {
Event {
time: 1,
teams: smallvec![
Team::with_members([Member::new(a)]),
Team::with_members([Member::new(b)]),
],
outcome: Outcome::scores([sa, sb]),
}
}
fn base() -> Vec<Event<i64, &'static str>> {
vec![
round("a", "b", 5.0, 2.0),
round("a", "c", 6.0, 1.0),
round("b", "c", 4.0, 3.0),
round("c", "d", 2.0, 1.0),
round("a", "d", 7.0, 2.0),
]
}
fn fit(extra: Option<Event<i64, &'static str>>, policy: UnknownKeys) -> H {
let mut h: History<i64, _, _, &'static str> = History::builder()
.mu(0.0)
.sigma(6.0)
.beta(1.0)
.score_sigma(2.0)
.drift(ConstantDrift(0.0))
.unknown_keys(policy)
.convergence(ConvergenceOptions {
max_iter: 20_000,
epsilon: 1e-13,
alpha: 1.0,
})
.build();
let mut ev = base();
if let Some(e) = extra {
ev.push(e);
}
h.add_events(ev).unwrap();
let _ = h.converge().unwrap();
h
}
/// The closed form must equal what actually happens if the matchup is played.
/// This is the assertion that makes the whole call trustworthy: a wrong
/// acquisition function returns plausible numbers and quietly picks worse
/// matchups forever.
#[test]
fn the_closed_form_matches_an_actual_refit() {
let h = fit(None, UnknownKeys::Reject);
let target: Vec<(&&str, f64)> = vec![(&"a", 1.0), (&"b", -1.0)];
let before = h.posterior_of(&target).unwrap().sigma().powi(2);
for (x, y) in [("a", "b"), ("c", "d"), ("a", "c"), ("b", "d")] {
let predicted = h
.expected_variance_reduction(&[&[&x], &[&y]], &target)
.unwrap();
let after = fit(Some(round(x, y, 3.0, 1.0)), UnknownKeys::Reject);
let actual = before - after.posterior_of(&target).unwrap().sigma().powi(2);
assert!(
(predicted - actual).abs() / actual.abs() < 1e-9,
"{x} vs {y}: predicted {predicted}, actual {actual}"
);
}
}
/// The reduction cannot depend on the score, because for a Gaussian likelihood
/// the posterior variance update is data-independent. This is why the call
/// needs no expectation despite its name.
#[test]
fn the_outcome_does_not_change_the_reduction() {
let target: Vec<(&&str, f64)> = vec![(&"a", 1.0), (&"b", -1.0)];
let h = fit(None, UnknownKeys::Reject);
let before = h.posterior_of(&target).unwrap().sigma().powi(2);
let mut seen = Vec::new();
for (sa, sb) in [(3.0, 1.0), (100.0, -50.0), (0.0, 0.0)] {
let after = fit(Some(round("c", "d", sa, sb)), UnknownKeys::Reject);
seen.push(before - after.posterior_of(&target).unwrap().sigma().powi(2));
}
for w in seen.windows(2) {
assert!(
(w[0] - w[1]).abs() < 1e-12,
"variance reduction moved with the observed score: {seen:?}"
);
}
}
/// The point of the call: it must rank candidate matchups usefully. Playing the
/// pair you are trying to separate helps most; an unrelated pair helps least.
#[test]
fn it_ranks_candidates_by_how_much_they_answer_the_question() {
let h = fit(None, UnknownKeys::Reject);
let target: Vec<(&&str, f64)> = vec![(&"a", 1.0), (&"b", -1.0)];
let direct = h
.expected_variance_reduction(&[&[&"a"], &[&"b"]], &target)
.unwrap();
let unrelated = h
.expected_variance_reduction(&[&[&"c"], &[&"d"]], &target)
.unwrap();
assert!(direct > 0.0 && unrelated > 0.0);
assert!(
direct > 5.0 * unrelated,
"playing the target pair should dominate: {direct} vs {unrelated}"
);
}
/// A matchup between two competitors nobody has seen still teaches something
/// about them, but nothing about a target that does not involve them.
#[test]
fn an_unrelated_unseen_matchup_teaches_nothing_about_the_target() {
let h = fit(None, UnknownKeys::Prior);
let target: Vec<(&&str, f64)> = vec![(&"a", 1.0), (&"b", -1.0)];
let reduction = h
.expected_variance_reduction(&[&[&"stranger"], &[&"nobody"]], &target)
.unwrap();
assert!(
reduction.abs() < 1e-12,
"an unseen pair shares nothing with the target: {reduction}"
);
}
#[test]
fn shape_errors_are_reported() {
let h = fit(None, UnknownKeys::Reject);
let target: Vec<(&&str, f64)> = vec![(&"a", 1.0), (&"b", -1.0)];
assert!(matches!(
h.expected_variance_reduction(&[&[&"a"]], &target),
Err(InferenceError::MismatchedShape {
expected: 2,
got: 1,
..
})
));
assert!(matches!(
h.expected_variance_reduction(&[&[&"a"], &[&"ghost"]], &target),
Err(InferenceError::UnknownKey { .. })
));
}