Lambdifying a symbolic integral#
Numerical integration#
SciPy’s quad()
function#
SciPy’s scipy.integrate.quad()
cannot integrate complex-valued functions:
def integrand(x):
return x * (x + 1j)
quad(integrand, 0.0, 2.0)
---------------------------------------------------------------------------
TypeError Traceback (most recent call last)
Cell In[2], line 5
1 def integrand(x):
2 return x * (x + 1j)
----> 5 quad(integrand, 0.0, 2.0)
File ~/work/report/report/docs/016/.venv/lib/python3.12/site-packages/scipy/integrate/_quadpack_py.py:459, in quad(func, a, b, args, full_output, epsabs, epsrel, limit, points, weight, wvar, wopts, maxp1, limlst, complex_func)
456 return retval
458 if weight is None:
--> 459 retval = _quad(func, a, b, args, full_output, epsabs, epsrel, limit,
460 points)
461 else:
462 if points is not None:
File ~/work/report/report/docs/016/.venv/lib/python3.12/site-packages/scipy/integrate/_quadpack_py.py:606, in _quad(func, a, b, args, full_output, epsabs, epsrel, limit, points)
604 if points is None:
605 if infbounds == 0:
--> 606 return _quadpack._qagse(func,a,b,args,full_output,epsabs,epsrel,limit)
607 else:
608 return _quadpack._qagie(func, bound, infbounds, args, full_output,
609 epsabs, epsrel, limit)
TypeError: must be real number, not complex
A proposed solution is to wrap the quad()
function in a special integrate function that integrates the real and imaginary part of a function separately:
def complex_integrate(func, a, b, **quad_kwargs):
def real_func(x):
return func(x).real
def imag_func(x):
return func(x).imag
real_integral, real_integral_err = quad(real_func, a, b, **quad_kwargs)
imag_integral, imag_integral_err = quad(imag_func, a, b, **quad_kwargs)
return (
real_integral + 1j * imag_integral,
real_integral_err**2 + 1j * imag_integral_err,
)
complex_integrate(integrand, 0.0, 2.0)
((2.666666666666667+2j), (8.765121169122355e-28+2.220446049250313e-14j))
Warning
The handling of uncertainties is incorrect.
SciPy’s quad_vec()
function#
The easiest solution, however, seems to be scipy.integrate.quad_vec()
:
quad_vec(integrand, 0.0, 2.0)
((2.666666666666667+1.9999999999999996j), 1.1302447741611209e-13)
This has the added benefit that it can handle functions that return arrays:
def gaussian(x, mu, sigma):
return np.exp(-((x - mu) ** 2) / (2 * sigma**2)) / (sigma * np.sqrt(2 * np.pi))
mu_values = np.linspace(-2, +3, num=10)
result, _ = quad_vec(lambda x: gaussian(x, mu_values, sigma=0.5), 0, 2.0)
result
array([3.16712418e-05, 1.93302827e-03, 3.77201760e-02, 2.52491007e-01,
6.71450766e-01, 9.32839322e-01, 9.04958400e-01, 5.87850435e-01,
1.87030892e-01, 2.27501310e-02])
Integrate with quadpy
#
Warning
quadpy
now requires a license. The examples below are only shown for documentation purposes.
Alternatively, one could use quadpy
, which essentially does the same as in quad()
, but can also (to a large degree) handle vectorized input and properly handles uncertainties. For example:
from functools import partial
def parametrized_func(s_prime, s):
return s_prime * (s_prime + s + 1j)
s_array = np.linspace(-1, 1, num=10)
quadpy.quad(
partial(parametrized_func, s=s_array),
a=0.0,
b=2.0,
)
Note
One may need to play around with the tolerance if the function is not smooth, see sigma-py/quadpy#255.
Tip
quadpy
raises exceptions with ModuleNotFoundError
s that are a bit unreadable. They are caused by the fact that orthopy
and ndim
need to be installed separately.
SymPy integral#
The dispersion integral from Eq. (2) in TR-003 features a variable \(s\) that is an argument to the function \(\Sigma_a\). This becomes a challenge when \(s\) gets vectorized (in this case: gets an event-wise numpy.array
of invariant masses). It seems that quad_vec()
can handle this well though.
def parametrized_func(s_prime, s):
return s_prime * (s_prime + s + 1j)
s_array = np.linspace(-1, +1, num=10)
quad_vec(
lambda x: parametrized_func(x, s=s_array),
a=0.0,
b=2.0,
)
(array([0.66666667+2.j, 1.11111111+2.j, 1.55555556+2.j, 2. +2.j,
2.44444444+2.j, 2.88888889+2.j, 3.33333333+2.j, 3.77777778+2.j,
4.22222222+2.j, 4.66666667+2.j]),
3.8263293934812236e-13)
We now attempt to design SymPy expression classes that correctly lambdify()
using this vectorized numerical integral for handles complex values. Note that this integral expression class derives from sympy.Integral
and that:
overwrites its
doit()
method, so that the integral cannot be evaluated by SymPy,provides a custom NumPy printer method (see TR-001) that lambdifies this expression node to
quadpy.quad()
,adds class variables that allow configuring
quad_vec()
,dummifies the integration variable in case it is not a valid Python variable name.
class UnevaluatableIntegral(sp.Integral):
abs_tolerance = 1e-5
rel_tolerance = 1e-5
limit = 50
dummify = True
def doit(self, **hints):
args = [arg.doit(**hints) for arg in self.args]
return self.func(*args)
def _numpycode(self, printer, *args):
integration_vars, limits = _unpack_integral_limits(self)
if len(limits) != 1 or len(integration_vars) != 1:
msg = f"Cannot handle {len(limits)}-dimensional integrals"
raise ValueError(msg)
x = integration_vars[0]
a, b = limits[0]
expr = self.args[0]
if self.dummify:
dummy = sp.Dummy()
expr = expr.xreplace({x: dummy})
x = dummy
integrate_func = "quad_vec"
printer.module_imports["scipy.integrate"].add(integrate_func)
return (
f"{integrate_func}(lambda {printer._print(x)}: {printer._print(expr)},"
f" {printer._print(a)}, {printer._print(b)},"
f" epsabs={self.abs_tolerance}, epsrel={self.abs_tolerance},"
f" limit={self.limit})[0]"
)
To test whether this works, test this integral expression on another unevaluated()
expression:
w, a, b = sp.symbols("w a b")
fourier_expr = UnevaluatableIntegral(expr * sp.exp(-sp.I * w * x), (x, a, b))
fourier_expr
Indeed the expression correctly lambdifies correctly, despite the doit()
call:
func = sp.lambdify([x, omega1, omega2, phi1, phi2], expr.doit())
fourier_func = sp.lambdify([w, omega1, omega2, phi1, phi2, a, b], fourier_expr.doit())
def _lambdifygenerated(w, omega1, omega2, phi1, phi2, a, b):
return quad_vec(
lambda _Dummy_34: (
sin(_Dummy_34 * omega1 + phi1) + sin(_Dummy_34 * omega2 + phi2)
)
* exp(-_Dummy_34 * 1j * w),
a,
b,
epsabs=1e-05,
epsrel=1e-05,
limit=50,
)[0]
domain = np.linspace(-7, +7, num=500)
parameters = dict(
omega1=1.2,
omega2=2.3,
phi1=-1.2,
phi2=+0.4,
)
func_output = func(domain, **parameters)
fourier_output = fourier_func(domain, **parameters, a=-10, b=+10)
Tip
See how this integral expression class is applied to the phase space factor in TR-003.