Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
141 changes: 135 additions & 6 deletions src/distrs/tdist.jl
Original file line number Diff line number Diff line change
Expand Up @@ -36,16 +36,145 @@ 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)

local tp::Float64
Comment thread
andreasnoack marked this conversation as resolved.
Outdated
tprob = 0.0
tpdif = 0.0
if pr >= 0.5 || (ν >= 1.0 && _tdist_cf_start_is_better(pr, ν))
# 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
tp = (((((27.0 * x + 339.0) * x + 930.0) * x - 1782.0) * x - 765.0) * x + 17955.0) / (368640.0 * ν)
tp = (tp + ((((79.0 * x + 776.0) * x + 1482.0) * x - 1920.0) * x - 945.0) / 92160.0) / ν
tp = (tp + (((3.0 * x + 19.0) * x + 17.0) * x - 15.0) / 384.0) / ν
tp = (tp + ((5.0 * x + 16.0) * x + 3.0) / 96.0) / ν
tp = (tp + (x + 1.0) / 4.0) / ν
tp = xn * (1.0 + tp)
# 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 ? -tp : tp
end
tprob = 0.0
tpdif = 1.0 + abs(tp)
elseif ν < 1.0
# leading power-law term of the tail solved in log space:
# pr ≈ (ν / t^2)^(ν / 2) / (ν * B)
lν = log(ν)
tp = -exp(lν / 2 - (log(pr) + lν + logB) / ν)
isfinite(tp) || return p > 0.5 ? -tp : tp
tprob, f = _tdistcdf_pdf(ν, B, tp)
if f < floatmin(Float64)
tpdif = 0.0
Comment thread
andreasnoack marked this conversation as resolved.
Outdated
else
tpdif = tprob / f * log1p((tprob - pr) / pr)
tpnew = tp - tpdif
tp = tpnew < 0.0 ? tpnew : tp / 2
end
else
return sqrt(fdistinvccdf(one(ν), ν, 2 * (1 - p)))
# invert the leading power-law term of the tail: pr ≈ f(0) * √ν * |t|^(-ν)
# where f(0) = 1 / (√ν * B) is the density at zero, map back to the t scale,
# apply one closed-form next-order correction, and one Newton step
u = exp(-log(ν * B * pr) / ν)
tp = -sqrt(ν) * sqrt(u - 1.0) * sqrt(u + 1.0)
isfinite(tp) || return p > 0.5 ? -tp : tp
tpdif = tp / ν
tpdif = -log1p((0.5 - 1.0 / (ν + 2.0)) / (1.0 + tpdif * tp)) * (tpdif + 1.0 / tp)
tp -= tpdif
tprob, f = _tdistcdf_pdf(ν, B, tp)
if f < floatmin(Float64)
tpdif = 0.0
Comment thread
andreasnoack marked this conversation as resolved.
Outdated
else
tpdif = tprob / f * log1p((tprob - pr) / pr)
tpnew = tp - tpdif
tp = tpnew < 0.0 ? tpnew : tp / 2
end
end

# 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 abs(tprob - pr) > smalllpr && abs(tpdif) > small * (1.0 + abs(tp))
(iter += 1) > 100 && break
tprob, f = _tdistcdf_pdf(ν, B, tp)
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)
tpnew = tp - tpdif
# keep the iterate in the negative half-line, where the solution lies
tp = tpnew < 0.0 ? tpnew : tp / 2
end
return p > 0.5 ? -tp : tp
end

# The kernel operates in Float64, like the Rmath-based functions elsewhere in the
# package; argument types with more precision than Float64 are truncated.
function tdistinvcdf(ν::T, p::T) where {T <: Real}
return convert(float(T), _tdistinvcdf(Float64(ν), Float64(p)))
end
tdistinvcdf(ν::Real, p::Real) = tdistinvcdf(map(float, promote(ν, p))...)
tdistinvcdf(ν::Real, p::Real) = tdistinvcdf(promote(ν, p)...)
Comment thread
andreasnoack marked this conversation as resolved.
Outdated

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
71 changes: 71 additions & 0 deletions test/misc.jl
Original file line number Diff line number Diff line change
Expand Up @@ -98,3 +98,74 @@
@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),
)
@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