Merge api/joint-layering (#78)
This commit is contained in:
+4
-1
@@ -58,8 +58,11 @@ fn bench_joint(c: &mut Criterion) {
|
|||||||
bencher.iter(|| std::hint::black_box(h.joint().unwrap().variables()));
|
bencher.iter(|| std::hint::black_box(h.joint().unwrap().variables()));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Factorise-and-query, the cost the deleted `History::posterior_of`
|
||||||
|
// wrapper paid on every call. Kept as the baseline the cached query below
|
||||||
|
// is measured against.
|
||||||
c.bench_function("posterior_of_one_shot_480_appearances", |bencher| {
|
c.bench_function("posterior_of_one_shot_480_appearances", |bencher| {
|
||||||
bencher.iter(|| std::hint::black_box(h.posterior_of(&terms).unwrap()));
|
bencher.iter(|| std::hint::black_box(h.joint().unwrap().posterior_of(&terms).unwrap()));
|
||||||
});
|
});
|
||||||
|
|
||||||
let joint = h.joint().unwrap();
|
let joint = h.joint().unwrap();
|
||||||
|
|||||||
+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.
|
/// Factorise the joint posterior once, to answer many questions against it.
|
||||||
///
|
///
|
||||||
/// [`History::posterior_of`] and its neighbours each build and factorise
|
/// The factorisation is `O(n^3)` in the history's *appearances* and
|
||||||
/// the joint, use it once, and drop it. The factorisation is `O(n^3)` in
|
/// depends only on the fit, so a caller asking about every pair in a
|
||||||
/// the history's *appearances* and depends only on the fit, so a caller
|
/// standings table, every cell in a grid, or every candidate in an
|
||||||
/// asking about every pair in a standings table, every cell in a grid, or
|
/// active-learning sweep should pay for it once rather than once per
|
||||||
/// every candidate in an active-learning sweep pays for the same
|
/// question. This handle is the only way to ask those questions: `History`
|
||||||
/// factorisation once per question.
|
/// 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
|
/// A `Joint` pays it once. Each subsequent query is `O(n^2)` — one forward
|
||||||
/// substitution — and returns exactly what the one-shot call would.
|
/// 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);
|
let variance = skill_gap.sigma().powi(2) + performance_noise + self.score_sigma.powi(2);
|
||||||
|
|
||||||
Ok(Gaussian::from_mv(skill_gap.mu(), variance))
|
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.
|
/// Posterior of a linear combination of competitors' skills.
|
||||||
///
|
///
|
||||||
/// Identical to [`History::posterior_of`], including which appearance each
|
/// `terms` pairs each competitor with a coefficient, so
|
||||||
/// competitor is read at, without re-paying the factorisation.
|
/// `[(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
|
/// # 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`.
|
/// Posterior of a linear combination, read as of `time`.
|
||||||
///
|
///
|
||||||
/// Identical to [`History::posterior_of_at`] without re-paying the
|
/// As [`Joint::posterior_of`], but every competitor is read at their
|
||||||
/// factorisation.
|
/// 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
|
/// # 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`.
|
/// How much observing this matchup would shrink the variance of `target`.
|
||||||
///
|
///
|
||||||
/// Identical to [`History::expected_variance_reduction`] without re-paying
|
/// `target` is a linear functional in the same shape
|
||||||
/// the factorisation, which is the shape this call is normally used in:
|
/// [`Joint::posterior_of`] takes: the question you want sharpened. The
|
||||||
/// one target, a field of candidate matchups, one unchanged fit.
|
/// 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
|
/// # Errors
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ fn additive_structure_makes_sums_wide_and_differences_tight() {
|
|||||||
|
|
||||||
println!("\n== the same nodes via posterior_of (exact marginal) ==");
|
println!("\n== the same nodes via posterior_of (exact marginal) ==");
|
||||||
for k in players.iter().chain(holes.iter()) {
|
for k in players.iter().chain(holes.iter()) {
|
||||||
let g = h.posterior_of(&[(k, 1.0)]).unwrap();
|
let g = h.joint().unwrap().posterior_of(&[(k, 1.0)]).unwrap();
|
||||||
println!(" {k}: mu {:>8.4} sigma {:>8.4}", g.mu(), g.sigma());
|
println!(" {k}: mu {:>8.4} sigma {:>8.4}", g.mu(), g.sigma());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,7 +97,7 @@ fn additive_structure_makes_sums_wide_and_differences_tight() {
|
|||||||
vec![(&"h0", 1.0), (&"h1", -1.0)],
|
vec![(&"h0", 1.0), (&"h1", -1.0)],
|
||||||
),
|
),
|
||||||
] {
|
] {
|
||||||
let joint = h.posterior_of(&terms).unwrap();
|
let joint = h.joint().unwrap().posterior_of(&terms).unwrap();
|
||||||
// what a consumer gets today by adding marginals
|
// what a consumer gets today by adding marginals
|
||||||
let naive: f64 = terms
|
let naive: f64 = terms
|
||||||
.iter()
|
.iter()
|
||||||
@@ -132,7 +132,12 @@ fn additive_structure_makes_sums_wide_and_differences_tight() {
|
|||||||
// shares with its partners is pinned only by the prior.
|
// shares with its partners is pinned only by the prior.
|
||||||
for k in players.iter().chain(holes.iter()) {
|
for k in players.iter().chain(holes.iter()) {
|
||||||
let bp = h.current_skill(k).unwrap().sigma();
|
let bp = h.current_skill(k).unwrap().sigma();
|
||||||
let exact = h.posterior_of(&[(k, 1.0)]).unwrap().sigma();
|
let exact = h
|
||||||
|
.joint()
|
||||||
|
.unwrap()
|
||||||
|
.posterior_of(&[(k, 1.0)])
|
||||||
|
.unwrap()
|
||||||
|
.sigma();
|
||||||
assert!(
|
assert!(
|
||||||
exact > 3.0 * bp,
|
exact > 3.0 * bp,
|
||||||
"{k}: exact marginal {exact} should be much wider than the reported \
|
"{k}: exact marginal {exact} should be much wider than the reported \
|
||||||
|
|||||||
@@ -84,13 +84,17 @@ fn fingerprint() -> String {
|
|||||||
let known = "p0".to_string();
|
let known = "p0".to_string();
|
||||||
terms.push((&known, -1.0));
|
terms.push((&known, -1.0));
|
||||||
|
|
||||||
let posterior = h.posterior_of(&terms).unwrap();
|
let posterior = h.joint().unwrap().posterior_of(&terms).unwrap();
|
||||||
|
|
||||||
let a = "p0".to_string();
|
let a = "p0".to_string();
|
||||||
let b = "p1".to_string();
|
let b = "p1".to_string();
|
||||||
let target = [(&a, 1.0), (&b, -1.0)];
|
let target = [(&a, 1.0), (&b, -1.0)];
|
||||||
let teams: [&[&String]; 2] = [&[&a], &[&b]];
|
let teams: [&[&String]; 2] = [&[&a], &[&b]];
|
||||||
let evr = h.expected_variance_reduction(&teams, &target).unwrap();
|
let evr = h
|
||||||
|
.joint()
|
||||||
|
.unwrap()
|
||||||
|
.expected_variance_reduction(&teams, &target)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let curves = h.learning_curves();
|
let curves = h.learning_curves();
|
||||||
let mut curve_bits: u64 = 0;
|
let mut curve_bits: u64 = 0;
|
||||||
|
|||||||
+17
-8
@@ -78,14 +78,19 @@ const PAIRS: [(&str, &str); 6] = [
|
|||||||
("c", "d"),
|
("c", "d"),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/// A joint reused across questions answers exactly what a fresh one per
|
||||||
|
/// question does. That is the whole correctness claim behind caching the
|
||||||
|
/// factorisation (#51); it used to be checked against the `History` one-shot
|
||||||
|
/// wrappers, which were deleted in #78, so it is checked against a fresh
|
||||||
|
/// factorisation instead — the same comparison, without the wrapper.
|
||||||
#[test]
|
#[test]
|
||||||
fn a_joint_answers_exactly_what_the_one_shot_call_does() {
|
fn a_reused_joint_answers_exactly_what_a_fresh_one_does() {
|
||||||
let h = fitted(UnknownKeys::Reject);
|
let h = fitted(UnknownKeys::Reject);
|
||||||
let joint = h.joint().unwrap();
|
let joint = h.joint().unwrap();
|
||||||
|
|
||||||
for (a, b) in PAIRS {
|
for (a, b) in PAIRS {
|
||||||
let terms = [(&a, 1.0), (&b, -1.0)];
|
let terms = [(&a, 1.0), (&b, -1.0)];
|
||||||
let one_shot = h.posterior_of(&terms).unwrap();
|
let one_shot = h.joint().unwrap().posterior_of(&terms).unwrap();
|
||||||
let cached = joint.posterior_of(&terms).unwrap();
|
let cached = joint.posterior_of(&terms).unwrap();
|
||||||
assert_eq!(one_shot.mu(), cached.mu(), "{a} - {b}");
|
assert_eq!(one_shot.mu(), cached.mu(), "{a} - {b}");
|
||||||
assert_eq!(one_shot.variance(), cached.variance(), "{a} - {b}");
|
assert_eq!(one_shot.variance(), cached.variance(), "{a} - {b}");
|
||||||
@@ -100,7 +105,7 @@ fn a_joint_agrees_at_a_pinned_time_too() {
|
|||||||
for time in 1..=5 {
|
for time in 1..=5 {
|
||||||
for (a, b) in PAIRS {
|
for (a, b) in PAIRS {
|
||||||
let terms = [(&a, 1.0), (&b, -1.0)];
|
let terms = [(&a, 1.0), (&b, -1.0)];
|
||||||
let one_shot = h.posterior_of_at(time, &terms);
|
let one_shot = h.joint().unwrap().posterior_of_at(time, &terms);
|
||||||
let cached = joint.posterior_of_at(time, &terms);
|
let cached = joint.posterior_of_at(time, &terms);
|
||||||
match (one_shot, cached) {
|
match (one_shot, cached) {
|
||||||
(Ok(x), Ok(y)) => {
|
(Ok(x), Ok(y)) => {
|
||||||
@@ -123,7 +128,11 @@ fn a_joint_scores_candidate_matchups_identically() {
|
|||||||
|
|
||||||
for (x, y) in PAIRS {
|
for (x, y) in PAIRS {
|
||||||
let teams: [&[&&str]; 2] = [&[&x], &[&y]];
|
let teams: [&[&&str]; 2] = [&[&x], &[&y]];
|
||||||
let one_shot = h.expected_variance_reduction(&teams, &target).unwrap();
|
let one_shot = h
|
||||||
|
.joint()
|
||||||
|
.unwrap()
|
||||||
|
.expected_variance_reduction(&teams, &target)
|
||||||
|
.unwrap();
|
||||||
let cached = joint.expected_variance_reduction(&teams, &target).unwrap();
|
let cached = joint.expected_variance_reduction(&teams, &target).unwrap();
|
||||||
assert_eq!(one_shot, cached, "{x} vs {y}");
|
assert_eq!(one_shot, cached, "{x} vs {y}");
|
||||||
}
|
}
|
||||||
@@ -252,15 +261,15 @@ fn unknown_keys_are_rejected_per_query() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Under `Prior`, an unseen competitor is independent of everything in the
|
/// Under `Prior`, an unseen competitor is independent of everything in the
|
||||||
/// history, and the cached path must add the same prior variance the one-shot
|
/// history, and a reused joint must add the same prior variance a fresh one
|
||||||
/// path does.
|
/// does.
|
||||||
#[test]
|
#[test]
|
||||||
fn unseen_competitors_match_the_one_shot_path() {
|
fn unseen_competitors_match_a_fresh_factorisation() {
|
||||||
let h = fitted(UnknownKeys::Prior);
|
let h = fitted(UnknownKeys::Prior);
|
||||||
let joint = h.joint().unwrap();
|
let joint = h.joint().unwrap();
|
||||||
let (a, z) = ("a", "nobody");
|
let (a, z) = ("a", "nobody");
|
||||||
let terms = [(&a, 1.0), (&z, -1.0)];
|
let terms = [(&a, 1.0), (&z, -1.0)];
|
||||||
let one_shot = h.posterior_of(&terms).unwrap();
|
let one_shot = h.joint().unwrap().posterior_of(&terms).unwrap();
|
||||||
let cached = joint.posterior_of(&terms).unwrap();
|
let cached = joint.posterior_of(&terms).unwrap();
|
||||||
assert_eq!(one_shot.mu(), cached.mu());
|
assert_eq!(one_shot.mu(), cached.mu());
|
||||||
assert_eq!(one_shot.variance(), cached.variance());
|
assert_eq!(one_shot.variance(), cached.variance());
|
||||||
|
|||||||
+18
-11
@@ -62,19 +62,26 @@ fn every_team_shaped_query_accepts_the_same_slice() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn linear_combinations_take_bare_keys() {
|
fn linear_combinations_take_bare_keys() {
|
||||||
// `&[(&K, f64)]` at `K = String` meant `&[(&String, f64)]` — no literals.
|
// `&[(&K, f64)]` at `K = String` meant `&[(&String, f64)]` — no literals.
|
||||||
let h = owned();
|
// A scored history, because the joint needs one.
|
||||||
let terms: &[(&str, f64)] = &[("alice", 1.0), ("bob", -1.0)];
|
let mut h: Owned = History::builder().key_type::<String>().build();
|
||||||
|
for t in 1..=4 {
|
||||||
|
h.event(t)
|
||||||
|
.team([String::from("alice")])
|
||||||
|
.team([String::from("bob")])
|
||||||
|
.scores([21.0, 9.0])
|
||||||
|
.commit()
|
||||||
|
.expect("ingests");
|
||||||
|
}
|
||||||
|
h.converge().expect("converges");
|
||||||
|
|
||||||
// Ranked history, so the joint is unavailable — but the *call* compiles,
|
let terms: &[(&str, f64)] = &[("alice", 1.0), ("bob", -1.0)];
|
||||||
// which is what this pins. The error proves it reached the joint check
|
let gap = h
|
||||||
// rather than failing to resolve a key.
|
.joint()
|
||||||
let err = h
|
.expect("scored history has a joint")
|
||||||
.posterior_of(terms)
|
.posterior_of(terms)
|
||||||
.expect_err("ranked history has no joint");
|
.expect("both keys are known");
|
||||||
assert!(
|
|
||||||
format!("{err}").contains("ranked"),
|
assert!(gap.mu() > 0.0, "alice outscored bob every round");
|
||||||
"expected the joint-unavailable path, got {err}"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `lookup` is gone with `Index` (#73); the accessors that answer the same
|
/// `lookup` is gone with `Index` (#73); the accessors that answer the same
|
||||||
|
|||||||
@@ -281,6 +281,8 @@ fn posterior_of_matches_the_exact_joint() {
|
|||||||
|
|
||||||
for (i, j) in [(0usize, 1usize), (0, 2), (1, 3), (2, 4)] {
|
for (i, j) in [(0usize, 1usize), (0, 2), (1, 3), (2, 4)] {
|
||||||
let got = h
|
let got = h
|
||||||
|
.joint()
|
||||||
|
.unwrap()
|
||||||
.posterior_of(&[(&key(i), 1.0), (&key(j), -1.0)])
|
.posterior_of(&[(&key(i), 1.0), (&key(j), -1.0)])
|
||||||
.expect("scored slice should have a joint");
|
.expect("scored slice should have a joint");
|
||||||
let exact_sd = (cov[i][i] + cov[j][j] - 2.0 * cov[i][j]).sqrt();
|
let exact_sd = (cov[i][i] + cov[j][j] - 2.0 * cov[i][j]).sqrt();
|
||||||
@@ -302,7 +304,7 @@ fn posterior_of_matches_the_exact_joint() {
|
|||||||
|
|
||||||
// A single competitor: this is where the loopy marginal was 2x narrow.
|
// A single competitor: this is where the loopy marginal was 2x narrow.
|
||||||
for (i, row) in cov.iter().enumerate() {
|
for (i, row) in cov.iter().enumerate() {
|
||||||
let got = h.posterior_of(&[(&key(i), 1.0)]).unwrap();
|
let got = h.joint().unwrap().posterior_of(&[(&key(i), 1.0)]).unwrap();
|
||||||
let exact_sd = row[i].sqrt();
|
let exact_sd = row[i].sqrt();
|
||||||
assert!(
|
assert!(
|
||||||
(got.sigma() - exact_sd).abs() / exact_sd < 1e-9,
|
(got.sigma() - exact_sd).abs() / exact_sd < 1e-9,
|
||||||
@@ -361,6 +363,8 @@ fn cost_scaling() {
|
|||||||
|
|
||||||
let t = Instant::now();
|
let t = Instant::now();
|
||||||
let g = h
|
let g = h
|
||||||
|
.joint()
|
||||||
|
.unwrap()
|
||||||
.posterior_of(&[(&names[0], 1.0), (&names[1], -1.0)])
|
.posterior_of(&[(&names[0], 1.0), (&names[1], -1.0)])
|
||||||
.unwrap();
|
.unwrap();
|
||||||
println!(" n={n:>4}: {:>10.2?} sigma {:.6}", t.elapsed(), g.sigma());
|
println!(" n={n:>4}: {:>10.2?} sigma {:.6}", t.elapsed(), g.sigma());
|
||||||
|
|||||||
@@ -120,6 +120,8 @@ fn swapping_the_teams_negates_the_margin() {
|
|||||||
fn the_predictive_interval_exceeds_the_skill_uncertainty() {
|
fn the_predictive_interval_exceeds_the_skill_uncertainty() {
|
||||||
let h = fitted(UnknownKeys::Prior);
|
let h = fitted(UnknownKeys::Prior);
|
||||||
let skill_gap = h
|
let skill_gap = h
|
||||||
|
.joint()
|
||||||
|
.unwrap()
|
||||||
.posterior_of(&[(&"veteran", 1.0), (&"regular", -1.0)])
|
.posterior_of(&[(&"veteran", 1.0), (&"regular", -1.0)])
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let predictive = h.predict_margin(&[&[&"veteran"], &[&"regular"]]).unwrap();
|
let predictive = h.predict_margin(&[&[&"veteran"], &[&"regular"]]).unwrap();
|
||||||
|
|||||||
@@ -120,7 +120,11 @@ fn a_two_slice_joint_matches_the_exact_posterior() {
|
|||||||
|
|
||||||
// The crate reads each competitor at their latest appearance: a1, b1.
|
// The crate reads each competitor at their latest appearance: a1, b1.
|
||||||
let exact_gap = (cov[2][2] + cov[3][3] - 2.0 * cov[2][3]).sqrt();
|
let exact_gap = (cov[2][2] + cov[3][3] - 2.0 * cov[2][3]).sqrt();
|
||||||
let got = h.posterior_of(&[(&"a", 1.0), (&"b", -1.0)]).unwrap();
|
let got = h
|
||||||
|
.joint()
|
||||||
|
.unwrap()
|
||||||
|
.posterior_of(&[(&"a", 1.0), (&"b", -1.0)])
|
||||||
|
.unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
(got.sigma() - exact_gap).abs() / exact_gap < 1e-9,
|
(got.sigma() - exact_gap).abs() / exact_gap < 1e-9,
|
||||||
"difference: got {} exact {exact_gap}",
|
"difference: got {} exact {exact_gap}",
|
||||||
@@ -128,7 +132,7 @@ fn a_two_slice_joint_matches_the_exact_posterior() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let exact_single = cov[2][2].sqrt();
|
let exact_single = cov[2][2].sqrt();
|
||||||
let got_single = h.posterior_of(&[(&"a", 1.0)]).unwrap();
|
let got_single = h.joint().unwrap().posterior_of(&[(&"a", 1.0)]).unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
(got_single.sigma() - exact_single).abs() / exact_single < 1e-9,
|
(got_single.sigma() - exact_single).abs() / exact_single < 1e-9,
|
||||||
"single node: got {} exact {exact_single}",
|
"single node: got {} exact {exact_single}",
|
||||||
@@ -154,6 +158,8 @@ fn competitors_last_seen_in_different_slices_are_comparable() {
|
|||||||
// b last appeared at time 0; a and c at time 20. All three must resolve.
|
// b last appeared at time 0; a and c at time 20. All three must resolve.
|
||||||
for (x, y) in [("a", "b"), ("b", "c"), ("a", "c")] {
|
for (x, y) in [("a", "b"), ("b", "c"), ("a", "c")] {
|
||||||
let g = h
|
let g = h
|
||||||
|
.joint()
|
||||||
|
.unwrap()
|
||||||
.posterior_of(&[(&x, 1.0), (&y, -1.0)])
|
.posterior_of(&[(&x, 1.0), (&y, -1.0)])
|
||||||
.unwrap_or_else(|e| panic!("{x} - {y} should resolve across slices: {e}"));
|
.unwrap_or_else(|e| panic!("{x} - {y} should resolve across slices: {e}"));
|
||||||
assert!(g.sigma() > 0.0 && g.sigma().is_finite());
|
assert!(g.sigma() > 0.0 && g.sigma().is_finite());
|
||||||
@@ -175,7 +181,7 @@ fn means_agree_with_the_marginals() {
|
|||||||
|
|
||||||
for k in ["a", "b", "c"] {
|
for k in ["a", "b", "c"] {
|
||||||
let marginal = h.current_skill(&k).unwrap().mu();
|
let marginal = h.current_skill(&k).unwrap().mu();
|
||||||
let joint = h.posterior_of(&[(&k, 1.0)]).unwrap().mu();
|
let joint = h.joint().unwrap().posterior_of(&[(&k, 1.0)]).unwrap().mu();
|
||||||
assert!(
|
assert!(
|
||||||
(marginal - joint).abs() < 1e-9,
|
(marginal - joint).abs() < 1e-9,
|
||||||
"{k}: marginal {marginal}, joint {joint}"
|
"{k}: marginal {marginal}, joint {joint}"
|
||||||
@@ -197,7 +203,10 @@ fn zero_drift_makes_slice_layout_irrelevant() {
|
|||||||
])
|
])
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let _ = h.converge().unwrap();
|
let _ = h.converge().unwrap();
|
||||||
h.posterior_of(&[(&"a", 1.0), (&"b", -1.0)]).unwrap()
|
h.joint()
|
||||||
|
.unwrap()
|
||||||
|
.posterior_of(&[(&"a", 1.0), (&"b", -1.0)])
|
||||||
|
.unwrap()
|
||||||
};
|
};
|
||||||
let together = {
|
let together = {
|
||||||
let mut h = history(0.0);
|
let mut h = history(0.0);
|
||||||
@@ -208,7 +217,10 @@ fn zero_drift_makes_slice_layout_irrelevant() {
|
|||||||
])
|
])
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let _ = h.converge().unwrap();
|
let _ = h.converge().unwrap();
|
||||||
h.posterior_of(&[(&"a", 1.0), (&"b", -1.0)]).unwrap()
|
h.joint()
|
||||||
|
.unwrap()
|
||||||
|
.posterior_of(&[(&"a", 1.0), (&"b", -1.0)])
|
||||||
|
.unwrap()
|
||||||
};
|
};
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
@@ -234,7 +246,11 @@ fn drift_widens_a_comparison_across_time() {
|
|||||||
let _ = h.converge().unwrap();
|
let _ = h.converge().unwrap();
|
||||||
|
|
||||||
// b was last seen at time 0; a at time 100.
|
// b was last seen at time 0; a at time 100.
|
||||||
let g = h.posterior_of(&[(&"a", 1.0), (&"b", -1.0)]).unwrap();
|
let g = h
|
||||||
|
.joint()
|
||||||
|
.unwrap()
|
||||||
|
.posterior_of(&[(&"a", 1.0), (&"b", -1.0)])
|
||||||
|
.unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
g.sigma() > previous,
|
g.sigma() > previous,
|
||||||
"gamma={gamma}: sigma {} did not exceed {previous}",
|
"gamma={gamma}: sigma {} did not exceed {previous}",
|
||||||
@@ -257,9 +273,21 @@ fn posterior_of_at_reads_as_of_a_time() {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
let _ = h.converge().unwrap();
|
let _ = h.converge().unwrap();
|
||||||
|
|
||||||
let early = h.posterior_of_at(0, &[(&"a", 1.0), (&"b", -1.0)]).unwrap();
|
let early = h
|
||||||
let late = h.posterior_of_at(20, &[(&"a", 1.0), (&"b", -1.0)]).unwrap();
|
.joint()
|
||||||
let latest = h.posterior_of(&[(&"a", 1.0), (&"b", -1.0)]).unwrap();
|
.unwrap()
|
||||||
|
.posterior_of_at(0, &[(&"a", 1.0), (&"b", -1.0)])
|
||||||
|
.unwrap();
|
||||||
|
let late = h
|
||||||
|
.joint()
|
||||||
|
.unwrap()
|
||||||
|
.posterior_of_at(20, &[(&"a", 1.0), (&"b", -1.0)])
|
||||||
|
.unwrap();
|
||||||
|
let latest = h
|
||||||
|
.joint()
|
||||||
|
.unwrap()
|
||||||
|
.posterior_of(&[(&"a", 1.0), (&"b", -1.0)])
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
// Asking as of the final slice is the same as asking for the latest.
|
// Asking as of the final slice is the same as asking for the latest.
|
||||||
assert!((late.mu() - latest.mu()).abs() < 1e-9);
|
assert!((late.mu() - latest.mu()).abs() < 1e-9);
|
||||||
@@ -275,7 +303,12 @@ fn posterior_of_at_reads_as_of_a_time() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// A time before any event has nothing to read.
|
// A time before any event has nothing to read.
|
||||||
assert!(h.posterior_of_at(-1, &[(&"a", 1.0)]).is_err());
|
assert!(
|
||||||
|
h.joint()
|
||||||
|
.unwrap()
|
||||||
|
.posterior_of_at(-1, &[(&"a", 1.0)])
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Times between slices resolve to the latest appearance at or before them.
|
/// Times between slices resolve to the latest appearance at or before them.
|
||||||
@@ -289,8 +322,16 @@ fn a_time_between_slices_reads_the_previous_appearance() {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
let _ = h.converge().unwrap();
|
let _ = h.converge().unwrap();
|
||||||
|
|
||||||
let at_zero = h.posterior_of_at(0, &[(&"a", 1.0)]).unwrap();
|
let at_zero = h
|
||||||
let between = h.posterior_of_at(50, &[(&"a", 1.0)]).unwrap();
|
.joint()
|
||||||
|
.unwrap()
|
||||||
|
.posterior_of_at(0, &[(&"a", 1.0)])
|
||||||
|
.unwrap();
|
||||||
|
let between = h
|
||||||
|
.joint()
|
||||||
|
.unwrap()
|
||||||
|
.posterior_of_at(50, &[(&"a", 1.0)])
|
||||||
|
.unwrap();
|
||||||
assert!((at_zero.mu() - between.mu()).abs() < 1e-12);
|
assert!((at_zero.mu() - between.mu()).abs() < 1e-12);
|
||||||
assert!((at_zero.sigma() - between.sigma()).abs() < 1e-12);
|
assert!((at_zero.sigma() - between.sigma()).abs() < 1e-12);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,15 +60,30 @@ fn fit(extra: Option<Event<i64, &'static str>>, policy: UnknownKeys) -> H {
|
|||||||
fn the_closed_form_matches_an_actual_refit() {
|
fn the_closed_form_matches_an_actual_refit() {
|
||||||
let h = fit(None, UnknownKeys::Reject);
|
let h = fit(None, UnknownKeys::Reject);
|
||||||
let target: Vec<(&&str, f64)> = vec![(&"a", 1.0), (&"b", -1.0)];
|
let target: Vec<(&&str, f64)> = vec![(&"a", 1.0), (&"b", -1.0)];
|
||||||
let before = h.posterior_of(&target).unwrap().sigma().powi(2);
|
let before = h
|
||||||
|
.joint()
|
||||||
|
.unwrap()
|
||||||
|
.posterior_of(&target)
|
||||||
|
.unwrap()
|
||||||
|
.sigma()
|
||||||
|
.powi(2);
|
||||||
|
|
||||||
for (x, y) in [("a", "b"), ("c", "d"), ("a", "c"), ("b", "d")] {
|
for (x, y) in [("a", "b"), ("c", "d"), ("a", "c"), ("b", "d")] {
|
||||||
let predicted = h
|
let predicted = h
|
||||||
|
.joint()
|
||||||
|
.unwrap()
|
||||||
.expected_variance_reduction(&[&[&x], &[&y]], &target)
|
.expected_variance_reduction(&[&[&x], &[&y]], &target)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let after = fit(Some(round(x, y, 3.0, 1.0)), UnknownKeys::Reject);
|
let after = fit(Some(round(x, y, 3.0, 1.0)), UnknownKeys::Reject);
|
||||||
let actual = before - after.posterior_of(&target).unwrap().sigma().powi(2);
|
let actual = before
|
||||||
|
- after
|
||||||
|
.joint()
|
||||||
|
.unwrap()
|
||||||
|
.posterior_of(&target)
|
||||||
|
.unwrap()
|
||||||
|
.sigma()
|
||||||
|
.powi(2);
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
(predicted - actual).abs() / actual.abs() < 1e-9,
|
(predicted - actual).abs() / actual.abs() < 1e-9,
|
||||||
@@ -84,12 +99,27 @@ fn the_closed_form_matches_an_actual_refit() {
|
|||||||
fn the_outcome_does_not_change_the_reduction() {
|
fn the_outcome_does_not_change_the_reduction() {
|
||||||
let target: Vec<(&&str, f64)> = vec![(&"a", 1.0), (&"b", -1.0)];
|
let target: Vec<(&&str, f64)> = vec![(&"a", 1.0), (&"b", -1.0)];
|
||||||
let h = fit(None, UnknownKeys::Reject);
|
let h = fit(None, UnknownKeys::Reject);
|
||||||
let before = h.posterior_of(&target).unwrap().sigma().powi(2);
|
let before = h
|
||||||
|
.joint()
|
||||||
|
.unwrap()
|
||||||
|
.posterior_of(&target)
|
||||||
|
.unwrap()
|
||||||
|
.sigma()
|
||||||
|
.powi(2);
|
||||||
|
|
||||||
let mut seen = Vec::new();
|
let mut seen = Vec::new();
|
||||||
for (sa, sb) in [(3.0, 1.0), (100.0, -50.0), (0.0, 0.0)] {
|
for (sa, sb) in [(3.0, 1.0), (100.0, -50.0), (0.0, 0.0)] {
|
||||||
let after = fit(Some(round("c", "d", sa, sb)), UnknownKeys::Reject);
|
let after = fit(Some(round("c", "d", sa, sb)), UnknownKeys::Reject);
|
||||||
seen.push(before - after.posterior_of(&target).unwrap().sigma().powi(2));
|
seen.push(
|
||||||
|
before
|
||||||
|
- after
|
||||||
|
.joint()
|
||||||
|
.unwrap()
|
||||||
|
.posterior_of(&target)
|
||||||
|
.unwrap()
|
||||||
|
.sigma()
|
||||||
|
.powi(2),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
for w in seen.windows(2) {
|
for w in seen.windows(2) {
|
||||||
assert!(
|
assert!(
|
||||||
@@ -107,9 +137,13 @@ fn it_ranks_candidates_by_how_much_they_answer_the_question() {
|
|||||||
let target: Vec<(&&str, f64)> = vec![(&"a", 1.0), (&"b", -1.0)];
|
let target: Vec<(&&str, f64)> = vec![(&"a", 1.0), (&"b", -1.0)];
|
||||||
|
|
||||||
let direct = h
|
let direct = h
|
||||||
|
.joint()
|
||||||
|
.unwrap()
|
||||||
.expected_variance_reduction(&[&[&"a"], &[&"b"]], &target)
|
.expected_variance_reduction(&[&[&"a"], &[&"b"]], &target)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let unrelated = h
|
let unrelated = h
|
||||||
|
.joint()
|
||||||
|
.unwrap()
|
||||||
.expected_variance_reduction(&[&[&"c"], &[&"d"]], &target)
|
.expected_variance_reduction(&[&[&"c"], &[&"d"]], &target)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
@@ -128,6 +162,8 @@ fn an_unrelated_unseen_matchup_teaches_nothing_about_the_target() {
|
|||||||
let target: Vec<(&&str, f64)> = vec![(&"a", 1.0), (&"b", -1.0)];
|
let target: Vec<(&&str, f64)> = vec![(&"a", 1.0), (&"b", -1.0)];
|
||||||
|
|
||||||
let reduction = h
|
let reduction = h
|
||||||
|
.joint()
|
||||||
|
.unwrap()
|
||||||
.expected_variance_reduction(&[&[&"stranger"], &[&"nobody"]], &target)
|
.expected_variance_reduction(&[&[&"stranger"], &[&"nobody"]], &target)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
@@ -142,7 +178,9 @@ fn shape_errors_are_reported() {
|
|||||||
let target: Vec<(&&str, f64)> = vec![(&"a", 1.0), (&"b", -1.0)];
|
let target: Vec<(&&str, f64)> = vec![(&"a", 1.0), (&"b", -1.0)];
|
||||||
|
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
h.expected_variance_reduction(&[&[&"a"]], &target),
|
h.joint()
|
||||||
|
.unwrap()
|
||||||
|
.expected_variance_reduction(&[&[&"a"]], &target),
|
||||||
Err(InferenceError::MismatchedShape {
|
Err(InferenceError::MismatchedShape {
|
||||||
expected: 2,
|
expected: 2,
|
||||||
got: 1,
|
got: 1,
|
||||||
@@ -150,7 +188,9 @@ fn shape_errors_are_reported() {
|
|||||||
})
|
})
|
||||||
));
|
));
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
h.expected_variance_reduction(&[&[&"a"], &[&"ghost"]], &target),
|
h.joint()
|
||||||
|
.unwrap()
|
||||||
|
.expected_variance_reduction(&[&[&"a"], &[&"ghost"]], &target),
|
||||||
Err(InferenceError::UnknownKey { .. })
|
Err(InferenceError::UnknownKey { .. })
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user