Skip to content

Evaluate fdistlogpdf symmetrically in the two degrees of freedom - #226

Merged
devmotion merged 5 commits into
masterfrom
fdistlogpdf-large-dof
Aug 20, 2026
Merged

devmotion merged 5 commits into
masterfrom
fdistlogpdf-large-dof

Conversation

@devmotion

@devmotion devmotion commented Jul 31, 2026 •

Copy link
Copy Markdown
Member

Fixes #224.

fdistlogpdf was evaluated as

(xlogy(ν1, ν1 / ν2) + xlogy(ν1 - 2, x) - xlogy(ν1 + ν2, 1 + (ν1 / ν2) * x)) / 2 - logbeta(ν1 / 2, ν2 / 2)

which breaks down at both ends: for a large ν2 the term (ν1 / ν2) * x is tiny and is
discarded by the explicit 1 +, and for a large ν1 the first and last terms both grow like
ν1 * log(ν1) and cancel.

It is now written in terms of the beta variate u = ν1 * x / (ν1 * x + ν2), of which the density
is u^a * (1 - u)^b / (x * beta(a, b)) with a = ν1 / 2 and b = ν2 / 2. Which expression is
used depends on the ratio r = u / (1 - u) = (ν1 / ν2) * x:

  • 1 / r finite: log(u) = -log1p(1 / r) and log(1 - u) = -log1p(r). This is symmetric in the
    two degrees of freedom, so neither failure mode can occur - a tiny ν1 * x / ν2 is not absorbed
    by an explicit 1 +, and no two terms growing like ν1 * log(ν1) have to cancel. The condition
    is 1 / r rather than the more obvious r > 1 because that is all the form actually requires,
    and the wider branch also covers the ridge x ≈ ν2 / ν1, where the textbook form distributes
    a over two logarithms that cancel (1.3e-14 there, against 3.0e-16 here).
  • otherwise r is zero or subnormal: the previous textbook expression, but with log1p(r) in
    place of log(1 + r), which would drop a tiny r entirely. Splitting log(u) into
    log(ν1 / ν2) + log(x) rather than writing log(r) keeps this branch accurate when r
    underflows, and it covers x == 0 as well as NaN, so no separate branch is needed for those.

The ratio is formed as (ν1 / ν2) * x, not ν1 * x / ν2, so that an ordinary x near floatmax
does not overflow it, and where r overflows anyway log1p(r) is taken as
log(ν1 / ν2) + log(x) - both summands are positive exactly when that happens.

x < 0 returns before log(x) is reached, which also removes the max(x, 0) that was only
there to keep log from erroring.

Relative error of the density against a 512-bit reference:

ν1 ν2 x master this PR
1 1e9 6.6 9.9e-9 1.8e-17
1 1e18 6.6 6.4e-1 8.0e-16
1 1e20 0.5 3.0e-1 4.7e-15
1e8 5 2 2.8e-8 7.2e-16
1e16 5 2 2.5e0 9.9e-16
1e18 5 2 6.2e1 7.6e-15
1e20 5 4.95e-20 1.3e-14 3.6e-17
3 7 1.5 8.1e-17 2.2e-16
0.5 0.5 2 9.3e-17 9.3e-17

The cdf family and the ChainRulesCore rule are built on the same ratio. Both used to overflow
at either end of the range, taking the result with them:

call master this PR
fdistlogcdf(3.0, 7.0, 1e-310) -Inf -1070.2806176752842
fdistlogcdf(Float16(1), Float16(7), Float16(1e-4)) -Inf -4.867
fdistlogpdf(1.0, 0.5, 1e308) -Inf -888.325654114039

R agrees to the last digit shown on both cdf values. The beta variate of the cdf is built as
r / (1 + r) rather than y / (y + ν2), whose denominator overflows for ν2 > floatmax / 2.
There the ratio is formed as (ν1 * x) / ν2, the other way round from fdistlogpdf: an
overflowing ν1 * x only saturates u to 1, which is the right answer, and dividing afterwards
keeps u exact where ν1 / ν2 would go subnormal.

The AD rule

∂/∂x was ((ν1 - 2) / x - ν1 * (ν1 + ν2) / (ν1 * x + ν2)) / 2, a difference of two terms both
growing like ν1 / x, which cancels catastrophically. Combining them over ν1 * x + ν2 collapses
the two into a single quotient, since a * ν2 - b * ν1 * x is (ν1 * ν2 / 2) * (1 - x):

-(ν1 * ν2a / 2 + 1) / x    # ν2a = ν2 * a, a = (x - 1) / (ν1 * x + ν2), both in the `@setup`

The product is grouped as ν1 * (ν2 * a) rather than ν1 * ν2 * a, because ν1 * ν2 overflows for
degrees of freedom of floatmax scale even where the derivative itself is ordinary, whereas
ν2 * a is a ratio of the two and stays in range. It is also the subexpression ∂/∂ν1 already
computes.

ν1 ν2 x master this PR
1e8 5 2 6.4e-10 8.3e-17
1e16 5 2 1.1e-1 1.4e-16
1e18 5 2 returns 0.0, true -1.125 1.4e-18
1e20 1 0.5 returns 0.0, true -1.0 2.0e-20
4 100 1e308 returns 1.0e-308, true -5.1e-307 1.7e-17
1e200 1e300 1e200 returns NaN, true -5.0e99 6.7e-17
1e300 1e200 2 returns -Inf, true -1.25e199 1.7e-16
1e200 1e200 2 returns -Inf, true -8.3e198 8.5e-17

What kept this rewrite out until now is that it changes the value at x = Inf. That turns out to
be a property of a = (x - 1) / (ν1 * x + ν2), which is NaN there and wrong once the
denominator overflows, rather than of the rewrite. With a guarded, the gradient at x = Inf
goes from [NaN, NaN, 0.0] to [-0.4319, -Inf, -0.0], all three of which are the correct limits,
and the other two partials become accurate for a large x as well: at (3, 7, 1e308) the
gradient goes from [0.735, -Inf, 5.0e-309] to [-0.432, -353.5, -4.5e-308].

Behaviour changes

  • fdistlogpdf(3, 7, Inf) returns -Inf instead of NaN.
  • the gradient at x = Inf is finite in ν1 and -Inf in ν2, where both were NaN.
  • NaN still propagates, now through xlogy.

Tests

The BigFloat references live in test/utils.jl next to Utils; the assertions join the other
numerical issue testsets in test/misc.jl, and the boundary values are a testset of Misc
alongside support of binomial distribution. They check against a BigFloat evaluation of the
textbook formula rather than Rmath - Rmath.df is itself off by 2.5e-8 at ν1 = 1e12, so it
cannot pin that direction.

The references evaluate at 1024 bits rather than the default 256. That is not belt and braces: the
logbeta(a, b) of the textbook formula cancels lgamma(b) - lgamma(a + b) at the hundredth digit
when the two degrees of freedom are 100 orders of magnitude apart, and ∂/∂x cancels to 300 digits.
At 256 bits the reference for logpdf(1e200, 1e300, 0.5) is off by a relative 3.6e3 and the one for
dlogpdf_dx(1e300, 1e200, 2.0) by a relative 1.0 - either would have validated anything.

The grid covers both large-degree-of-freedom directions over the whole range of x, from where
the ratio underflows to where it overflows, including the ridge x ≈ ν2 / ν1, and both degrees of
freedom at floatmax scale, where the products ν1 * x and ν1 * ν2 overflow. 63 of its 106
assertions fail on master; the rest are the points master already gets right, kept so that the
grid is a grid. The ridge is not one of the 63 - master's error there is 1.3e-14, comfortably
inside the 1e-12 tolerance - so those points are coverage of the branch boundary rather than a
pin on its accuracy. Tightening the tolerance far enough to pin it would put the tolerance within
a factor of two of master's error, which is exactly the sort of margin that turns into a spurious
CI failure on another platform. For the same reason the floatmax-scale pairs are 1e200 against
1e300, accurate to 7.6e-14, and not floatmax itself against 1e300, where the logbeta of the
implementation loses enough to reach 4.9e-13. The cdf is pinned against the u -> 0 asymptote
u^a / (a * beta(a, b)), which needs no incomplete beta function and is exact to 2.1e-16 at
x = 1e-310.

The boundary testset covers the sign of x, ±Inf, NaN, x == 0 and -0.0; a separate
testset checks that all three branches of fdistlogpdf and the four cdf functions return
float(promote_type(...)) for mixed Int, Rational, Float16, Float32 and BigInt
arguments. Moving the x < 0 result off the shared ternary and onto its own branch exposed the
fact that it was never executed: there were no fdist tests below the support until #209 added
some, and the ternary counted as covered whenever x > 0 ran.

For the AD rule, test_frule is used at two arguments where it used to overflow, with the tangent
of x scaled to x itself since the default tangent is of order one and its finite differences
would leave the domain. ∂/∂x cannot be checked that way at all - a finite difference in ν1 is
lost in the rounding of ν1 itself - so the pullback is evaluated directly against a BigFloat
reference.

#225 tracks the remaining case where both degrees of freedom are large, which this does not
address.

🤖 Generated with Claude Code

@codecov

codecov Bot commented Jul 31, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 75.91%. Comparing base (eb8e81f) to head (409f3be).

Additional details and impacted files
@@            Coverage Diff             @@
##           master     #226      +/-   ##
==========================================
+ Coverage   75.50%   75.91%   +0.40%     
==========================================
  Files          23       23              
  Lines        1135     1146      +11     
==========================================
+ Hits          857      870      +13     
+ Misses        278      276       -2     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

The density was evaluated as

    (xlogy(ν1, ν1 / ν2) + xlogy(ν1 - 2, x)
        - xlogy(ν1 + ν2, 1 + (ν1 / ν2) * x)) / 2 - logbeta(ν1 / 2, ν2 / 2)

which breaks down at both ends:

  * for large ν2 the term (ν1 / ν2) * x is tiny and the explicit 1 +
    discards it - the argument rounds to exactly 1.0 from ν2 ≈ 1e17 on,
    so the factor drops out. fdistlogpdf(1, 1e18, 6.6) returned -1.862
    instead of -5.162, i.e. the density was off by e^3.3 ≈ 27.
  * for large ν1 the first and last terms both grow like ν1 * log(ν1)
    and cancel. fdistlogpdf(1e16, 5, 2) returned -5.914 instead of
    -1.670.

Writing the density in terms of the beta variate u = ν1x / (ν1x + ν2),
with log(u) = -log1p(ν2 / (ν1x)) and log1p(-u) = -log1p(ν1x / ν2), is
symmetric in ν1 and ν2 and removes both. The relative error stays below
1e-14 in either limit.

As a side effect fdistlogpdf(3, 7, Inf) now returns -Inf rather than NaN.

The x < 0 result moved from a shared line onto its own branch, which
exposed the fact that it was never executed: before this commit there
were no fdist tests below the support, and the ternary counted as covered
whenever x > 0 ran. A second testitem pins the boundary values - the sign
of x, ±Inf, NaN, x == 0 and the oftype conversion that keeps the
out-of-support branch from widening to Float64.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@devmotion
devmotion force-pushed the fdistlogpdf-large-dof branch from 4dbacb7 to f8bbaed Compare August 3, 2026 09:05

@andreasnoack andreasnoack left a comment

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.

Thanks for this! I checked out the branch and exercised the new fdistlogpdf: the algebra matches the textbook form (a·log u + b·log1p(-u) - log x - logbeta(a, b)), all the new assertions pass, type stability holds for Int, Rational{Int}, Float16, Float32, Float64 and BigFloat on all three branches, NaN propagates correctly, and the existing ChainRulesCore rule already used the same log1p form, so primal and derivative agree. A 20k-point random sweep (ν1, ν2 ∈ [1e-3, 1e9], x ∈ [1e-6, 1e6]) against a 512-bit reference showed no accuracy regression apart from two ~5e-13 cases in the both-dof-large regime that #225 already tracks.

Two comments:

1. ν2 / (ν1 * x) overflows for small x

In the general branch, ν2 / (ν1 * x) overflows once x < ν2 / (ν1 * floatmax(T)), xlog1py then receives Inf, and the whole log-density collapses to -Inf. The previous formulation had no such term and was correct in this regime, so this is a regression:

call this PR correct
fdistlogpdf(1.0, 1e18, 1e-291) -Inf 333.0
fdistlogpdf(3.0, 7.0, 1e-310) -Inf -356.07
fdistlogpdf(1f0, 7f0, 1f-38) -Inf 42.79
fdistlogpdf(Float16(1), Float16(7), Float16(1e-4)) -Inf 3.65

The Float16 case is the one I'd worry about most: x = 1e-4 is an entirely ordinary normal-range argument, but 7 / 1e-4 already exceeds floatmax(Float16) = 65504, so pdf silently returns zero probability. Note also that this bites inside the large-ν2 regime the PR targets — at ν1 = 1, ν2 = 1e18, x = 1e-290 still gives 332.96, and then x = 1e-291 falls off a cliff to -Inf.

One way out is to derive both terms from the single ratio r = ν1 * x / ν2 that the second term already needs:

r = ν1 * x / ν2
aterm = r > 1 ? -xlog1py(a, inv(r)) : xlogy(a, r) - xlog1py(a, r)

Both branches are cancellation-free, and inv(r) cannot overflow.

test/accuracy.jl only probes x ∈ {0.5, 2.0, 6.6}, so it can't currently see this — worth adding a few tiny-x cases.

2. rtol = 1.0e-13 in test/accuracy.jl is quite tight

The tightest of the new assertions, (ν1, ν2, x) = (1e12, 5.0, 0.5), comes in at 2.9e-14 relative (and (1e18, 5.0, 0.5) at 2.4e-14), so there's only ~3.5x headroom. The error is dominated by logbeta, so a future SpecialFunctions change or a different platform's log could flip this to a CI failure. 1e-12 would still fail every one of these cases on master (errors there range from 1e-8 to 6e1), so it loses no regression-detecting power.

Very minor: the PR description says the accuracy item is 30 assertions; it's 60.

— reviewed and drafted by Claude Code on my behalf

…D rule

`ν2 / (ν1 * x)` overflows for `x < ν2 / (ν1 * floatmax(T))`, which made the
log-density collapse to `-Inf` - for `Float16(1e-4)`, an entirely ordinary
argument. `log(u)` is now taken as `-log1p(1 / r)` only for `r = ν1 * x / ν2 > 1`;
below that the previous textbook form is kept, with `log1p(r)` in place of
`log(1 + r)`. That form also stays accurate when `r` underflows, and it absorbs
the `x == 0` and `NaN` cases, so one branch disappears.

The same ratio is used to build the beta variate of the cdf family and in the
`∂/∂ν1` of the ChainRules rule, with the same overflow: on master
`fdistlogcdf(Float16(1), Float16(7), Float16(1e-4))` is `-Inf` instead of
`-4.87`. Both now split on the same halves of the domain.

The tolerance of the accuracy tests is relaxed from `1e-13` to `1e-12`, which
still fails every one of those assertions on master (their errors range from
1e-8 to 6e1) but leaves more than the previous 3.5x of headroom over the
tightest case, whose error is dominated by `logbeta`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@devmotion

Copy link
Copy Markdown
Member Author

Thanks, both confirmed and fixed in 555ec9a.

1. Small x. Reproduced exactly as described. I took a slightly different route than the
suggested xlogy(a, r) - xlog1py(a, r): that feeds r to log, and r goes subnormal (or
underflows) in the very regime being repaired — at ν1 = 1, ν2 = 1e20, x = 1e-300 it is off by
1.6e-8, where the old expression is exact. Keeping the previous textbook form for r <= 1, with
log1p(r) in place of log(1 + r) and log(u) split as log(ν1 / ν2) + log(x), gives 1.4e-16
there and is accurate on all four of your cases. It also absorbs the x == 0 and NaN cases, so
the function is down to two branches.

2. rtol. Relaxed to 1e-12 — every assertion still fails on master, where the errors on
that grid range from 9.9e-9 to 6e1. The x grid now also reaches 1e-300 and 1e8, which is
where the regression above hid.

While checking where else the ratio is formed: fdistcdf and friends build the beta variate as
inv(1 + ν2 / (ν1 * max(0, x))), and the ∂/∂ν1 of the ChainRules rule is -log1p(ν2 / (x * ν1))
— both overflow at the same arguments, on master as well. fdistlogcdf(Float16(1), Float16(7), Float16(1e-4)) is -Inf there instead of -4.87. Both are fixed here, split on the same halves
of the domain, and both are tested.

Assertion count corrected in the description (it is 143 now).

One thing I have deliberately left alone: ∂/∂x is ((ν1 - 2) / x - ν1 * νsum / temp1) / 2, which
cancels catastrophically for large ν1 — at (1e18, 5, 2) it returns 0.0 where the true value is
-1.125. Different failure mode from this PR, and the obvious rewrite changes the value at
x = Inf, so I would rather do it separately. Shall I open an issue?

— drafted by Claude Code on my behalf

@andreasnoack

Copy link
Copy Markdown
Member

You are right about log(r), and my suggestion was worse than what you did. I reproduced it: at ν1 = 1, ν2 = 1e20, x = 1e-300 my form is off by 1.6e-8 against yours at 1.6e-16, and at (2.5, 1e25, 1e-305) the product underflows to exactly 0, so xlogy(a, r) gives -Inf where your split into log(ν1 / ν2) + log(x) is exact. Splitting log(u) is the right call.

I also confirmed the rest of 555ec9a: all four small-x cases are fixed, fdistlogcdf(Float16(1), Float16(7), Float16(1e-4)) now gives -4.867 instead of -Inf, the whole suite passes, and the ∂/∂x cancellation you describe is real — the frule returns 0.0 at (1e18, 5, 2) where a 512-bit central difference gives -1.125.

Two things the new commit regresses, though, both in the opposite direction from the one it repairs — huge arguments rather than tiny ones. Both are correct on master:

call 555ec9a master / correct
fdistlogpdf(1e200, 1e300, 1e200) -Inf -1.1512925464970229e302
fdistcdf(1.0, 1e308, 1e308) 0.0 1.0
fdistcdf(1.0, 1e308, 0.9e308) 0.0 1.0

1. r = ν1 * x / ν2 overflows in the product

1e200 * 1e200 is Inf even though the true r = 1e100 is comfortably representable. The r > 1 branch then evaluates xlog1py(b, Inf) and the whole density collapses to -Inf. Master formed ν1 / ν2 first and so was fine here.

Worth noting that the underflow direction of the same product is already safe — at ν1 = 1e-200, ν2 = 1e-300, x = 1e-200 the product is 0 but the r <= 1 branch's log(ν1 / ν2) + log(x) split still gives the exact answer. So only the overflow side needs attention, and it only shows up in log1p(r); recovering it there, e.g. log(ν1) + log(x) - log(ν2) when r is infinite, would be enough. (The same xν1 = x * ν1 overflows in the ChainRulesCore rule, but that one is on master too, so it is not new here.)

2. The cdf branch is split on the wrong quantity

ν2 / y only overflows for tiny y, so branching at y > ν2 sends the huge-y case down the y / (y + ν2) path — where the sum overflows instead, and u comes out 0.0 rather than 0.5. Branching on whether ν2 / y actually overflowed keeps both ends:

q = ν2 / y
u = isinf(q) ? y / (y + ν2) : inv(1 + q)

y = 0 gives q = Inf and u = 0, y = Inf gives q = 0 and u = 1, and y = ν2 = 1e308 gives 0.5.

Since the x grid in test/accuracy.jl now reaches 1e-300 and 1e8, it would be worth pushing it out to floatmax-scale degrees of freedom as well — neither of the above is visible at 1e8.

And yes, please do open a separate issue for ∂/∂x. Different failure mode, pre-existing on master, and if the fix changes the value at x = Inf it deserves its own discussion.

— reviewed and drafted by Claude Code on my behalf

devmotion and others added 3 commits August 19, 2026 15:19
Three problems, all of the same kind as the ones already addressed on this
branch, and all found while re-reviewing it.

  * the ratio `ν1 * x / ν2` overflows once `ν1 * x` does, even when the ratio
    itself is perfectly representable, and the log-density then collapses to
    `-Inf`. That is a regression against master, which formed the ratio as
    `(ν1 / ν2) * x`: `fdistlogpdf(Float16(4), Float16(100), Float16(6e4))` was
    `-Inf` instead of `-392.2`, for an entirely ordinary `Float16` argument.
    The ratio is built from `ν1 / ν2` again, which the textbook branch needs
    anyway, and `log1p(r)` is taken as `log(ν1 / ν2) + log(x)` where `r` itself
    still overflows - both summands are positive whenever it does. That also
    repairs `fdistlogpdf(3.0, 7.0, 1e308)`, `-Inf` on master as well.

  * the branch condition `r > 1` was tighter than necessary. The symmetric form
    only requires that `1 / r` not overflow, and widening the condition to
    `isfinite(inv(r))` hands the whole ridge `x ≈ ν2 / ν1` - where the textbook
    form distributes `ν1 / 2` over two logarithms that cancel - to the
    well-conditioned branch. The relative error there drops from 1.3e-14 to
    3.0e-16. The textbook branch is still reached for `x == 0`, for an
    underflowing `r`, and for `NaN`.

  * the beta variate of the cdf family overflowed in `y + ν2` for
    `ν2 > floatmax / 2`, returning `u = 0`: `fdistcdf(1.0, 1e308, 1e308)` was
    `0.0` instead of `0.5`. It is now built from the same ratio as the density.

In the `ChainRulesCore` rule, `∂/∂x` was a difference of two terms both growing
like `ν1 / x`, which cancelled completely - at `(1e18, 5, 2)` it returned `0.0`
where the derivative is `-1.125`. Combining them over `ν1 * x + ν2` collapses
the two into `-(ν1 * ν2 * a / 2 + 1) / x`, since `a * ν2 - b * ν1 * x` is
`(ν1 * ν2 / 2) * (1 - x)`; `a` is already in the setup. The relative error at
that point goes from 1 to 1.4e-18.

The reason to leave this alone was that the rewrite changes the value at
`x = Inf`, but that is a property of `a = (x - 1) / temp1`, which is `NaN`
there and wrong once `temp1` overflows, not of the rewrite. With `a` guarded,
`x = Inf` returns the correct limits for all three partials rather than `NaN`
for two of them, and `∂/∂ν1` and `∂/∂ν2` become accurate for a large `x` as
well.

The tests move out of `test/accuracy.jl` and into the files that already cover
this ground: the `BigFloat` references join `Utils` in `test/utils.jl`, the
accuracy assertions join the other `numerical issue` testsets in
`test/misc.jl`, and the boundary values become a testset of `Misc` next to
`support of binomial distribution`. The grid gains the ridge and the large-`x`
end, the three branches are checked for type stability across mixed argument
types, and the assertions on `fdistpdf` are dropped, since it is defined as
`exp(fdistlogpdf)`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
In the cdf an overflowing `ν1 * x` only saturates the beta variate to 1, which
is the right answer there, whereas in `fdistlogpdf` it collapses the density to
`-Inf`. Dividing afterwards therefore costs nothing, and it keeps `u` exact
where `ν1 / ν2` would go subnormal: `fdistcdf(1.0, floatmax(), floatmax())`
builds `u = 0.5` rather than one ulp below it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`-(ν1 * ν2 * a / 2 + 1) / x` multiplies the two degrees of freedom directly, which overflows for
degrees of freedom of `floatmax` scale even where the derivative is ordinary: at `(1e200, 1e300,
1e200)` it returned `-Inf` for a true `-5e99`. Grouping the product as `ν1 * (ν2 * a)` keeps every
intermediate in range, since `ν2 * a` is a ratio of the two rather than a product, and it is the
subexpression `∂/∂ν1` already computes.

The grid now reaches `floatmax`-scale degrees of freedom in both directions, which is what exposed
this. That needed the `BigFloat` references to evaluate at 1024 bits rather than the default 256:
they are the textbook cancelling forms on purpose, and `logbeta(a, b)` cancels `lgamma(b) -
lgamma(a + b)` at the hundredth digit when the two degrees of freedom are 100 orders of magnitude
apart, while `∂/∂x` cancels to 300 digits. At 256 bits the reference for `logpdf(1e200, 1e300,
0.5)` is off by a relative 3.6e3 and the one for `dlogpdf_dx(1e300, 1e200, 2.0)` by a relative 1.0,
so either would have validated anything.

The pdf grid is 106 assertions now, 63 of which fail on master. The pairs are `1e200` against
`1e300` rather than `floatmax` against `1e300`, where the `Float64` `logbeta` alone costs enough to
reach 4.9e-13 - within a factor of two of the tolerance. The two `fdistcdf` values reported in
review are pinned as well.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@devmotion

Copy link
Copy Markdown
Member Author

bde7fff and bae0589 were already pushed by the time I read this - they were written locally
against the same two problems, so they converge with your report rather than respond to it. Both of
your cases are correct now:

call 555ec9a this branch
fdistlogpdf(1e200, 1e300, 1e200) -Inf -1.1512925464970229e302 (rel 3.1e-17)
fdistcdf(1.0, 1e308, 1e308) 0.0 1.0
fdistcdf(1.0, 1e308, 0.9e308) 0.0 1.0

1. The product. Your diagnosis was right, the remedy differs. Rather than recovering log1p(r)
once r is already Inf, the ratio is formed as (ν1 / ν2) * x in the first place, so
1e200 * 1e200 never happens: the product only overflows where ν1 / ν2 and x are both huge,
and there your isinf recovery is what handles it - both summands of log(ν1 / ν2) + log(x) are
positive exactly when that happens.

2. The cdf branch. Also fixed, though split on r > 1 with r = (ν1 * x) / ν2 rather than on
whether ν2 / y overflowed. Your form works too; this one keeps the ratio in the same shape as
fdistlogpdf's, formed the other way round for the reason you give - an overflowing ν1 * x only
saturates u to 1, which is the right answer, where an overflowing sum gives 0.

3. The grid. Done, and it earned its keep twice over.

It caught a third problem, in the ∂/∂x that bde7fff had just rewritten:
-(ν1 * ν2 * a / 2 + 1) / x forms ν1 * ν2, which overflows at floatmax-scale degrees of freedom
even where the derivative is ordinary. ν2 * a is a ratio of the two, so grouping it as
ν1 * (ν2 * a) keeps every intermediate in range - and it is the subexpression ∂/∂ν1 already
computes. Fixed in 409f3be, along with the grid:

ν1 ν2 x master ν1 * ν2 * a ν1 * (ν2 * a) true
1e200 1e300 1e200 NaN -Inf -5.0e99 -5.0e99
1e300 1e200 2 -Inf -Inf -1.25e199 -1.25e199
1e200 1e200 2 -Inf -Inf -8.333333333333332e198 -8.3333...e198

And it caught a trap in the tests themselves: the BigFloat references are the textbook cancelling
forms on purpose, and at these arguments the default 256 bits are not enough either. logbeta(a, b)
cancels lgamma(b) - lgamma(a + b) at the hundredth digit once the two degrees of freedom are 100
orders of magnitude apart, and ∂/∂x cancels to 300 digits. At 256 bits the reference for
logpdf(1e200, 1e300, 0.5) is off by a relative 3.6e3 and the one for dlogpdf_dx(1e300, 1e200, 2.0)
by a relative 1.0 - both would have validated anything. They evaluate at 1024 bits now, which is
also how the numbers above are checked.

The pdf grid is 106 assertions, 63 of which fail on master (it was 90 and 52). The pairs are 1e200
against 1e300 rather than floatmax against 1e300: the implementation is accurate to 7.6e-14 on
the former and 4.9e-13 on the latter, and 4.9e-13 sits within a factor of two of the 1e-12
tolerance, which is the margin that turns into a platform-dependent CI failure. ν1 = ν2 = floatmax
is still NaN, but through logbeta(floatmax / 2, floatmax / 2) itself rather than anything here -
that is #225.

One correction to your pointer: the grid moved out of test/accuracy.jl and into test/misc.jl in
bde7fff, next to the other numerical issue testsets.

4. ∂/∂x. This is the one place I did not do as asked - it is fixed here rather than tracked
separately. The only thing that had kept the rewrite out was that it changes the value at x = Inf,
and that turned out to be a property of a = (x - 1) / (ν1 * x + ν2), which is NaN there and
wrong as soon as the denominator overflows, rather than of the rewrite. With a guarded the gradient
at x = Inf goes from [NaN, NaN, 0.0] to [-0.4319, -Inf, -0.0], all three the correct limits, so
there was nothing left to discuss on its own. Happy to split it back out if you would still rather
review it separately.

— drafted by Claude Code on my behalf

@devmotion
devmotion merged commit 0b2a662 into master Aug 20, 2026
14 checks passed
@devmotion
devmotion deleted the fdistlogpdf-large-dof branch August 20, 2026 06:54
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.

fdistlogpdf is inaccurate when one degree of freedom is large

2 participants