quadax
quadax is a library for numerical quadrature and integration using JAX.
vmap-able,jit-able, differentiable in both forward and reverse mode.Pluggable
adjointmethods controlling how derivatives are computed, including with respect to the limits of integration and to moving discontinuities.Scalar, vector, or matrix valued integrands, real or complex.
Finite or infinite domains, with discontinuities or singularities inside the domain of integration.
Globally adaptive Gauss-Kronrod and Clenshaw-Curtis quadrature for general integrands (similar to
scipy.integrate.quad), with rules of arbitrary order.tanh-sinh and Romberg quadrature using uniform refinement.
Globally adaptive cubature in several dimensions, over boxes or over regions whose limits depend on the other coordinates (similar to
scipy.integrate.nquad).Quadrature from sampled values using trapezoidal and Simpson’s rules.
Every routine reports an error estimate, the number of evaluations, and a status code saying why it stopped.
Full documentation, including a user guide and API reference, is at quadax.readthedocs.io.
Coming soon:
QMC methods
Integration with weight functions
Sparse grids (maybe, need to play with data structures and JAX)
Installation
quadax is installable with pip:
pip install quadax
Usage
JAX uses 32 bit floats by default. The examples below turn on 64 bit precision to show what the methods can reach:
import jax
jax.config.update("jax_enable_x64", True)
import jax.numpy as jnp
Basic integration
Every routine returns the integral along with an info tuple holding an error
estimate, the number of integrand evaluations, and a status code.
from quadax import quadgk
y, info = quadgk(lambda t: t * jnp.log(1 + t), [0, 1], epsabs=1e-12, epsrel=1e-12)
# y = 0.25, info.err ~ 3e-15, info.neval = 21, info.status = 0 (converged)
Infinite intervals and breakpoints
Use jnp.inf for an unbounded limit. If the integrand has a jump, kink, or
singularity at a known interior point, pass that point in interval:
y, info = quadgk(lambda x: jnp.exp(-x**2), [-jnp.inf, jnp.inf]) # sqrt(pi)
f = lambda x: jnp.where(x < 1, x**2, jnp.cos(x))
y, info = quadgk(f, [0, 1, 3]) # 42 evaluations; [0, 3] takes 399
Vector valued integrands and extra arguments
An integrand can return an array of any shape, and gets extra arguments through
args. The whole array is integrated over one shared adaptive mesh:
k = jnp.arange(1, 6)
y, info = quadgk(lambda x, k: jnp.exp(-x) * jnp.cos(k * x), [0, jnp.inf], args=(k,))
# y = 1 / (1 + k**2)
Composing with jit and vmap
All routines work under JAX transformations, so you can batch over parameters or limits and compile the whole thing:
@jax.jit
@jax.vmap
def laplace_sin(s):
y, _ = quadgk(lambda t: jnp.exp(-s * t) * jnp.sin(t), [0, jnp.inf])
return y
laplace_sin(jnp.linspace(0.5, 3, 4)) # 1 / (1 + s**2)
Differentiation
Integrals are differentiable with respect to parameters and limits, in forward and reverse mode:
from quadax import LeibnizAdjoint
def Gamma(s):
integrand = lambda t, s: t ** (s - 1) * jnp.exp(-t)
return quadgk(integrand, [0, jnp.inf], args=(s,))[0]
jax.grad(Gamma)(3.5) # Gamma(3.5) * digamma(3.5)
jax.grad(lambda b: quadgk(jnp.sin, [0, b])[0])(1.2) # sin(1.2)
The adjoint argument controls how the derivative is computed. The default,
DirectAdjoint, differentiates the mesh the solve converged on, while
LeibnizAdjoint runs a second adaptive solve with its own error control:
def Gamma(s):
integrand = lambda t, s: t ** (s - 1) * jnp.exp(-t)
return quadgk(integrand, [0, jnp.inf], args=(s,), adjoint=LeibnizAdjoint())[0]
If a discontinuity moves with a parameter, mark it with a breakpoint that depends on that same parameter, and the derivative includes the contribution from the jump:
def area(c):
f = lambda x, c: jnp.where(x < c, jnp.exp(x), 0.0)
return quadgk(f, [0, c, 2], args=(c,))[0]
jax.grad(area)(1.0) # e; integrating over [0, 2] with no breakpoint gives 0
Multidimensional integrals
cubegm does globally adaptive cubature with Genz-Malik rules. The region can be a
box, or each axis’ limits can be a function of the coordinates before it:
from quadax import cubegm
# Gaussian over the cube [-1, 1]^3
y, info = cubegm(lambda x: jnp.exp(-x @ x), [[-1, 1]] * 3)
# Same integrand over the unit disk: the y limits depend on x
disk = [[-1, 1], lambda x: jnp.sqrt(1 - x[0] ** 2) * jnp.array([-1, 1])]
y, info = cubegm(lambda x: jnp.exp(-x @ x), disk) # pi * (1 - 1/e)
Sampled data
When the integrand is only known at a set of points:
from quadax import simpson, cumulative_trapezoid
x = jnp.linspace(0, jnp.pi, 101)
simpson(jnp.sin(x), x=x) # 2
cumulative_trapezoid(jnp.sin(x), x=x, initial=0) # running integral
Checking the result
A run that stops before reaching the tolerance still returns a value, and
info.status says why it stopped. quadax.STATUS turns the code into a message,
or pass throw=True to raise an error instead:
from quadax import STATUS
y, info = quadgk(lambda x: 1 / jnp.sqrt(jnp.abs(jnp.sin(50 * x))), [0, 10], max_ninter=10)
if info.status != STATUS.normal:
print(STATUS[info.status]) # explains the failure and what to try next
Which method?
Situation |
Start with |
|---|---|
Anything, if you’re not sure |
|
Infinite interval where |
|
Smooth, cheap integrand where wall time matters |
|
Highly oscillatory, smooth integrand |
|
Integrals in several dimensions |
|
Only sampled values |
|
The user guide has more on choosing a method, precision, differentiation, performance, and diagnosing failures.