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>,
}
/// 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 {
fn is_empty(self) -> bool {
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))
}
/// 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.
///
/// `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);
}
let mut contrast = vec![0.0; order.len()];
let mut mean = 0.0;
// A competitor the slice has never seen shares no event with anything
// in it, so it is independent by construction and its contribution is
// simply additive rather than part of the solve.
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 ResolvedTerms {
contrast,
unseen,
mean,
} = self.resolve_terms(terms, slice, &row_of, order.len())?;
let z =
crate::joint::solve_spd(lambda, &contrast).ok_or(InferenceError::JointUnavailable {
reason: "the precision matrix is not positive-definite, which means \
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))
}
/// 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.
///
/// Answers "what will the gap be, and how wide is that interval" for a