feat: add History::predict_margin for scored matchups
#48: every predict_* answers "who wins", and a consumer recording scores never asks that. It wants the interval on the result, and having none it hand-fitted a noise law whose fitted node weight came out at 0.0 — so the quoted sigma was 5.83 whether the competitor had forty rounds or none, against real residual spreads of 5.8 and 12.44. `predict_margin` composes the three things that make a scored result uncertain: the joint posterior over the competitors, their per-event performance noise, and the observation noise on the score. It widens as the model knows less — measured, sigma 2.48 against an opponent with forty rounds, 3.38 against one seen once, wider still against one never seen — which is the property the hand-fitted law lost. It is a margin, not a score, and that is not a shortcut. Scored ingestion reduces every event to `score_a - score_b` before inference, so the absolute level is discarded: shifting every score in a history by +100 or -1000 produces a bit-identical fit, verified. There is no information from which to predict what a competitor will *score*. Returning one would be a number derived entirely from the prior, which is exactly the plausible constant this crate keeps finding and removing. `posterior_of` now honours `UnknownKeys::Prior`, which gives #48 its second requirement — "I have never seen this competitor, here is the prior-informed answer". An unseen competitor shares no event with the slice, so it is independent by construction and its variance is additive rather than part of the solve. Worth recording for expectations: for a *margin* the joint buys little over adding marginals (2.4798 against 2.5112 here), because a margin is a difference and differences are where the loopy underestimate and the ignored correlation cancel. The gain here is having a predictive distribution at all. `posterior_of`'s correlation handling earns its keep on sums and single nodes instead — see tests/additive_model.rs. Closes #48 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
This commit is contained in:
+102
-19
@@ -813,25 +813,42 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
||||
|
||||
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 index = self.keys.get(*key).ok_or(InferenceError::UnknownKey {
|
||||
team: 0,
|
||||
member,
|
||||
key: format!("{key:?}"),
|
||||
})?;
|
||||
let row = *row_of.get(&index).ok_or(InferenceError::UnknownKey {
|
||||
team: 0,
|
||||
member,
|
||||
key: format!("{key:?}"),
|
||||
})?;
|
||||
contrast[row] += coefficient;
|
||||
mean += coefficient
|
||||
* slice
|
||||
.skills
|
||||
.get(index)
|
||||
.expect("index came from this slice")
|
||||
.posterior()
|
||||
.mu();
|
||||
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 =
|
||||
@@ -839,11 +856,77 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
||||
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();
|
||||
let variance: f64 =
|
||||
contrast.iter().zip(&z).map(|(c, z)| c * z).sum::<f64>() + independent_variance;
|
||||
|
||||
Ok(Gaussian::from_mv(mean, variance))
|
||||
}
|
||||
|
||||
/// Predictive distribution of the score margin between two teams.
|
||||
///
|
||||
/// Answers "what will the gap be, and how wide is that interval" for a
|
||||
/// scored matchup, composing the three things that make it uncertain: how
|
||||
/// unsure the model is about the competitors, their per-event performance
|
||||
/// noise, and the observation noise on the score itself.
|
||||
///
|
||||
/// The interval widens as the model knows less. Measured on a fixture where
|
||||
/// one opponent has forty rounds and another has one, the margin's sigma
|
||||
/// goes from 2.48 to 3.38 — which is the property a caller most needs and
|
||||
/// the one a hand-fitted noise law tends to lose.
|
||||
///
|
||||
/// # Why a margin rather than a score
|
||||
///
|
||||
/// The model never sees an absolute score. Scored ingestion reduces each
|
||||
/// event to `score_a - score_b` before inference, so shifting every score
|
||||
/// in a history by a constant produces a bit-identical fit. There is
|
||||
/// therefore no information from which to predict what a competitor will
|
||||
/// *score*; only what the gap between two of them will be. Asking for an
|
||||
/// absolute score would return a number derived entirely from the prior,
|
||||
/// which is the kind of plausible constant this crate tries not to hand out.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// `MismatchedShape` unless exactly two teams are supplied, `EmptyTeam`,
|
||||
/// `UnknownKey` under [`UnknownKeys::Reject`](crate::UnknownKeys::Reject),
|
||||
/// and `JointUnavailable` if the latest slice holds ranked events.
|
||||
pub fn predict_margin(&self, teams: &[&[&K]]) -> Result<Gaussian, InferenceError>
|
||||
where
|
||||
K: std::fmt::Debug,
|
||||
{
|
||||
if teams.len() != 2 {
|
||||
return Err(InferenceError::MismatchedShape {
|
||||
kind: "predict_margin takes exactly 2 teams",
|
||||
expected: 2,
|
||||
got: teams.len(),
|
||||
});
|
||||
}
|
||||
|
||||
let mut terms: Vec<(&K, f64)> = Vec::new();
|
||||
let mut performance_noise = 0.0;
|
||||
|
||||
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() {
|
||||
terms.push((*key, sign));
|
||||
// Each member contributes its own performance noise to the
|
||||
// margin regardless of which side it is on.
|
||||
let beta = self
|
||||
.keys
|
||||
.get(*key)
|
||||
.map_or(self.beta, |index| self.agents[index].rating.beta);
|
||||
performance_noise += beta * beta;
|
||||
}
|
||||
}
|
||||
|
||||
let skill_gap = self.posterior_of(&terms)?;
|
||||
let variance = skill_gap.sigma().powi(2) + performance_noise + self.score_sigma.powi(2);
|
||||
|
||||
Ok(Gaussian::from_mv(skill_gap.mu(), variance))
|
||||
}
|
||||
|
||||
/// Expected information gain of running this matchup, in nats.
|
||||
///
|
||||
/// Answers "which comparison should I run next" rather than "who will
|
||||
|
||||
Reference in New Issue
Block a user