Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 128 additions & 6 deletions src/distrs/tdist.jl
Original file line number Diff line number Diff line change
Expand Up @@ -36,16 +36,138 @@ tdistlogcdf(ν::Real, x::Real) = tdistlogcdf(map(float, promote(ν, x))...)

tdistlogccdf(ν::Real, x::Real) = tdistlogcdf(ν, -x)

function tdistinvcdf(ν::T, p::T) where {T <: Real}
if isinf(ν)
# Pure Julia implementation of the inverse CDF, based on VBA invtdist by Ian Smith:
# start from a Cornish-Fisher expansion of the quantile around the normal quantile
# (central region) or from inverting the leading power-law term of the tail (tail
# region), then polish with Newton's method applied to log(cdf), where each
# iteration costs a single evaluation of the incomplete beta function.

# Crossover curves between the Cornish-Fisher and tail starting approximations.
# Based on VBA BetterThanTailApprox by Ian Smith.
function _tdist_cf_start_is_better(pr::Float64, ν::Float64)
if ν <= 2.0
return pr > 0.25 * exp((1.0 - ν) * 1.78514841051368)
elseif ν <= 5.0
return pr > 0.045 * exp((2.0 - ν) * 1.30400766847605)
elseif ν <= 20.0
return pr > 0.0009 * exp((5.0 - ν) * 0.921034037197618)
else
return pr > 9.0e-10 * exp((20.0 - ν) * 0.690775527898214)
end
end

# cdf and pdf of the t distribution at x <= 0, with B = beta(ν / 2, 1 / 2) precomputed
function _tdistcdf_pdf(ν::Float64, B::Float64, x::Float64)
if abs(x) >= min(1.0, ν)
# this form of k2 = ν / (ν + x^2) and x2 = x^2 / (ν + x^2) avoids
# premature overflow of x^2
k2 = ν / x
t = x + k2
k2 = k2 / t
x2 = x / t
else
x² = x * x
t = ν + x²
x2 = x² / t
k2 = ν / t
end
p = first(beta_inc(ν / 2, 0.5, k2, x2)) / 2
f = k2^((ν + 1) / 2) / (sqrt(ν) * B)
return p, f
end

function _tdistinvcdf(ν::Float64, p::Float64)
if isnan(ν) || isnan(p) || !(0.0 <= p <= 1.0) || !(ν > 0.0)
return NaN
elseif isinf(ν)
return norminvcdf(p)
elseif p < 0.5
return -sqrt(fdistinvccdf(one(ν), ν, 2 * p))
elseif p == 0.0
return -Inf
elseif p == 1.0
return Inf
elseif p == 0.5
return 0.0
end

# work with the smaller tail; the result is negated for p > 1/2 on return
pr = p > 0.5 ? 1.0 - p : p
small = 1.0e-14
smalllpr = -small * log(pr) * pr

logB = logbeta(ν / 2, 0.5)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Neither logB nor B is used before the early return for large ν below, and together they cost ~65 ns. Could we move them into the branches that need them? That takes tdistinvcdf(1000, 0.975) and tdistinvcdf(1e5, 0.999) from ~105 ns to ~50 ns here.

B = exp(logB)

tp = if pr >= 0.5 || (ν >= 1.0 && _tdist_cf_start_is_better(pr, ν))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is pr >= 0.5 reachable? p == 0.5 returns earlier and 1 - p is exact for p > 1/2, so pr < 0.5 always as far as I can tell.

# Cornish-Fisher expansion of the t quantile in powers of 1/ν around the
# normal quantile (Fisher & Cornish, Technometrics 2 (1960), 209-225)
xn = norminvcdf(pr)
x = xn * xn
t = (((((27.0 * x + 339.0) * x + 930.0) * x - 1782.0) * x - 765.0) * x + 17955.0) / (368640.0 * ν)
t = (t + ((((79.0 * x + 776.0) * x + 1482.0) * x - 1920.0) * x - 945.0) / 92160.0) / ν
t = (t + (((3.0 * x + 19.0) * x + 17.0) * x - 15.0) / 384.0) / ν
t = (t + ((5.0 * x + 16.0) * x + 3.0) / 96.0) / ν
t = (t + (x + 1.0) / 4.0) / ν
t = xn * (1.0 + t)
# for large ν the expansion has already converged to full precision and the
# Newton polish can be skipped (validated relative error <= 2.2e-15 in this
# region against a high-precision reference)
if ν >= 250.0 && x <= ν / 100.0
return p > 0.5 ? -t : t
end
t
elseif ν < 1.0
# leading power-law term of the tail solved in log space:
# pr ≈ (ν / t^2)^(ν / 2) / (ν * B)
lν = log(ν)
-exp(lν / 2 - (log(pr) + lν + logB) / ν)
else
return sqrt(fdistinvccdf(one(ν), ν, 2 * (1 - p)))
# invert the leading power-law term of the tail, pr ≈ f(0) * √ν * |t|^(-ν)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't quite follow this one: f(0) * √ν * |t|^(-ν) is |t|^(-ν) / B, and inverting that gives (B * pr)^(-1/ν) rather than √ν * (ν * B * pr)^(-1/ν). AFAICT the relation actually inverted here is the same as in the branch above, pr ≈ (ν / t^2)^(ν/2) / (ν * B), and the only difference is that sqrt(u - 1) * sqrt(u + 1) keeps x = ν / (ν + t^2) exact instead of dropping to the leading power of t. Could we use the same wording as above? The description has the same formula.

# where f(0) = 1 / (√ν * B) is the density at zero, map back to the t
# scale, and apply one closed-form next-order correction
u = exp(-log(ν * B * pr) / ν)
t = -sqrt(ν) * sqrt(u - 1.0) * sqrt(u + 1.0)
if isfinite(t)
d = t / ν
t -= -log1p((0.5 - 1.0 / (ν + 2.0)) / (1.0 + d * t)) * (d + 1.0 / t)
end
t
end
# the true quantile may overflow in the extreme tails
isfinite(tp) || return p > 0.5 ? -tp : tp

# Newton iteration applied to log(cdf): step = cdf / pdf * log(cdf / pr).
# Near the center this is an ordinary Newton step; in the tails, where the cdf
# is nearly exponential in t, the log transform makes the problem nearly linear.
iter = 0
while true
tprob, f = _tdistcdf_pdf(ν, B, tp)
# the density underflows only where the start already carries full precision
f < floatmin(Float64) && break

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For ν < 1 the exponent (ν + 1) / 2 is smaller than 1, so f is much larger than k2 and stays well above floatmin long after k2 itself has become subnormal. In that window beta_inc only sees a handful of significant bits of its argument, tprob comes back wrong in the third digit, and the step throws away a start that was already good to ~3e-14. I get a relative error of 3.4e-1 for (0.9, 1e-146) and 1.7e-1 for (0.35, 1e-57), and for eg (0.5, 1e-81) and (0.9, 1e-145) the loop runs into the iteration cap - 100 beta_inc evaluations, ~11 µs instead of ~200 ns. Per-df maxima over p ∈ [1e-300, 1/2]: 3.1e-8 at ν = 0.2, 1.7e-1 at 0.35, 1.5e-2 at 0.5, 8.9e-4 at 0.7, 3.4e-1 at 0.9. qt is fine (~5e-14) at all of these.

Could we guard on the incomplete beta argument rather than on the density, ie return k2 from _tdistcdf_pdf and break on k2 < floatmin(Float64)? With that the maximum for ν < 1 is back to 1.6e-13. The comment above assumes the two underflow together, which only holds for ν ≥ 1.

tpdif = tprob / f * log1p((tprob - pr) / pr)
# second-order coefficient of the iteration, |d²log(cdf)/dt² / dlog(cdf)/dt|,
# for the quadratic-convergence estimate of the error remaining after the step
curv = abs(-(ν + 1.0) * tp / (ν + tp * tp) - f / tprob)
tpnew = tp - tpdif
# keep the iterate in the negative half-line, where the solution lies
tp = tpnew < 0.0 ? tpnew : tp / 2
tol = small * (1.0 + abs(tp))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tol is essentially absolute for |tp| ≪ 1, and (tprob - pr) / pr cancels once tprob ≈ pr ≈ 1/2, so we lose relative accuracy close to the median. For ν = 1 I get 2.7e-13 at p = 0.5 - 1e-4, 2.6e-11 at 0.5 - 1e-6, 4.6e-8 at 0.5 - 1e-10 and 4.2e-4 at 0.5 - 1e-14, where the current implementation stays at ~1e-16 throughout (Rmath is as bad as this PR here). Same shape for every ν up to ~100. The absolute error is fine everywhere, so perhaps this is acceptable, but it is a regression and the description claims accuracy equivalence with qt without mentioning it.

It also looks cheap to avoid: beta_inc already returns 1/2 - cdf as its second component, and 0.5 - pr is exact for pr ≥ 1/4. Iterating on those two for pr > 1/4 gives me ~1e-16 over the whole range with the same number of beta_inc evaluations.

# converged if the cdf already matched, the step was negligible, or the
# estimated error remaining after the step is negligible (the exact
# second-order coefficient is 1/2; the factor 32 is a 64x safety margin)
abs(tprob - pr) <= smalllpr && break
abs(tpdif) <= tol && break
32.0 * curv * tpdif * tpdif <= tol && break
(iter += 1) >= 100 && break
end
return p > 0.5 ? -tp : tp
end
tdistinvcdf(ν::Real, p::Real) = tdistinvcdf(map(float, promote(ν, p))...)

# Only Float16 and Float32 are routed explicitly through the Float64 kernel;
# wider types such as BigFloat are unsupported rather than silently computed
# at Float64 precision.
_tdistinvcdf(ν::Float16, p::Float16) = convert(Float16, _tdistinvcdf(Float64(ν), Float64(p)))
_tdistinvcdf(ν::Float32, p::Float32) = convert(Float32, _tdistinvcdf(Float64(ν), Float64(p)))
tdistinvcdf(ν::Real, p::Real) = _tdistinvcdf(map(float, promote(ν, p))...)

tdistinvccdf(ν::Real, p::Real) = -tdistinvcdf(ν, p)
function tdistinvlogcdf(ν::T, logp::T) where {T <: Real}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should tdistinvlogcdf go through the new kernel as well? Right now tdistinvcdf(0.5, 1e-8) is fixed but tdistinvlogcdf(0.5, log(1e-8)) still returns -Inf, and for (2.5, log(1e-12)) the two disagree by 7e-9. The tail start is computed in log space anyway, so it looks like log(pr) could just become logp there. Happy for it to be a follow-up.

Expand Down
75 changes: 75 additions & 0 deletions test/misc.jl
Original file line number Diff line number Diff line change
Expand Up @@ -98,3 +98,78 @@
@test isnan(@inferred(tdistinvcdf(0, 0.975)))
end
end

@testitem "tdistinvcdf" begin
using StatsFuns
using Test

@testset "reference values" begin
# reference values computed with a 512-bit MPFR Newton iteration on the
# regularized incomplete beta representation of the cdf
for (ν, p, t) in (
(0.35, 0.499, -0.0041388393417127554),
(0.5, 0.3, -1.0095258786071661),
(0.5, 1.0e-8, -1.02849115631634e15),
(1.0, 1.0e-100, -3.1830988618379064e99),
(2.5, 1.0e-12, -55306.174076515817),
(5.0, 1.0e-8, -62.40450611096729),
(5.0, 0.95, 2.0150483733330233),
(12.0, 0.3, -0.53861766820191637),
(50.0, 0.999, 3.261409055798318),
(300.0, 0.025, -1.9679030112610869),
(1000.0, 0.975, 1.9623390808264081),
(1.0e4, 1.0e-20, -9.282474153254304),
(1.0e6, 1 - 1.0e-12, 7.0345756932732169),
# the density underflows at these two points, exercising the
# early exit from the Newton polish
(0.9, 1.0e-150, -1.2783541486158767e166),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At both of these k2 underflows to exactly zero, so they exit through f == 0. The cases that go wrong are one decade earlier, where k2 is subnormal but nonzero - eg (0.9, 1e-144), (0.35, 1e-56) or (0.7, 1e-110), see my comment in tdist.jl. Could we add one of those? Nothing bounds the iteration count either, so the points that need 100 iterations pass silently.

(1.5, 1.0e-200, -1.1245005997832135e133),
)
@test tdistinvcdf(ν, p) ≈ t rtol = 1.0e-13
@test tdistinvccdf(ν, p) == -tdistinvcdf(ν, p)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tdistinvccdf is defined as -tdistinvcdf, so this holds by construction - is it testing anything?

end
end

@testset "round trips" begin

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The round trips and the Rmath tests both stay away from p ≈ 1/2 - the Rmath grid only reaches p = 0.5 ± 0.03, and rmathcomp compares quantiles with atol = 2e-14 - so nothing here notices the loss of relative accuracy near the median. Would a couple of reference values at p = 0.5 - 1e-6 and similar be worth adding?

# t -> cdf -> invcdf on a grid where the cdf itself is accurate; deep
# tails are covered by the reference values above since tdistcdf
# currently loses precision there
# the quantile is always represented through its own tail: going through
# the complementary probability is ill-conditioned for any implementation
for ν in (0.5, 1.0, 2.5, 5.0, 20.0, 100.0, 1.0e3, 1.0e6),
t in (-8.0, -3.0, -0.5, 0.0, 1.0, 6.0)

if t <= 0
p = tdistcdf(ν, t)
0 < p < 1 || continue
@test tdistinvcdf(ν, p) ≈ t atol = 1.0e-14 rtol = 1.0e-11
else
q = tdistccdf(ν, t)
0 < q < 1 || continue
@test tdistinvccdf(ν, q) ≈ t atol = 1.0e-14 rtol = 1.0e-11
end
end
end

@testset "edge cases and types" begin
@test tdistinvcdf(5, 0.0) == -Inf
@test tdistinvcdf(5, 1.0) == Inf
@test tdistinvcdf(5, 0.5) === 0.0
@test isnan(tdistinvcdf(5.0, NaN))
@test isnan(tdistinvcdf(NaN, 0.5))
@test isnan(tdistinvcdf(5.0, -0.1))
@test isnan(tdistinvcdf(5.0, 1.1))
@test isnan(tdistinvcdf(-1.0, 0.5))
@test tdistinvcdf(Inf, 0.975) == norminvcdf(0.975)
@test @inferred(tdistinvcdf(1, 0.75f0)) isa Float32
@test @inferred(tdistinvcdf(Float16(1), Float16(0.75))) isa Float16
@test @inferred(tdistinvcdf(1, 0.75)) isa Float64
# same-type non-IEEE arguments use the same kernel
@test tdistinvcdf(1 // 2, 1 // 100) == tdistinvcdf(0.5, 0.01)
@test @inferred(tdistinvcdf(5, 1)) == Inf
# StatsFuns#228: deep tails for small ν used to underflow to -Inf
@test isfinite(tdistinvcdf(0.5, 1.0e-8))
# symmetry (1 - 0.75 is exact in binary)
@test tdistinvcdf(3.5, 0.25) == -tdistinvcdf(3.5, 0.75)
end
end