fix: take quality's determinant ratio in log space
`quality()` computed `det(ata) / det(middle)` in linear space. Both are products of `k - 1` diagonal entries, so they leave f64's range long before their ratio does — and the ratio is the only thing the answer needs. Measured at the crate defaults: 150 groups correct at 8.45e-53, 200 returned 0, 250 returned NaN where the truth is 9.51e-88. With a small beta it bit far sooner: at sigma = beta = 1e-3, 60 groups returned NaN against a true 1.32e-9 — a value nine orders of magnitude inside the normal range. Neither `quality()` nor `History::predict_quality` caps the group count, unlike `predict_outcome`, so those are supported calls. `Lu::ln_abs_determinant` accumulates `ln|diagonal|` instead of multiplying, and the call site becomes `exp(e_arg + 0.5 * ln_ratio)`. Verified against the closed form `(beta / sqrt(beta^2 + sigma^2))^(k-1)` rather than against recorded output, across three parameter sets and group counts to 300: every case now agrees to 1e-11 or better, including 9.88e-324 at 300 groups, which is subnormal. Also documents the remaining panic: every rating at zero sigma with a zero beta makes `middle` singular and `inverse()` panics. Documented rather than converted — nothing is uncertain there, so there is no distribution to take the quality of. Closes #59 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
This commit is contained in:
+19
-2
@@ -673,6 +673,13 @@ pub(crate) fn sort_time<T: Copy + Ord>(xs: &[T], reverse: bool) -> Vec<usize> {
|
|||||||
/// Panics if fewer than two rating groups are supplied, or if any group is
|
/// Panics if fewer than two rating groups are supplied, or if any group is
|
||||||
/// empty — match quality is a property of a contest between at least two
|
/// empty — match quality is a property of a contest between at least two
|
||||||
/// non-empty sides.
|
/// non-empty sides.
|
||||||
|
///
|
||||||
|
/// Also panics with "cannot invert a singular matrix" when every rating has
|
||||||
|
/// zero sigma *and* `beta` is zero. Nothing is then uncertain, so there is no
|
||||||
|
/// distribution to take the quality of; `Gaussian::from_ms(mu, 0.0)` is a point
|
||||||
|
/// mass and its `mu()` is not even well defined. Documented rather than
|
||||||
|
/// converted, because the input has no meaningful answer rather than an
|
||||||
|
/// awkward one.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn quality(rating_groups: &[&[Gaussian]], beta: f64) -> f64 {
|
pub fn quality(rating_groups: &[&[Gaussian]], beta: f64) -> f64 {
|
||||||
assert!(
|
assert!(
|
||||||
@@ -738,9 +745,19 @@ pub fn quality(rating_groups: &[&[Gaussian]], beta: f64) -> f64 {
|
|||||||
let end = &rotated_a_matrix * &mean_matrix;
|
let end = &rotated_a_matrix * &mean_matrix;
|
||||||
|
|
||||||
let e_arg = (-0.5 * &start * &middle.inverse() * &end).determinant();
|
let e_arg = (-0.5 * &start * &middle.inverse() * &end).determinant();
|
||||||
let s_arg = ata.determinant() / middle.determinant();
|
|
||||||
|
|
||||||
libm::exp(e_arg) * s_arg.sqrt()
|
// `sqrt(det(ata) / det(middle))`, taken in log space. Both determinants are
|
||||||
|
// products of `k - 1` diagonal entries, so they leave `f64`'s range long
|
||||||
|
// before their ratio does: measured at the crate defaults, 150 groups was
|
||||||
|
// correct at `8.45e-53`, 200 returned `0`, and 250 returned `NaN` where the
|
||||||
|
// true value is `9.51e-88`. With a small beta it is sharper still — at
|
||||||
|
// `sigma = beta = 1e-3`, 60 groups returned `NaN` against a true `1.32e-9`.
|
||||||
|
//
|
||||||
|
// The ratio is what the answer needs and it is representable throughout, so
|
||||||
|
// the intermediates are the only thing that ever overflowed.
|
||||||
|
let ln_s_arg = ata.ln_abs_determinant() - middle.ln_abs_determinant();
|
||||||
|
|
||||||
|
libm::exp(e_arg + 0.5 * ln_s_arg)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -91,6 +91,29 @@ impl Lu {
|
|||||||
det
|
det
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `ln |det|`, accumulated term by term rather than multiplied out.
|
||||||
|
///
|
||||||
|
/// The determinant of an `n x n` Gram matrix is a product of `n` diagonal
|
||||||
|
/// entries, so it leaves `f64`'s range long before the quantities built
|
||||||
|
/// from it do. `quality()` only ever wants a *ratio* of two determinants,
|
||||||
|
/// and that ratio is perfectly representable while the determinants
|
||||||
|
/// themselves are not — measured, at 250 rating groups both overflow and
|
||||||
|
/// the ratio came back `NaN` where the true answer is `9.51e-88`.
|
||||||
|
///
|
||||||
|
/// Returns `-inf` for a singular matrix, so `exp` of it is zero.
|
||||||
|
fn ln_abs_determinant(&self) -> f64 {
|
||||||
|
if self.sign == 0.0 {
|
||||||
|
return f64::NEG_INFINITY;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut acc = 0.0;
|
||||||
|
for i in 0..self.n {
|
||||||
|
acc += libm::log(self.lu[i * self.n + i].abs());
|
||||||
|
}
|
||||||
|
|
||||||
|
acc
|
||||||
|
}
|
||||||
|
|
||||||
/// Solve `Ax = b` for a single column of the identity, giving one column
|
/// Solve `Ax = b` for a single column of the identity, giving one column
|
||||||
/// of the inverse.
|
/// of the inverse.
|
||||||
fn solve_column(&self, col: usize, out: &mut [f64]) {
|
fn solve_column(&self, col: usize, out: &mut [f64]) {
|
||||||
@@ -157,6 +180,24 @@ impl Matrix {
|
|||||||
Lu::decompose(self).determinant()
|
Lu::decompose(self).determinant()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `ln |det|` of a square matrix; `-inf` when singular.
|
||||||
|
///
|
||||||
|
/// See [`Lu::ln_abs_determinant`] for why a ratio of determinants must be
|
||||||
|
/// taken this way.
|
||||||
|
pub fn ln_abs_determinant(&self) -> f64 {
|
||||||
|
assert_eq!(
|
||||||
|
self.width, self.height,
|
||||||
|
"determinant requires a square matrix, got {}x{}",
|
||||||
|
self.height, self.width
|
||||||
|
);
|
||||||
|
|
||||||
|
if self.width == 0 {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
Lu::decompose(self).ln_abs_determinant()
|
||||||
|
}
|
||||||
|
|
||||||
/// Matrix inverse via LU decomposition.
|
/// Matrix inverse via LU decomposition.
|
||||||
///
|
///
|
||||||
/// # Panics
|
/// # Panics
|
||||||
|
|||||||
@@ -164,3 +164,52 @@ fn quality_matches_the_reference_implementation() {
|
|||||||
let refs: Vec<&[Gaussian]> = five.iter().map(Vec::as_slice).collect();
|
let refs: Vec<&[Gaussian]> = five.iter().map(Vec::as_slice).collect();
|
||||||
assert!((quality(&refs, beta) - 0.040).abs() < 1e-9);
|
assert!((quality(&refs, beta) - 0.040).abs() < 1e-9);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `quality()` used to compute `det(ata) / det(middle)` in linear space. Both
|
||||||
|
/// are products of `k - 1` diagonal entries, so they leave `f64`'s range long
|
||||||
|
/// before their ratio does — and the ratio is the only thing the answer needs.
|
||||||
|
///
|
||||||
|
/// Measured before the fix: at the crate defaults 150 groups was correct, 200
|
||||||
|
/// returned `0`, and 250 returned `NaN` where the truth is `9.51e-88`. With a
|
||||||
|
/// small beta it bit sooner — `sigma = beta = 1e-3` returned `NaN` at 60 groups
|
||||||
|
/// against a true `1.32e-9`, a value that is entirely ordinary.
|
||||||
|
///
|
||||||
|
/// For `k` single-member groups with equal means the answer has a closed form,
|
||||||
|
/// `(beta / sqrt(beta^2 + sigma^2))^(k-1)`, so this checks against arithmetic
|
||||||
|
/// rather than against a recorded output.
|
||||||
|
#[test]
|
||||||
|
fn quality_matches_its_closed_form_past_the_overflow_point() {
|
||||||
|
for (sigma, beta) in [(25.0 / 3.0, 25.0 / 6.0), (1e-3, 1e-3), (50.0, 25.0 / 6.0)] {
|
||||||
|
let rating = vec![Gaussian::from_ms(25.0, sigma)];
|
||||||
|
for k in [2usize, 50, 60, 150, 200, 250, 300] {
|
||||||
|
let groups: Vec<&[Gaussian]> = (0..k).map(|_| rating.as_slice()).collect();
|
||||||
|
let got = quality(&groups, beta);
|
||||||
|
let expected = (beta / (beta * beta + sigma * sigma).sqrt()).powi(k as i32 - 1);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
got.is_finite(),
|
||||||
|
"sigma {sigma}, beta {beta}, {k} groups: got {got}"
|
||||||
|
);
|
||||||
|
// Subnormal results have no relative precision left to check.
|
||||||
|
if expected > f64::MIN_POSITIVE {
|
||||||
|
let rel = ((got - expected) / expected).abs();
|
||||||
|
assert!(
|
||||||
|
rel < 1e-11,
|
||||||
|
"sigma {sigma}, beta {beta}, {k} groups: got {got:e}, \
|
||||||
|
closed form {expected:e}, rel {rel:e}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The overflow was in the intermediates, never in the answer: every value
|
||||||
|
/// above is an ordinary float. This pins the specific case that returned `NaN`
|
||||||
|
/// where the true answer is nine orders of magnitude inside the normal range.
|
||||||
|
#[test]
|
||||||
|
fn a_small_beta_does_not_overflow_at_sixty_groups() {
|
||||||
|
let rating = vec![Gaussian::from_ms(25.0, 1e-3)];
|
||||||
|
let groups: Vec<&[Gaussian]> = (0..60).map(|_| rating.as_slice()).collect();
|
||||||
|
let got = quality(&groups, 1e-3);
|
||||||
|
assert!((got - 1.317_089e-9).abs() / 1.317_089e-9 < 1e-6, "{got:e}");
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user