Skip to content

add integrate_1d_gauss_kronrod and integrate_1d_double_exponential#3326

Open
avehtari wants to merge 5 commits into
developfrom
integrate-1d-gauss-kronrod
Open

add integrate_1d_gauss_kronrod and integrate_1d_double_exponential#3326
avehtari wants to merge 5 commits into
developfrom
integrate-1d-gauss-kronrod

Conversation

@avehtari
Copy link
Copy Markdown
Member

@avehtari avehtari commented May 20, 2026

Closes #3000.

Summary

Adds two new explicit-name 1-D quadrature functions, both wrapping
existing Boost quadrature routines (no new vendored code; Boost 1.87.0
already ships them). They cover disjoint integrand weaknesses and live
side by side with the existing integrate_1d:

Function Backend Strength Weakness
integrate_1d (existing) tanh_sinh / exp_sinh / sinh_sinh (DE) endpoint singularities off-centre peaks, no abs_tol
integrate_1d_double_exponential (new) same as integrate_1d endpoint singularities off-centre peaks
integrate_1d_gauss_kronrod (new) Boost gauss_kronrod<double, 21> smooth integrands, off-centre peaks endpoint singularities

integrate_1d_double_exponential is a strict superset of
integrate_1d: same DE dispatch, same xc semantics, plus two new
optional arguments (absolute_tolerance, max_refinements).

integrate_1d_gauss_kronrod is a new function with the same
Stan-facing API shape but adaptive Gauss-Kronrod backend (K21, fixed
order), optional arguments (absolute_tolerance, max_depth).

My use case is integrals of functions which are product of normal
density and a smooth function. Due to the light tails of the normal
density this functions goes to zero in tails and Gauss-Kronrod excels
compare to double exponential. I really needed this for some
experiments I'm running, and abs_tol was also required to get rid of
numerical problems.

Why add these alongside integrate_1d?

  • Off-centre peaks. DE concentrates nodes near the integration
    endpoints; integrands with near-zero endpoints and a sharp peak
    in the middle get undersampled. Gauss-Kronrod distributes nodes across the whole
    interval by Legendre weights and picks up such peaks directly.
  • QUADPACK-style mixed convergence. Both new functions accept
    an explicit absolute_tolerance argument; the convergence test
    becomes error <= max(rel_tol * L1, abs_tol). With
    absolute_tolerance = 0 (default) this reduces to the strict
    pure-relative test of integrate_1d. With abs_tol > 0 the
    user can escape the pathological regime in which the strict
    test is measuring accumulated floating-point round-off against
    itself (e.g. failure mode of nested
    integrate_1d_* in the deep tail of a Gaussian factor).
  • Explicit max-iteration knob. Both new functions expose the
    per-class refinement / bisection cap (max_refinements for DE,
    max_depth for GK) as an optional argument. integrate_1d
    currently uses Boost's per-class defaults implicitly with no way
    to override.

Public API

Both functions expose four overloads in Stan-language, all dispatching
to the same C++ implementation in the relevant autodiff layer:

integrate_1d_gauss_kronrod(f, a, b, theta, x_r, x_i);
integrate_1d_gauss_kronrod(f, a, b, theta, x_r, x_i, rel_tol);
integrate_1d_gauss_kronrod(f, a, b, theta, x_r, x_i, rel_tol, abs_tol);
integrate_1d_gauss_kronrod(f, a, b, theta, x_r, x_i, rel_tol, abs_tol, max_depth);

integrate_1d_double_exponential(f, a, b, theta, x_r, x_i);
integrate_1d_double_exponential(f, a, b, theta, x_r, x_i, rel_tol);
integrate_1d_double_exponential(f, a, b, theta, x_r, x_i, rel_tol, abs_tol);
integrate_1d_double_exponential(f, a, b, theta, x_r, x_i, rel_tol, abs_tol, max_refinements);

C++ user-facing form for both:

double integrate_1d_{gauss_kronrod|double_exponential}(
    const F& f, double a, double b,
    const std::vector<double>& theta,
    const std::vector<double>& x_r,
    const std::vector<int>& x_i,
    std::ostream* msgs,
    double relative_tolerance = std::sqrt(EPSILON),
    double absolute_tolerance = 0.0,
    int max_depth_or_refinements = 15);

The user-supplied integrand functor signature is unchanged from
integrate_1d. xc is meaningful under DE; under GK it is always
passed as NaN (Boost's gauss_kronrod has no distance-to-boundary
concept).

Design notes

Gauss-Kronrod

  • K21 default Kronrod order. Matches scipy/MATLAB/QUADPACK
    conventions. Polynomial exactness of the K rule at N=21 is
    degree 31, comfortable for Gaussian-times-likelihood shapes
    that dominate Stan use. Higher N would tighten initial node
    spacing on sharply peaked integrands at 1.5x-3x constant cost
    on smooth integrals. The two robustness tools we already have
    (absolute_tolerance and informed bound choice) cover the
    failure modes we have encountered; exposing N is a sensible
    future extension but deliberately out of scope here.
  • absolute_tolerance argument. Boost's adaptive
    Gauss-Kronrod accumulates error += error_local across
    bisection leaves, each carrying a 2 * eps * |K_local| floor.
    Accumulated round-off scales as ~2^max_depth * eps * L1. For
    integrals whose L1 falls below ~1e-8, the strict
    pure-relative test measures noise against itself; QUADPACK-style
    mixed convergence is the established fix.
  • Deliberate weakness on endpoint singularities. Documented
    in tests (a endpoint_singularity_throws test asserts that GK
    throws on 1/sqrt(x) and beta-with-small-shapes integrands).
    Users with such integrands keep using DE.

Double-exponential

  • absolute_tolerance applies per piece of the zero-crossing
    split.
    The existing integrate_1d worker splits integrals
    that cross zero into two pieces (per Boost's exp_sinh docs); the
    new convergence test applies independently to each piece. This
    is the simplest interpretation of "abs_tol is the absolute error
    floor we accept" and the right behaviour when one piece of the
    split has near-zero L1.
  • max_refinements default 15. Matches Boost's tanh_sinh
    default and integrate_1d_gauss_kronrod's max_depth for
    symmetry. Boost's exp_sinh/sinh_sinh defaults are 9; the
    unified default of 15 here is mildly more conservative on
    infinite-interval cases.
  • xc semantics preserved. DE computes a meaningful xc
    (distance to nearest boundary) and passes it to the user
    functor exactly as integrate_1d does. User code that
    exploits xc carries over unchanged.

Practical guidance

Integrand class Recommended
Smooth on bounded interval either (GK marginally faster)
Smooth on ±∞ tail either
Algebraic/log endpoint singularity integrate_1d_double_exponential
Gradient with endpoint log-singularity (Beta/Gamma/Weibull lpdf gradients) integrate_1d_double_exponential
Off-centre peak with near-zero endpoints integrate_1d_gauss_kronrod
Nested integration with deep-tail evaluations either, with abs_tol > 0
Modest oscillation either

Implementation layout

stan/math/prim/functor/integrate_1d_gauss_kronrod.hpp
stan/math/prim/functor/integrate_1d_double_exponential.hpp
stan/math/rev/functor/integrate_1d_gauss_kronrod.hpp
stan/math/rev/functor/integrate_1d_double_exponential.hpp
stan/math/fwd/functor/integrate_1d_gauss_kronrod.hpp
stan/math/fwd/functor/integrate_1d_double_exponential.hpp
stan/math/{prim,rev,fwd}/functor.hpp                # one #include each, two new
test/unit/math/{prim,rev,mix}/functor/              # 71 new tests total, all passing

The user-facing functor signature is adapted through the existing
integrate_1d_adapter (one adapter, three callers).

stanc3 changes will be in a companion PR:

Testing

  • 71 new unit tests across prim (15), rev (54), and mix (2),
    all passing. Tests for the new DE function are 1:1 clones of
    integrate_1d's tests with the function name renamed (the new
    function is a strict superset at abs_tol = 0); tests for GK
    are adapted from the same baseline with the endpoint-singular
    cases marked as expected-throw and the gradient-endpoint-
    log-singular PDFs (Beta, ChiSquare, Gamma, Weibull) omitted.
  • End-to-end cmdstan smoke tests for both functions exercise
    all four overloads on integrals with known closed forms. The
    GK model uses mu as a parameters-block variable to
    exercise the rev autodiff specialisation across 200 MCMC
    draws (returns exactly 1 every time).
  • Real-world stress test for integrate_1d_gauss_kronrod: the
    nested random-effects marginal likelihood from a Student-t hierarchical
    linear model (144 observations, 4 chains x 100 draws) produces ELPD
    estimates that agree with independent bridge sampling to
    0.05 nats per fold at the 99th percentile. The existing integrate_1d was
    silently returning zeros.

Future work

  • Variadic function arguments
  • Maybe expose the Kronrod order N as an optional parameter to
    integrate_1d_gauss_kronrod (a switch over the six valid
    Boost-supported N values). Deliberately out of scope here.

Companion PRs

Will be made after this PR has been approved

  • stanc3: signatures + codegen pattern extension (small,
    self-contained).
  • stan-dev/docs: function reference entries for both new
    functions (deferred until this PR is merged).

Checklist

  • Copyright holder: (fill in copyright holder information)

Stan Development Team. The code is duplication of existing integrate_1d code and there is no additional creativity.

The copyright holder is typically you or your assignee, such as a university or company. By submitting this pull request, the copyright holder is agreeing to the license the submitted work under the following licenses:
  - Code: BSD 3-clause (https://opensource.org/licenses/BSD-3-Clause)
  - Documentation: CC-BY 4.0 (https://creativecommons.org/licenses/by/4.0/)
  • the basic tests are passing

    • unit tests pass (to run, use: ./runTests.py test/unit)
    • header checks pass, (make test-headers)
    • dependencies checks pass, (make test-math-dependencies)
    • docs build, (make doxygen)
    • code passes the built in C++ standards checks (make cpplint)
  • the code is written in idiomatic C++ and changes are documented in the doxygen

  • the new changes are tested

AI Use Disclosure

The duplication of the existing integrate_1d code and tests was made using Claude AI Agent. The actual quadrature algorithm is in Boost library, and the code is just a wrapper to call Boost.

@WardBrian
Copy link
Copy Markdown
Member

Would it make sense to add a suffixed alias of the existing integrate_1d as part of this? I think we have done similar in the past when adding e.g. a second kind of algebra solver

@avehtari
Copy link
Copy Markdown
Member Author

If we would add integrate_1d_double_exponential I would then also add abs_tol argument and it would not be just an alias.

@WardBrian
Copy link
Copy Markdown
Member

That would also be good, then.

If I recall correctly these functions are also already ready to be variadic at the math level but just aren’t in the language, should we make these new versions variadic from the start?

@avehtari
Copy link
Copy Markdown
Member Author

I' m confident I can get Claude to create integrate_1d_double_exponential with added abs_tol argument, but I'm not confident I could trust it it to do the change to variadic arguments reliably. variadic arguments would be nice as there is a lot of packing and unpacking going on now, like this

    int K = x_i[1];
    real nu     = theta[1];
    real sigma  = theta[2];
    real sd1_1  = theta[3];
    real sd1_2  = theta[4];
    real rho    = theta[5];
    real z1     = theta[6 + 3 * K];

@WardBrian
Copy link
Copy Markdown
Member

In the existing code (and this PR, looking over it briefly) the _impl function already is variadic. So we would just rename that to drop the _impl, remove the adaptor struct wrapping F, and we should be good to go.

I can help with this (and the stanc stuff, since it gets slightly more complicated to add a variadic), but I think it’s worth doing if we’re introducing new names anyway

@avehtari
Copy link
Copy Markdown
Member Author

I can add integrate_1d_double_exponential with the additional arguments, and the test also if adding variadic arguments is easy. Should include them both to this PR?

@avehtari
Copy link
Copy Markdown
Member Author

Ah, didn't see your latest comment. I can add integrate_1d_double_exponential with additional arguments and then you can add variadic

@WardBrian
Copy link
Copy Markdown
Member

Sounds good. As long as claude follows the lead on the existing code, the extra steps to make it variadic should be very easy. The main reason it hasn’t been so far is that we need to come up with a new name for that version, which you’re already doing!

@avehtari avehtari changed the title add integrate_1d_gauss_kronrod add integrate_1d_gauss_kronrod and integrate_1d_double_exponential May 20, 2026
@avehtari
Copy link
Copy Markdown
Member Author

Ok, added integrate_1d_double_exponential

@WardBrian
Copy link
Copy Markdown
Member

Ok. I will try to review tomorrow, but if not it will take a few days due to the long holiday weekend in the US.

If changes are necessary for the signatures to be variadic in Stanc, would you like me to comment on the diff or just go ahead and make them?

@avehtari
Copy link
Copy Markdown
Member Author

Just go ahead and make them so you can test right away

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.

Add integrate_1d_gauss_kronord

3 participants