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:
@@ -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 { .. })
|
||||
));
|
||||
}
|
||||
Reference in New Issue
Block a user