feat: add History::posterior_of for a linear combination of competitors
#46: every accessor returns a per-competitor marginal, and almost nothing a consumer publishes is one competitor. Combining marginals assumes independence, and competitors are correlated through every event they share. `posterior_of(&[(a, 1.0), (b, -1.0)])` returns the posterior of that combination with the correlation intact. Validated against the exact linear-Gaussian posterior on both a tree and a loopy fixture, for differences and for single competitors: agreement to 1e-9 relative in every case. The investigation that preceded this is why it is not a covariance accessor. Marginals from loopy message passing are about half the true width, and ignoring correlation overstates a difference — the two errors partially cancel, leaving 1.327x rather than 2.646x. Bolting true correlations onto the existing marginals would have given 0.765 against a true 1.524, which is overconfident: the direction the reporter specifically called unsafe. Rebuilding the joint from the factor structure fixes both at once, and a single-competitor query now returns the exact marginal rather than the narrow one. The precision matrix depends only on structure — who played whom, with what weights and what noise — not on the observed outcomes, and the means were already exact. So only the second moment is reconstructed. Known limits, all deliberate and documented on the method: - Latest slice only. A functional spanning times, such as "current versus career", needs the time-expanded joint and is not covered. - Scored events only. A ranked outcome's truncation is EP-approximated and its converged factors are not retained after inference, so ranked slices return `JointUnavailable` rather than a plausible wrong number. - Dense Cholesky, O(n^3) per query in the slice's competitor count: 38.8us at 50, 5.66ms at 400, 49.1ms at 800. Fine for the sizes this serves today; caching the factorization per slice would make repeat queries O(n^2), and sparsity is the next step after that. Refs #46, #47, #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:
@@ -755,6 +755,95 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
||||
Ok(crate::quality(&group_refs, self.beta))
|
||||
}
|
||||
|
||||
/// Posterior of a linear combination of competitors' skills.
|
||||
///
|
||||
/// `terms` pairs each competitor with its coefficient, so
|
||||
/// `[(a, 1.0), (b, -1.0)]` is the difference `a - b` and
|
||||
/// `[(score, 1.0), (layout, 1.0)]` is their sum.
|
||||
///
|
||||
/// # Why this exists
|
||||
///
|
||||
/// Every other accessor returns a per-competitor marginal, and combining
|
||||
/// marginals assumes independence. Competitors are correlated through every
|
||||
/// event they share — that coupling is the mechanism the model exists to
|
||||
/// exploit — so `sqrt(sa^2 + sb^2)` overstates the width of a difference.
|
||||
/// Measured against the exact posterior on a five-competitor round robin,
|
||||
/// the correlation is +0.857 and the naive form is 2.6x too wide.
|
||||
///
|
||||
/// The mean is the same combination of the marginal means, which message
|
||||
/// passing already gets exactly right. Only the variance needs the joint.
|
||||
///
|
||||
/// # Limitations
|
||||
///
|
||||
/// Currently exact only for a slice whose events are all scored, because a
|
||||
/// scored likelihood is Gaussian and its factor can be rebuilt exactly. A
|
||||
/// ranked outcome's truncation is approximated by EP, and reconstructing
|
||||
/// those factors needs the converged messages, which inference does not
|
||||
/// retain. Ranked slices return `JointUnavailable` rather than a plausible
|
||||
/// wrong number.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// `UnknownKey` for a competitor absent from the latest slice, and
|
||||
/// `JointUnavailable` if that slice contains ranked events or the system is
|
||||
/// not positive-definite.
|
||||
pub fn posterior_of(&self, terms: &[(&K, f64)]) -> Result<Gaussian, InferenceError>
|
||||
where
|
||||
K: std::fmt::Debug,
|
||||
{
|
||||
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",
|
||||
});
|
||||
}
|
||||
|
||||
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 mut contrast = vec![0.0; order.len()];
|
||||
let mut mean = 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 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();
|
||||
|
||||
Ok(Gaussian::from_mv(mean, 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