Skip to content

Improve precision and speed of the Kolmogorov-Smirnov distribution - #1468

Open
evanmiller wants to merge 2 commits into
boostorg:developfrom
evanmiller:kolmogorov-smirnov-precision
Open

evanmiller wants to merge 2 commits into
boostorg:developfrom
evanmiller:kolmogorov-smirnov-precision

Conversation

@evanmiller

Copy link
Copy Markdown
Contributor

Measured against a 50-digit reference (cpp_bin_float_50 through the same header), the Kolmogorov-Smirnov distribution had three problems. This PR fixes them without touching the Jacobi theta functions, which the CDF still uses.

Quantile

The Newton-Raphson bracket was hard-coded to [0, 1] in x, so for small n, where the upper-tail quantile exceeds unity, it either threw (quantile(dist(1), 0.9)) or was clamped: for n = 10 the complement quantile of any q <= 1e-10 returned exactly 1. Mid-range it needed about 9 iterations.

The iteration now runs on ln cdf in W = pi^2/(8 x^2 n) for p < 0.6, and on ln(1 - cdf) in V = x^2 n above, where the residual is very nearly linear. The starting value inverts the first two terms of the series in each variable; that is already exact to working precision over most of the range, so a single evaluation usually confirms it. p = 0 returns 0 and p = 1 raises an overflow error, as in the other distributions.

Mode, median, skewness, kurtosis

These are constants of the standardized distribution times a power of n. The mode was found at run-time by Brent's minimizer, which cannot locate a flat maximum better than sqrt(epsilon); skewness and kurtosis were computed from closed forms that cancel away 40-260 ulp. They are now tabulated to 100 decimal digits (verified two independent ways), with run-time fallbacks for types of higher precision. A median() overload is added.

PDF

Each series is evaluated from a single exponential, with the remaining terms obtained by multiplication, and the rounding of x*x*n, of pi^2/8 and of the division is tracked with Dekker products so it is not amplified by the size of the exponent. Just above the smallest normal number the leading term is evaluated as a square of exp(-W/2), which fixes a loss of three decimal digits to an intermediate underflow (visible as the clipped points in the old ULP graph).

Results, n = 10, double

before after
quantile worst error ~1e6 ulp (complement: ~1e13) 3.1 ulp, mean 0.6
pdf mean / worst error 11 / 694 ulp 1.1 / 5.8 ulp
mode / skewness / kurtosis excess 1e7 / 39 / 261 ulp 0.6 / 0.3 / 0.4 ulp
quantile time 375 ns ~190 ns
mode time 549 ns 3 ns

A random stress test of 640,000 quantiles (n from 1 to 1e6, p log-uniform over the full range, float and double) had no failures.

Tests and docs

test_kolmogorov_smirnov.cpp now covers small n, both tails down to the smallest normal number (by bracketing, which stays meaningful where one ulp of x is many ulps of p), reference values computed at 120 digits, the moment constants against their closed forms, and the high-precision fallbacks with a 110-digit type. The implementation table in the docs is updated and the PDF ULP graph regenerated; its vertical axis is now ±5 ulp.

Not addressed: the complement CDF keeps its 10-120 ulp error in the far upper tail, because the rounding of tau is amplified inside the theta function. That is only fixable inside theta, or by evaluating the z = 0 series here directly.

🤖 Generated with Claude Code

Quantile: the Newton-Raphson bracket was hard-coded to [0, 1] in x, so
for small n the upper-tail quantile (which exceeds unity) either threw or
was clamped: quantile(dist(1), 0.9) threw, and for n = 10 the complement
quantile of any q <= 1e-10 returned exactly 1. The iteration now runs on
the logarithm of the CDF in W = pi^2/(8*x*x*n) for p < 0.6, and on the
logarithm of the complement in V = x*x*n above, where the residual is
very nearly linear. The starting value inverts the first two terms of
the series in each variable, which is already exact to working precision
over most of the range, so a single evaluation usually confirms it.
p = 0 returns 0 and p = 1 raises an overflow error, as elsewhere.
Worst error against a 50-digit reference goes from ~1e6 ulp (1e13 for
the complement) to 3 ulp, and the average time roughly halves.

Mode, median, skewness and kurtosis are constants of the standardized
distribution times a power of n. They are now tabulated to 100 decimal
digits (with run-time fallbacks for types of higher precision) instead
of being found by Brent's minimizer, which cannot locate a flat maximum
better than sqrt(epsilon), or computed from closed forms that cancel
away 40-260 ulp. A median() overload is added.

PDF: each series is evaluated from a single exponential, the remaining
terms coming from multiplication, and the rounding of x*x*n, of pi^2/8
and of the division is tracked with Dekker products so that it is not
amplified by the size of the exponent. Mean error drops from 11 to 1.1
ulp and the worst from ~700 to 6 ulp. Just above the smallest normal
number the leading term is evaluated as a square of exp(-W/2), which
fixes a loss of three decimal digits to an intermediate underflow.

The test now covers small n, both tails down to the smallest normal
number, reference values computed at 120 digits, the moment constants
against their closed forms, and the high-precision fallbacks. The PDF
ULP graph is regenerated; the vertical axis is now +-5 ulp.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
For 2*x*x*n > pi the CDF and its complement called jacobi_theta4m1tau
with tau = 2*x*x*n/pi. The theta function multiplies tau back by pi and
by n^2 before exponentiating, so the four roundings in forming tau cost
about 4*x*x*n ulps in the result: against a 50-digit reference the
complement was off by up to 93 ulps (mean 13) for x*sqrt(n) in [1.25, 6],
and no change inside the theta function can recover what the caller has
already rounded away.

Pass the nome q = exp(-2*x*x*n) instead, with the rounding of x*x*n
compensated by the same product-error helper the PDF uses. Below
exp(-pi) the theta function raises q to integer powers, which amplifies
nothing, and the complement is now within 1.8 ulps (mean 0.5) over the
same range. The lower branch (2*x*x*n <= pi) still goes through tau,
where it has no equivalent route.

The reference-value test checks the complement to 10 epsilon on the
upper branch instead of 200. The upper branch costs about 20 ns more
per evaluation in double, because the theta function evaluates q^(n^2)
with pow() rather than exp().

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant