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>,
|
||||
}
|
||||
|
||||
/// 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
|
||||
|
||||
Reference in New Issue
Block a user