Case Study: Creating an Integrator — gvar 13.0 documentation

# Case Study: Creating an Integrator

This case study illustrates how to convert an existing numerical analysis routine, `scipy.integrate.quad()`, to work with `gvar.GVar`s.

## The Problem

We want a Python code that can evaluate one dimensional integrals such as

where any of the integration limits or are `gvar.GVar`s and is an arbitrary function coded as a Python function.

One approach is to implement an integration function directly in Python, as then it is likely to work just as well for `gvar.GVar`s as for floats. For example, the code

    >>> import gvar as gv
    >>> import numpy as np
    >>>
    >>> def trap_integral(f, interval, n=100):
    ...     """ Estimate integral of f(x) on interval=(a,b) using the Trapezoidal Rule. """
    ...     a, b = interval
    ...     x = a + (b - a) * np.linspace(0, 1., n+1)
    ...     fx = np.array([f(xi) for xi in x])
    ...     I =  np.sum(fx[:-1] + fx[1:]) * (b - a) / (2. * n)
    ...     return I
    ...
    >>> A = gv.gvar(2, 0.1)
    >>> K = gv.gvar(1, 0.11)
    >>> D = gv.gvar(1., 0.4)
    >>>
    >>> def f(x):
    ...     return A * np.cos(K * x**2 + D) ** 2
    ...
    >>> a = gv.gvar(0, 0.1)
    >>> b = gv.gvar(4, 0.1)
    >>> Itrap = trap_integral(f, (a, b), n=100)
    >>> print(f'Itrap = {Itrap:#P}')
    Itrap = 3.45 ± 0.32

estimates the integral of `f(x)` over the interval between 0 ± 0.1 and 4 ± 0.1 using the Trapezoidal Rule.

This code is simple because we are using one of the simplest numerical estimates of the integral. A general purpose integrators needs a much more robust algorithm. For example, `trap_integral` fails badly when applied to a much more singular function:

    >>> def g(x):
    ...    return A * x /(K * x**2 + 1e-6)
    ...
    >>> Itrap_g = trap_integral(g, (a, b), n=100)
    >>> print(f'Itrap_g = {Itrap_g:#P}')
    Itrap_g = 10.3633 ± 4.0e+03

The correct answer is 16.6 ± 1.9. We need a much larger number of integrand samples `n` (100x larger) to get reasonable results.

## Leveraging Existing Code

Coding a more robust integrator is complicated and time consuming. A better strategy is, if possible, to build on existing libraries. Here we will use integrators from the `scipy.integrate` module.

The integral is a function of its endpoints and of any parameters buried in the definition of the function : where and for are the parameters implicit in the integrand (e.g., `A` and `K` in the examples above). We want an integrator that works when any of these parameters is replaced by a `gvar.GVar`.

We can do this using `gvar.gvar_function``(p, I, dI_dp)` where `p` is an array of the `gvar.GVar`-valued parameters, `I` is the integral evaluated with these parameters replaced by their mean values, and `dI_dp` is the array of derivatives of `I` with respect to each of these parameters — — again evaluated with their mean values.

The integral `I` (with the parameters replaced by their mean values) can be evaluated using standard routines as no `gvar.GVar`s are involved. The derivatives with respect to the endpoints are also easily evaluated:

The derivatives with respect to the function parameters involve different integrals, which again can be evaluated using standard routines:

In the following code we use the integrators `quad(...)` and `quad_vec(...)` from `scipy.integrate` to evaluate the integrals needed to calculate `I` and elements of `dI_dp`, respectively:

    import scipy.integrate

    def integral(f, interval, tol=1e-8):
        """ GVar-compatible integrator """
        a, b = interval

        # collect GVar-valued parameters
        p = []
        dI_dp = []
        if isinstance(a, gv.GVar):
            p += [a]
            dI_dp += [-f(a).mean]
            a = a.mean
        if isinstance(b, gv.GVar):
            p += [b]
            dI_dp += [f(b).mean]
            b = b.mean

        # evaluate integral I of f(x).mean
        sum_fx = [0]
        def fmean(x):
            fx = f(x)
            if isinstance(fx, gv.GVar):
                sum_fx[0] += f(x)
                return f(x).mean
            else:
                return fx
        I = scipy.integrate.quad(fmean, a, b, epsrel=tol)[0]

        # parameters from the integrand
        pf = gv.dependencies(sum_fx[0], all=True)

        # evaluate dI/dpf
        if len(pf) > 0:
            # vector-valued integrand returns df(x)/dpf
            def df_dpf(x):
                fx = f(x)
                if isinstance(fx, gv.GVar):
                    return fx.deriv(pf)
                else:
                    return np.array(len(pf) * [0.0])

            # integrate df/dpf to obtain dI/dpf
            dI_dpf = scipy.integrate.quad_vec(df_dpf, a, b, epsrel=tol)[0]

            # combine with other parameters, if any
            p += list(pf)
            dI_dp += list(dI_dpf)

        return gv.gvar_function(p, I, dI_dp) if len(p) > 0 else I

A key ingredient of this code is the use of `gvar.dependencies()` to obtain an array `pf` of the `gvar.GVar`-valued parameters implicit in the integrand `f(x)`. This is done without knowing anything about `f(x)` beyond the sum `sum_fx[0]` of its values at all the integration points used to calculate `I`. Given parameters `pf[i]`, the derivatives of `f(x)` with respect to those parameters are obtained using `f(x).deriv(pf)` (see the documentation for `gvar.GVar.deriv()`).

This new integrator works well with the first example above and gives the same result:

    >>> I = integral(f, (a, b))
    >>> print(f'I = {I:#P}')
    I = 3.45 ± 0.32

It also works well with the much more singular integrand `g(x)`:

    >>> I_g = integral(g, (a, b))
    >>> print(f'I_g = {I_g:#P}')
    I_g = 16.6 ± 1.9

`gvar` comes with a different integrator, `gvar.ode.integral()`, that gives the same results with similar performance: for example,

    >>> Iode = gv.ode.integral(f, (a, b))
    >>> print(f'Iode = {Iode:#P}')
    Iode = 3.45 ± 0.32
    >>> Iode_g = gv.ode.integral(g, (a, b))
    >>> print(f'Iode_g = {Iode_g:#P}')
    Iode_g = 16.6 ± 1.9

We can generate error budgets for each of the integral estimates to see where the final uncertainties come from:

    >>> inputs = dict(a=a, b=b, A=A, K=K, D=D)
    >>> outputs = dict(I=I, Iode=Iode, Itrap=Itrap)
    >>> print(gv.fmt_errorbudget(inputs=inputs, outputs=outputs))
    Partial % Errors:
                       I      Iode     Itrap
    ----------------------------------------
            a:      1.69      1.69      1.69
            b:      0.44      0.44      0.52
            A:      5.00      5.00      5.00
            K:      4.53      4.53      4.35
            D:      6.29      6.29      6.25
    ----------------------------------------
        total:      9.39      9.39      9.28

As expected the different methods are in good agreement (the Trapezoidal Rule gives slightly different results because `n` is a bit too small).