feat: factorise the joint once with History::joint
`posterior_of`, `posterior_of_at` and `expected_variance_reduction` each
built the joint precision matrix, factorised it, asked one question and
threw it away. 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 paid for the same factorisation once per question.
`History::joint()` returns a `Joint` handle that pays it once. Measured
on 1976 appearances, 90 queries: 68.4s one-shot against 745ms factorise
plus 93ms of queries — 81.6x, with bit-identical answers. Per query,
Criterion at 480 appearances: 9.0ms one-shot against 48us cached, 187x.
The handle borrows the history, which is what makes it correct with no
invalidation logic: the borrow checker forbids adding events or refitting
while it is alive, so there is no window in which the factorisation could
describe a fit that no longer exists. It also makes the lifetime of the
n^2 factor explicit rather than parking it in the history forever — at
4000 appearances that is 128MB, which is not something to cache silently.
Every question the joint answers turns out to be a bilinear form,
c^T A^-1 a = (L^-1 c) . (L^-1 a)
so no caller ever needs L^-1 c itself. Replacing the general solve with a
forward substitution drops the back substitution as wasted work, halving
a query, and removes a failure mode: a variance as `c . (A^-1 c)` is a
difference of products that can round negative, where `|L^-1 c|^2` is a
sum of squares and cannot.
The one-shot calls are unchanged in cost and now delegate to the handle,
so the two paths cannot drift apart. tests/joint_handle.rs asserts they
agree bit for bit, including at pinned times, under UnknownKeys::Prior,
and across candidate matchups.
Refs #51
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
This commit is contained in:
+114
-61
@@ -1,99 +1,152 @@
|
||||
//! Posterior of a linear combination of competitors.
|
||||
//! Cholesky factorisation of a joint precision matrix.
|
||||
//!
|
||||
//! Every accessor on `History` returns a per-competitor marginal, and almost
|
||||
//! nothing a consumer publishes is one competitor: "can we tell these two
|
||||
//! apart" is a difference, "what was this round worth" is a sum. Combining
|
||||
//! marginals means assuming the competitors are independent, and they are
|
||||
//! correlated through every event they share — which is the mechanism the model
|
||||
//! exists to exploit.
|
||||
//! Every question the joint answers is a *bilinear form* in the precision
|
||||
//! matrix's inverse — the variance of a contrast is `c^T L^-1 c`, and the
|
||||
//! covariance of two contrasts is `c^T L^-1 a`. None of them wants `L^-1 c`
|
||||
//! itself, which is what makes the shape here worth stating explicitly.
|
||||
//!
|
||||
//! Measured on a five-competitor round robin, the exact correlation is +0.857,
|
||||
//! so `sqrt(sa^2 + sb^2)` overstates the width of a difference by 2.6x.
|
||||
//! Writing the precision as `A = L L^T`,
|
||||
//!
|
||||
//! ```text
|
||||
//! c^T A^-1 a = c^T L^-T L^-1 a = (L^-1 c) . (L^-1 a)
|
||||
//! ```
|
||||
//!
|
||||
//! so a single forward substitution per contrast answers everything, and the
|
||||
//! back substitution a general solve would do is wasted work. That halves the
|
||||
//! cost of a query, and it removes a failure mode: a variance computed as
|
||||
//! `c . (A^-1 c)` is a difference of products that can round to a small
|
||||
//! negative number, where the same quantity as `|L^-1 c|^2` is a sum of
|
||||
//! squares and cannot.
|
||||
//!
|
||||
//! Factorising is `O(n^3)` and whitening is `O(n^2)`, so the split also
|
||||
//! matters structurally: the expensive half depends only on the fit, and is
|
||||
//! shared across every query a [`Joint`](crate::Joint) answers.
|
||||
|
||||
/// Solve `A z = b` for a symmetric positive-definite `A`, by Cholesky.
|
||||
///
|
||||
/// `a` is row-major and is consumed as scratch.
|
||||
///
|
||||
/// Returns `None` if the matrix is not positive-definite, which for a precision
|
||||
/// matrix means the model is improper — a competitor with no prior and no
|
||||
/// evidence.
|
||||
pub(crate) fn solve_spd(mut a: Vec<f64>, b: &[f64]) -> Option<Vec<f64>> {
|
||||
let n = b.len();
|
||||
debug_assert_eq!(a.len(), n * n);
|
||||
/// A factorised symmetric positive-definite matrix, reusable across queries.
|
||||
pub(crate) struct Cholesky {
|
||||
/// Lower triangle of `L`, row-major `n * n`. The upper triangle is
|
||||
/// leftover scratch from the factorisation and is never read.
|
||||
l: Vec<f64>,
|
||||
n: usize,
|
||||
}
|
||||
|
||||
// In-place Cholesky: A = L L^T, lower triangle.
|
||||
for j in 0..n {
|
||||
let mut d = a[j * n + j];
|
||||
for k in 0..j {
|
||||
d -= a[j * n + k] * a[j * n + k];
|
||||
}
|
||||
// Explicit rather than `!(d > 0.0)`: a NaN pivot must fail here too,
|
||||
// and a negated comparison would let it through as "not positive".
|
||||
if d.is_nan() || d <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let d = d.sqrt();
|
||||
a[j * n + j] = d;
|
||||
impl Cholesky {
|
||||
/// Factorise `a` (row-major, `n * n`, symmetric) into `L L^T`.
|
||||
///
|
||||
/// `a` is consumed as scratch.
|
||||
///
|
||||
/// Returns `None` if the matrix is not positive-definite, which for a
|
||||
/// precision matrix means the model is improper — a competitor with
|
||||
/// neither a proper prior nor any evidence.
|
||||
pub(crate) fn factor(mut a: Vec<f64>, n: usize) -> Option<Self> {
|
||||
debug_assert_eq!(a.len(), n * n);
|
||||
|
||||
for i in j + 1..n {
|
||||
let mut s = a[i * n + j];
|
||||
for j in 0..n {
|
||||
let mut d = a[j * n + j];
|
||||
for k in 0..j {
|
||||
s -= a[i * n + k] * a[j * n + k];
|
||||
d -= a[j * n + k] * a[j * n + k];
|
||||
}
|
||||
// Explicit rather than `!(d > 0.0)`: a NaN pivot must fail here
|
||||
// too, and a negated comparison would let it through as "not
|
||||
// positive".
|
||||
if d.is_nan() || d <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let d = d.sqrt();
|
||||
a[j * n + j] = d;
|
||||
|
||||
for i in j + 1..n {
|
||||
let mut s = a[i * n + j];
|
||||
for k in 0..j {
|
||||
s -= a[i * n + k] * a[j * n + k];
|
||||
}
|
||||
a[i * n + j] = s / d;
|
||||
}
|
||||
a[i * n + j] = s / d;
|
||||
}
|
||||
|
||||
Some(Self { l: a, n })
|
||||
}
|
||||
|
||||
// Forward substitution, then back substitution.
|
||||
let mut z = b.to_vec();
|
||||
for i in 0..n {
|
||||
let mut s = z[i];
|
||||
for k in 0..i {
|
||||
s -= a[i * n + k] * z[k];
|
||||
/// Whiten a contrast: `y = L^-1 b`.
|
||||
///
|
||||
/// The point of the result is the dot product, not the vector: for two
|
||||
/// contrasts `b` and `b'`, `y . y'` is `b^T A^-1 b'`. See the module docs.
|
||||
pub(crate) fn whiten(&self, b: &[f64]) -> Vec<f64> {
|
||||
debug_assert_eq!(b.len(), self.n);
|
||||
let n = self.n;
|
||||
let mut y = b.to_vec();
|
||||
for i in 0..n {
|
||||
// Folded from `y[i]` rather than summed and subtracted once, so the
|
||||
// accumulation order matches a plain substitution loop exactly.
|
||||
let row = &self.l[i * n..i * n + i];
|
||||
let s = row
|
||||
.iter()
|
||||
.zip(&y[..i])
|
||||
.fold(y[i], |acc, (l, v)| acc - l * v);
|
||||
y[i] = s / self.l[i * n + i];
|
||||
}
|
||||
z[i] = s / a[i * n + i];
|
||||
}
|
||||
for i in (0..n).rev() {
|
||||
let mut s = z[i];
|
||||
for k in i + 1..n {
|
||||
s -= a[k * n + i] * z[k];
|
||||
}
|
||||
z[i] = s / a[i * n + i];
|
||||
y
|
||||
}
|
||||
}
|
||||
|
||||
Some(z)
|
||||
/// `b^T A^-1 b'`, given the two whitened contrasts.
|
||||
pub(crate) fn bilinear(y: &[f64], y_prime: &[f64]) -> f64 {
|
||||
y.iter().zip(y_prime).map(|(a, b)| a * b).sum()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// `[[4, 1], [1, 3]] z = [1, 2]` has `z = [1/11, 7/11]`, so the quadratic
|
||||
/// form `b^T A^-1 b` is `1 * 1/11 + 2 * 7/11 = 15/11`.
|
||||
#[test]
|
||||
fn solves_a_known_system() {
|
||||
// [[4, 1], [1, 3]] z = [1, 2] => z = [1/11, 7/11]
|
||||
let a = vec![4.0, 1.0, 1.0, 3.0];
|
||||
let z = solve_spd(a, &[1.0, 2.0]).unwrap();
|
||||
assert!((z[0] - 1.0 / 11.0).abs() < 1e-12, "{z:?}");
|
||||
assert!((z[1] - 7.0 / 11.0).abs() < 1e-12, "{z:?}");
|
||||
fn reproduces_a_known_quadratic_form() {
|
||||
let c = Cholesky::factor(vec![4.0, 1.0, 1.0, 3.0], 2).unwrap();
|
||||
let y = c.whiten(&[1.0, 2.0]);
|
||||
assert!((bilinear(&y, &y) - 15.0 / 11.0).abs() < 1e-12);
|
||||
}
|
||||
|
||||
/// Whitening `e_i` recovers the inverse's diagonal, which is the variance
|
||||
/// of a single variable.
|
||||
#[test]
|
||||
fn recovers_the_inverse_diagonal() {
|
||||
// A = [[2, -1, 0], [-1, 2, -1], [0, -1, 2]]; inverse diagonal is
|
||||
// [0.75, 1.0, 0.75].
|
||||
let a = vec![2.0, -1.0, 0.0, -1.0, 2.0, -1.0, 0.0, -1.0, 2.0];
|
||||
let c = Cholesky::factor(a, 3).unwrap();
|
||||
for (i, expected) in [0.75, 1.0, 0.75].into_iter().enumerate() {
|
||||
let mut e = vec![0.0; 3];
|
||||
e[i] = 1.0;
|
||||
let z = solve_spd(a.clone(), &e).unwrap();
|
||||
assert!((z[i] - expected).abs() < 1e-12, "row {i}: {z:?}");
|
||||
let y = c.whiten(&e);
|
||||
assert!((bilinear(&y, &y) - expected).abs() < 1e-12, "row {i}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The off-diagonal bilinear form is symmetric and matches the inverse.
|
||||
#[test]
|
||||
fn recovers_an_off_diagonal_covariance() {
|
||||
// Same A; (A^-1)_{0,1} = 0.5.
|
||||
let a = vec![2.0, -1.0, 0.0, -1.0, 2.0, -1.0, 0.0, -1.0, 2.0];
|
||||
let c = Cholesky::factor(a, 3).unwrap();
|
||||
let y0 = c.whiten(&[1.0, 0.0, 0.0]);
|
||||
let y1 = c.whiten(&[0.0, 1.0, 0.0]);
|
||||
assert!((bilinear(&y0, &y1) - 0.5).abs() < 1e-12);
|
||||
assert!((bilinear(&y1, &y0) - 0.5).abs() < 1e-12);
|
||||
}
|
||||
|
||||
/// A variance can never come out negative, because it is a sum of squares.
|
||||
#[test]
|
||||
fn a_quadratic_form_is_never_negative() {
|
||||
let a = vec![1e12, 1e12 - 1.0, 1e12 - 1.0, 1e12];
|
||||
let c = Cholesky::factor(a, 2).unwrap();
|
||||
let y = c.whiten(&[1.0, -1.0]);
|
||||
assert!(bilinear(&y, &y) >= 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_a_non_positive_definite_matrix() {
|
||||
// Singular: the second row is a multiple of the first.
|
||||
let a = vec![1.0, 2.0, 2.0, 4.0];
|
||||
assert!(solve_spd(a, &[1.0, 1.0]).is_none());
|
||||
assert!(Cholesky::factor(vec![1.0, 2.0, 2.0, 4.0], 2).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user