How to implement custom integration rules for use by NIntegrate?

The simplest way to make new NIntegrate algorithms is by user defined integration rules. Below are given examples using a simple rule (the Simpson rule) and how NIntegrate's framework can utilize the new rule implementations with its algorithms. (Adaptive, symbolic processing, and singularity handling algorithms are seamlessly applied.)

Basic 1D rule implementation (Simpson rule)

This easiest way to add a new rule is to use NIntegrate's symbol GeneralRule. Such a rule is simply initialized with a list of three elements:

{abscissas, integral weights, error weights}

The Simpson rule:

$$ \int_0^1 f(x)dx \approx \frac{1}{6} \lgroup f(0)+4 f(\frac{1}{2})+f(1) \rgroup$$

is implemented with the following definition:

SimpsonRule /: 
 NIntegrate`InitializeIntegrationRule[SimpsonRule, nfs_, ranges_, ruleOpts_, allOpts_] := 
 NIntegrate`GeneralRule[{{0, 1/2, 1}, {1/6, 4/6, 1/6}, {1/6, 4/6, 1/6} - {1/2, 0, 1/2}}]

The error weights are calculated as the difference between the Simson rule and the trapezoidal rule.

Signature

We can see that the new rule SimpsonRule is defined through TagSetDelayed for SimpsonRule and NIntegrate`InitializeIntegrationRule. The rest of the arguments are:

nfs -- numerical function objects; several might be given depending on the integrand and ranges;

ranges -- a list of ranges for the integration variables;

ruleOpts -- the options given to the rule;

allOpts -- all options given to NIntegrate.

Note that here we discuss the rule algorithm initialization only. The discussed intializations produce general rules for which there is an implemented computation algorithm.

(Explaining the making of definitions for integration rule computation algorithms is postponed for now. See the MichaelE2 answer or this blog post and related package AdaptiveNumericalLebesgueIntegration.m for examples of how to hook-up integration rules computation algorithms.)

NIntegrate's plug-in mechanism is fairly analogous to NDSolve's -- see the tutorial "NDSolve Method Plugin Framework".

Basic 1D rule tests

Here is the test with the SimpsonRule implemented above.

NIntegrate[Sqrt[x], {x, 0, 1}, Method -> SimpsonRule]

(* 0.666667 *)

Here are the sampling points of the integration above.

k = 0;
ListPlot[Reap[
   NIntegrate[Sqrt[x], {x, 0, 1}, Method -> SimpsonRule, 
    EvaluationMonitor :> Sow[{x, ++k}]]][[2, 1]], 
 PlotTheme -> "Detailed", ImageSize -> Large]

enter image description here

Multi-panel Simpson rule implementation

Here is an implementation of the multi-panel Simson rule:

Options[MultiPanelSimpsonRule] = {"Panels" -> 5};
MultiPanelSimpsonRuleProperties = Part[Options[MultiPanelSimpsonRule], All, 1];
MultiPanelSimpsonRule /: 
  NIntegrate`InitializeIntegrationRule[MultiPanelSimpsonRule, nfs_, ranges_, 
   ruleOpts_, allOpts_] :=

  Module[{t, panels, pos, absc, weights, errweights},

   t = NIntegrate`GetMethodOptionValues[MultiPanelSimpsonRule, 
     MultiPanelSimpsonRuleProperties, ruleOpts];
   If[t === $Failed, Return[$Failed]];
   {panels} = t;

   If[! TrueQ[NumberQ[panels] && 1 <= panels < Infinity], 
    pos = NIntegrate`OptionNamePosition[ruleOpts, "Panels"];
    Message[NIntegrate::intpm, ruleOpts, {pos, 2}];
    Return[$Failed];
    ];

   weights = Table[{1/6, 4/6, 1/6}, {panels}];
   weights = 
    Fold[Join[Drop[#1, -1], {#1[[-1]] + #2[[1]]}, Rest[#2]] &, First[weights],
       Rest[weights]]/panels;
   {absc, errweights, t} = 
    NIntegrate`TrapezoidalRuleData[(Length[weights] + 1)/2, 
     WorkingPrecision /. allOpts];
   NIntegrate`GeneralRule[{absc, weights, (weights - errweights)}]
   ];

Multi-panel Simpson rule tests

Here is an integral calculation with the multi-panel Simson rule

NIntegrate[Sqrt[x], {x, 0, 1}, 
 Method -> {MultiPanelSimpsonRule, "Panels" -> 12}]

(* 0.666667 *)

Here are the sampling points of the integration above:

k = 0;
ListPlot[Reap[
   NIntegrate[Sqrt[x], {x, 0, 1}, 
    Method -> {MultiPanelSimpsonRule, "Panels" -> 12}, MaxRecursion -> 10, 
    EvaluationMonitor :> Sow[{x, ++k}]]][[2, 1]]]

enter image description here

Note the traces of the "DoubleExponential" singularity handler application on the right side around 220th and 750th sampling points.

Two dimensional integration with a Cartisian product of Simpson multi-panel rules

The 1D multi-panel rule implemented above can be used for multi-dimensional integration.

This is what we get with NIntegrate's default method

NIntegrate[Sqrt[x + y], {x, 0, 1}, {y, 0, 1}]

(* 0.975161 *)

Here is the estimate with the custom multi-panel rule:

NIntegrate[Sqrt[x + y], {x, 0, 1}, {y, 0, 1}, 
 Method -> {MultiPanelSimpsonRule, "Panels" -> 5}, MaxRecursion -> 10]

(* 0.975161 *)

Note that the command above is equivalent to:

NIntegrate[Sqrt[x + y], {x, 0, 1}, {y, 0, 1}, 
 Method -> {"CartesianRule", 
   Method -> {MultiPanelSimpsonRule, "Panels" -> 5}}, MaxRecursion -> 10]

(* 0.975161 *)

Here is a plot of the sampling points:

k = 0;
ListPlot[Reap[
   NIntegrate[Sqrt[x + y], {x, 0, 1}, {y, 0, 1}, PrecisionGoal -> 5, 
    Method -> {MultiPanelSimpsonRule, "Panels" -> 5}, MaxRecursion -> 10, 
    EvaluationMonitor :> Sow[{x, y}]]][[2, 1]]]

enter image description here

Note the trace of the application of the singularity handler "DuffyCoordinates" at the left-bottom corner.

A complete example

This Lebesgue integration implementation, AdaptiveNumericalLebesgueIntegration.m -- discussed in detail in "Adaptive numerical Lebesgue integration by set measure estimates" -- has implementations of integration rules with the complete signatures for the plug-in mechanism.


Here is another type of integration rule. It has a customized error estimator, incorporating which was my main interest. In fact, the rule below computes an integral of arbitrary dimension, using its own internal formula for the integral as well.

Introduction

A rule of the type NIintegrate`GeneralRule[{abscissas, weights, errweights}] computes the integral and error estimates via dot products in more or less the following way:

fvals = f /@ abscissas;
integral = fvals . weights;
error = fvals . errweights;

Many 1D integration rules are of this form (hence the name GeneralRule).

There is a level above this. Recall that in adaptive integration NIntegrate subdivides regions (intervals in 1D), and applies the integration rule to each subregion. If the error is too great in a subregion, NIntegrate divides it again. When NIntegrate applies a GeneralRule to a subregion of integration, the result returned to NIntegrate has the form

{integral value, error estimate, axis to be subdivided}

A GeneralRule is always 1D, so the axis is always 1. In multidimensional rules the number will indicate along which axis the region is to be split in half, if the error is too great. Ultimately, the triple above is what an integration rule needs to return when it is called by NIntegrate.

A rule, let's call it chebRule as we will below, can be called by NIntegrate in at least two ways, one of which I have figured out. The call has the form

chebRule[data___]["ApproximateIntegral"[region]]

where chebRule[data___] is the structure returned by NIntegrate`InitializeIntegrationRule and region is a NIntegrate`IntegrationRegion[] that contains data (including the integrand) for computing the integral over the region. (An NIntegrate`IntegrationRegion[] is also the argument #1 in the option IntegrationMonitor, an undocumented option which has been discussed in these Q&A.) An IntegrationRegion has many methods. We will need two:

region@"Boundaries"
region["EvaluateTransformedIntegrand"[{##}]] &

which give the boundaries of the region and a function for evaluating the integrand, respectively. You evaluate the function on the rule's sampling points taken from the interval {0, 1}. The method takes care of the rescaling for you. All you need to do inside chebRule[data___]["ApproximateIntegral"[region]] is calculate the three things above, the integral, error, and, if implementing a multidimensional rule, which axis to subdivide next.

Example: Computing iterated integrals via Chebyshev series

The initialization code processes the options, of which currently there is only one, and gets the Clenshaw-Curtis sampling points for use in the rule. The only option is "Points", which works like the "ClenshawCurtisRule" option and generates 2 n - 1 points for a setting of n. Unlike "ClenshawCurtisRule", it will take a list of point settings for multidimensional integrals.

ClearAll[chebRule];

Options[chebRule] = {"Points" -> Automatic};
chebRuleProperties = Part[Options[chebRule], All, 1];
chebRule::dim = "The value of option `` should be a list of length ``";

(* initialization of rule *)
chebRule /: NIntegrate`InitializeIntegrationRule[
   chebRule, nfs_, ranges_, ruleOpts_, allOpts_] :=

 Module[{absc, points, minpts = 3, dim, wprec, t},

  wprec = WorkingPrecision /. allOpts;
  t = NIntegrate`GetMethodOptionValues[chebRule, chebRuleProperties, ruleOpts];
  If[t === $Failed, Return[$Failed]];
  {points} = t;
  points = points /. Automatic -> 5;
  dim = Length@ranges;
  If[! AllTrue[Flatten@{points}, # >= minpts &],
   Message[NIntegrate::mintmin, First@FilterRules[ruleOpts, "Points"], minpts];
   Return[$Failed]
   ];

  (* "Points" -> list of length dim *)
  If[ListQ@points && Length@points != dim,
   Message[chebRule::dim, First@FilterRules[ruleOpts, "Points"], dim];
   Return[$Failed]
   ];
  (* absc = list of ascissas for each dimension *)
  If[ListQ@points,
   absc = First@NIntegrate`ClenshawCurtisRuleData[#, wprec] & /@ points,
   absc = First@NIntegrate`ClenshawCurtisRuleData[points, wprec];
   absc = Table[absc, {dim}]
   ];

  chebRule[absc]
  ]

Here is the integration code. First there is the error estimator. It comes from the convergence theory for Chebyshev series (see Boyd, reference below). What really matters is that it computes a number that estimates the error of the quadrature to pass back to NIntegrate. The code below is used to come up with an error for each axis (from the iterated integral), which is then used in the main integrator below to determine how the subregion should be divided next.

(* self["localError"[coeff]] --
 * estimates error of Chebyshev series `coeff`,
 * provided truncated series is long enough *)
chebLE = Compile[{{coeff, _Real, 1}},
   With[{e2 = Total[Abs@coeff[[{-2, -1}]]], e4 = Total[Abs@coeff[[{-4, -3, -2, -1}]]]},
    With[{r2 = e2/e4},
     If[Length@coeff > 8 && e4 != 0 && r2 < 1,
      1.21 e2*r2/(1 - Sqrt[r2]), (* extrapolate error *)
      e2]                        (* use last two terms as upper bound *)
     ]]
   ];
(* called many times, MachinePrecision OK(?), hence compiled *)
chebRule[_]["localError"[coeff_]] := chebLE[coeff];

(* self["globalError"[error[[n]],Times@@widths[[;;n]],n]] --
 * estimates "global" error of integrating along axis `n`
 * by integrating local error bound along axis `(n-1)`
 * and multiplying by the other dimensions (`volume`) *)
chebGE = Compile[{{errors, _Real, 1}, {prevabsc, _Real, 1}, {volume, _Real}},
   Module[{error, ints},
    (* integrate errors along next axis up (trapezoidal rule) *)
    error = Partition[Flatten[errors], Length@prevabsc];
    ints =
     error.(Join[{First@#}, Rest@# + Most@#, {Last@#}] &@
        Differences@prevabsc);
    0.5 volume*Max@ints (*
    max integral will be the error bound *)
    ]
   ];
chebRule[absc_]["globalError"[errors_, volume_, 1]] :=  volume*Max[errors];
(* compiling won't help much, except maybe in high dimensional integrals *)
chebRule[absc_]["globalError"[errors_, volume_, axis_]] :=
  chebGE[errors, absc[[axis - 1]], volume];

The computation of the Chebyshev series follows Boyd, which I first encountered here. Finding the integral from a Chebyshev series is well-known and easy to derive.

chebRule[absc_]["Abscissas"[]] := absc;

(* Subroutines for computing and integrating Chebyshev series *)
chebRule[_]["series"[vals_, width_]] := Module[{coeff},
   coeff =
    Sqrt[2/(Length@vals - 1)]*FourierDCT[Reverse@vals, 1]/width;
   coeff[[{1, -1}]] /= 2;
   coeff
   ];
chebRule[_]["integrate"[coeff_, width_]] :=
  Map[     (* integration rule for a Chebyshev series *)
   Total[width/(1 - Range[0, Length@# - 1, 2]^2)*#[[1 ;; ;; 2]]] &,
   coeff,
   {-2}];


(*
 * Main integrator  
 *)
(self : chebRule[___])["ApproximateIntegral"[region_]] :=
  Module[{absc, ranges, dim, fvals, coeff, integral, widths, error, axis},

   absc = self@"Abscissas"[];
   ranges = region@"Boundaries";
   dim = Length@ranges;
   widths = Flatten[Differences /@ ranges];
   error = Table[{}, {dim}];

   fvals =  (* evaluate integrand *)
    Outer[region["EvaluateTransformedIntegrand"[{##}]] &,
     Sequence @@ absc];

   axis = dim; (* convenient iterator with Nest[] *)
   integral = Nest[
     (* replaces each bottom level list of function/integral values by its integral *)
     Function[{vals},
      (*first compute Chebyshev series *)
      coeff = Map[self["series"[#, widths[[axis]]]] &, vals, {-2}];

      (* estimate the errors *)
      error[[axis]] =  (* first of the Chebyshev approximation *)
          Map[self["localError"[#]] &, coeff, {-2}];
      error[[axis]] =  (* next for the whole level (axis n) *)
       self["globalError"[error[[axis]], Times @@ widths[[;; axis]],
         axis]];

      (* caveat: decrement  axis  one step too early *)
      axis--;
      Map[self["integrate"[#, widths[[axis + 1]]]] &, coeff, {-2}]
      ],
     fvals,
     dim];

   axis = First@Ordering[error, -1];
   dbPrint[ranges -> {integral, error, axis}];
   error =  (* convert from MachinePrecision to working precision *)
     SetPrecision[Max@error, region@"WorkingPrecision"];
   {integral, error, axis}
   ];

Examples

1. Here you can see the recursive subdivision and the convergence error estimates. If you follow carefully, you can see how the error and the axis determine the subdivisions. (There are four iterations; they can be grouped as the first printed rule and then groups of two; the last two pairs show the subdivision of the first two pairs, in reversed order according to their error estimates, 1.93 x 10^-6 vs. 3.27 * 10^-6. The errors that IntegrationMonitor sows are the sums at each step of the errors over the current subintervals.)

Block[{dbPrint = Print},
 Reap@NIntegrate[Cos[7 x] Sin[5 y], {x, 0, 2}, {y, 0, 3},
   Method -> {chebRule, "Points" -> 9},
   IntegrationMonitor :> ((Sow@Total@Through[#1@"Error"]) &)]
 ]
First@% - Integrate[Cos[7 x] Sin[5 y], {x, 0, 2}, {y, 0, 3}]

Mathematica graphics

2. A relatively innocuous integrand is

f1 = Sin[2 x] Sin[y^3] Sin[x y];

We will integrate it with calls of the form

NIntegrate[f1, {x, 0, 2}, {y, 0, 3}, Method -> ...]

with Method settings like

Method -> chebRule
Method -> {chebRule, "Points" -> 9}
Method -> {chebRule, "Points" -> 17}
Method -> {chebRule, "Points" -> {9, 17}}

We compare it with similar calls to the rules "GaussKronrod", "ClenshawCurtisRule", except for the {9, 17} setting, which they do not support. For comparison, the exact value to 16 digits is

exact = 0.1998439750348762

and the default method "MultidimensionalRule" takes 2.38428 seconds, 8325 function evaluations and has an error of 3.75823*10^-10.

Mathematica graphics
Comparison of methods (code at end)

First of all, the error for chebRule is closer to the goal, which should be around 10^-8 for this integral. The function f1 evaluates quickly so one can see that chebRule appears to be slower per function evaluation than "ClenshawCurtisRule", but it seems also to use fewer function calls. The larger error and fewer calls are related to the difference in the error estimators of the two methods. If the integrand is expensive to calculate, then chebRule may have an advantage over "ClenshawCurtisRule".

3. An example where chebRule can beat "ClenshawCurtisRule" is shown below. One thing to realize is that one does not take advantage of the convergence rate of the Chebyshev series if a small value for "Points" is used (this is true for the Clenshaw-Curtis rule, too). Another is that if we do not use MachinePrecision, then we cannot take advantage of current hardware that optimizes matrix multiplication, and "ClenshawCurtisRule" loses some of its advantage.

exact = Integrate[Cos[x]^2 Cos[10 x]^2, {x, 0, 50}];
NIntegrate[Cos[x]^2 Cos[10 x]^2, {x, 0, 50}, 
   Method -> {chebRule, "Points" -> 1 + 2^6}, 
   WorkingPrecision -> 32] - exact // AbsoluteTiming
NIntegrate[Cos[x]^2 Cos[10 x]^2, {x, 0, 50}, 
   Method -> {"ClenshawCurtisRule", "Points" -> 1 + 2^6}, 
   WorkingPrecision -> 32] - exact // AbsoluteTiming
(*
  {0.137261, -2.*10^-30}
  {0.317895, 0.*10^-31}
*)

One can improve chebRule quite a bit by writing one's own "chebPoints" routine that calculates just the abscissas (and not the weights and error weights).


Background (TBD)

I decided to put the back story in the back (at the end of the post).

The error weights in a GeneralRule are typically the difference of the weights of two related integration rules which is known to approximate (or at least bound) the error. In my case, I wanted to compute the error in a different way, basically for exploring my own interests and not to create a better integrator. From Anton's examples and a little spelunking, I seem to have figured how to do this. Since it is an answer to this question and it extends the (currently only) other answer, I thought I should share it. I doubt my code is bullet-proof. It can perform quite well; it can also do just okay; and I haven't tested enough pathological cases to find one where it breaks, but that is not my main interest. Hopefully, it will be accepted as an example of how such a integration rule can be incorporated in NIntegrate[].

The rule chebRule is closely related to the Clenshaw-Curtis rule, which itself follows the form of a GeneralRule. Instead of the usual error estimate, I wanted to estimate the error from the Chebyshev series of integrand over the integration region. If the function is analytic in a (complex) neighborhood of the interval of integration, then the coefficients converge to zero geometrically and one can estimate the error of approximation from the tail of such a truncated series. (See, for instance, Boyd, Chebyshev and Fourier Spectral Methods, Dover, New York, 2001, ch. 2). Since one can construct a series in which any number of coefficients are zero, this is not a fool-proof way to estimate the error. Except for (nearly) even or odd functions, which have series in which every other coefficient is (nearly) zero, functions in practice tend to have only a few (finite number of) terms that do not behave.

Extra code

Code for the comparison of methods:

f1 = Sin[2 x] Sin[y^3] Sin[x y];
exact = 0.19984397503487617461507951200812381171`16.;

Needs["Integration`NIntegrateUtilities`"];
ClearAll[ni];
ni[{"ClenshawCurtisRule" | "GaussKronrod", "Points" -> {__}}] := "";
ni[meth_] := {"IntegralEstimate", "IntegralEstimate" - exact,
      "Evaluations", "Timing"} /.
     NIntegrateProfile@NIntegrate[f1, {x, 0, 2}, {y, 0, 3},
       Method -> meth] /.
    Plus[InputForm[a_?NumericQ], b_?NumericQ] :> a + b // Column;

$rules = {Automatic; "GaussKronrod", "ClenshawCurtisRule", chebRule};
tbl = Table[ni[{rule, pts}],
   {pts, {"Points" -> Automatic, "Points" -> 9, "Points" -> 17,
     "Points" -> {9, 17}}},
   {rule, $rules}];
Join[{{"Points\\Method", SpanFromLeft, Sequence @@ $rules}},
  Transpose@
   Join[{{"Auto.", 9, 17, {9, 17}},
     Table[Column[{"Est", "Error", "Evals", "Time"}], {4}]},
    Transpose[tbl]]
  ] // Grid[#, Dividers -> All, BaseStyle -> Smaller] &