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:
@@ -132,8 +132,9 @@ fn key(i: usize) -> &'static str {
|
||||
}
|
||||
|
||||
/// Returns (worst mean error, worst sd ratio).
|
||||
fn run(name: &str, obs: Vec<(usize, usize, f64)>) -> (f64, f64) {
|
||||
println!("\n########## {name} ##########");
|
||||
fn fitted(
|
||||
obs: &[(usize, usize, f64)],
|
||||
) -> History<i64, ConstantDrift, trueskill_tt::NullObserver, &'static str> {
|
||||
let mut h: History<i64, _, _, &'static str> = History::builder()
|
||||
.mu(MU0)
|
||||
.sigma(SIGMA0)
|
||||
@@ -167,6 +168,13 @@ fn run(name: &str, obs: Vec<(usize, usize, f64)>) -> (f64, f64) {
|
||||
report.final_step
|
||||
);
|
||||
|
||||
h
|
||||
}
|
||||
|
||||
/// Returns (worst mean error, worst sd ratio gap).
|
||||
fn run(name: &str, obs: Vec<(usize, usize, f64)>) -> (f64, f64) {
|
||||
println!("\n########## {name} ##########");
|
||||
let h = fitted(&obs);
|
||||
let (mean, cov) = exact_for(&obs);
|
||||
|
||||
println!("\n== marginals: crate vs the exact linear-Gaussian posterior ==");
|
||||
@@ -256,3 +264,104 @@ fn with_cycles_the_means_stay_exact_but_the_variances_shrink() {
|
||||
issue and these docs need revisiting (worst ratio gap {sd_gap})"
|
||||
);
|
||||
}
|
||||
|
||||
/// The point of #46: `posterior_of` must reproduce the exact joint, including
|
||||
/// the correlation that marginals cannot express.
|
||||
#[test]
|
||||
fn posterior_of_matches_the_exact_joint() {
|
||||
for (name, obs) in [("tree", tree_fixture()), ("loopy", fixture())] {
|
||||
let h = fitted(&obs);
|
||||
let (_, cov) = exact_for(&obs);
|
||||
|
||||
println!("\n== posterior_of vs exact ({name}) ==");
|
||||
println!(
|
||||
"{:>12} {:>14} {:>14} {:>10}",
|
||||
"functional", "posterior_of", "exact", "ratio"
|
||||
);
|
||||
|
||||
for (i, j) in [(0usize, 1usize), (0, 2), (1, 3), (2, 4)] {
|
||||
let got = h
|
||||
.posterior_of(&[(&key(i), 1.0), (&key(j), -1.0)])
|
||||
.expect("scored slice should have a joint");
|
||||
let exact_sd = (cov[i][i] + cov[j][j] - 2.0 * cov[i][j]).sqrt();
|
||||
println!(
|
||||
"{:>12} {:>14.6} {:>14.6} {:>10.4}",
|
||||
format!("{}-{}", key(i), key(j)),
|
||||
got.sigma(),
|
||||
exact_sd,
|
||||
got.sigma() / exact_sd
|
||||
);
|
||||
assert!(
|
||||
(got.sigma() - exact_sd).abs() / exact_sd < 1e-9,
|
||||
"{name} {}-{}: posterior_of gave {} where the exact joint is {exact_sd}",
|
||||
key(i),
|
||||
key(j),
|
||||
got.sigma()
|
||||
);
|
||||
}
|
||||
|
||||
// A single competitor: this is where the loopy marginal was 2x narrow.
|
||||
for (i, row) in cov.iter().enumerate() {
|
||||
let got = h.posterior_of(&[(&key(i), 1.0)]).unwrap();
|
||||
let exact_sd = row[i].sqrt();
|
||||
assert!(
|
||||
(got.sigma() - exact_sd).abs() / exact_sd < 1e-9,
|
||||
"{name} {}: posterior_of gave {} where exact is {exact_sd}",
|
||||
key(i),
|
||||
got.sigma()
|
||||
);
|
||||
}
|
||||
println!(" single-competitor marginals also exact");
|
||||
}
|
||||
}
|
||||
|
||||
/// Cost of the dense solve as the slice grows. Recorded, not asserted.
|
||||
#[test]
|
||||
#[ignore = "timing probe, run explicitly"]
|
||||
fn cost_scaling() {
|
||||
use std::time::Instant;
|
||||
for n in [50usize, 100, 200, 400, 800] {
|
||||
let names: Vec<String> = (0..n).map(|i| format!("c{i}")).collect();
|
||||
let mut h: History<i64, _, _, String> = History::builder_with_key()
|
||||
.score_sigma(2.0)
|
||||
.drift(ConstantDrift(0.0))
|
||||
.convergence(ConvergenceOptions {
|
||||
max_iter: 200,
|
||||
epsilon: 1e-8,
|
||||
alpha: 1.0,
|
||||
})
|
||||
.build();
|
||||
let mut seed = 5u64;
|
||||
let mut rnd = move || {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 7;
|
||||
seed ^= seed << 17;
|
||||
seed
|
||||
};
|
||||
let events: Vec<Event<i64, String>> = (0..n * 4)
|
||||
.map(|_| {
|
||||
let a = (rnd() as usize) % n;
|
||||
let mut b = (rnd() as usize) % n;
|
||||
if b == a {
|
||||
b = (b + 1) % n;
|
||||
}
|
||||
Event {
|
||||
time: 1,
|
||||
teams: smallvec![
|
||||
Team::with_members([Member::new(names[a].clone())]),
|
||||
Team::with_members([Member::new(names[b].clone())]),
|
||||
],
|
||||
outcome: Outcome::scores([1.0, 0.0]),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
h.add_events(events).unwrap();
|
||||
let _ = h.converge().unwrap();
|
||||
|
||||
let t = Instant::now();
|
||||
let g = h
|
||||
.posterior_of(&[(&names[0], 1.0), (&names[1], -1.0)])
|
||||
.unwrap();
|
||||
println!(" n={n:>4}: {:>10.2?} sigma {:.6}", t.elapsed(), g.sigma());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user