fix: correct erfc_inv's sign error and keep evidence in log space
A systematic scan for precision defects, following the tail-precision work in7341669. Three findings; the first is a correctness bug in a released version. 1. `erfc_inv`'s initial guess had the wrong sign. Numerical Recipes' `inverfc` uses -0.70711 as the leading coefficient; this used +FRAC_1_SQRT_2. Since `rational - t` is negative, that put Newton on the mirror image of the root, and three fixed iterations could not cross back. Measured against exact standard-normal quantiles: p_draw old rel err new rel err 0.50 1.46e-7 8.40e-8 0.90 1.02e-1 8.63e-9 0.95 3.06e-1 1.91e-8 0.99 8.05e-1 5.89e-9 `compute_margin` inherited it, so the draw margin was wrong for any `p_draw` above about 0.6 and *non-monotone* above 0.9 — it ran 0.674, 1.476, 0.503, 0.982 as p_draw went 0.5, 0.9, 0.99, 0.999. A history configured for a 0.99 draw rate was being fitted at 0.385. Note it was slightly wrong everywhere, not only in the tail. 2. `MarginFactor` computed a density and clamped it. `pdf` underflows past ~38 sigma, so `ln` of the clamped zero reported -708 nats however far out the score actually was: 4292 nats adrift at 100 sigma, and unbounded beyond. This is the same defect as the one fixed in `TruncFactor`, one file over, on the scored-outcome path. 3. `TruncFactor` still bottomed out past ~38 sigma even after7341669removed the cancellation, because the linear probability itself underflows there. 2 and 3 are fixed the same way: factors cache a *log* evidence, built from new `ln_pdf`, `ln_sf` and `ln_interval` helpers that factor the shared exponential out analytically via the `erfcx` added earlier. Nothing underflows, at any separation. One golden moved. `test_1vs1vs1` runs at `p_draw = 0.5`, so it goes through `compute_margin`; its 1e-6-place values shifted. Verified as movement *toward* analytic truth by comparing both the old and new inverse against exact quantiles, per the goldens policy in CLAUDE.md — not re-baselined on faith. Two test tolerances are asserted at 1e-6 rather than tighter because above x = 2 `erfcx` uses a continued fraction accurate to ~1e-15 while `erfc` carries ~1e-7, so the log path is the more accurate of the two and they part company at `erfc`'s error. That floor is tracked in #41. Refs #41 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
This commit is contained in:
+226
-1
@@ -249,7 +249,15 @@ fn erfc_inv(mut y: f64) -> f64 {
|
||||
|
||||
let t = (-2.0 * (y / 2.0).ln()).sqrt();
|
||||
|
||||
let mut x = FRAC_1_SQRT_2 * ((2.30753 + t * 0.27061) / (1.0 + t * (0.99229 + t * 0.04481)) - t);
|
||||
// The leading coefficient is NEGATIVE. `rational - t` is negative here, so
|
||||
// a positive coefficient mirrors the starting point to `-x0` — the
|
||||
// reflection of the root. Newton then has to cross the origin to get back,
|
||||
// which a fixed iteration count does not manage: measured against the true
|
||||
// value, `erfc_inv(0.1)` returned 1.044 instead of 1.16309, and the error
|
||||
// grew as y shrank until `compute_margin` stopped being monotone in
|
||||
// `p_draw` altogether.
|
||||
let mut x =
|
||||
-FRAC_1_SQRT_2 * ((2.30753 + t * 0.27061) / (1.0 + t * (0.99229 + t * 0.04481)) - t);
|
||||
|
||||
for _ in 0..3 {
|
||||
let err = erfc(x) - y;
|
||||
@@ -318,6 +326,73 @@ fn erfcx(x: f64) -> f64 {
|
||||
}
|
||||
}
|
||||
|
||||
/// `ln` of the normal density at `x`.
|
||||
///
|
||||
/// The density itself underflows to zero past about 38 sigma, and `ln` of a
|
||||
/// clamped zero is -708 whatever the truth was. The log form is a polynomial:
|
||||
/// it stays exact at any separation, and the values it produces (-5001 nats at
|
||||
/// 100 sigma, -500001 at 1000) are perfectly representable.
|
||||
pub(crate) fn ln_pdf(x: f64, mu: f64, sigma: f64) -> f64 {
|
||||
let z = (x - mu) / sigma;
|
||||
-(SQRT_TAU * sigma).ln() - 0.5 * z * z
|
||||
}
|
||||
|
||||
/// `ln P(X > x)` for `X ~ N(mu, sigma^2)`.
|
||||
///
|
||||
/// In the upper tail the `exp(-z^2 / 2)` common to the tail integral is
|
||||
/// factored out analytically via `erfcx`, so this never underflows — where
|
||||
/// `sf(..).ln()` bottoms out at -708 once `erfc` itself reaches zero.
|
||||
pub(crate) fn ln_sf(x: f64, mu: f64, sigma: f64) -> f64 {
|
||||
let z = (x - mu) / sigma;
|
||||
|
||||
if z > 0.0 {
|
||||
// ln(0.5 * erfc(z/sqrt2)) with erfc(y) = exp(-y^2) * erfcx(y).
|
||||
-std::f64::consts::LN_2 - 0.5 * z * z + erfcx(z / SQRT_2).ln()
|
||||
} else {
|
||||
// The mass here is at least a half; nothing to lose.
|
||||
sf(x, mu, sigma).ln()
|
||||
}
|
||||
}
|
||||
|
||||
/// `ln P(lo < X < hi)` for `X ~ N(mu, sigma^2)`.
|
||||
///
|
||||
/// When the interval sits in a tail both endpoint probabilities underflow
|
||||
/// together, so their difference is taken in scaled form with the shared
|
||||
/// exponential factored out. When it straddles the mean nothing is small and
|
||||
/// the direct difference is exact.
|
||||
pub(crate) fn ln_interval(lo: f64, hi: f64, mu: f64, sigma: f64) -> f64 {
|
||||
let z_lo = (lo - mu) / sigma;
|
||||
let z_hi = (hi - mu) / sigma;
|
||||
|
||||
if z_hi <= z_lo {
|
||||
return f64::NEG_INFINITY;
|
||||
}
|
||||
|
||||
// Fold a lower-tail interval onto the upper tail; the normal is symmetric.
|
||||
let (near, far) = if z_lo >= 0.0 {
|
||||
(z_lo, z_hi)
|
||||
} else if z_hi <= 0.0 {
|
||||
(-z_hi, -z_lo)
|
||||
} else {
|
||||
// Straddles the mean: the interval holds a non-negligible share of the
|
||||
// mass, so neither endpoint is near enough to 1 to cancel.
|
||||
return (cdf(hi, mu, sigma) - cdf(lo, mu, sigma))
|
||||
.max(f64::MIN_POSITIVE)
|
||||
.ln();
|
||||
};
|
||||
|
||||
let (a, b) = (near / SQRT_2, far / SQRT_2);
|
||||
// b > a >= 0, so this ratio of exponentials is at most 1 and cannot overflow.
|
||||
let scale = (a * a - b * b).exp();
|
||||
let bracket = erfcx(a) - scale * erfcx(b);
|
||||
|
||||
if bracket <= 0.0 {
|
||||
return f64::NEG_INFINITY;
|
||||
}
|
||||
|
||||
-std::f64::consts::LN_2 - a * a + bracket.ln()
|
||||
}
|
||||
|
||||
fn pdf(x: f64, mu: f64, sigma: f64) -> f64 {
|
||||
let normalizer = (SQRT_TAU * sigma).powi(-1);
|
||||
let functional = (-((x - mu).powi(2)) / (2.0 * sigma.powi(2))).exp();
|
||||
@@ -746,6 +821,156 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// `erfc_inv`'s initial guess had the wrong sign, putting Newton on the
|
||||
/// mirror image of the root. Three fixed iterations could not cross back,
|
||||
/// so the error grew as the argument shrank: at `p_draw = 0.99` the margin
|
||||
/// came out 0.503 where the answer is 2.576.
|
||||
#[test]
|
||||
fn erfc_inv_matches_known_quantiles() {
|
||||
// sqrt(2) * erfc_inv(1 - p) is the standard normal quantile
|
||||
// Phi^-1((1 + p) / 2).
|
||||
for (p, exact) in [
|
||||
(0.5f64, 0.674_489_750_196_081_7f64),
|
||||
(0.9, 1.644_853_626_951_472_7),
|
||||
(0.95, 1.959_963_984_540_054_2),
|
||||
(0.99, 2.575_829_303_548_9),
|
||||
(0.999, 3.290_526_731_491_896_4),
|
||||
] {
|
||||
let got = SQRT_2 * erfc_inv(1.0 - p);
|
||||
assert!(
|
||||
(got - exact).abs() / exact < 1e-6,
|
||||
"p={p}: got {got}, exact {exact}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The draw margin must grow with the draw probability. It did not: it ran
|
||||
/// 0.674 -> 1.476 -> 0.503 -> 0.982 as `p_draw` went 0.5 -> 0.9 -> 0.99 ->
|
||||
/// 0.999, which is not a rounding error but a broken function.
|
||||
#[test]
|
||||
fn compute_margin_is_monotone_in_the_draw_probability() {
|
||||
let mut previous = 0.0;
|
||||
for p_draw in [
|
||||
0.001f64, 0.01, 0.1, 0.25, 0.5, 0.75, 0.9, 0.99, 0.999, 0.9999,
|
||||
] {
|
||||
let margin = compute_margin(p_draw, 1.0);
|
||||
assert!(
|
||||
margin > previous,
|
||||
"p_draw={p_draw}: margin {margin} did not exceed {previous}"
|
||||
);
|
||||
previous = margin;
|
||||
}
|
||||
}
|
||||
|
||||
/// Round-tripping the margin back through the model's own CDF must recover
|
||||
/// the draw probability it was built from.
|
||||
#[test]
|
||||
fn compute_margin_round_trips_through_the_cdf() {
|
||||
for p_draw in [0.001f64, 0.1, 0.5, 0.9, 0.99, 0.999] {
|
||||
for sd in [0.5f64, 1.0, 5.892_557] {
|
||||
let margin = compute_margin(p_draw, sd);
|
||||
// P(|X| < margin) for X ~ N(0, sd^2).
|
||||
let recovered = 1.0 - 2.0 * cdf(-margin, 0.0, sd);
|
||||
assert!(
|
||||
(recovered - p_draw).abs() < 1e-6,
|
||||
"p_draw={p_draw} sd={sd}: recovered {recovered}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `ln_pdf`, `ln_sf` and `ln_interval` exist so evidence stays exact where
|
||||
/// the linear forms underflow. Past ~38 sigma the linear value is zero and
|
||||
/// its log is whatever floor it was clamped to.
|
||||
#[test]
|
||||
fn log_space_helpers_stay_exact_where_the_linear_forms_underflow() {
|
||||
for z in [40.0f64, 60.0, 100.0, 1000.0] {
|
||||
assert_eq!(pdf(z, 0.0, 1.0), 0.0, "pdf should underflow at {z}");
|
||||
assert_eq!(sf(z, 0.0, 1.0), 0.0, "sf should underflow at {z}");
|
||||
|
||||
let lp = ln_pdf(z, 0.0, 1.0);
|
||||
let expected_lp = -(SQRT_TAU).ln() - 0.5 * z * z;
|
||||
assert!(
|
||||
(lp - expected_lp).abs() < 1e-9,
|
||||
"ln_pdf({z}) = {lp}, expected {expected_lp}"
|
||||
);
|
||||
|
||||
let ls = ln_sf(z, 0.0, 1.0);
|
||||
// ln Phi(-z) ~ -z^2/2 - ln(z) - ln(sqrt(2 pi)) for large z.
|
||||
let approx = -0.5 * z * z - z.ln() - SQRT_TAU.ln();
|
||||
assert!(
|
||||
(ls - approx).abs() / approx.abs() < 1e-3,
|
||||
"ln_sf({z}) = {ls}, asymptote {approx}"
|
||||
);
|
||||
assert!(
|
||||
ls < f64::MIN_POSITIVE.ln(),
|
||||
"ln_sf({z}) still on the clamp floor"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Where nothing underflows, the log helpers must agree with the direct
|
||||
/// forms exactly enough that nothing else in the crate shifts.
|
||||
#[test]
|
||||
fn log_space_helpers_agree_with_the_linear_forms_in_range() {
|
||||
for z in [-3.0f64, -1.0, 0.0, 1.0, 2.0, 5.0, 10.0, 20.0] {
|
||||
let lp = ln_pdf(z, 0.5, 2.0);
|
||||
let direct_pdf = pdf(z, 0.5, 2.0);
|
||||
assert!(
|
||||
(lp.exp() - direct_pdf).abs() <= 1e-12 * direct_pdf,
|
||||
"ln_pdf at {z}: {} vs {direct_pdf}",
|
||||
lp.exp()
|
||||
);
|
||||
|
||||
// Bounded by `erfc`'s ~1e-7, not tighter: above x = 2 `erfcx` uses
|
||||
// a continued fraction accurate to ~1e-15, so the log path is the
|
||||
// *more* accurate of the two and they part company at `erfc`'s
|
||||
// error rather than at round-off.
|
||||
let ls = ln_sf(z, 0.5, 2.0);
|
||||
let direct = sf(z, 0.5, 2.0);
|
||||
assert!(
|
||||
(ls.exp() - direct).abs() <= 1e-6 * direct.max(1e-300),
|
||||
"ln_sf at {z}: {} vs {direct}",
|
||||
ls.exp()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ln_interval_matches_the_direct_difference_when_nothing_is_small() {
|
||||
for mu in [-2.0f64, 0.0, 0.5, 2.0] {
|
||||
let direct = cdf(1.0, mu, 1.0) - cdf(-1.0, mu, 1.0);
|
||||
let logged = ln_interval(-1.0, 1.0, mu, 1.0).exp();
|
||||
// See `log_space_helpers_agree_with_the_linear_forms_in_range`:
|
||||
// the gap here is `erfc`'s own error, and the log path is the more
|
||||
// accurate side of it.
|
||||
assert!(
|
||||
(logged - direct).abs() <= 1e-6 * direct,
|
||||
"mu={mu}: {logged} vs {direct}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A window far out in the tail: both endpoints underflow together, so the
|
||||
/// difference has to be taken in scaled form.
|
||||
#[test]
|
||||
fn ln_interval_survives_a_window_deep_in_the_tail() {
|
||||
for mu in [-50.0f64, -100.0, -1000.0] {
|
||||
let logged = ln_interval(-1.0, 1.0, mu, 1.0);
|
||||
assert!(logged.is_finite(), "mu={mu}: {logged}");
|
||||
assert!(
|
||||
logged < f64::MIN_POSITIVE.ln(),
|
||||
"mu={mu}: {logged} is stuck on the clamp floor"
|
||||
);
|
||||
// Dominated by the near edge: ln P ~ ln Phi(-(|mu| - 1)).
|
||||
let near = ln_sf(-1.0, mu, 1.0);
|
||||
assert!(
|
||||
(logged - near).abs() < 5.0,
|
||||
"mu={mu}: {logged} strays from the near-edge tail {near}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_quality() {
|
||||
let a = Gaussian::from_ms(25.0, 3.0);
|
||||
|
||||
Reference in New Issue
Block a user