refactor!: the joint is reached through Joint, not mirrored on History
`posterior_of`, `posterior_of_at` and `expected_variance_reduction`
existed twice: once on `Joint`, and once on `History` as one-shot
wrappers whose whole body was `self.joint()?.<same>(..)`.
The wrappers re-factorised on every call — their own docs said so,
warning the reader to take a `Joint` instead — and they were what
smuggled the scored-only precondition onto the flat surface. A user
following the quickstart builds a ranked history, sees `posterior_of` in
the method list, and it never works. `h.joint()?.posterior_of(..)` is
one call longer and tells the truth: you need a joint, and a joint needs
a scored history.
That leaves three tiers instead of a flat surface with a hidden
precondition: `History` fits and reads, `predict_*` forecasts, `Joint`
answers exact joint questions.
`predict_margin` was itself calling `self.posterior_of`; it goes through
`self.joint()?` directly now.
The `Joint` methods' docs referred back to the wrappers for their real
content ("Identical to `History::posterior_of`, without re-paying the
factorisation"), so they now carry it: what a linear functional means,
which appearance each competitor is read at, and why
`expected_variance_reduction` belongs on the handle.
`tests/joint_handle.rs` had three tests comparing the wrapper against
the handle. That comparison is gone, but the property behind it is not —
they now compare a *reused* joint against a *fresh* one per question,
which is the actual correctness claim behind caching the factorisation
(#51), without the wrapper in the middle.
Closes #78.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
This commit is contained in:
+29
-156
@@ -1529,156 +1529,16 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
||||
})
|
||||
}
|
||||
|
||||
/// 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.
|
||||
///
|
||||
/// # Which appearance each competitor is read at
|
||||
///
|
||||
/// Each competitor is read at *their own* latest appearance, which is where
|
||||
/// [`History::current_skill`] reads them too, so the two agree about which
|
||||
/// posterior they describe. That matters in a Through-Time history: with
|
||||
/// per-day or per-event slices, competitors are rarely all present in any
|
||||
/// one of them. Use [`History::posterior_of_at`] to pin a time instead.
|
||||
///
|
||||
/// # Asking more than one question
|
||||
///
|
||||
/// This factorises the joint, uses it once, and throws it away. The
|
||||
/// factorisation is the expensive part and it depends only on the fit, so
|
||||
/// asking `n` questions this way pays for it `n` times. Take a
|
||||
/// [`Joint`] with [`History::joint`] instead — the answers are identical,
|
||||
/// and only the first one pays.
|
||||
///
|
||||
/// # Cost
|
||||
///
|
||||
/// A dense solve over the history's *appearances*, not its competitors. A
|
||||
/// drift-free competitor collapses to a single variable however long the
|
||||
/// history, so the same events can differ enormously in cost depending on
|
||||
/// the drift configuration — see [`Joint`], which also amortises this
|
||||
/// across many questions.
|
||||
///
|
||||
/// # Limitations
|
||||
///
|
||||
/// Exact only for a history 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 —
|
||||
/// so a history containing ranked events returns `JointUnavailable` rather
|
||||
/// than a plausible wrong number.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// `UnknownKey` for a competitor the history has never seen, and
|
||||
/// `JointUnavailable` for ranked events or a system that is not
|
||||
/// positive-definite.
|
||||
pub fn posterior_of<Q>(&self, terms: &[(&Q, f64)]) -> Result<Gaussian, InferenceError>
|
||||
where
|
||||
K: Borrow<Q>,
|
||||
Q: Hash + Eq + ?Sized + std::fmt::Debug,
|
||||
{
|
||||
self.joint()?.posterior_of(terms)
|
||||
}
|
||||
|
||||
/// Posterior of a linear combination, read as of `time`.
|
||||
///
|
||||
/// Each competitor is taken at their latest appearance at or before `time`,
|
||||
/// which is the same reading [`History::learning_curve`] gives. Use this
|
||||
/// when a comparison must be anchored to a moment — "how did these two
|
||||
/// stand at the end of last season" — rather than to wherever each
|
||||
/// competitor was last seen.
|
||||
///
|
||||
/// As with [`History::posterior_of`], this factorises the joint for one
|
||||
/// question; [`History::joint`] amortises that across many.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// As [`History::posterior_of`], plus `UnknownKey` for a competitor with no
|
||||
/// appearance at or before `time`.
|
||||
pub fn posterior_of_at<Q>(
|
||||
&self,
|
||||
time: T,
|
||||
terms: &[(&Q, f64)],
|
||||
) -> Result<Gaussian, InferenceError>
|
||||
where
|
||||
K: Borrow<Q>,
|
||||
Q: Hash + Eq + ?Sized + std::fmt::Debug,
|
||||
{
|
||||
self.joint()?.posterior_of_at(time, terms)
|
||||
}
|
||||
|
||||
/// 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.
|
||||
///
|
||||
/// Scoring a field of candidates is the whole point of this call, and each
|
||||
/// candidate is one question against an unchanged fit — so use
|
||||
/// [`Joint::expected_variance_reduction`] for anything past a single
|
||||
/// candidate, or pay for the factorisation once per candidate.
|
||||
///
|
||||
/// # 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<Q>(
|
||||
&self,
|
||||
teams: &[&[&Q]],
|
||||
target: &[(&Q, f64)],
|
||||
) -> Result<f64, InferenceError>
|
||||
where
|
||||
K: Borrow<Q>,
|
||||
Q: Hash + Eq + ?Sized + std::fmt::Debug,
|
||||
{
|
||||
self.joint()?.expected_variance_reduction(teams, target)
|
||||
}
|
||||
|
||||
/// Factorise the joint posterior once, to answer many questions against it.
|
||||
///
|
||||
/// [`History::posterior_of`] and its neighbours each build and factorise
|
||||
/// the joint, use it once, and drop it. The factorisation is `O(n^3)` in
|
||||
/// the history's *appearances* and depends only on the fit, so a caller
|
||||
/// asking about every pair in a standings table, every cell in a grid, or
|
||||
/// every candidate in an active-learning sweep pays for the same
|
||||
/// factorisation once per question.
|
||||
/// The factorisation is `O(n^3)` in the history's *appearances* and
|
||||
/// depends only on the fit, so a caller asking about every pair in a
|
||||
/// standings table, every cell in a grid, or every candidate in an
|
||||
/// active-learning sweep should pay for it once rather than once per
|
||||
/// question. This handle is the only way to ask those questions: `History`
|
||||
/// carried one-shot wrappers that re-factorised every call, and they were
|
||||
/// removed in #78 precisely because the borrow here is what makes the cost
|
||||
/// visible.
|
||||
///
|
||||
/// A `Joint` pays it once. Each subsequent query is `O(n^2)` — one forward
|
||||
/// substitution — and returns exactly what the one-shot call would.
|
||||
@@ -1815,7 +1675,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
||||
}
|
||||
}
|
||||
|
||||
let skill_gap = self.posterior_of(&terms)?;
|
||||
let skill_gap = self.joint()?.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))
|
||||
@@ -2911,8 +2771,13 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> Joint<'_, T, D,
|
||||
|
||||
/// Posterior of a linear combination of competitors' skills.
|
||||
///
|
||||
/// Identical to [`History::posterior_of`], including which appearance each
|
||||
/// competitor is read at, without re-paying the factorisation.
|
||||
/// `terms` pairs each competitor with a coefficient, so
|
||||
/// `[(a, 1.0), (b, -1.0)]` is the skill *gap* between them — with the
|
||||
/// covariance between the two accounted for, which is the whole reason to
|
||||
/// go through the joint rather than subtract two marginals.
|
||||
///
|
||||
/// Each competitor is read at their own latest appearance. Use
|
||||
/// [`Joint::posterior_of_at`] to anchor the reading to a moment instead.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
@@ -2930,8 +2795,11 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> Joint<'_, T, D,
|
||||
|
||||
/// Posterior of a linear combination, read as of `time`.
|
||||
///
|
||||
/// Identical to [`History::posterior_of_at`] without re-paying the
|
||||
/// factorisation.
|
||||
/// As [`Joint::posterior_of`], but every competitor is read at their
|
||||
/// latest appearance at or before `time` — the same reading
|
||||
/// [`History::learning_curve`] gives. Use it when a comparison must be
|
||||
/// anchored to a moment ("how did these two stand at the end of last
|
||||
/// season") rather than to wherever each competitor was last seen.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
@@ -2970,9 +2838,14 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> Joint<'_, T, D,
|
||||
|
||||
/// How much observing this matchup would shrink the variance of `target`.
|
||||
///
|
||||
/// Identical to [`History::expected_variance_reduction`] without re-paying
|
||||
/// the factorisation, which is the shape this call is normally used in:
|
||||
/// one target, a field of candidate matchups, one unchanged fit.
|
||||
/// `target` is a linear functional in the same shape
|
||||
/// [`Joint::posterior_of`] takes: the question you want sharpened. The
|
||||
/// answer is how much observing this matchup would shrink that question's
|
||||
/// variance.
|
||||
///
|
||||
/// This is the shape the call is normally used in — one target, a field of
|
||||
/// candidate matchups, one unchanged fit — which is why it lives on the
|
||||
/// handle and the factorisation is paid once for the whole field.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
|
||||
Reference in New Issue
Block a user