| import math |
| import heapq |
| import itertools |
|
|
| from dataclasses import dataclass, field |
| from types import ModuleType |
| from typing import Any, TypeAlias |
|
|
| from scipy._lib._array_api import ( |
| array_namespace, |
| xp_size, |
| xp_copy, |
| xp_broadcast_promote |
| ) |
| from scipy._lib._util import MapWrapper |
|
|
| from scipy.integrate._rules import ( |
| ProductNestedFixed, |
| GaussKronrodQuadrature, |
| GenzMalikCubature, |
| ) |
| from scipy.integrate._rules._base import _split_subregion |
|
|
| __all__ = ['cubature'] |
|
|
| Array: TypeAlias = Any |
|
|
|
|
| @dataclass |
| class CubatureRegion: |
| estimate: Array |
| error: Array |
| a: Array |
| b: Array |
| _xp: ModuleType = field(repr=False) |
|
|
| def __lt__(self, other): |
| |
| |
| |
|
|
| this_err = self._xp.max(self._xp.abs(self.error)) |
| other_err = self._xp.max(self._xp.abs(other.error)) |
|
|
| return this_err > other_err |
|
|
|
|
| @dataclass |
| class CubatureResult: |
| estimate: Array |
| error: Array |
| status: str |
| regions: list[CubatureRegion] |
| subdivisions: int |
| atol: float |
| rtol: float |
|
|
|
|
| def cubature(f, a, b, *, rule="gk21", rtol=1e-8, atol=0, max_subdivisions=10000, |
| args=(), workers=1, points=None): |
| r""" |
| Adaptive cubature of multidimensional array-valued function. |
| |
| Given an arbitrary integration rule, this function returns an estimate of the |
| integral to the requested tolerance over the region defined by the arrays `a` and |
| `b` specifying the corners of a hypercube. |
| |
| Convergence is not guaranteed for all integrals. |
| |
| Parameters |
| ---------- |
| f : callable |
| Function to integrate. `f` must have the signature:: |
| |
| f(x : ndarray, *args) -> ndarray |
| |
| `f` should accept arrays ``x`` of shape:: |
| |
| (npoints, ndim) |
| |
| and output arrays of shape:: |
| |
| (npoints, output_dim_1, ..., output_dim_n) |
| |
| In this case, `cubature` will return arrays of shape:: |
| |
| (output_dim_1, ..., output_dim_n) |
| a, b : array_like |
| Lower and upper limits of integration as 1D arrays specifying the left and right |
| endpoints of the intervals being integrated over. Limits can be infinite. |
| rule : str, optional |
| Rule used to estimate the integral. If passing a string, the options are |
| "gauss-kronrod" (21 node), or "genz-malik" (degree 7). If a rule like |
| "gauss-kronrod" is specified for an ``n``-dim integrand, the corresponding |
| Cartesian product rule is used. "gk21", "gk15" are also supported for |
| compatibility with `quad_vec`. See Notes. |
| rtol, atol : float, optional |
| Relative and absolute tolerances. Iterations are performed until the error is |
| estimated to be less than ``atol + rtol * abs(est)``. Here `rtol` controls |
| relative accuracy (number of correct digits), while `atol` controls absolute |
| accuracy (number of correct decimal places). To achieve the desired `rtol`, set |
| `atol` to be smaller than the smallest value that can be expected from |
| ``rtol * abs(y)`` so that `rtol` dominates the allowable error. If `atol` is |
| larger than ``rtol * abs(y)`` the number of correct digits is not guaranteed. |
| Conversely, to achieve the desired `atol`, set `rtol` such that |
| ``rtol * abs(y)`` is always smaller than `atol`. Default values are 1e-8 for |
| `rtol` and 0 for `atol`. |
| max_subdivisions : int, optional |
| Upper bound on the number of subdivisions to perform. Default is 10,000. |
| args : tuple, optional |
| Additional positional args passed to `f`, if any. |
| workers : int or map-like callable, optional |
| If `workers` is an integer, part of the computation is done in parallel |
| subdivided to this many tasks (using :class:`python:multiprocessing.pool.Pool`). |
| Supply `-1` to use all cores available to the Process. Alternatively, supply a |
| map-like callable, such as :meth:`python:multiprocessing.pool.Pool.map` for |
| evaluating the population in parallel. This evaluation is carried out as |
| ``workers(func, iterable)``. |
| points : list of array_like, optional |
| List of points to avoid evaluating `f` at, under the condition that the rule |
| being used does not evaluate `f` on the boundary of a region (which is the |
| case for all Genz-Malik and Gauss-Kronrod rules). This can be useful if `f` has |
| a singularity at the specified point. This should be a list of array-likes where |
| each element has length ``ndim``. Default is empty. See Examples. |
| |
| Returns |
| ------- |
| res : object |
| Object containing the results of the estimation. It has the following |
| attributes: |
| |
| estimate : ndarray |
| Estimate of the value of the integral over the overall region specified. |
| error : ndarray |
| Estimate of the error of the approximation over the overall region |
| specified. |
| status : str |
| Whether the estimation was successful. Can be either: "converged", |
| "not_converged". |
| subdivisions : int |
| Number of subdivisions performed. |
| atol, rtol : float |
| Requested tolerances for the approximation. |
| regions: list of object |
| List of objects containing the estimates of the integral over smaller |
| regions of the domain. |
| |
| Each object in ``regions`` has the following attributes: |
| |
| a, b : ndarray |
| Points describing the corners of the region. If the original integral |
| contained infinite limits or was over a region described by `region`, |
| then `a` and `b` are in the transformed coordinates. |
| estimate : ndarray |
| Estimate of the value of the integral over this region. |
| error : ndarray |
| Estimate of the error of the approximation over this region. |
| |
| Notes |
| ----- |
| The algorithm uses a similar algorithm to `quad_vec`, which itself is based on the |
| implementation of QUADPACK's DQAG* algorithms, implementing global error control and |
| adaptive subdivision. |
| |
| The source of the nodes and weights used for Gauss-Kronrod quadrature can be found |
| in [1]_, and the algorithm for calculating the nodes and weights in Genz-Malik |
| cubature can be found in [2]_. |
| |
| The rules currently supported via the `rule` argument are: |
| |
| - ``"gauss-kronrod"``, 21-node Gauss-Kronrod |
| - ``"genz-malik"``, n-node Genz-Malik |
| |
| If using Gauss-Kronrod for an ``n``-dim integrand where ``n > 2``, then the |
| corresponding Cartesian product rule will be found by taking the Cartesian product |
| of the nodes in the 1D case. This means that the number of nodes scales |
| exponentially as ``21^n`` in the Gauss-Kronrod case, which may be problematic in a |
| moderate number of dimensions. |
| |
| Genz-Malik is typically less accurate than Gauss-Kronrod but has much fewer nodes, |
| so in this situation using "genz-malik" might be preferable. |
| |
| Infinite limits are handled with an appropriate variable transformation. Assuming |
| ``a = [a_1, ..., a_n]`` and ``b = [b_1, ..., b_n]``: |
| |
| If :math:`a_i = -\infty` and :math:`b_i = \infty`, the i-th integration variable |
| will use the transformation :math:`x = \frac{1-|t|}{t}` and :math:`t \in (-1, 1)`. |
| |
| If :math:`a_i \ne \pm\infty` and :math:`b_i = \infty`, the i-th integration variable |
| will use the transformation :math:`x = a_i + \frac{1-t}{t}` and |
| :math:`t \in (0, 1)`. |
| |
| If :math:`a_i = -\infty` and :math:`b_i \ne \pm\infty`, the i-th integration |
| variable will use the transformation :math:`x = b_i - \frac{1-t}{t}` and |
| :math:`t \in (0, 1)`. |
| |
| References |
| ---------- |
| .. [1] R. Piessens, E. de Doncker, Quadpack: A Subroutine Package for Automatic |
| Integration, files: dqk21.f, dqk15.f (1983). |
| |
| .. [2] A.C. Genz, A.A. Malik, Remarks on algorithm 006: An adaptive algorithm for |
| numerical integration over an N-dimensional rectangular region, Journal of |
| Computational and Applied Mathematics, Volume 6, Issue 4, 1980, Pages 295-302, |
| ISSN 0377-0427 |
| :doi:`10.1016/0771-050X(80)90039-X` |
| |
| Examples |
| -------- |
| **1D integral with vector output**: |
| |
| .. math:: |
| |
| \int^1_0 \mathbf f(x) \text dx |
| |
| Where ``f(x) = x^n`` and ``n = np.arange(10)`` is a vector. Since no rule is |
| specified, the default "gk21" is used, which corresponds to Gauss-Kronrod |
| integration with 21 nodes. |
| |
| >>> import numpy as np |
| >>> from scipy.integrate import cubature |
| >>> def f(x, n): |
| ... # Make sure x and n are broadcastable |
| ... return x[:, np.newaxis]**n[np.newaxis, :] |
| >>> res = cubature( |
| ... f, |
| ... a=[0], |
| ... b=[1], |
| ... args=(np.arange(10),), |
| ... ) |
| >>> res.estimate |
| array([1. , 0.5 , 0.33333333, 0.25 , 0.2 , |
| 0.16666667, 0.14285714, 0.125 , 0.11111111, 0.1 ]) |
| |
| **7D integral with arbitrary-shaped array output**:: |
| |
| f(x) = cos(2*pi*r + alphas @ x) |
| |
| for some ``r`` and ``alphas``, and the integral is performed over the unit |
| hybercube, :math:`[0, 1]^7`. Since the integral is in a moderate number of |
| dimensions, "genz-malik" is used rather than the default "gauss-kronrod" to |
| avoid constructing a product rule with :math:`21^7 \approx 2 \times 10^9` nodes. |
| |
| >>> import numpy as np |
| >>> from scipy.integrate import cubature |
| >>> def f(x, r, alphas): |
| ... # f(x) = cos(2*pi*r + alphas @ x) |
| ... # Need to allow r and alphas to be arbitrary shape |
| ... npoints, ndim = x.shape[0], x.shape[-1] |
| ... alphas = alphas[np.newaxis, ...] |
| ... x = x.reshape(npoints, *([1]*(len(alphas.shape) - 1)), ndim) |
| ... return np.cos(2*np.pi*r + np.sum(alphas * x, axis=-1)) |
| >>> rng = np.random.default_rng() |
| >>> r, alphas = rng.random((2, 3)), rng.random((2, 3, 7)) |
| >>> res = cubature( |
| ... f=f, |
| ... a=np.array([0, 0, 0, 0, 0, 0, 0]), |
| ... b=np.array([1, 1, 1, 1, 1, 1, 1]), |
| ... rtol=1e-5, |
| ... rule="genz-malik", |
| ... args=(r, alphas), |
| ... ) |
| >>> res.estimate |
| array([[-0.79812452, 0.35246913, -0.52273628], |
| [ 0.88392779, 0.59139899, 0.41895111]]) |
| |
| **Parallel computation with** `workers`: |
| |
| >>> from concurrent.futures import ThreadPoolExecutor |
| >>> with ThreadPoolExecutor() as executor: |
| ... res = cubature( |
| ... f=f, |
| ... a=np.array([0, 0, 0, 0, 0, 0, 0]), |
| ... b=np.array([1, 1, 1, 1, 1, 1, 1]), |
| ... rtol=1e-5, |
| ... rule="genz-malik", |
| ... args=(r, alphas), |
| ... workers=executor.map, |
| ... ) |
| >>> res.estimate |
| array([[-0.79812452, 0.35246913, -0.52273628], |
| [ 0.88392779, 0.59139899, 0.41895111]]) |
| |
| **2D integral with infinite limits**: |
| |
| .. math:: |
| |
| \int^{ \infty }_{ -\infty } |
| \int^{ \infty }_{ -\infty } |
| e^{-x^2-y^2} |
| \text dy |
| \text dx |
| |
| >>> def gaussian(x): |
| ... return np.exp(-np.sum(x**2, axis=-1)) |
| >>> res = cubature(gaussian, [-np.inf, -np.inf], [np.inf, np.inf]) |
| >>> res.estimate |
| 3.1415926 |
| |
| **1D integral with singularities avoided using** `points`: |
| |
| .. math:: |
| |
| \int^{ 1 }_{ -1 } |
| \frac{\sin(x)}{x} |
| \text dx |
| |
| It is necessary to use the `points` parameter to avoid evaluating `f` at the origin. |
| |
| >>> def sinc(x): |
| ... return np.sin(x)/x |
| >>> res = cubature(sinc, [-1], [1], points=[[0]]) |
| >>> res.estimate |
| 1.8921661 |
| """ |
|
|
| |
| |
|
|
| xp = array_namespace(a, b) |
| max_subdivisions = float("inf") if max_subdivisions is None else max_subdivisions |
| points = [] if points is None else points |
|
|
| |
| |
| a, b, *points = xp_broadcast_promote(a, b, *points, force_floating=True) |
| result_dtype = a.dtype |
|
|
| if xp_size(a) == 0 or xp_size(b) == 0: |
| raise ValueError("`a` and `b` must be nonempty") |
|
|
| if a.ndim != 1 or b.ndim != 1: |
| raise ValueError("`a` and `b` must be 1D arrays") |
|
|
| |
| if isinstance(rule, str): |
| ndim = xp_size(a) |
|
|
| if rule == "genz-malik": |
| rule = GenzMalikCubature(ndim, xp=xp) |
| else: |
| quadratues = { |
| "gauss-kronrod": GaussKronrodQuadrature(21, xp=xp), |
|
|
| |
| "gk21": GaussKronrodQuadrature(21, xp=xp), |
| "gk15": GaussKronrodQuadrature(15, xp=xp), |
| } |
|
|
| base_rule = quadratues.get(rule) |
|
|
| if base_rule is None: |
| raise ValueError(f"unknown rule {rule}") |
|
|
| rule = ProductNestedFixed([base_rule] * ndim) |
|
|
| |
| |
| sign = (-1) ** xp.sum(xp.astype(a > b, xp.int8), dtype=result_dtype) |
|
|
| a_flipped = xp.min(xp.stack([a, b]), axis=0) |
| b_flipped = xp.max(xp.stack([a, b]), axis=0) |
|
|
| a, b = a_flipped, b_flipped |
|
|
| |
| if xp.any(xp.isinf(a)) or xp.any(xp.isinf(b)): |
| f = _InfiniteLimitsTransform(f, a, b, xp=xp) |
| a, b = f.transformed_limits |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| points = [xp.reshape(point, (1, -1)) for point in points] |
| points = [f.inv(point) for point in points] |
| points = [xp.reshape(point, (-1,)) for point in points] |
|
|
| |
| points.extend(f.points) |
|
|
| |
| |
| |
| |
| |
| if len(points) == 0: |
| initial_regions = [(a, b)] |
| else: |
| initial_regions = _split_region_at_points(a, b, points, xp) |
|
|
| regions = [] |
| est = 0.0 |
| err = 0.0 |
|
|
| for a_k, b_k in initial_regions: |
| est_k = rule.estimate(f, a_k, b_k, args) |
| err_k = rule.estimate_error(f, a_k, b_k, args) |
| regions.append(CubatureRegion(est_k, err_k, a_k, b_k, xp)) |
|
|
| est += est_k |
| err += err_k |
|
|
| subdivisions = 0 |
| success = True |
|
|
| with MapWrapper(workers) as mapwrapper: |
| while xp.any(err > atol + rtol * xp.abs(est)): |
| |
| region_k = heapq.heappop(regions) |
|
|
| est_k = region_k.estimate |
| err_k = region_k.error |
|
|
| a_k, b_k = region_k.a, region_k.b |
|
|
| |
| |
| |
| est -= est_k |
| err -= err_k |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| executor_args = zip( |
| itertools.repeat(f), |
| itertools.repeat(rule), |
| itertools.repeat(args), |
| _split_subregion(a_k, b_k, xp), |
| ) |
|
|
| for subdivision_result in mapwrapper(_process_subregion, executor_args): |
| a_k_sub, b_k_sub, est_sub, err_sub = subdivision_result |
|
|
| est += est_sub |
| err += err_sub |
|
|
| new_region = CubatureRegion(est_sub, err_sub, a_k_sub, b_k_sub, xp) |
|
|
| heapq.heappush(regions, new_region) |
|
|
| subdivisions += 1 |
|
|
| if subdivisions >= max_subdivisions: |
| success = False |
| break |
|
|
| status = "converged" if success else "not_converged" |
|
|
| |
| est = sign * est |
|
|
| return CubatureResult( |
| estimate=est, |
| error=err, |
| status=status, |
| subdivisions=subdivisions, |
| regions=regions, |
| atol=atol, |
| rtol=rtol, |
| ) |
|
|
|
|
| def _process_subregion(data): |
| f, rule, args, coord = data |
| a_k_sub, b_k_sub = coord |
|
|
| est_sub = rule.estimate(f, a_k_sub, b_k_sub, args) |
| err_sub = rule.estimate_error(f, a_k_sub, b_k_sub, args) |
|
|
| return a_k_sub, b_k_sub, est_sub, err_sub |
|
|
|
|
| def _is_strictly_in_region(a, b, point, xp): |
| if xp.all(point == a) or xp.all(point == b): |
| return False |
|
|
| return xp.all(a <= point) and xp.all(point <= b) |
|
|
|
|
| def _split_region_at_points(a, b, points, xp): |
| """ |
| Given the integration limits `a` and `b` describing a rectangular region and a list |
| of `points`, find the list of ``[(a_1, b_1), ..., (a_l, b_l)]`` which breaks up the |
| initial region into smaller subregion such that no `points` lie strictly inside |
| any of the subregions. |
| """ |
|
|
| regions = [(a, b)] |
|
|
| for point in points: |
| if xp.any(xp.isinf(point)): |
| |
| |
| |
| |
| continue |
|
|
| new_subregions = [] |
|
|
| for a_k, b_k in regions: |
| if _is_strictly_in_region(a_k, b_k, point, xp): |
| subregions = _split_subregion(a_k, b_k, xp, point) |
|
|
| for left, right in subregions: |
| |
| if xp.any(left == right): |
| continue |
| else: |
| new_subregions.append((left, right)) |
|
|
| new_subregions.extend(subregions) |
|
|
| else: |
| new_subregions.append((a_k, b_k)) |
|
|
| regions = new_subregions |
|
|
| return regions |
|
|
|
|
| class _VariableTransform: |
| """ |
| A transformation that can be applied to an integral. |
| """ |
|
|
| @property |
| def transformed_limits(self): |
| """ |
| New limits of integration after applying the transformation. |
| """ |
|
|
| raise NotImplementedError |
|
|
| @property |
| def points(self): |
| """ |
| Any problematic points introduced by the transformation. |
| |
| These should be specified as points where ``_VariableTransform(f)(self, point)`` |
| would be problematic. |
| |
| For example, if the transformation ``x = 1/((1-t)(1+t))`` is applied to a |
| univariate integral, then points should return ``[ [1], [-1] ]``. |
| """ |
|
|
| return [] |
|
|
| def inv(self, x): |
| """ |
| Map points ``x`` to ``t`` such that if ``f`` is the original function and ``g`` |
| is the function after the transformation is applied, then:: |
| |
| f(x) = g(self.inv(x)) |
| """ |
|
|
| raise NotImplementedError |
|
|
| def __call__(self, t, *args, **kwargs): |
| """ |
| Apply the transformation to ``f`` and multiply by the Jacobian determinant. |
| This should be the new integrand after the transformation has been applied so |
| that the following is satisfied:: |
| |
| f_transformed = _VariableTransform(f) |
| |
| cubature(f, a, b) == cubature( |
| f_transformed, |
| *f_transformed.transformed_limits(a, b), |
| ) |
| """ |
|
|
| raise NotImplementedError |
|
|
|
|
| class _InfiniteLimitsTransform(_VariableTransform): |
| r""" |
| Transformation for handling infinite limits. |
| |
| Assuming ``a = [a_1, ..., a_n]`` and ``b = [b_1, ..., b_n]``: |
| |
| If :math:`a_i = -\infty` and :math:`b_i = \infty`, the i-th integration variable |
| will use the transformation :math:`x = \frac{1-|t|}{t}` and :math:`t \in (-1, 1)`. |
| |
| If :math:`a_i \ne \pm\infty` and :math:`b_i = \infty`, the i-th integration variable |
| will use the transformation :math:`x = a_i + \frac{1-t}{t}` and |
| :math:`t \in (0, 1)`. |
| |
| If :math:`a_i = -\infty` and :math:`b_i \ne \pm\infty`, the i-th integration |
| variable will use the transformation :math:`x = b_i - \frac{1-t}{t}` and |
| :math:`t \in (0, 1)`. |
| """ |
|
|
| def __init__(self, f, a, b, xp): |
| self._xp = xp |
|
|
| self._f = f |
| self._orig_a = a |
| self._orig_b = b |
|
|
| |
| self._double_inf_pos = (a == -math.inf) & (b == math.inf) |
|
|
| |
| start_inf_mask = (a != -math.inf) & (b == math.inf) |
|
|
| |
| inf_end_mask = (a == -math.inf) & (b != math.inf) |
|
|
| |
| |
| self._semi_inf_pos = start_inf_mask | inf_end_mask |
|
|
| |
| |
| self._orig_a[inf_end_mask] = -b[inf_end_mask] |
| self._orig_b[inf_end_mask] = -a[inf_end_mask] |
|
|
| self._num_inf = self._xp.sum( |
| self._xp.astype(self._double_inf_pos | self._semi_inf_pos, self._xp.int64), |
| ).__int__() |
|
|
| @property |
| def transformed_limits(self): |
| a = xp_copy(self._orig_a) |
| b = xp_copy(self._orig_b) |
|
|
| a[self._double_inf_pos] = -1 |
| b[self._double_inf_pos] = 1 |
|
|
| a[self._semi_inf_pos] = 0 |
| b[self._semi_inf_pos] = 1 |
|
|
| return a, b |
|
|
| @property |
| def points(self): |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| if self._num_inf != 0: |
| return [self._xp.zeros(self._orig_a.shape)] |
| else: |
| return [] |
|
|
| def inv(self, x): |
| t = xp_copy(x) |
| npoints = x.shape[0] |
|
|
| double_inf_mask = self._xp.tile( |
| self._double_inf_pos[self._xp.newaxis, :], |
| (npoints, 1), |
| ) |
|
|
| semi_inf_mask = self._xp.tile( |
| self._semi_inf_pos[self._xp.newaxis, :], |
| (npoints, 1), |
| ) |
|
|
| |
| |
| |
| |
| |
| zero_mask = x[double_inf_mask] == 0 |
| non_zero_mask = double_inf_mask & ~zero_mask |
| t[zero_mask] = math.inf |
| t[non_zero_mask] = 1/(x[non_zero_mask] + self._xp.sign(x[non_zero_mask])) |
|
|
| start = self._xp.tile(self._orig_a[self._semi_inf_pos], (npoints,)) |
| t[semi_inf_mask] = 1/(x[semi_inf_mask] - start + 1) |
|
|
| return t |
|
|
| def __call__(self, t, *args, **kwargs): |
| x = xp_copy(t) |
| npoints = t.shape[0] |
|
|
| double_inf_mask = self._xp.tile( |
| self._double_inf_pos[self._xp.newaxis, :], |
| (npoints, 1), |
| ) |
|
|
| semi_inf_mask = self._xp.tile( |
| self._semi_inf_pos[self._xp.newaxis, :], |
| (npoints, 1), |
| ) |
|
|
| |
| x[double_inf_mask] = ( |
| (1 - self._xp.abs(t[double_inf_mask])) / t[double_inf_mask] |
| ) |
|
|
| start = self._xp.tile(self._orig_a[self._semi_inf_pos], (npoints,)) |
|
|
| |
| x[semi_inf_mask] = start + (1 - t[semi_inf_mask]) / t[semi_inf_mask] |
|
|
| jacobian_det = 1/self._xp.prod( |
| self._xp.reshape( |
| t[semi_inf_mask | double_inf_mask]**2, |
| (-1, self._num_inf), |
| ), |
| axis=-1, |
| ) |
|
|
| f_x = self._f(x, *args, **kwargs) |
| jacobian_det = self._xp.reshape(jacobian_det, (-1, *([1]*(len(f_x.shape) - 1)))) |
|
|
| return f_x * jacobian_det |
|
|