feat!: N-team outcome prediction with draw mass, replacing the 2-team panic
`predict_outcome` asserted `teams.len() == 2` and returned `[p, 1 - p]`, allocating no probability to a draw even with `p_draw > 0`. For a draw-enabled model the numbers were simply wrong, at any team count. It now returns `Result<Prediction, InferenceError>` and supports N teams. Two algorithms, both deterministic: - Who finishes first. Performances are independent Gaussians, so this separates into a one-dimensional integral per team rather than a multivariate orthant probability. Adaptive Gauss-Kronrod evaluates it to ~1e-15, matching the exact two-team closed form. - A specific finishing order. The factor graph only constrains rank-adjacent teams, so a full order is a chain of local constraints, not a general orthant integral. That chain collapses into a sequential recursion over cumulative integrals: O(teams * grid) per order. Fixed-node Gauss-Hermite is the obvious tool for the first and is a trap: when a rival's sigma is small the CDF product becomes a step narrower than the node spacing, and the nodes step over it. Measured 4.4e-4 off the closed form on a mildly skewed matchup and 1.7e-2 on a small-sigma one, while still returning something that looks like a probability. Adaptive refinement is what makes that case safe, and `win_probabilities_survive_a_rival_with_a_tiny_sigma` pins it down. The acceptance test is an identity rather than a golden: the outcome space is exhaustive and disjoint, so the probabilities sum to one. Any drift is integration error and nothing else. Gauss-Hermite failed it at 4.4e-4; this holds to ~1e-9. Also from #21: unknown keys are now reported rather than dropped, so a team of strangers can no longer produce a confident-looking prediction. `predict_quality` returns `Result` for the same reason. BREAKING CHANGE: `predict_outcome` returns `Result<Prediction, _>` instead of `Vec<f64>`; `predict_quality` returns `Result<f64, _>`. Refs #21, #39 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
This commit is contained in:
@@ -0,0 +1,322 @@
|
||||
//! Deterministic numerical integration for the prediction paths.
|
||||
//!
|
||||
//! Prediction asks two questions that have no closed form beyond two teams:
|
||||
//! "who finishes first" and "how likely is this exact finishing order". Both
|
||||
//! reduce to integrals over a single performance variable, so neither needs a
|
||||
//! sampler — and that matters, because a Monte Carlo predictor would make
|
||||
//! `predict_*` non-reproducible and would answer a slightly different question
|
||||
//! on every call.
|
||||
//!
|
||||
//! Two routines live here:
|
||||
//!
|
||||
//! - [`integrate`], adaptive Gauss-Kronrod G7-K15, for the first-place
|
||||
//! marginals. It carries its own error estimate, so it can refine where the
|
||||
//! integrand actually bends instead of guessing a node count up front.
|
||||
//! - [`Grid`], a uniform grid with trapezoid prefix sums, for the ranking
|
||||
//! chain recursion, where each level needs the *running* integral of the
|
||||
//! level below at arbitrary points rather than one definite integral.
|
||||
//!
|
||||
//! Fixed-node Gauss-Hermite is the obvious tool for the first of these and is
|
||||
//! a trap: the integrand is a product of normal CDFs, and when one team's
|
||||
//! sigma is much smaller than the integrating team's, that product turns into
|
||||
//! a near-step function narrower than the node spacing. The nodes step over
|
||||
//! it and the result is wrong by ~1e-2 while still looking like a probability.
|
||||
//! Adaptive refinement is what makes the small-sigma case safe.
|
||||
|
||||
/// Kronrod 15-point abscissae, non-negative half, descending.
|
||||
const XGK: [f64; 8] = [
|
||||
0.991_455_371_120_813,
|
||||
0.949_107_912_342_759,
|
||||
0.864_864_423_359_769,
|
||||
0.741_531_185_599_394,
|
||||
0.586_087_235_467_691,
|
||||
0.405_845_151_377_397,
|
||||
0.207_784_955_007_898,
|
||||
0.0,
|
||||
];
|
||||
|
||||
/// Kronrod 15-point weights, matching [`XGK`].
|
||||
const WGK: [f64; 8] = [
|
||||
0.022_935_322_010_529,
|
||||
0.063_092_092_629_979,
|
||||
0.104_790_010_322_250,
|
||||
0.140_653_259_715_525,
|
||||
0.169_004_726_639_267,
|
||||
0.190_350_578_064_785,
|
||||
0.204_432_940_075_298,
|
||||
0.209_482_141_084_728,
|
||||
];
|
||||
|
||||
/// Gauss 7-point weights, applying to the odd-indexed [`XGK`] entries.
|
||||
const WG: [f64; 4] = [
|
||||
0.129_484_966_168_870,
|
||||
0.279_705_391_489_277,
|
||||
0.381_830_050_505_119,
|
||||
0.417_959_183_673_469,
|
||||
];
|
||||
|
||||
/// Panels are bisected worst-first; this bounds the work on a pathological
|
||||
/// integrand rather than letting it spin.
|
||||
const MAX_SUBDIVISIONS: usize = 200;
|
||||
|
||||
/// One G7-K15 panel over `[a, b]`: `(integral, absolute error estimate)`.
|
||||
///
|
||||
/// The error estimate is the gap between the embedded 7-point Gauss rule and
|
||||
/// the 15-point Kronrod extension. It is the only reason this is preferable
|
||||
/// to a fixed rule: it tells the caller *where* the integrand is hard.
|
||||
fn gk15<F: Fn(f64) -> f64>(f: &F, a: f64, b: f64) -> (f64, f64) {
|
||||
let centre = 0.5 * (a + b);
|
||||
let half = 0.5 * (b - a);
|
||||
|
||||
let mut kronrod = 0.0;
|
||||
let mut gauss = 0.0;
|
||||
|
||||
for i in 0..8 {
|
||||
let offset = XGK[i] * half;
|
||||
// XGK[7] is the centre node and must not be counted twice.
|
||||
let sum = if i == 7 {
|
||||
f(centre)
|
||||
} else {
|
||||
f(centre - offset) + f(centre + offset)
|
||||
};
|
||||
kronrod += WGK[i] * sum;
|
||||
if i % 2 == 1 {
|
||||
gauss += WG[i / 2] * sum;
|
||||
}
|
||||
}
|
||||
|
||||
(kronrod * half, ((kronrod - gauss) * half).abs())
|
||||
}
|
||||
|
||||
/// Adaptively integrate `f` over `[a, b]` to relative tolerance `tol`.
|
||||
///
|
||||
/// `seeds` are interior points where the integrand is known to bend sharply —
|
||||
/// for a product of normal CDFs, each rival's transition centre. Splitting
|
||||
/// there up front costs nothing and saves the adaptive loop from having to
|
||||
/// discover a step by bisection.
|
||||
///
|
||||
/// Returns the integral. The error estimate is consumed internally rather
|
||||
/// than returned: callers here integrate probability densities, where the
|
||||
/// meaningful check is the sum-to-one identity over a whole outcome space,
|
||||
/// not a per-integral residual.
|
||||
pub(crate) fn integrate<F: Fn(f64) -> f64>(f: F, a: f64, b: f64, seeds: &[f64], tol: f64) -> f64 {
|
||||
// Explicit rather than `!(b > a)`: a NaN bound must fall through to zero
|
||||
// rather than being read as a valid ordering.
|
||||
if a.partial_cmp(&b) != Some(std::cmp::Ordering::Less) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
let mut edges: Vec<f64> = Vec::with_capacity(seeds.len() + 2);
|
||||
edges.push(a);
|
||||
edges.push(b);
|
||||
for &s in seeds {
|
||||
if s > a && s < b {
|
||||
edges.push(s);
|
||||
}
|
||||
}
|
||||
edges.sort_by(|p, q| p.partial_cmp(q).expect("integration bounds are finite"));
|
||||
edges.dedup();
|
||||
|
||||
// (lo, hi, integral, error)
|
||||
let mut panels: Vec<(f64, f64, f64, f64)> = edges
|
||||
.windows(2)
|
||||
.map(|w| {
|
||||
let (v, e) = gk15(&f, w[0], w[1]);
|
||||
(w[0], w[1], v, e)
|
||||
})
|
||||
.collect();
|
||||
|
||||
for _ in 0..MAX_SUBDIVISIONS {
|
||||
let total: f64 = panels.iter().map(|p| p.2).sum();
|
||||
let error: f64 = panels.iter().map(|p| p.3).sum();
|
||||
|
||||
// Absolute floor as well as relative: these integrands are
|
||||
// probabilities, so an absolute 1e-15 is already past the useful
|
||||
// precision of the underlying `cdf`.
|
||||
if error <= tol * total.abs().max(1e-12) || error < 1e-15 {
|
||||
break;
|
||||
}
|
||||
|
||||
let worst = panels
|
||||
.iter()
|
||||
.enumerate()
|
||||
.fold((0usize, f64::NEG_INFINITY), |(bi, be), (i, p)| {
|
||||
if p.3 > be { (i, p.3) } else { (bi, be) }
|
||||
})
|
||||
.0;
|
||||
|
||||
let (lo, hi, _, _) = panels[worst];
|
||||
let mid = 0.5 * (lo + hi);
|
||||
// Bisection has hit the floating-point floor; refining further would
|
||||
// loop without reducing the error.
|
||||
if !(mid > lo && mid < hi) {
|
||||
break;
|
||||
}
|
||||
|
||||
let (v1, e1) = gk15(&f, lo, mid);
|
||||
let (v2, e2) = gk15(&f, mid, hi);
|
||||
panels[worst] = (lo, mid, v1, e1);
|
||||
panels.push((mid, hi, v2, e2));
|
||||
}
|
||||
|
||||
panels.iter().map(|p| p.2).sum()
|
||||
}
|
||||
|
||||
/// A uniform grid carrying trapezoid prefix sums of one integrand.
|
||||
///
|
||||
/// The ranking recursion needs, at every level, the running integral of the
|
||||
/// level below evaluated at arbitrary points — a cumulative integral, not a
|
||||
/// definite one. Prefix sums give that in O(1) per query after an O(G) build,
|
||||
/// which is what keeps a full ranking probability linear in the team count.
|
||||
pub(crate) struct Grid {
|
||||
lo: f64,
|
||||
step: f64,
|
||||
/// Integrand sampled at each node.
|
||||
values: Vec<f64>,
|
||||
/// `prefix[i]` is the integral from `lo` to node `i`.
|
||||
prefix: Vec<f64>,
|
||||
}
|
||||
|
||||
impl Grid {
|
||||
/// Build directly from already-sampled values.
|
||||
///
|
||||
/// The ranking recursion evaluates every level on the same nodes, so the
|
||||
/// per-team densities are sampled once and reused; re-evaluating `exp`
|
||||
/// per level would dominate the cost.
|
||||
pub(crate) fn from_values(lo: f64, step: f64, values: Vec<f64>) -> Self {
|
||||
let mut prefix = vec![0.0; values.len()];
|
||||
for i in 1..values.len() {
|
||||
prefix[i] = prefix[i - 1] + 0.5 * step * (values[i - 1] + values[i]);
|
||||
}
|
||||
Self {
|
||||
lo,
|
||||
step,
|
||||
values,
|
||||
prefix,
|
||||
}
|
||||
}
|
||||
|
||||
/// Integral from the grid's lower bound up to `x`.
|
||||
///
|
||||
/// Clamped at both ends: the caller sizes the grid to cover the whole
|
||||
/// support, so a query outside it is asking for a tail that is zero (below)
|
||||
/// or the whole mass (above).
|
||||
pub(crate) fn integral_to(&self, x: f64) -> f64 {
|
||||
let last = self.values.len() - 1;
|
||||
if x <= self.lo {
|
||||
return 0.0;
|
||||
}
|
||||
if x >= self.lo + last as f64 * self.step {
|
||||
return self.prefix[last];
|
||||
}
|
||||
|
||||
let scaled = (x - self.lo) / self.step;
|
||||
let i = scaled.floor() as usize;
|
||||
let frac = scaled - i as f64;
|
||||
|
||||
// Whole cells, plus the trapezoid over the partial cell. The integrand
|
||||
// is linear within a cell under the trapezoid rule, so the partial
|
||||
// piece is exact with respect to that same approximation.
|
||||
self.prefix[i]
|
||||
+ frac
|
||||
* self.step
|
||||
* (self.values[i] + 0.5 * frac * (self.values[i + 1] - self.values[i]))
|
||||
}
|
||||
|
||||
/// Integral over `[from, to]`.
|
||||
pub(crate) fn integral_between(&self, from: f64, to: f64) -> f64 {
|
||||
(self.integral_to(to) - self.integral_to(from)).max(0.0)
|
||||
}
|
||||
|
||||
/// Total integral over the whole grid.
|
||||
pub(crate) fn total(&self) -> f64 {
|
||||
self.prefix[self.values.len() - 1]
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const TOL: f64 = 1e-10;
|
||||
|
||||
/// Sample `f` over `[lo, hi]` at `points` nodes.
|
||||
fn sample<F: FnMut(f64) -> f64>(lo: f64, hi: f64, points: usize, mut f: F) -> Grid {
|
||||
let step = (hi - lo) / (points - 1) as f64;
|
||||
Grid::from_values(
|
||||
lo,
|
||||
step,
|
||||
(0..points).map(|i| f(lo + i as f64 * step)).collect(),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn integrates_a_polynomial_exactly() {
|
||||
// G7-K15 is exact for polynomials well past cubic, so a single panel
|
||||
// should already be at round-off.
|
||||
let v = integrate(|x| 3.0 * x * x + 2.0 * x + 1.0, 0.0, 2.0, &[], TOL);
|
||||
assert!((v - 14.0).abs() < 1e-12, "got {v}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn integrates_a_gaussian_density_to_one() {
|
||||
let f = |x: f64| (-0.5 * x * x).exp() / (2.0 * std::f64::consts::PI).sqrt();
|
||||
let v = integrate(f, -10.0, 10.0, &[], TOL);
|
||||
assert!((v - 1.0).abs() < 1e-12, "got {v}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_a_step_far_narrower_than_the_initial_panel() {
|
||||
// The failure mode that rules out fixed-node quadrature: a transition
|
||||
// 1e-4 wide inside a range of 20. A fixed rule steps over it.
|
||||
let f = |x: f64| if x < 0.5 { 0.0 } else { 1.0 };
|
||||
let v = integrate(f, -10.0, 10.0, &[0.5], TOL);
|
||||
assert!((v - 9.5).abs() < 1e-6, "got {v}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seeds_do_not_change_the_value_of_a_smooth_integrand() {
|
||||
let f = |x: f64| (-0.5 * x * x).exp();
|
||||
let plain = integrate(f, -8.0, 8.0, &[], TOL);
|
||||
let seeded = integrate(f, -8.0, 8.0, &[-3.0, 0.25, 5.5], TOL);
|
||||
assert!((plain - seeded).abs() < 1e-12, "{plain} vs {seeded}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_or_inverted_range_integrates_to_zero() {
|
||||
assert_eq!(integrate(|_| 1.0, 1.0, 1.0, &[], TOL), 0.0);
|
||||
assert_eq!(integrate(|_| 1.0, 2.0, 1.0, &[], TOL), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_prefix_matches_a_known_cumulative_integral() {
|
||||
// f(x) = x over [0, 4]; integral to x is x^2/2.
|
||||
let g = sample(0.0, 4.0, 4001, |x| x);
|
||||
for probe in [0.0, 0.5, 1.0, 2.5, 3.75, 4.0] {
|
||||
let want = probe * probe / 2.0;
|
||||
let got = g.integral_to(probe);
|
||||
assert!(
|
||||
(got - want).abs() < 1e-9,
|
||||
"at {probe}: got {got}, want {want}"
|
||||
);
|
||||
}
|
||||
assert!((g.total() - 8.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_between_is_the_difference_of_two_prefixes() {
|
||||
let g = sample(-5.0, 5.0, 8001, |x| (-0.5 * x * x).exp());
|
||||
let whole = g.integral_between(-5.0, 5.0);
|
||||
let split = g.integral_between(-5.0, 0.3) + g.integral_between(0.3, 5.0);
|
||||
assert!((whole - split).abs() < 1e-12, "{whole} vs {split}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_clamps_queries_outside_its_support() {
|
||||
let g = sample(0.0, 1.0, 101, |_| 1.0);
|
||||
assert_eq!(g.integral_to(-3.0), 0.0);
|
||||
assert!((g.integral_to(9.0) - 1.0).abs() < 1e-12);
|
||||
// Reversed bounds must not produce negative probability mass.
|
||||
assert_eq!(g.integral_between(0.8, 0.2), 0.0);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user