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:
+206
-41
@@ -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
|
||||||
|
|||||||
@@ -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