Skip to content

Exact chainrules derivatives for beta_inc and beta_inc_inv - #506

Open
lrnv wants to merge 1 commit into
JuliaMath:masterfrom
lrnv:chainrules-for-beta_inc-and-beta_inc_inv
Open

Exact chainrules derivatives for beta_inc and beta_inc_inv#506
lrnv wants to merge 1 commit into
JuliaMath:masterfrom
lrnv:chainrules-for-beta_inc-and-beta_inc_inv

Conversation

@lrnv

@lrnv lrnv commented Oct 4, 2025

Copy link
Copy Markdown

Summary

This PR adds ChainRules coverage for the regularized incomplete beta function and its inverse.

  • Adds analytic frule/rrule definitions for:
    • beta_inc(a, b, x) -> (p, q)
    • beta_inc(a, b, x, y), with x + y = 1
    • beta_inc_inv(a, b, p) -> (x, 1 - x)
    • beta_inc_inv(a, b, p, q), with p + q = 1
  • Uses symmetric tangent/cotangent splits for the redundant four-argument parametrizations.
  • Supports integer inputs through promotion and supports Float16/Float32 by computing the differentiated continued fraction in Float64 and converting back.
  • Handles endpoints and degenerate shape parameters explicitly.
  • Adds extensive tests for forward and reverse rules, endpoint behavior, type stability, allocations, promotion, non-convergence, and derivatives along the four-argument constraints.

Implementation

The parameter derivatives of beta_inc are computed by differentiating the continued-fraction method described by Boik & Robinson-Cox (1999). This is not a translation of the S+ source code: the implementation is independently adapted from arzwa/IncBetaDer, now MIT-licensed, whose author also explicitly permitted reuse.

The continued fraction exits as soon as its values converge. Its upper bound is maxapp = 10_000, rather than the historical value of 200, because difficult nearly symmetric cases with large shape parameters require substantially more approximants. If convergence is not reached, the helper emits a rate-limited warning and returns NaN derivatives instead of silently returning an inaccurate gradient.

Small recurrence helpers used only once have been integrated into the loop. The remaining shared helper stays @inline because it is called four times per iteration and benchmarking showed a material performance benefit.

Motivation

This allows automatic differentiation through beta_inc and beta_inc_inv. Missing derivatives affect, among other use cases:

  • beta quantiles and beta-distribution marginals;
  • Student-t and F distribution workflows built on incomplete beta functions;
  • fitting Distributions.MvTDist;
  • applications in packages such as Copulas.jl;
  • the use case discussed in this Julia Discourse thread.

These rules naturally belong in SpecialFunctions.jl's ChainRulesCore extension.

Validation

The tests cover a broad grid of (a, b, x), including values close to x = 0 and x = 1, integer and reduced-precision inputs, and the constrained four-argument APIs. Sensitive large-parameter regression cases include a = b = 10^6 and a = b = 10^8 at x = 0.5; these exercise cases that need more than 200 and, for 10^8, thousands of approximants.

The full SpecialFunctions.jl test suite passes, and the new code is covered by the test suite.

Reference

Boik, R. J., & Robinson-Cox, J. F. (1999). Derivatives of the incomplete beta function. Journal of Statistical Software, 3(1). https://doi.org/10.18637/jss.v003.i01

@codecov

codecov Bot commented Oct 4, 2025

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.91304% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 95.03%. Comparing base (adbeb4b) to head (c91356f).
⚠️ Report is 8 commits behind head on master.

Files with missing lines Patch % Lines
ext/SpecialFunctionsChainRulesCoreExt.jl 98.91% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master     #506      +/-   ##
==========================================
+ Coverage   94.49%   95.03%   +0.53%     
==========================================
  Files          14       14              
  Lines        3016     3200     +184     
==========================================
+ Hits         2850     3041     +191     
+ Misses        166      159       -7     
Flag Coverage Δ
unittests 95.03% <98.91%> (+0.53%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ 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.

Comment thread ext/SpecialFunctionsChainRulesCoreExt.jl Outdated
@lrnv

lrnv commented Oct 5, 2025

Copy link
Copy Markdown
Author

@asinghvi17, @devmotion: I have switched implementations from the porting of the original code (which was probably not licensed correctly as we discussed yesterday) to a port of this repository, which is an independent implementation from @arzwa, unlicensed yet.

I have better hopes that @arzwa will allow us to use his code, and have emailed him. By looking at the diff you'll see that this is clearly an independent implementation, and thus, if @arzwa agrees, we are good license-wise.

I also have contacted the original authors, so if they come around faster we can still revert my last commit.

@arzwa

arzwa commented Oct 5, 2025

Copy link
Copy Markdown

I agree, you can use the code as you wish, I added an MIT license to the repo.

@lrnv

lrnv commented Oct 5, 2025

Copy link
Copy Markdown
Author

I knew it'll be faster 🤣

@arzwa

arzwa commented Oct 5, 2025

Copy link
Copy Markdown

@asinghvi17, @devmotion: I have switched implementations from the porting of the original code (which was probably not licensed correctly as we discussed yesterday) to a port of this repository, which is an independent implementation from @arzwa, unlicensed yet.

Now that I'm reading mentioned discussion, I would like to add that I definitely implemented this using the approach described in the mentioned paper (Boik & Robinson-Cox 1998). As far as I remember, I did not 'translate' the code but implemented the described numerical method 'independently', based on their mathematical description (in fact I'm pretty sure I have never seen S-plus code in my life). However, I don't know what that would mean license-wise and whether it's OK to use an MIT license (I have never had to think about those kind of issues before). Any insights on this?

@bdeonovic

Copy link
Copy Markdown

I would test a much larger range of a,b values. Look at the code for calculation of the incomplete beta in this repo. They use different algorithms depending on the domain.

@devmotion devmotion 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.

All the nested functions, multiple assignments on a single line and generally lack of comments make it quite challenging to follow the code. Maybe the implementation should be cleaned up a bit before it should be considered for inclusion in SpecialFunctions.

@lrnv

lrnv commented Oct 13, 2025

Copy link
Copy Markdown
Author

So, I have :

  1. Moved the internal methods out of the function for readability
  2. Added a lot of comments to help following the code (also look at the paper that will help)
  3. Increased largely the coverage of (a,b,x) in my tests, by looking at what was done in test/beta_inc.jl, I think I hit most of the branches as @bdeonovic mentioned.
  4. Fixed the behavior by "merging" two methods (not conform to what @arzwa implemented previously) to avoid cancellations.

A slightly potentially-problematic outcome : the tests of the new chainrule alone last for ~40s on my machine, which is a large part of the total test time of the package :/

Ready for review round 2.

@ViralBShah

Copy link
Copy Markdown
Member

Test time is probably not a concern. If this is good to go, we should merge.

Comment thread ext/SpecialFunctionsChainRulesCoreExt.jl Outdated
Comment thread ext/SpecialFunctionsChainRulesCoreExt.jl Outdated
Comment thread ext/SpecialFunctionsChainRulesCoreExt.jl Outdated
Comment thread ext/SpecialFunctionsChainRulesCoreExt.jl Outdated
Comment thread ext/SpecialFunctionsChainRulesCoreExt.jl Outdated
Comment thread ext/SpecialFunctionsChainRulesCoreExt.jl Outdated
Comment thread ext/SpecialFunctionsChainRulesCoreExt.jl Outdated
Comment thread ext/SpecialFunctionsChainRulesCoreExt.jl Outdated
Comment thread ext/SpecialFunctionsChainRulesCoreExt.jl Outdated
Comment thread ext/SpecialFunctionsChainRulesCoreExt.jl Outdated
@lrnv

lrnv commented Jan 7, 2026

Copy link
Copy Markdown
Author

@devmotion Thanks a lot for the review and sorry for the delay ! All your suggestions were taken into account (I marked them as resolved), except 3: the first one is IMHO not a good idea for stability, the remaining two are points on which i agree with you but do not know how to do it myself, maybe you could help ?

Tests passes, faillures on PRE seems unrelated.

@droodman

droodman commented Jan 28, 2026

Copy link
Copy Markdown

Thank you @lrnv for doing this. Pending its incorporation into SpecialFunctions, I have copied your code directly into a project I'm working on.

I just wanted to check if this is a bug, in _beta_inc_grad():

    maxapp = max(1000, maxapp)
    minapp = max(5, minapp)

The asymmetry here looks funny, with max() in both lines. The function's signature sets a default of maxapp=200 and here it is boosted to 1000 even if the user specifies, say, 250?

@lrnv

lrnv commented Jan 28, 2026

Copy link
Copy Markdown
Author

The maxapp and minapp arguments specify the maximum resp. minimum number of
approximants to use in the continued fraction evaluation. Letting the first be at least 1000 and the second at least 5 is coherent with what the original paper percognises, while @arzwa used defaults as minapp=3, maxapp=200 and does not force anything on them. You are right this is troubling. @droodman, did you investigate what appends to the tests if you simply remove these two lines ?

@droodman

droodman commented Jan 28, 2026

Copy link
Copy Markdown

Sorry, I did not check the tests.
I feel a bit out of my depth here. Based on pattern-matching intuition, it seems that one line should use min() and the other max().

Separately:

  • I found that decorating all the small helper functions with @inline halved run-time whether with Float64 or ForwardDiff.Dual.

  • On that note, I got this working with ForwardDiff (which in my project is working best for automatic differentiation) by making a couple of changes. I changed all instances of AbstractFloat to Real so it would accept ForwardDiff.Duals. And I added:

using ForwardDiff, ForwardDiffChainRules                                                  
import SpecialFunctions.beta_inc                                                          
@ForwardDiff_frule beta_inc(a::ForwardDiff.Dual, b::ForwardDiff.Dual, x::ForwardDiff.Dual)

I assume the middle line is only needed because my copy of your code is outside of SpecialFunctions. I'm just using beta_inc() (actually cdf(TDist(...)...)) so I only bothered creating a ForwardDiff chain rule for that function.

Again, since I'm out of my depth, I leave it to you to judge if there's any practical upshot for you.

@lrnv

lrnv commented Jan 29, 2026

Copy link
Copy Markdown
Author

Thanks @droodman for the hint on inlining, indeed it helped :)

I am not sure the inclusion of ForwardDiff here is really in the scope of this PR.

@lrnv
lrnv force-pushed the chainrules-for-beta_inc-and-beta_inc_inv branch from d7d26d4 to 500d94c Compare January 29, 2026 09:48
@lrnv

lrnv commented Jan 29, 2026

Copy link
Copy Markdown
Author

Hum... Apart ExplicitImports which yells about @horner not being exported from Base.math, which is unrelated to this PR, everything looks great. I solved one more comment by moving to ChainRulesCore.muladd() to join the tangeants and partials are requested, and i am now ready for a new review round.

@droodman

Copy link
Copy Markdown

Hi @lrnv. Follow-up comments:

  • It might be out-of-scope to add the definitions of ForwardDiff-specific chain rules. But do you actually want to block users of ForwardDiff from using your code? I think delcaring AbstractFloat instead of Real does that. In my use case, I would continue to get error messages when using ForwardDiff for automatic differentiation of an objective function that calls beta_inc() (or the cdf of the t or F distributions). I might turn to Zygote or the like; previous explorations in my case have found the alternatives crash or run vastly slower.
  • This sequence looks peculiar to me:
function _beta_inc_grad(a::T, b::T, x::T; maxapp::Int=200, minapp::Int=3, err::T=eps(T)*T(1e4))
...
ϵ = min(err, T(1e-14))

First the tolerance is set to eps(T)*T(1e4). Then it is lowered to 1e-14. I believe the original code uses 1e12. I tried changing to ϵ = err and also commenting out the min/maxapp lines I mentioned before and it passed all tests (but you should check me on that). This has modest performance implications.

@lrnv

lrnv commented Jan 29, 2026

Copy link
Copy Markdown
Author

@droodman I removed the minapp/maxapp/eps weridness, thanks. I also removed the type restriction alltogether so that you can pass anything through.

PS: I am not sure however that this is compliant with what SpecialFunctions.jl requires for merging ;)

@lrnv
lrnv requested review from asinghvi17 and devmotion January 29, 2026 15:54
Comment thread ext/SpecialFunctionsChainRulesCoreExt.jl Outdated
Comment thread ext/SpecialFunctionsChainRulesCoreExt.jl Outdated
@droodman

droodman commented Jan 31, 2026

Copy link
Copy Markdown

In the last couple of days, this code basically became the inner loop in my project and was consuming hours of time. So I was highly motivated to optimize it. I got another 2X improvement (at least on an M4 Mac), in addition (or multiplication) to the 2X improvement from adding @inline to functions.

I forked @lrnv's fork of this repo and made a commit with the changes. However, I'm not that experienced with the complexities of GitHub--commits to different branches of different forks...--and I don't want to mess up anything that you are doing. Here is a link showing the new code block for ext/SpecialFunctionsChainRulesCoreExt.jl.

As noted there, the main improvement is removing redundant calculations. I also deleted the first of the four return values of _beta_inc_grad() since it is never used.

All tests pass.

Timing code:

test_points = (
                0.05, 0.08, 0.10, 0.12, 0.14, 0.18, 0.20, 0.22, 0.26,
                0.28, 0.30, 0.32, 0.35, 0.38, 0.40, 0.42, 0.45,
                0.49, 0.50, 0.51, 0.55, 0.58, 0.60, 0.62, 0.65,
                0.68, 0.70, 0.72, 0.76, 0.80, 0.85, 0.90
            )
ab = (0.4, 0.6, 0.9, 1.1, 2.5, 5.0, 16.0, 45.0, 100.5, 150.0)

using TimerOutputs
# run twice and ignore results first time
const to = TimerOutput()
for a in ab, b in ab, x in test_points
    @timeit to "total" SpecialFunctionsChainRulesCoreExt._beta_inc_grad(a, b, x)
end
show(to)

Old timings:

────────────────────────────────────────────────────────────────────
                           Time                    Allocations
                  ───────────────────────   ────────────────────────
Tot / % measured:     22.1ms /   5.6%           1.08MiB /  13.6%

Section   ncalls     time    %tot     avg     alloc    %tot      avg
────────────────────────────────────────────────────────────────────
total      3.20k   **1.25ms**  100.0%   390ns    150KiB  100.0%    48.0B
────────────────────────────────────────────────────────────────────

New timings:

────────────────────────────────────────────────────────────────────
                           Time                    Allocations
                  ───────────────────────   ────────────────────────
Tot / % measured:     11.9ms /   5.0%           1.03MiB /   9.5%

Section   ncalls     time    %tot     avg     alloc    %tot      avg
────────────────────────────────────────────────────────────────────
total      3.20k    **596μs**  100.0%   186ns    100KiB  100.0%    32.0B
────────────────────────────────────────────────────────────────────

@lrnv

lrnv commented Jan 31, 2026

Copy link
Copy Markdown
Author

@droodman You created your branch out of my master branch, while you should have build it on top of my feature branch. Anyway sicne there is only one commit, I copy/pasted it here, thanks :)

Ready again for review

@lrnv
lrnv requested a review from devmotion February 2, 2026 14:48
@lrnv

lrnv commented Apr 20, 2026

Copy link
Copy Markdown
Author

Hey @devmotion would you consider giving this another look ?

@lrnv
lrnv force-pushed the chainrules-for-beta_inc-and-beta_inc_inv branch from d6071da to 7bc16eb Compare May 4, 2026 09:18
@lrnv

lrnv commented May 4, 2026

Copy link
Copy Markdown
Author

Rebased on master; faillure on pre seems unrelated.

Comment thread ext/SpecialFunctionsChainRulesCoreExt.jl Outdated
Comment thread ext/SpecialFunctionsChainRulesCoreExt.jl Outdated
Comment thread test/chainrules.jl Outdated
Comment thread test/chainrules.jl Outdated
Comment thread test/chainrules.jl Outdated
Comment thread test/chainrules.jl Outdated
Comment thread test/chainrules.jl Outdated
@lrnv
lrnv requested a review from devmotion July 3, 2026 16:45
@lrnv

lrnv commented Jul 4, 2026

Copy link
Copy Markdown
Author

@devmotion Thanks for looking around ! I had troubles with iszero(-1e-19) was not working as good as isapprox(-1e-19, 0.0, atol=1e-16), so i kept the last one in two particular places. Otherwise i got rid of all atols as requested.

Furthermore, as @droodman said, the numerous inline of small functions are here really needed for performance. We could get rid of it by literally inlining them, but that'll be going backward w.r.t. previous discussions.

Still failling on pre for unrelated reasons. You may take another look.

@devmotion devmotion 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.

Given the size of the diff I consulted Claude to make this review more thorough than I'd have managed by hand.

_beta_inc_grad agrees with central differences to 1e-10..1e-13 over a wide range of (a, b, x), including (500, 3, 0.99), (3, 500, 0.001) and (1e-3, 2, 0.01), at ~295ns vs ~230ns for the primal. So the continued fraction itself is not the problem — everything below is in the rules and the edge cases.

The 4-argument rule is off by a factor of 2

_beta_inc throws unless x + y ≈ 1 (src/beta_inc.jl:766), so y is not a free argument and there is no ∂/∂y at fixed x — perturbing one of them alone just leaves the domain. The only observable derivative is the one along the constraint. For f(z) = beta_inc(a, b, g(z), 1 - g(z))[1] the chain rule gives g'(z) * (∂x - ∂y), so whatever the rule does it has to satisfy

∂x - ∂y == ∂I/∂x

The PR returns (dIx, -dIx), which gives 2 * dIx. I checked: with g = logistic(z) and g = logistic(-z), a ∈ (0.4, 1.2, 45.0), b ∈ (2.3, 5.0, 16.0), z ∈ (-1.5, 0.0, 0.8), both frule and rrule come out at exactly 2.000x the finite difference in z — all 18 combinations.

Since x and y are a redundant parametrisation I'd split symmetrically, the same way we handle Symmetric/Hermitian cotangents:

    (dIa, dIb, dIx / 2, -dIx / 2),
    (-dIa, -dIb, -dIx / 2, dIx / 2),

With that all 18 cases give 1.000x. (dIx, ZeroTangent()) satisfies the identity as well but makes one argument a gradient sink, which seems worse in case most of the dependency happens to flow through y. Please don't use NoTangent() here — y is a perfectly differentiable Number and it would end up in a slot where ProjectTo expects a Real.

Worth getting right since beta_inc(a, b, x, y) is the form we advertise in docs/src/functions_overview.md:27, so it's what people will copy.

The current tests encode the factor 2 (test/chainrules.jl:316-318 and 340), so they need to change too. I'd drop the hand-written comparison of the individual partials and test the composition instead — that is testable with finite differences, unlike the raw 4-arg form, since z -> (g(z), 1 - g(z)) never leaves the domain:

@testset "4-arg beta_inc: derivative along the constraint y = 1 - x" begin
    # ∂/∂x and ∂/∂y are not separately testable since beta_inc requires x + y == 1.
    # What is observable is the derivative along the constraint, ie. for x = g(z),
    # y = 1 - g(z) both modes have to reproduce
    #     d/dz I_{g(z)}(a, b) = pdf(g(z)) * g'(z),
    # ie. the rule has to satisfy ∂x - ∂y == ∂I/∂x, however it splits the two.
    logistic(z) = inv(1 + exp(-z))
    # opposite signs of g' and x mirrored around 1/2, so that sign errors show up and
    # the internal tail swap is exercised in both directions
    gs = (
        (name = "logistic(z)",  g = z ->  logistic(z), g′ = z ->  logistic(z)  * (1 - logistic(z))),
        (name = "logistic(-z)", g = z -> logistic(-z), g′ = z -> -logistic(-z) * (1 - logistic(-z))),
    )
    for gg in gs, a in (0.4, 1.2, 45.0), b in (2.3, 5.0, 16.0), z in (-1.5, 0.0, 0.8)
        x = gg.g(z)
        d = gg.g′(z)
        expected = x^(a - 1) * (1 - x)^(b - 1) / beta(a, b) * d
        @testset "$(gg.name) a=$a b=$b z=$z" begin
            @test all(isapprox.(beta_inc(a, b, x, 1 - x), beta_inc(a, b, x); rtol=1e-12))

            _, Δ = frule((NoTangent(), 0.0, 0.0, d, -d), beta_inc, a, b, x, 1 - x)
            @test isapprox(Δ[1], expected; rtol=1e-12)
            @test isapprox(Δ[2], -expected; rtol=1e-12)

            _, pb = rrule(beta_inc, a, b, x, 1 - x)
            _, ā, b̄, x̄, ȳ = pb((1.0, 0.0))
            @test isapprox(x̄ * d + ȳ * -d, expected; rtol=1e-12)

            _, pb3 = rrule(beta_inc, a, b, x)
            _, ā3, b̄3, _ = pb3((1.0, 0.0))
            @test isapprox(ā, ā3; rtol=1e-12)
            @test isapprox(b̄, b̄3; rtol=1e-12)
        end
    end
end

Also @test isapprox(ȳ4, -x̄3; rtol=1e-1) on line 337 — that relation is exact by construction and everything around it uses 1e-11, so I assume 1e-1 is left over from debugging.

_beta_inc_grad returns four values for x == 1

isone(x) && return oneT, zeroT, zeroT, zeroT

Every other branch returns three, and all three call sites destructure three. Julia silently drops the extra element, so dIa picks up the primal:

julia> ext._beta_inc_grad(3.0, 1.0, 1.0)
(1.0, 0.0, 0.0, 0.0)

julia> frule((NoTangent(), 1.0, 0.0, 0.0), beta_inc, 1.2, 2.3, 1.0)[2]
Tangent{Tuple{Float64, Float64}}(1.0, -1.0)   # should be (0.0, 0.0)

The x == 0 branch has the same arity problem, it just happens to be all zeros. Though ∂I/∂x at x == 0 is b for a == 1 and Inf for a < 1, not 0, so that branch isn't right either.

Independently of the wrong value this costs an allocation on every call, because the two arities widen the return type:

julia> Base.return_types(ext._beta_inc_grad, Tuple{Float64,Float64,Float64})
1-element Vector{Any}:
 Union{Tuple{Float64, Float64, Float64}, NTuple{4, Float64}}

julia> @allocated ext._beta_inc_grad(1.2, 2.3, 0.4)
32

We have allocation tests in the suite already, so an @inferred/@allocated check on _beta_inc_grad would be worth adding.

beta_inc_inv rule doesn't promote its arguments

The two beta_inc rules use map(float, promote(a, b, x)) but the beta_inc_inv one calls _beta_inc_grad(a, b, x) directly, so anything but Float64 arguments fails:

julia> frule((NoTangent(), 1.0, 0.0, 0.0), beta_inc_inv, 1, 2, 0.5)
ERROR: MethodError: no method matching _beta_inc_grad(::Int64, ::Int64, ::Float64)

beta_inc_inv(1, 2, 0.5) itself works fine, so this is a regression introduced by the rule.

Float16 isn't supported although the primal supports it

_beta_inc_grad is restricted to Union{Float64, Float32}, but _beta_inc has a Union{Float16, Float32} method (src/beta_inc.jl:908) that computes in Float64 and converts back. Could you follow that pattern here? It fixes Float16, avoids compiling the whole CF a second time for Float32, and should let you tighten the rtol=5e-4 in the Float32 tests considerably.

Unconverged results are returned with just a @warn

maxapp = 200 isn't enough for large parameters, and the result can be quite wrong:

a, b, x rel. error of ∂I/∂a
1000, 1000, 0.5 1.5e-12 converged
1e4, 1e4, 0.5 2.4e-10 converged
1e5, 1e5, 0.5 2.5e-8 warns
1e6, 1e6, 0.5 2.3e-2 warns

A 2% gradient isn't something a caller can be expected to notice. I'd either increase maxapp, or return NaN, or at the very least document the limitation. And the @warn needs maxlog=1 — as is it will be emitted on every gradient evaluation inside an optimiser or sampler. Note that @test_logs in test/chainrules.jl:290 pins the exact message, so that test would have to be adjusted along with it.

NaNs for degenerate arguments

julia> beta_inc(0.0, 2.0, 0.4), ext._beta_inc_grad(0.0, 2.0, 0.4)
((1.0, 0.0), (NaN, NaN, 0.0))

julia> frule((NoTangent(), 0.0, 0.0, 1.0), beta_inc_inv, 1.2, 2.3, 1.0)[2]
Tangent{Tuple{Float64, Float64}}(NaN, NaN)

Same for b == 0 and for beta_inc_inv at p == 0. The beta_inc_inv ones come from 0 * inv(0) in -dIa * inv_dIx, partly aggravated by the x == 1 issue above. The primal handles all of these, so I think the rules should too, even if the answer is a deliberate NaN.

sign(Bn) in the guard against small Bn

invBn = (Bn > tiny || Bn < -tiny) && isfinite(Bn) ? inv(Bn) : inv(sign(Bn) * tiny)

sign(0.0) == 0.0, so for Bn == 0 — the case the guard is there for — this returns Inf. inv(copysign(tiny, Bn)) would do what you want.

Smaller things

  • _dan_dq takes q and da1_dq and uses neither, and its comment claims it returns the precomputed ∂a₁/∂q for n == 1, which it doesn't. Same for the n == 1 remark in _dan_dp. The loop starts at n = 2, so I think all the n == 1 comments in these helpers can go, together with the unused arguments.
  • _dK_dq is missing the return, unlike _dK_dp right above it.
  • An, an, Bn = _nextapp1(f, p, q) assigns the returned bn to Bn. It's correct since B₁ == b₁, but it reads like a bug — maybe just return (An, Bn)?
  • ϵ = eps(T)*T(1e4) with the # 0) Previously keyword arguments: comment looks like leftover. Either make it a keyword like maxapp/minapp or drop the comment. Also the step numbering skips 2.
  • _dbn_dp and _dbn_dq recompute A, N1, N and D identically and are both called every iteration.
  • pfq_2 (pf/q + 2) and pf_2q (pf + 2q) differing by one character is going to bite someone eventually.
  • Mixed 2*n / 2p / 2q spacing, and a double blank line between _dan_dp and _dan_dq.
  • The PR description cites Boik & Robinson-Cox 1998 (CSDA 27(1)) while the comment cites 1999 (JSS 3(1)). Both exist, but probably pick one. Keeping the reference in the comments is fine IMO, that's what we do in src/beta_inc.jl as well.
  • The code says it's adapted from arzwa/IncBetaDer.jl — could you check what licence that is under and whether a comment is enough?

Tests

The grid over (a, b, x) is good and it only adds ~30s, so no complaints there. But:

  • Nothing near x -> 0 or x -> 1, which is exactly where the bug above is.
  • No Integer arguments, no Float16.
  • test_frule/test_rrule are called without a surrounding @testset, so a failure gives you no idea which (a, b, x) it was. Could you wrap the loop bodies in @testset "a=$a b=$b x=$x"?

Other

beta_inc_inv(a, b, p, q) doesn't get a rule, although its 4-argument form is documented next to beta_inc(a, b, x, y) in the overview table. Could you add it with the same symmetric split?

Would you mind squashing/rewording the commits before merge?

@lrnv

lrnv commented Aug 6, 2026

Copy link
Copy Markdown
Author

Response to the latest review

@devmotion below is a response to the various points you highlighted. The branch is now ready for another round of review and/or final merging, as you please.

Given the size of the diff I consulted Claude to make this review more thorough than I'd have managed by hand.

I'm also using LLMs, no worries.

_beta_inc_grad agrees with central differences to 1e-10..1e-13 over a wide range of (a, b, x), including (500, 3, 0.99), (3, 500, 0.001) and (1e-3, 2, 0.01), at ~295ns vs ~230ns for the primal. So the continued fraction itself is not the problem — everything below is in the rules and the edge cases.

Agreed. I kept the continued-fraction algorithm and focused these changes on the rules, edge cases, convergence failure, type handling, and the internal cleanup listed below.

The 4-argument rule is off by a factor of 2

_beta_inc throws unless x + y ≈ 1 (src/beta_inc.jl:766), so y is not a free argument and there is no ∂/∂y at fixed x — perturbing one of them alone just leaves the domain. The only observable derivative is the one along the constraint. For f(z) = beta_inc(a, b, g(z), 1 - g(z))[1] the chain rule gives g'(z) * (∂x - ∂y), so whatever the rule does it has to satisfy

∂x - ∂y == ∂I/∂x

The PR returns (dIx, -dIx), which gives 2 * dIx. I checked: with g = logistic(z) and g = logistic(-z), a ∈ (0.4, 1.2, 45.0), b ∈ (2.3, 5.0, 16.0), z ∈ (-1.5, 0.0, 0.8), both frule and rrule come out at exactly 2.000x the finite difference in z — all 18 combinations.

Since x and y are a redundant parametrisation I'd split symmetrically, the same way we handle Symmetric/Hermitian cotangents:

    (dIa, dIb, dIx / 2, -dIx / 2),
    (-dIa, -dIb, -dIx / 2, dIx / 2),

With that all 18 cases give 1.000x. (dIx, ZeroTangent()) satisfies the identity as well but makes one argument a gradient sink, which seems worse in case most of the dependency happens to flow through y. Please don't use NoTangent() here — y is a perfectly differentiable Number and it would end up in a slot where ProjectTo expects a Real.

Worth getting right since beta_inc(a, b, x, y) is the form we advertise in docs/src/functions_overview.md:27, so it's what people will copy.

Fixed exactly as suggested. The four-argument rule now uses the symmetric (dIx / 2, -dIx / 2) split, without NoTangent(), so its derivative along y = 1 - x is dIx rather than 2dIx.

The current tests encode the factor 2 (test/chainrules.jl:316-318 and 340), so they need to change too. I'd drop the hand-written comparison of the individual partials and test the composition instead — that is testable with finite differences, unlike the raw 4-arg form, since z -> (g(z), 1 - g(z)) never leaves the domain:

@testset "4-arg beta_inc: derivative along the constraint y = 1 - x" begin
    # ∂/∂x and ∂/∂y are not separately testable since beta_inc requires x + y == 1.
    # What is observable is the derivative along the constraint, ie. for x = g(z),
    # y = 1 - g(z) both modes have to reproduce
    #     d/dz I_{g(z)}(a, b) = pdf(g(z)) * g'(z),
    # ie. the rule has to satisfy ∂x - ∂y == ∂I/∂x, however it splits the two.
    logistic(z) = inv(1 + exp(-z))
    # opposite signs of g' and x mirrored around 1/2, so that sign errors show up and
    # the internal tail swap is exercised in both directions
    gs = (
        (name = "logistic(z)",  g = z ->  logistic(z), g′ = z ->  logistic(z)  * (1 - logistic(z))),
        (name = "logistic(-z)", g = z -> logistic(-z), g′ = z -> -logistic(-z) * (1 - logistic(-z))),
    )
    for gg in gs, a in (0.4, 1.2, 45.0), b in (2.3, 5.0, 16.0), z in (-1.5, 0.0, 0.8)
        x = gg.g(z)
        d = gg.g′(z)
        expected = x^(a - 1) * (1 - x)^(b - 1) / beta(a, b) * d
        @testset "$(gg.name) a=$a b=$b z=$z" begin
            @test all(isapprox.(beta_inc(a, b, x, 1 - x), beta_inc(a, b, x); rtol=1e-12))

            _, Δ = frule((NoTangent(), 0.0, 0.0, d, -d), beta_inc, a, b, x, 1 - x)
            @test isapprox(Δ[1], expected; rtol=1e-12)
            @test isapprox(Δ[2], -expected; rtol=1e-12)

            _, pb = rrule(beta_inc, a, b, x, 1 - x)
            _, ā, b̄, x̄, ȳ = pb((1.0, 0.0))
            @test isapprox(x̄ * d + ȳ * -d, expected; rtol=1e-12)

            _, pb3 = rrule(beta_inc, a, b, x)
            _, ā3, b̄3, _ = pb3((1.0, 0.0))
            @test isapprox(ā, ā3; rtol=1e-12)
            @test isapprox(b̄, b̄3; rtol=1e-12)
        end
    end
end

Updated. The tests now exercise the observable derivative along the constraint and expect one beta density, not twice the density. They also check the symmetric forward and reverse behavior across both tail-swap directions.

Also @test isapprox(ȳ4, -x̄3; rtol=1e-1) on line 337 — that relation is exact by construction and everything around it uses 1e-11, so I assume 1e-1 is left over from debugging.

Fixed. The symmetric split is now checked with rtol=1e-11 and the expected half-gradient on each redundant input.

_beta_inc_grad returns four values for x == 1

isone(x) && return oneT, zeroT, zeroT, zeroT

Every other branch returns three, and all three call sites destructure three. Julia silently drops the extra element, so dIa picks up the primal:

julia> ext._beta_inc_grad(3.0, 1.0, 1.0)
(1.0, 0.0, 0.0, 0.0)

julia> frule((NoTangent(), 1.0, 0.0, 0.0), beta_inc, 1.2, 2.3, 1.0)[2]
Tangent{Tuple{Float64, Float64}}(1.0, -1.0)   # should be (0.0, 0.0)

The x == 0 branch has the same arity problem, it just happens to be all zeros. Though ∂I/∂x at x == 0 is b for a == 1 and Inf for a < 1, not 0, so that branch isn't right either.

Fixed. Every path returns exactly three derivatives. At both endpoints the shape derivatives are zero and the x derivative is the correct beta-density limit: zero, b/a when a == 1/b == 1, or Inf when the corresponding shape is below one.

Independently of the wrong value this costs an allocation on every call, because the two arities widen the return type:

julia> Base.return_types(ext._beta_inc_grad, Tuple{Float64,Float64,Float64})
1-element Vector{Any}:
 Union{Tuple{Float64, Float64, Float64}, NTuple{4, Float64}}

julia> @allocated ext._beta_inc_grad(1.2, 2.3, 0.4)
32

We have allocation tests in the suite already, so an @inferred/@allocated check on _beta_inc_grad would be worth adding.

Added both checks. The specialized Float64 call now infers an NTuple{3,Float64} and allocates zero bytes.

beta_inc_inv rule doesn't promote its arguments

The two beta_inc rules use map(float, promote(a, b, x)) but the beta_inc_inv one calls _beta_inc_grad(a, b, x) directly, so anything but Float64 arguments fails:

julia> frule((NoTangent(), 1.0, 0.0, 0.0), beta_inc_inv, 1, 2, 0.5)
ERROR: MethodError: no method matching _beta_inc_grad(::Int64, ::Int64, ::Float64)

beta_inc_inv(1, 2, 0.5) itself works fine, so this is a regression introduced by the rule.

Fixed. The inverse rules now use map(float, promote(...)) before calling _beta_inc_grad, and integer arguments are covered in forward- and reverse-mode regression tests.

Float16 isn't supported although the primal supports it

_beta_inc_grad is restricted to Union{Float64, Float32}, but _beta_inc has a Union{Float16, Float32} method (src/beta_inc.jl:908) that computes in Float64 and converts back. Could you follow that pattern here? It fixes Float16, avoids compiling the whole CF a second time for Float32, and should let you tighten the rtol=5e-4 in the Float32 tests considerably.

Done. The continued fraction is compiled only for Float64; Float16 and Float32 compute through that method and convert the three derivatives back. Float16 rule and return-type coverage was added.

Unconverged results are returned with just a @warn

maxapp = 200 isn't enough for large parameters, and the result can be quite wrong:

a, b, x rel. error of ∂I/∂a
1000, 1000, 0.5 1.5e-12 converged
1e4, 1e4, 0.5 2.4e-10 converged
1e5, 1e5, 0.5 2.5e-8 warns
1e6, 1e6, 0.5 2.3e-2 warns

A 2% gradient isn't something a caller can be expected to notice. I'd either increase maxapp, or return NaN, or at the very least document the limitation. And the @warn needs maxlog=1 — as is it will be emitted on every gradient evaluation inside an optimiser or sampler. Note that @test_logs in test/chainrules.jl:290 pins the exact message, so that test would have to be adjusted along with it.

Changed to an adaptive but bounded safe mode. The loop exits as soon as all three values converge, with an empirical ceiling of 10,000 approximants instead of the historical JSS default of 200. This recovers difficult central cases that still produce accurate gradients (a = b = 1e6 needs about 507 approximants and a = b = 1e8 about 6,388) without adding work to ordinary calls; both cases are now explicit regression tests. If the new ceiling is reached, the function emits a warning with maxlog=1 and returns three NaNs instead of a potentially inaccurate gradient. The failure-path test now forces maxapp=2, so it does not require a valid input to remain unsupported by the default configuration.

NaNs for degenerate arguments

julia> beta_inc(0.0, 2.0, 0.4), ext._beta_inc_grad(0.0, 2.0, 0.4)
((1.0, 0.0), (NaN, NaN, 0.0))

julia> frule((NoTangent(), 0.0, 0.0, 1.0), beta_inc_inv, 1.2, 2.3, 1.0)[2]
Tangent{Tuple{Float64, Float64}}(NaN, NaN)

Same for b == 0 and for beta_inc_inv at p == 0. The beta_inc_inv ones come from 0 * inv(0) in -dIa * inv_dIx, partly aggravated by the x == 1 issue above. The primal handles all of these, so I think the rules should too, even if the answer is a deliberate NaN.

Handled explicitly. For a == 0 or b == 0, the undefined shape derivatives deliberately return NaN and the interior x derivative returns zero. At inverse endpoints, zero shape derivatives are no longer multiplied by an infinite inverse density, avoiding accidental 0 * Inf NaNs.

sign(Bn) in the guard against small Bn

invBn = (Bn > tiny || Bn < -tiny) && isfinite(Bn) ? inv(Bn) : inv(sign(Bn) * tiny)

sign(0.0) == 0.0, so for Bn == 0 — the case the guard is there for — this returns Inf. inv(copysign(tiny, Bn)) would do what you want.

Fixed at both division sites using inv(copysign(tiny, Bn)).

Smaller things

  • _dan_dq takes q and da1_dq and uses neither, and its comment claims it returns the precomputed ∂a₁/∂q for n == 1, which it doesn't. Same for the n == 1 remark in _dan_dp. The loop starts at n = 2, so I think all the n == 1 comments in these helpers can go, together with the unused arguments.

Fixed. These single-use helper formulas are now integrated directly into the n ≥ 2 loop, so the unused arguments, inaccurate comments, and unnecessary helper boundaries are gone.

  • _dK_dq is missing the return, unlike _dK_dp right above it.

Added the explicit return.

  • An, an, Bn = _nextapp1(f, p, q) assigns the returned bn to Bn. It's correct since B₁ == b₁, but it reads like a bug — maybe just return (An, Bn)?

Fixed. _nextapp1 now returns (An, Bn) directly.

  • ϵ = eps(T)*T(1e4) with the # 0) Previously keyword arguments: comment looks like leftover. Either make it a keyword like maxapp/minapp or drop the comment. Also the step numbering skips 2.

Removed the leftover comment and numbered-step structure. The tolerance and iteration choices are documented as fixed Float64 convergence parameters.

  • _dbn_dp and _dbn_dq recompute A, N1, N and D identically and are both called every iteration.

Fixed. Both derivatives are now computed together in the loop and share A, N, and D.

  • pfq_2 (pf/q + 2) and pf_2q (pf + 2q) differing by one character is going to bite someone eventually.

Fixed. The abbreviated names were replaced with explicit names such as pf_plus_2q, p_plus_2q_minus_2, and pq_times_p_minus_2_minus_pf.

  • Mixed 2*n / 2p / 2q spacing, and a double blank line between _dan_dp and _dan_dq.

Cleaned up the spacing and extra blank line. The two derivative formulas are now adjacent in their single loop call site.

  • The PR description cites Boik & Robinson-Cox 1998 (CSDA 27(1)) while the comment cites 1999 (JSS 3(1)). Both exist, but probably pick one. Keeping the reference in the comments is fine IMO, that's what we do in src/beta_inc.jl as well.

The source consistently keeps the full 1999 JSS reference. I have updated the PR description to use the same reference and corrcted the details in it (w.r.t. the source and licences in particular).

  • The code says it's adapted from arzwa/IncBetaDer.jl — could you check what licence that is under and whether a comment is enough?

The source comment now records the MIT licence and links to @arzwa's explicit permission to reuse the code: #506 (comment).

Tests

The grid over (a, b, x) is good and it only adds ~30s, so no complaints there. But:

  • Nothing near x -> 0 or x -> 1, which is exactly where the bug above is.

Added endpoint-limit tests for both x == 0 and x == 1, including zero, finite, and infinite beta-density limits.

  • No Integer arguments, no Float16.

Added integer inverse-rule coverage and Float16 gradient/rule/type coverage.

  • test_frule/test_rrule are called without a surrounding @testset, so a failure gives you no idea which (a, b, x) it was. Could you wrap the loop bodies in @testset "a=$a b=$b x=$x"?

Done. Both parameter grids now wrap each point in a named nested testset (using x for beta_inc and p for beta_inc_inv).

Other

beta_inc_inv(a, b, p, q) doesn't get a rule, although its 4-argument form is documented next to beta_inc(a, b, x, y) in the overview table. Could you add it with the same symmetric split?

Added the four-argument inverse rule with the same symmetric split between p and q, plus a constrained-derivative regression test.

Would you mind squashing/rewording the commits before merge?

Yes. I will squash and reword the branch history after these review changes are confirmed, before merge.

Follow-up on @inline

The @inline discussion was attached to an earlier inline review comment rather than the review body above. I benchmarked the five difficult cases from @droodman's follow-up at #506 (comment). Removing every annotation increased the median time from about 1.61 μs to 2.50 μs per _beta_inc_grad call (roughly 55% slower), with identical results and zero allocations. Six of the seven hot helpers had only one call site, so their formulas are now integrated directly into the continued-fraction loop, as suggested in that discussion. This retains the full performance (about 1.61 μs). Only _dnextapp remains an @inline helper because it is used four times per iteration and expanding it would duplicate the same recurrence four times.

@lrnv
lrnv force-pushed the chainrules-for-beta_inc-and-beta_inc_inv branch from f5747af to 03540af Compare August 6, 2026 10:17
@lrnv
lrnv requested a review from devmotion August 6, 2026 10:24
@devmotion

Copy link
Copy Markdown
Member

Thanks, this is a lot better. I went through every point again and they all hold up. As before I used Claude for the numerical checks, since most of these are only decidable by running them.

Confirming the ones that mattered, at 03540af:

  • The symmetric split satisfies ∂x - ∂y == ∂I/∂x to 8.7e-15 over all 18 (g, a, b, z) combinations, in both modes.
  • _beta_inc_grad returns three values on every path, Base.return_types gives Tuple{Float64,Float64,Float64}, and @allocated is 0.
  • The endpoint limits are right: (0, 0, 0), (0, 0, b), (0, 0, Inf) for a > 1, a == 1, a < 1, mirrored at x == 1.
  • Raising maxapp genuinely fixes the accuracy rather than just silencing it: a = b = 1e5 through 1e8 now agree with central differences to 2e-9 or better, against 2.3e-2 at 1e6 before.
  • No 0 * Inf left at the inverse endpoints, p == 0 and p == 1 give clean ±Inf or 0.
  • Promotion, copysign, _dK_dq, _nextapp1, the renamings, the licence note and the new four-argument inverse rule are all as discussed.

I also rederived a₁, b₁, aₙ, bₙ and the four partials against the paper and they are correct. The point that makes them come out as compactly as they do is that p * f = q * x / (1 - x) does not depend on p and p * f / q = x / (1 - x) depends on neither, so the (pf/q)² prefactor is a constant of the recurrence. Worth keeping in mind if anyone touches these formulas later.

test/chainrules.jl passes here in full, 130055 tests in 66s.

A few new things, one of which I would like fixed before merging.

NaN arguments now run the full 10000 approximants

_beta_inc short-circuits non-finite arguments, but _beta_inc_grad does not, so they run to the new ceiling instead:

_beta_inc_grad(NaN, 2.0, 0.5)  ->  (NaN, NaN, NaN)   ~150 µs
_beta_inc_grad(2.0, NaN, 0.5)  ->  (NaN, NaN, NaN)   ~150 µs
_beta_inc_grad(2.0, 3.0, NaN)  ->  (NaN, NaN, NaN)   ~150 µs
_beta_inc_grad(Inf, 2.0, 0.5)  ->  (NaN, NaN, NaN)   ~150 µs
_beta_inc_grad(2.0, 3.0, 0.5)  ->  converged         ~0.4 µs

Going from 200 to 10000 made this about 50x more expensive, and the warning blames the approximant count rather than the input, which is misleading. A sampler or optimiser that steps into NaN pays this on every gradient evaluation. Could you add the same guard the primal has, next to the endpoint branches?

if isnan(x) || isnan(y) || isnan(a) || isnan(b)
return (NaN, NaN)

# At either endpoint I_x is independent of a and b. The x derivative is the
# endpoint limit of the beta density.
if iszero(x)
dx = a < oneT ? Inf : isone(a) ? b : zeroT
return zeroT, zeroT, dx
elseif isone(x)
dx = b < oneT ? Inf : isone(b) ? a : zeroT
return zeroT, zeroT, dx
elseif iszero(a) || iszero(b)
# Parameter derivatives are undefined at degenerate shapes; the primal is
# constant in x in the interior, hence its x derivative is zero.
return NaN, NaN, zeroT
end

∂I/∂x is less accurate than it needs to be for large shapes

logbetapq = logbeta(a, b) # Time-consuming step; symmetric in a and b.
dx = exp((a - oneT) * logx + (b - oneT) * log1mx - logbetapq)

This subtracts two quantities of magnitude a * log(x), so the exponent picks up an absolute error of roughly a * eps and the result loses digits as the shapes grow. Against a 500 bit reference over 1813 (a, b, x) points:

formulation worst relative error
current 4.75e-9, at a = 1e6, b = 1e8, x = 0.01 (1.4e-9 at a = b = 1e8)
beta_integrand(a, b, x, 1 - x, -log(x) - log1p(-x)) 1.31e-11

beta_integrand computes e^μ x^a y^b / B(a, b) with the DiDonato-Morris log1pmx/stirling_corr scheme that exists precisely to avoid this cancellation, and _beta_inc already relies on it. Its mu argument absorbs the x^-1 y^-1 factor without an intermediate underflow, which also gets subnormal cases like a = 45, b = 1e6, x = 1e-12 right.

"""
function beta_integrand(a::Float64, b::Float64, x::Float64, y::Float64, mu::Float64=0.0)
a0, b0 = minmax(a,b)

This matters more after this round than before it, since a = b = 1e8 is now a supported and tested case, and since the beta_inc_inv rules are built on inv(dIx) and inherit the loss.

It is not a strict improvement everywhere: at 55 of those 1813 points the beta_integrand route is more than 4x worse than the log form, although it never exceeds 1.31e-11 anywhere. So the question is whether a uniform 1.3e-11 bound is preferable to a 4.8e-9 tail. I think it is, and reusing what the primal uses is worth something on its own, but I do not feel strongly.

The a = b = 1e8 case is closer to the ceiling than the comment suggests

(1e6, 1e6, 0.5), # requires more than the historical 200 approximants
(1e8, 1e8, 0.5), # requires thousands of approximants but converges below 10,000
)

Bisecting, the last converging symmetric case is a = b ≈ 1.35e8; from about 1.5e8 upwards the rules return NaN while beta_inc itself is still perfectly happy at 1e10. So the test sits at roughly 64% of the budget and the cliff is only a factor 1.35 away.

The test is fine and NaN is much better than the old silent 2% error, so I am not asking for another increase. But of the three options I offered last time this leaves the third undone. Could you note the approximate ceiling in the comment above _beta_inc_grad? It will save whoever bisects this next.

function _beta_inc_grad(a::Float64, b::Float64, x::Float64; maxapp::Int=10_000, minapp::Int=3)
# Compute I_x(a,b) and partial derivatives (∂I/∂a, ∂I/∂b, ∂I/∂x)

Smaller things

One numbered step survived the cleanup:

# 7) Undo tail-swap if applied; ∂I/∂x is the pdf at original (a,b,x)
if swap

The four-argument beta_inc rule promotes only three of the four arguments, so beta_inc(1.5f0, 2.25f0, 0.3f0, y::Float64) computes the derivative in Float32 against a Float64 primal. ProjectTo tidies up the tangent types so nothing breaks, but the four-argument inverse rule promotes all four, so this is at least inconsistent:

beta_inc(a::Number, b::Number, x::Number, y::Number),
@setup((dIa, dIb, dIx) = _beta_inc_grad(map(float, promote(a, b, x))...)),
(dIa, dIb, dIx / 2, -dIx / 2),

While you are there, _p in the three-argument inverse setup and _p, _q in the four-argument one are computed and never used:

@setup(
(_a, _b, _p, _q) = map(float, promote(a, b, p, q)),
x = first(Ω),

Finally, on my earlier suggestion to tighten the Float32 tolerance: I was wrong that it could come down considerably. 1e-4 passes but 1e-5 does not, because ChainRulesTestUtils builds its finite-difference reference from the Float32 primal, so the reference is the limiting factor rather than the rule. Either tighten it to 1e-4 or leave it and say so in the comment, both are fine.

# Finite-difference checks for Float32 are noisier; use looser tolerances
test_frule(beta_inc, a32, b32, x32; rtol=5e-4)
test_rrule(beta_inc, a32, b32, x32; rtol=5e-4)
p32 = first(beta_inc(a32, b32, x32))
test_frule(beta_inc_inv, a32, b32, p32; rtol=5e-4)
test_rrule(beta_inc_inv, a32, b32, p32; rtol=5e-4)

Only the NaN guard is blocking from my side. With that in, and the commits squashed, this is good to go.

@lrnv

lrnv commented Aug 6, 2026

Copy link
Copy Markdown
Author

Thanks, this is a lot better. I went through every point again and they all hold up. As before I used Claude for the numerical checks, since most of these are only decidable by running them.

Confirming the ones that mattered, at 03540af:

  • The symmetric split satisfies ∂x - ∂y == ∂I/∂x to 8.7e-15 over all 18 (g, a, b, z) combinations, in both modes.
  • _beta_inc_grad returns three values on every path, Base.return_types gives Tuple{Float64,Float64,Float64}, and @allocated is 0.
  • The endpoint limits are right: (0, 0, 0), (0, 0, b), (0, 0, Inf) for a > 1, a == 1, a < 1, mirrored at x == 1.
  • Raising maxapp genuinely fixes the accuracy rather than just silencing it: a = b = 1e5 through 1e8 now agree with central differences to 2e-9 or better, against 2.3e-2 at 1e6 before.
  • No 0 * Inf left at the inverse endpoints, p == 0 and p == 1 give clean ±Inf or 0.
  • Promotion, copysign, _dK_dq, _nextapp1, the renamings, the licence note and the new four-argument inverse rule are all as discussed.

I also rederived a₁, b₁, aₙ, bₙ and the four partials against the paper and they are correct. The point that makes them come out as compactly as they do is that p * f = q * x / (1 - x) does not depend on p and p * f / q = x / (1 - x) depends on neither, so the (pf/q)² prefactor is a constant of the recurrence. Worth keeping in mind if anyone touches these formulas later.

test/chainrules.jl passes here in full, 130055 tests in 66s.

A few new things, one of which I would like fixed before merging.

Thank you for rechecking all of this, and especially for independently rederiving the recurrence and its partials. I kept the recurrence formulas unchanged and added a maintenance comment next to pf, pfq, and pfq2 recording that p*f is independent of p, that p*f/q is independent of both recurrence parameters, and therefore that the pfq² prefactor is constant with respect to them. I addressed each new item below.

NaN arguments now run the full 10000 approximants

_beta_inc short-circuits non-finite arguments, but _beta_inc_grad does not, so they run to the new ceiling instead:

_beta_inc_grad(NaN, 2.0, 0.5)  ->  (NaN, NaN, NaN)   ~150 µs
_beta_inc_grad(2.0, NaN, 0.5)  ->  (NaN, NaN, NaN)   ~150 µs
_beta_inc_grad(2.0, 3.0, NaN)  ->  (NaN, NaN, NaN)   ~150 µs
_beta_inc_grad(Inf, 2.0, 0.5)  ->  (NaN, NaN, NaN)   ~150 µs
_beta_inc_grad(2.0, 3.0, 0.5)  ->  converged         ~0.4 µs

Going from 200 to 10000 made this about 50x more expensive, and the warning blames the approximant count rather than the input, which is misleading. A sampler or optimiser that steps into NaN pays this on every gradient evaluation. Could you add the same guard the primal has, next to the endpoint branches?

if isnan(x) || isnan(y) || isnan(a) || isnan(b)
return (NaN, NaN)

# At either endpoint I_x is independent of a and b. The x derivative is the
# endpoint limit of the beta density.
if iszero(x)
dx = a < oneT ? Inf : isone(a) ? b : zeroT
return zeroT, zeroT, dx
elseif isone(x)
dx = b < oneT ? Inf : isone(b) ? a : zeroT
return zeroT, zeroT, dx
elseif iszero(a) || iszero(b)
# Parameter derivatives are undefined at degenerate shapes; the primal is
# constant in x in the interior, hence its x derivative is zero.
return NaN, NaN, zeroT
end

Fixed. _beta_inc_grad now returns (NaN, NaN, NaN) immediately whenever a, b, or x is non-finite, before entering the continued fraction and without emitting the misleading nonconvergence warning. Regression tests cover NaN and Inf independently in all three positions and assert that no log message is emitted.

∂I/∂x is less accurate than it needs to be for large shapes

logbetapq = logbeta(a, b) # Time-consuming step; symmetric in a and b.
dx = exp((a - oneT) * logx + (b - oneT) * log1mx - logbetapq)

This subtracts two quantities of magnitude a * log(x), so the exponent picks up an absolute error of roughly a * eps and the result loses digits as the shapes grow. Against a 500 bit reference over 1813 (a, b, x) points:

formulation worst relative error
current 4.75e-9, at a = 1e6, b = 1e8, x = 0.01 (1.4e-9 at a = b = 1e8)
beta_integrand(a, b, x, 1 - x, -log(x) - log1p(-x)) 1.31e-11

beta_integrand computes e^μ x^a y^b / B(a, b) with the DiDonato-Morris log1pmx/stirling_corr scheme that exists precisely to avoid this cancellation, and _beta_inc already relies on it. Its mu argument absorbs the x^-1 y^-1 factor without an intermediate underflow, which also gets subnormal cases like a = 45, b = 1e6, x = 1e-12 right.

"""
function beta_integrand(a::Float64, b::Float64, x::Float64, y::Float64, mu::Float64=0.0)
a0, b0 = minmax(a,b)

This matters more after this round than before it, since a = b = 1e8 is now a supported and tested case, and since the beta_inc_inv rules are built on inv(dIx) and inherit the loss.

It is not a strict improvement everywhere: at 55 of those 1813 points the beta_integrand route is more than 4x worse than the log form, although it never exceeds 1.31e-11 anywhere. So the question is whether a uniform 1.3e-11 bound is preferable to a 4.8e-9 tail. I think it is, and reusing what the primal uses is worth something on its own, but I do not feel strongly.

Changed as suggested. ∂I/∂x now uses SpecialFunctions.beta_integrand(a, b, x, 1 - x, -log(x) - log1p(-x)). I agree that the much tighter uniform worst-case bound and consistency with the primal outweigh the small local regressions. Since beta_integrand is intentionally internal, this qualified access from the package's own extension is documented in the existing ExplicitImports QA allowlist. Tests cover the reported asymmetric large-shape case, the symmetric 1e8 case, and the subnormal-tail example.

The a = b = 1e8 case is closer to the ceiling than the comment suggests

(1e6, 1e6, 0.5), # requires more than the historical 200 approximants
(1e8, 1e8, 0.5), # requires thousands of approximants but converges below 10,000
)

Bisecting, the last converging symmetric case is a = b ≈ 1.35e8; from about 1.5e8 upwards the rules return NaN while beta_inc itself is still perfectly happy at 1e10. So the test sits at roughly 64% of the budget and the cliff is only a factor 1.35 away.

The test is fine and NaN is much better than the old silent 2% error, so I am not asking for another increase. But of the three options I offered last time this leaves the third undone. Could you note the approximate ceiling in the comment above _beta_inc_grad? It will save whoever bisects this next.

function _beta_inc_grad(a::Float64, b::Float64, x::Float64; maxapp::Int=10_000, minapp::Int=3)
# Compute I_x(a,b) and partial derivatives (∂I/∂a, ∂I/∂b, ∂I/∂x)

Documented. The comment above _beta_inc_grad now states that symmetric cases stop converging at approximately a = b = 1.35e8 with maxapp = 10_000, notes that the precise threshold may depend on the platform and tolerance, and reiterates that cases beyond it deliberately return NaNs.

Smaller things

One numbered step survived the cleanup:

# 7) Undo tail-swap if applied; ∂I/∂x is the pdf at original (a,b,x)
if swap

Fixed: the remaining # 7) prefix was removed.

The four-argument beta_inc rule promotes only three of the four arguments, so beta_inc(1.5f0, 2.25f0, 0.3f0, y::Float64) computes the derivative in Float32 against a Float64 primal. ProjectTo tidies up the tangent types so nothing breaks, but the four-argument inverse rule promotes all four, so this is at least inconsistent:

beta_inc(a::Number, b::Number, x::Number, y::Number),
@setup((dIa, dIb, dIx) = _beta_inc_grad(map(float, promote(a, b, x))...)),
(dIa, dIb, dIx / 2, -dIx / 2),

Fixed. The four-argument beta_inc setup now promotes a, b, x, and y together before computing the derivative. A mixed Float32/Float64 regression test verifies that the derivative is computed in Float64.

While you are there, _p in the three-argument inverse setup and _p, _q in the four-argument one are computed and never used:

@setup(
(_a, _b, _p, _q) = map(float, promote(a, b, p, q)),
x = first(Ω),

Fixed. The unused promoted values are now ignored explicitly in the destructuring assignments.

Finally, on my earlier suggestion to tighten the Float32 tolerance: I was wrong that it could come down considerably. 1e-4 passes but 1e-5 does not, because ChainRulesTestUtils builds its finite-difference reference from the Float32 primal, so the reference is the limiting factor rather than the rule. Either tighten it to 1e-4 or leave it and say so in the comment, both are fine.

# Finite-difference checks for Float32 are noisier; use looser tolerances
test_frule(beta_inc, a32, b32, x32; rtol=5e-4)
test_rrule(beta_inc, a32, b32, x32; rtol=5e-4)
p32 = first(beta_inc(a32, b32, x32))
test_frule(beta_inc_inv, a32, b32, p32; rtol=5e-4)
test_rrule(beta_inc_inv, a32, b32, p32; rtol=5e-4)

The beta_inc Float32 checks are tightened to rtol=1e-4. On the current Julia 1.12 test environment, the beta_inc_inv reverse-rule finite-difference reference fails at 1e-4, so the inverse checks retain rtol=5e-4 with a comment explaining that the Float32 primal reference is the limiting factor.

Only the NaN guard is blocking from my side. With that in, and the commits squashed, this is good to go.

The blocking guard is now in place. I will squash the branch once again with good commit message. @devmotion, btw, did you note that i will talk about this PR at juliacon next week ? Come by if you want :)

lrnv added a commit to lrnv/SpecialFunctions.jl that referenced this pull request Aug 6, 2026
## Summary

This PR adds ChainRules coverage for the regularized incomplete beta function and its inverse.

- Adds analytic `frule`/`rrule` definitions for:
  - `beta_inc(a, b, x) -> (p, q)`
  - `beta_inc(a, b, x, y)`, with `x + y = 1`
  - `beta_inc_inv(a, b, p) -> (x, 1 - x)`
  - `beta_inc_inv(a, b, p, q)`, with `p + q = 1`
- Uses symmetric tangent/cotangent splits for the redundant four-argument parametrizations.
- Supports integer inputs through promotion and supports `Float16`/`Float32` by computing the differentiated continued fraction in `Float64` and converting back.
- Handles endpoints and degenerate shape parameters explicitly.
- Adds extensive tests for forward and reverse rules, endpoint behavior, type stability, allocations, promotion, non-convergence, and derivatives along the four-argument constraints.

## Implementation

The parameter derivatives of `beta_inc` are computed by differentiating the continued-fraction method described by Boik & Robinson-Cox (1999). This is **not a translation of the S+ source code**: the implementation is independently adapted from [arzwa/IncBetaDer](https://github.com/arzwa/IncBetaDer), now MIT-licensed, whose author also [explicitly permitted reuse](JuliaMath#506 (comment)).

The continued fraction exits as soon as its values converge. Its upper bound is `maxapp = 10_000`, rather than the historical value of 200, because difficult nearly symmetric cases with large shape parameters require substantially more approximants. If convergence is not reached, the helper emits a rate-limited warning and returns `NaN` derivatives instead of silently returning an inaccurate gradient.

Small recurrence helpers used only once have been integrated into the loop. The remaining shared helper stays `@inline` because it is called four times per iteration and benchmarking showed a material performance benefit.

## Motivation

This allows automatic differentiation through `beta_inc` and `beta_inc_inv`. Missing derivatives affect, among other use cases:

- beta quantiles and beta-distribution marginals;
- Student-t and F distribution workflows built on incomplete beta functions;
- fitting `Distributions.MvTDist`;
- applications in packages such as Copulas.jl;
- the use case discussed in [this Julia Discourse thread](https://discourse.julialang.org/t/gaussian-copula-priors-in-turing-auto-differentiation-error-with-beta-quantile-function/132779/5).

These rules naturally belong in SpecialFunctions.jl's ChainRulesCore extension.

## Validation

The tests cover a broad grid of `(a, b, x)`, including values close to `x = 0` and `x = 1`, integer and reduced-precision inputs, and the constrained four-argument APIs. Sensitive large-parameter regression cases include `a = b = 10^6` and `a = b = 10^8` at `x = 0.5`; these exercise cases that need more than 200 and, for `10^8`, thousands of approximants.

The full SpecialFunctions.jl test suite passes, and the new code is covered by the test suite.

## Reference

> Boik, R. J., & Robinson-Cox, J. F. (1999). Derivatives of the incomplete beta function. *Journal of Statistical Software, 3*(1). https://doi.org/10.18637/jss.v003.i01
@lrnv
lrnv force-pushed the chainrules-for-beta_inc-and-beta_inc_inv branch from 8afa458 to 988b671 Compare August 6, 2026 18:22
lrnv added a commit to lrnv/SpecialFunctions.jl that referenced this pull request Aug 6, 2026
## Summary

This PR adds ChainRules coverage for the regularized incomplete beta function and its inverse.

- Adds analytic `frule`/`rrule` definitions for:
  - `beta_inc(a, b, x) -> (p, q)`
  - `beta_inc(a, b, x, y)`, with `x + y = 1`
  - `beta_inc_inv(a, b, p) -> (x, 1 - x)`
  - `beta_inc_inv(a, b, p, q)`, with `p + q = 1`
- Uses symmetric tangent/cotangent splits for the redundant four-argument parametrizations.
- Supports integer inputs through promotion and supports `Float16`/`Float32` by computing the differentiated continued fraction in `Float64` and converting back.
- Handles endpoints and degenerate shape parameters explicitly.
- Adds extensive tests for forward and reverse rules, endpoint behavior, type stability, allocations, promotion, non-convergence, and derivatives along the four-argument constraints.

## Implementation

The parameter derivatives of `beta_inc` are computed by differentiating the continued-fraction method described by Boik & Robinson-Cox (1999). This is **not a translation of the S+ source code**: the implementation is independently adapted from [arzwa/IncBetaDer](https://github.com/arzwa/IncBetaDer), now MIT-licensed, whose author also [explicitly permitted reuse](JuliaMath#506 (comment)).

The continued fraction exits as soon as its values converge. Its upper bound is `maxapp = 10_000`, rather than the historical value of 200, because difficult nearly symmetric cases with large shape parameters require substantially more approximants. If convergence is not reached, the helper emits a rate-limited warning and returns `NaN` derivatives instead of silently returning an inaccurate gradient.

Small recurrence helpers used only once have been integrated into the loop. The remaining shared helper stays `@inline` because it is called four times per iteration and benchmarking showed a material performance benefit.

## Motivation

This allows automatic differentiation through `beta_inc` and `beta_inc_inv`. Missing derivatives affect, among other use cases:

- beta quantiles and beta-distribution marginals;
- Student-t and F distribution workflows built on incomplete beta functions;
- fitting `Distributions.MvTDist`;
- applications in packages such as Copulas.jl;
- the use case discussed in [this Julia Discourse thread](https://discourse.julialang.org/t/gaussian-copula-priors-in-turing-auto-differentiation-error-with-beta-quantile-function/132779/5).

These rules naturally belong in SpecialFunctions.jl's ChainRulesCore extension.

## Validation

The tests cover a broad grid of `(a, b, x)`, including values close to `x = 0` and `x = 1`, integer and reduced-precision inputs, and the constrained four-argument APIs. Sensitive large-parameter regression cases include `a = b = 10^6` and `a = b = 10^8` at `x = 0.5`; these exercise cases that need more than 200 and, for `10^8`, thousands of approximants.

The full SpecialFunctions.jl test suite passes, and the new code is covered by the test suite.

## Reference

> Boik, R. J., & Robinson-Cox, J. F. (1999). Derivatives of the incomplete beta function. *Journal of Statistical Software, 3*(1). https://doi.org/10.18637/jss.v003.i01
@lrnv
lrnv force-pushed the chainrules-for-beta_inc-and-beta_inc_inv branch from 988b671 to 2e3418f Compare August 6, 2026 18:44
@devmotion

Copy link
Copy Markdown
Member

Thanks, the blocking item is properly fixed. I went through the round again at 2e3418f, in the same way as before, and I also spent some time on the parts of the algorithm that neither of us had looked at closely yet.

Confirming what changed:

  • The non-finite guard works. All eight of (NaN, Inf, -Inf) in the three positions return (NaN, NaN, NaN) at about 15 ns against 0.7 µs for a converging call, and a TestLogger captures no records at all.
  • The @test_logs assertion around it is genuinely sensitive, which was not obvious to me given the maxlog=1 warning that fires earlier in the same testset. maxlog counters live in logger.message_limits per logger instance and @test_logs installs a fresh TestLogger, so the earlier warning does not mask the later ones. With the guard stubbed out the loop fails five times and errors once.
  • The beta_integrand switch does what it was supposed to. Over 41805 points the worst relative error is 5.45e-13 against 7.53e-8 for the old log form, and the point I quoted last time reproduces exactly at 4.746e-9.
  • The 1.35e8 figure in the new comment is right. Bisection gives 1.35060e8 as the last converging symmetric case.
  • The promotion fix, the removed _p/_q, the removed # 7), and the pf/pfq/pfq2 comment are all in. Macroexpansion confirms the @setup body is emitted once and that nothing is evaluated twice.
  • Both halves of the tolerance story hold. beta_inc passes at 1e-4 and fails at 1e-5, and for beta_inc_inv it is specifically the rrule that fails at 1e-4 while the frule still passes at 5e-5, so the comment is accurate.
  • The full suite passes here on 1.12.6.

I also checked whether beta_integrand misbehaves in the regimes the primal never routes through it, since the gradient now calls it for every (a, b, x). It does not: 440000 points across a, b in [1e-9, 1e8] with x in both tails give no throws and no non-finite results, and the assertions in stirling_corr and loggammadiv are unreachable for valid arguments. The overhead is about 15% of one converging call.

Two things I got wrong last time, before the new items.

The subnormal example was the wrong way round

I said beta_integrand also gets a = 45, b = 1e6, x = 1e-12 right. It is the old log form that is right there, and this is now pinned as a test:

# The x derivative uses the primal's cancellation-resistant beta density.
for (a, b, x) in ((1e6, 1e8, 0.01), (1e8, 1e8, 0.5), (45.0, 1e6, 1e-12))
dx = ext._beta_inc_grad(a, b, x)[3]
expected = SpecialFunctions.beta_integrand(
a, b, x, 1 - x, -log(x) - log1p(-x),
)
@test dx == expected

reference (600 bit) = 3.76556512893e-313
old log form        = 3.76556512893e-313   (exact)
beta_integrand      = 3.76556512898e-313   (about 1.8 ulp)

Both are within two ulp of the subnormal grid, so nothing is broken, but the third point now enshrines the less accurate of the two values. The 1.31e-11 worst case I quoted came from exactly this kind of subnormal point where ulp granularity dominates; over normal-range points the new form is uniformly better than I claimed. The conclusion is unchanged and if anything stronger, the justification was wrong. Could you drop that third point, or compare against a reference rather than against a respelling of the implementation? As it stands the test passes unchanged if beta_integrand regresses.

The 2e-9 figure was too optimistic

a = b = 1e5 through 1e8 do not agree to 2e-9. Against a 600 bit reference the errors are 1.74e-10, 9.08e-10, 1.39e-8 and 9.12e-9. My Float64 central differences were the limit, not the rules. This does not change that raising maxapp was the right call, but see the last section for where the remaining error comes from.

The mixed promotion test does not test the promotion

# All four arguments participate in promotion of the derivative calculation.
_, mixed_delta = frule(
(NoTangent(), 0.0, 0.0, 1.0, -1.0), beta_inc,
1.5f0, 2.25f0, 0.3f0, 1 - Float64(0.3f0),
)
@test mixed_delta[1] isa Float64

The tangents are Float64, so muladd promotes the result regardless of the type the derivative was computed in. Reverting the four-argument @setup to the old three-argument promote leaves this passing:

derivatives_given_output -> Tuple{NTuple{4, Float32}, NTuple{4, Float32}}   # bug is back
mixed_delta[1] isa Float64 -> true                                          # assertion still holds

The fix itself is correct, only the test cannot see it. Asserting on the derivative tuple works:

d = ChainRulesCore.derivatives_given_output(Ω, beta_inc, 1.5f0, 2.25f0, 0.3f0, 1 - Float64(0.3f0))
@test eltype(first(d)) === Float64

The convergence test stops being a relative test below eps

denomI = max(abs(Ixpqn), abs(Ixpq), eps(Float64))
denomp = max(abs(dI_dp), abs(dI_dp_prev), eps(Float64))
denomq = max(abs(dI_dq), abs(dI_dq_prev), eps(Float64))
rI = (Ixpqn - Ixpq) / denomI
rp = (dI_dp - dI_dp_prev) / denomp
rq = (dI_dq - dI_dq_prev) / denomq
if -ϵ < rI < ϵ && -ϵ < rp < ϵ && -ϵ < rq < ϵ

The eps(Float64) floor is there to guard against tiny denominators, but once |∂I/∂a| is well below 2.2e-16 the floor dominates and rp is around 1e-33 whatever the iteration is doing. The test then passes on the first opportunity and the loop exits at n = minapp = 3 with the third approximant. Using the minapp keyword on the shipped function:

(a, b, x) minapp=3 minapp=200 relative difference
(1e8, 1e8, 0.4995) -5.666e-49 -1.050e-48 46%
(1e8, 1e8, 0.49957573) -5.003e-37 -1.518e-36 67%
(3e5, 1e6, 0.2345) -1.44601e-26 -1.44600e-26 4.6e-6

The affected values are all far below anything that matters in a gradient, and I checked that: over 68880 points, restricted to |d| > 1e-10, the worst disagreement between minapp=3 and minapp=40 is 3.4e-12. So there is no practical problem. But this is the case the comment above the function says will not happen, and minapp=10 removes it entirely for a handful of extra iterations. Could you either raise minapp or drop the claim that nonconvergence returns NaNs rather than a plausible but inaccurate gradient?

The documented ceiling only covers the symmetric case

# loop still exits as soon as all three values converge, while difficult, central,
# nearly symmetric cases can require thousands of approximants. With this limit,
# symmetric cases stop converging around a = b = 1.35e8 (the exact threshold is
# platform- and tolerance-dependent); nonconvergence deliberately returns NaNs.

Asymmetric shapes stop converging at roughly half the symmetric value:

b/a last converging a
1 1.3506e8
2 1.0155e8
10 7.475e7
100 6.872e7
1e4 6.806e7

b = 100a at a = 6.8e7 converges and at a = 7.0e7 does not. One clause in the existing comment covers this.

Version

Project.toml is now identical to master, both at 2.8.3, so the bump got lost in the rebase. New rules are a feature, so this wants 2.9.0 before release.

Smaller things

The tiny guard is what sets the ceiling, and it is expensive:

With tiny = 1e-300 the symmetric ceiling moves from 1.35e8 to 6.96e9 and convergence gets faster rather than slower, 2388 approximants instead of 6388 at a = b = 1e8. After the rescaling by s, Bn can legitimately be smaller than sqrt(eps), and the guard then replaces a perfectly good small value with 1.49e-8. Worth a look if anyone wants the ceiling raised later.

The remaining error at large shapes is not in the continued fraction, it is in the prefactor:

function _dK_dp(logx::T, p::T, K::T, ψpq::T, ψp::T) where {T}
# ∂K/∂p using digamma identities: d/dp log B(p,q) = ψ(p) - ψ(p+q)
return K * (logx - inv(p) + ψpq - ψp)

The bracket logx - 1/p + ψ(p+q) - ψ(p) cancels at x near a/(a+b). Its own relative error is 6.9e-13 at a = b = 1e3, 2.1e-9 at 1e6 and 1.5e-7 at 1e8, tracking p * eps. That accounts for the plateau above, and it explains why forcing minapp from 10 up to 9000 does not improve those numbers at all. Not something to fix here, but it is the thing to attack if the parameter derivatives ever need to be better for large shapes.

Strongly imbalanced shapes return NaNs where the primal is fine. _beta_inc_grad(1e-6, 100.0, 1e-7) needs about 29740 approximants; the primal gives (0.99998906, 1.09e-5) without complaint. It is a contiguous band around min(a, b) ≲ 1e-5 with max(a, b) ≳ 1e3, roughly 1% of random log-uniform draws over [1e-7, 1e6]². Both existing test points in that corner happen to converge.

The four-argument beta_inc rule discards the caller's y and recomputes 1 - x. For beta_inc(2.0, 0.5, 1.0, 1e-16), which the primal handles, the rule takes the isone(x) branch and returns infinite and NaN tangents where the derivative is 7.5e7. Low reach, since x has to round to exactly 1, but that is the case the four-argument signature exists for.

On the tightened tolerance: 1e-4 is fine but the margin is thinner than it looks. Over 200 fresh RNG seeds test_rrule(beta_inc, ...) fails 11 times at 1e-4 against once at 5e-4, and test_rrule(beta_inc_inv, ...) already fails 30 times at 5e-4. The current pass is reproducible because testsets restore the RNG, so this is not urgent, but it will not survive a change to the Julia RNG stream or to how ChainRulesTestUtils draws tangents.

Finally, the licence note names IncBetaDer.jl and links the permission, but does not reproduce the upstream copyright line. The precedent in this repo is src/logabsgamma/e_lgamma_r.jl, which includes the notice verbatim.

## Summary

This PR adds ChainRules coverage for the regularized incomplete beta function and its inverse.

- Adds analytic `frule`/`rrule` definitions for:
  - `beta_inc(a, b, x) -> (p, q)`
  - `beta_inc(a, b, x, y)`, with `x + y = 1`
  - `beta_inc_inv(a, b, p) -> (x, 1 - x)`
  - `beta_inc_inv(a, b, p, q)`, with `p + q = 1`
- Uses symmetric tangent/cotangent splits for the redundant four-argument parametrizations.
- Supports integer inputs through promotion and supports `Float16`/`Float32` by computing the differentiated continued fraction in `Float64` and converting back.
- Handles endpoints and degenerate shape parameters explicitly.
- Adds extensive tests for forward and reverse rules, endpoint behavior, type stability, allocations, promotion, non-convergence, and derivatives along the four-argument constraints.

## Implementation

The parameter derivatives of `beta_inc` are computed by differentiating the continued-fraction method described by Boik & Robinson-Cox (1999). This is **not a translation of the S+ source code**: the implementation is independently adapted from [arzwa/IncBetaDer](https://github.com/arzwa/IncBetaDer), now MIT-licensed, whose author also [explicitly permitted reuse](JuliaMath#506 (comment)).

The continued fraction exits as soon as its values converge. Its upper bound is `maxapp = 10_000`, rather than the historical value of 200, because difficult nearly symmetric cases with large shape parameters require substantially more approximants. If convergence is not reached, the helper emits a rate-limited warning and returns `NaN` derivatives instead of silently returning an inaccurate gradient.

Small recurrence helpers used only once have been integrated into the loop. The remaining shared helper stays `@inline` because it is called four times per iteration and benchmarking showed a material performance benefit.

## Motivation

This allows automatic differentiation through `beta_inc` and `beta_inc_inv`. Missing derivatives affect, among other use cases:

- beta quantiles and beta-distribution marginals;
- Student-t and F distribution workflows built on incomplete beta functions;
- fitting `Distributions.MvTDist`;
- applications in packages such as Copulas.jl;
- the use case discussed in [this Julia Discourse thread](https://discourse.julialang.org/t/gaussian-copula-priors-in-turing-auto-differentiation-error-with-beta-quantile-function/132779/5).

These rules naturally belong in SpecialFunctions.jl's ChainRulesCore extension.

## Validation

The tests cover a broad grid of `(a, b, x)`, including values close to `x = 0` and `x = 1`, integer and reduced-precision inputs, and the constrained four-argument APIs. Sensitive large-parameter regression cases include `a = b = 10^6` and `a = b = 10^8` at `x = 0.5`; these exercise cases that need more than 200 and, for `10^8`, thousands of approximants.

The full SpecialFunctions.jl test suite passes, and the new code is covered by the test suite.

## Reference

> Boik, R. J., & Robinson-Cox, J. F. (1999). Derivatives of the incomplete beta function. *Journal of Statistical Software, 3*(1). https://doi.org/10.18637/jss.v003.i01
@lrnv
lrnv force-pushed the chainrules-for-beta_inc-and-beta_inc_inv branch from 2e3418f to c91356f Compare August 9, 2026 08:33
@lrnv

lrnv commented Aug 9, 2026

Copy link
Copy Markdown
Author

Thank you for another very thorough numerical pass. I addressed the test and implementation issues below, and recorded the useful algorithmic limitations in the code where a change would require a separate numerical study.

The subnormal example was the wrong way round

I said beta_integrand also gets a = 45, b = 1e6, x = 1e-12 right. It is the old log form that is right there, and this is now pinned as a test:

# The x derivative uses the primal's cancellation-resistant beta density.
for (a, b, x) in ((1e6, 1e8, 0.01), (1e8, 1e8, 0.5), (45.0, 1e6, 1e-12))
dx = ext._beta_inc_grad(a, b, x)[3]
expected = SpecialFunctions.beta_integrand(
a, b, x, 1 - x, -log(x) - log1p(-x),
)
@test dx == expected

reference (600 bit) = 3.76556512893e-313
old log form        = 3.76556512893e-313   (exact)
beta_integrand      = 3.76556512898e-313   (about 1.8 ulp)

Both are within two ulp of the subnormal grid, so nothing is broken, but the third point now enshrines the less accurate of the two values. The 1.31e-11 worst case I quoted came from exactly this kind of subnormal point where ulp granularity dominates; over normal-range points the new form is uniformly better than I claimed. The conclusion is unchanged and if anything stronger, the justification was wrong. Could you drop that third point, or compare against a reference rather than against a respelling of the implementation? As it stands the test passes unchanged if beta_integrand regresses.

Fixed. I dropped the subnormal point and replaced the two remaining self-comparisons with hard-coded 600-bit references at the asymmetric and symmetric large-shape points. The new tests fail with the old log formulation and can detect a regression in beta_integrand.

The 2e-9 figure was too optimistic

a = b = 1e5 through 1e8 do not agree to 2e-9. Against a 600 bit reference the errors are 1.74e-10, 9.08e-10, 1.39e-8 and 9.12e-9. My Float64 central differences were the limit, not the rules. This does not change that raising maxapp was the right call, but see the last section for where the remaining error comes from.

Agreed; thank you for correcting the record. I no longer claim the 2e-9 figure. The remaining large-shape error is documented at the prefactor below.

The mixed promotion test does not test the promotion

# All four arguments participate in promotion of the derivative calculation.
_, mixed_delta = frule(
(NoTangent(), 0.0, 0.0, 1.0, -1.0), beta_inc,
1.5f0, 2.25f0, 0.3f0, 1 - Float64(0.3f0),
)
@test mixed_delta[1] isa Float64

The tangents are Float64, so muladd promotes the result regardless of the type the derivative was computed in. Reverting the four-argument @setup to the old three-argument promote leaves this passing:

derivatives_given_output -> Tuple{NTuple{4, Float32}, NTuple{4, Float32}}   # bug is back
mixed_delta[1] isa Float64 -> true                                          # assertion still holds

The fix itself is correct, only the test cannot see it. Asserting on the derivative tuple works:

d = ChainRulesCore.derivatives_given_output(Ω, beta_inc, 1.5f0, 2.25f0, 0.3f0, 1 - Float64(0.3f0))
@test eltype(first(d)) === Float64

Fixed exactly along these lines. The regression test now inspects derivatives_given_output and asserts that the generated derivative tuple is Float64, so reverting to three-argument promotion makes it fail.

The convergence test stops being a relative test below eps

denomI = max(abs(Ixpqn), abs(Ixpq), eps(Float64))
denomp = max(abs(dI_dp), abs(dI_dp_prev), eps(Float64))
denomq = max(abs(dI_dq), abs(dI_dq_prev), eps(Float64))
rI = (Ixpqn - Ixpq) / denomI
rp = (dI_dp - dI_dp_prev) / denomp
rq = (dI_dq - dI_dq_prev) / denomq
if -ϵ < rI < ϵ && -ϵ < rp < ϵ && -ϵ < rq < ϵ

The eps(Float64) floor is there to guard against tiny denominators, but once |∂I/∂a| is well below 2.2e-16 the floor dominates and rp is around 1e-33 whatever the iteration is doing. The test then passes on the first opportunity and the loop exits at n = minapp = 3 with the third approximant. Using the minapp keyword on the shipped function:

(a, b, x) minapp=3 minapp=200 relative difference
(1e8, 1e8, 0.4995) -5.666e-49 -1.050e-48 46%
(1e8, 1e8, 0.49957573) -5.003e-37 -1.518e-36 67%
(3e5, 1e6, 0.2345) -1.44601e-26 -1.44600e-26 4.6e-6

The affected values are all far below anything that matters in a gradient, and I checked that: over 68880 points, restricted to |d| > 1e-10, the worst disagreement between minapp=3 and minapp=40 is 3.4e-12. So there is no practical problem. But this is the case the comment above the function says will not happen, and minapp=10 removes it entirely for a handful of extra iterations. Could you either raise minapp or drop the claim that nonconvergence returns NaNs rather than a plausible but inaccurate gradient?

Fixed. The default is now minapp=10. A regression compares the meaningful (3e5, 1e6, 0.2345) parameter derivatives against minapp=40; it fails with minapp=3. I also rewrote the comments to describe the stopping test as mixed relative/absolute below the eps floor, and to say precisely that reaching the iteration ceiling without satisfying that test returns NaNs, without claiming strict relative convergence for values far below machine epsilon.

The documented ceiling only covers the symmetric case

# loop still exits as soon as all three values converge, while difficult, central,
# nearly symmetric cases can require thousands of approximants. With this limit,
# symmetric cases stop converging around a = b = 1.35e8 (the exact threshold is
# platform- and tolerance-dependent); nonconvergence deliberately returns NaNs.

Asymmetric shapes stop converging at roughly half the symmetric value:

b/a last converging a
1 1.3506e8
2 1.0155e8
10 7.475e7
100 6.872e7
1e4 6.806e7

b = 100a at a = 6.8e7 converges and at a = 7.0e7 does not. One clause in the existing comment covers this.

Documented. The function comment now gives approximately 1.35e8 for symmetric large shapes and 6.8e7 for the smaller parameter of strongly asymmetric large shapes, while retaining the platform/tolerance qualification.

Version

Project.toml is now identical to master, both at 2.8.3, so the bump got lost in the rebase. New rules are a feature, so this wants 2.9.0 before release.

Fixed: the package version is now 2.9.0.

Smaller things

The tiny guard is what sets the ceiling, and it is expensive:

With tiny = 1e-300 the symmetric ceiling moves from 1.35e8 to 6.96e9 and convergence gets faster rather than slower, 2388 approximants instead of 6388 at a = b = 1e8. After the rescaling by s, Bn can legitimately be smaller than sqrt(eps), and the guard then replaces a perfectly good small value with 1.49e-8. Worth a look if anyone wants the ceiling raised later.

I kept sqrt(eps(Float64)) in this PR. Lowering the clamp changes the currently validated domain and interacts with invBn², so it deserves a separate accuracy/overflow study rather than adopting an isolated threshold. I added a maintenance comment at the guard recording that it sets the documented ceiling and that a smaller value is the avenue to investigate.

The remaining error at large shapes is not in the continued fraction, it is in the prefactor:

function _dK_dp(logx::T, p::T, K::T, ψpq::T, ψp::T) where {T}
# ∂K/∂p using digamma identities: d/dp log B(p,q) = ψ(p) - ψ(p+q)
return K * (logx - inv(p) + ψpq - ψp)

The bracket logx - 1/p + ψ(p+q) - ψ(p) cancels at x near a/(a+b). Its own relative error is 6.9e-13 at a = b = 1e3, 2.1e-9 at 1e6 and 1.5e-7 at 1e8, tracking p * eps. That accounts for the plateau above, and it explains why forcing minapp from 10 up to 9000 does not improve those numbers at all. Not something to fix here, but it is the thing to attack if the parameter derivatives ever need to be better for large shapes.

Agreed; I did not change the formula in this PR. I added a comment directly above the bracket documenting the cancellation near x = p/(p+q), its p*eps(Float64) scaling, and that it limits the prefactor accuracy independently of the continued-fraction iteration count.

Strongly imbalanced shapes return NaNs where the primal is fine. _beta_inc_grad(1e-6, 100.0, 1e-7) needs about 29740 approximants; the primal gives (0.99998906, 1.09e-5) without complaint. It is a contiguous band around min(a, b) ≲ 1e-5 with max(a, b) ≳ 1e3, roughly 1% of random log-uniform draws over [1e-7, 1e6]². Both existing test points in that corner happen to converge.

I kept the 10,000 default deliberately. Raising it globally would make genuinely nonconverging calls several times more expensive, and this imbalanced regime is outside the paper's validated range; the explicit NaN is preferable to silently accepting an immature gradient. The function comment now documents (1e-6, 100, 1e-7) as a concrete limitation so callers and future maintainers do not mistake the current ceiling for full primal-domain coverage.

The four-argument beta_inc rule discards the caller's y and recomputes 1 - x. For beta_inc(2.0, 0.5, 1.0, 1e-16), which the primal handles, the rule takes the isone(x) branch and returns infinite and NaN tangents where the derivative is 7.5e7. Low reach, since x has to round to exactly 1, but that is the case the four-argument signature exists for.

Fixed. The differentiated helper now has a four-argument path that carries the caller's y through log(y), beta_integrand(a,b,x,y,...), endpoint handling, and the swapped-tail continued fraction. The three-argument path delegates with y = 1 - x. A regression at exactly (2.0, 0.5, 1.0, 1e-16) checks that the constrained derivative is finite and agrees with the stable beta density.

On the tightened tolerance: 1e-4 is fine but the margin is thinner than it looks. Over 200 fresh RNG seeds test_rrule(beta_inc, ...) fails 11 times at 1e-4 against once at 5e-4, and test_rrule(beta_inc_inv, ...) already fails 30 times at 5e-4. The current pass is reproducible because testsets restore the RNG, so this is not urgent, but it will not survive a change to the Julia RNG stream or to how ChainRulesTestUtils draws tangents.

Fixed without relying on a favorable random draw. All four Float32 checks now use explicit, moderate input tangents, and both reverse-rule checks use an explicit non-symmetric output cotangent. They therefore no longer depend on Julia's RNG stream or on future changes to ChainRulesTestUtils.rand_tangent. With those fixed directions, the beta_inc frule differs from its Float32 finite-difference reference by about 1.9e-4, so all four checks now use 5e-4 with a documented reason and reproducible margin.

Finally, the licence note names IncBetaDer.jl and links the permission, but does not reproduce the upstream copyright line. The precedent in this repo is src/logabsgamma/e_lgamma_r.jl, which includes the notice verbatim.

Fixed. The source now includes the upstream line Copyright (c) 2025 Arthur Zwaenepoel. I also corrected the repository URL from the nonexistent arzwa/IncBetaDer.jl path to arzwa/IncBetaDer.

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.

7 participants