Source code for pyhs3.normalization
"""
Normalization utilities for HS3 distributions.
Provides Gauss-Legendre quadrature for computing normalization integrals symbolically.
"""
from __future__ import annotations
from typing import cast
import numpy as np
import pytensor.tensor as pt
from pytensor.graph.replace import graph_replace
from pyhs3.typing.aliases import TensorVar
# Precompute 64-point Gauss-Legendre nodes and weights (fixed, deterministic)
_GL_ORDER = 64
_GL_NODES, _GL_WEIGHTS = np.polynomial.legendre.leggauss(_GL_ORDER)
_GL_NODES_T = pt.constant(_GL_NODES)
_GL_WEIGHTS_T = pt.constant(_GL_WEIGHTS)
[docs]
def gauss_legendre_integral(
expression: TensorVar,
variable: TensorVar, # note: variable must be the 1-D leaf tensor, not an ExpandDims view
lower: TensorVar,
upper: TensorVar,
) -> TensorVar:
r"""
Compute a definite integral symbolically via Gauss-Legendre quadrature.
Gauss-Legendre quadrature approximates the integral over :math:`[a, b]` by
evaluating the integrand at specific nodes and summing with weights. This
implementation uses 64-point quadrature, which integrates polynomials up to
degree 127 exactly.
The standard Gauss-Legendre formula is defined on :math:`[-1, 1]`:
.. math::
\int_{-1}^{1} f(t)\,dt \approx \sum_{i=1}^{N} w_i\,f(t_i)
To integrate over an arbitrary interval :math:`[a, b]`, we apply a linear
change of variables:
.. math::
x = \frac{b-a}{2}t + \frac{a+b}{2}, \quad t \in [-1, 1], \quad x \in [a, b]
The Jacobian :math:`dx = \frac{b-a}{2}dt` (the "half width") transforms the
integral to:
.. math::
\int_a^b f(x)\,dx = \frac{b-a}{2} \int_{-1}^{1} f\left(\frac{b-a}{2}t + \frac{a+b}{2}\right) dt
Applying Gauss-Legendre quadrature yields:
.. math::
\int_a^b f(x)\,dx \approx \frac{b-a}{2} \sum_{i=1}^{N} w_i\,f\left(\frac{b-a}{2}t_i + \frac{a+b}{2}\right)
where :math:`t_i` are the standard Legendre nodes and :math:`w_i` are the
standard weights on :math:`[-1, 1]`.
See Also:
https://en.wikipedia.org/wiki/Gauss%E2%80%93Legendre_quadrature
Args:
expression: The PyTensor expression to integrate
variable: The 1-D leaf tensor to integrate over (not an ExpandDims view).
Passing the leaf means graph_replace propagates the substitution through
any ExpandDims(variable) views present in the expression.
lower: Lower bound :math:`a` (PyTensor expression)
upper: Upper bound :math:`b` (PyTensor expression)
Returns:
Symbolic integral as a PyTensor expression
"""
half_width = (upper - lower) / 2.0
midpoint = (upper + lower) / 2.0
# Quadrature nodes mapped to [lower, upper]: shape (64,)
x_points = half_width * _GL_NODES_T + midpoint
# Substitute the leaf with the (64,) quadrature points. Any ExpandDims(variable)
# views inside expression inherit the substitution and become (64, 1), so f_vals
# may be (64,) or (64, ...). Use strict=False so that constant integrands
# (not depending on variable at all) are returned unchanged rather than raising.
f_vals = cast(
TensorVar,
graph_replace(expression, replace=[(variable, x_points)], strict=False),
)
weights = _GL_WEIGHTS_T.reshape((-1,) + (1,) * (f_vals.ndim - 1)) # type: ignore[no-untyped-call]
integral = half_width * pt.sum(weights * f_vals, axis=0) # type: ignore[no-untyped-call]
return cast(TensorVar, integral)