Repository: scipy/scipy

Issue:
Solution for low accuracy of simps with even number of points
# problem

The Simpson's rule is suitable for arrays with an odd number of points. The current implementation allows for even number of points by integration the first or last interval using the trapezium rule, which is highly inaccurate.
# solution

In case of an even number n points the integral over the first n-1 points can be calculated using the normal Simpson scheme, i.e. the weights are 1/12_(2, 8, 2) over all intervals of three points. To calculated the contribution of the last interval, the 2nd order polynomial fit over the last 3 points is integrated over the interval between the last 2 points. This results in a weight scheme of 1/24_[-1, 8, 5] for the last inverval.
# result

Accuracy with n+1 points is better than with n points, even if n+1 is even. This is demonstrated for int_0^pi sin(x) dx in the attached code.

[BetterSimps.txt](https://github.com/scipy/scipy/files/70330/BetterSimps.txt)



Discussion:
Adding the code as comment here, with hard tabs fixed:

```
from numpy import ones_like, zeros_like, array

def Simpson(y, x):
    """ Calculate the integral of y(x) over x using the trapezium rule

    If the length of x and y is odd, the normal trapezium rule is used.
    I the even case the sub integral last interval is calculated using a 2nd order interpolation
    over the last three points. In this case the weights for the interval are then (-1, 8, -5)/12
    and for all others (2 8 2)/12 as for the normal Simpson's rule. The weight scheme for the total
    integral is then:
       [..., 2, 8, 2, 0, 0, 0], [..., 0, 0, 2, 8, 2, 0] + 1/2*[..., 0, 0, 0, -1, 8, 5] = [..., 4, 8, 4, 15/2, 6, 5/2]
    The coefficients can also be derived from the tailer polynomial around the the last but one
    data point with the first derivative calculated using the midpoint rule (df/dx = (f(x+dx)-f(x-dx))/2dx
    and the second using standard method (d2f/dx2 = (f(x+dx) - 2f(x) + f(x-dx))/dx**2
    """

    # x has to be equally spaced
    dx = x[1]-x[0]

    # index of the last odd point
    l = len(x)-1 if len(x)%2 == 1 else len(x)-2

    w = zeros_like(x)
    w[0], w[l] = 2, 2
    w[1:l:2] = 8
    w[2:l-1:2] = 4
    # Add the contriubtion of the last interval in case there is an even number of points
    if len(x) % 2 == 0:
        w[-3:] += array([-1.0, 8, 5])/2

    I = dx/6 * sum(w*y)
    return(I)


if __name__ == "__main__":
    from numpy import linspace, pi, sin
    from scipy.integrate import simps
    print("%2s\t%12s\t%12s\t%12s\t%12s" % ("n", "I(numpy)", "Err(numpy)", "I(curr)", "Err(curr)"))
    for i in range(5, 13):
        t  = linspace(0, pi/2, i)
        I1 = simps(sin(t), t)
        I2 = Simpson(sin(t), t)
        print("%2i\t%12.7f\t%12.7f\t%12.7f\t%12.7f" % (i, I1, I1-1, I2, I2-1))
```

@martijnzm you're very welcome to submit a pull request that improves `integrate.simps`.

Do I need to retain the old behaviour for sake of compatibility? I suggest removing the trapezium rule related code and accept but ignore the "even" option.
Is labelling this issue as defect not a little exaggerated? The current code works perfectly with an odd number of points and many users will know the limitations Simpsons rule.

> Do I need to retain the old behaviour for sake of compatibility? I suggest removing the trapezium rule related code and accept but ignore the "even" option.

That makes sense to me - it then doesn't change the API, so no existing code will break.

> Is labelling this issue as defect not a little exaggerated? The current code works perfectly with an odd number of points and many users will know the limitations Simpsons rule.

`defect` is not something dramatic - things are either a defect or an enhancement, and can be about small things. Looking at the awful docstring notes on `even`, the clumsy API and the low accuracy, I think `defect` is the right label....

gh-9871 suggested a combination of the 1/3 and 3/8 rules, so I thought I'd bring that idea over here. I haven't looked into its merits.

Resolving patch:
diff --git a/scipy/integrate/_quadrature.py b/scipy/integrate/_quadrature.py
--- a/scipy/integrate/_quadrature.py
+++ b/scipy/integrate/_quadrature.py
@@ -522,7 +522,7 @@ def _basic_simpson(y, start, stop, x, dx, axis):
 
 # Note: alias kept for backwards compatibility. simps was renamed to simpson
 # because the former is a slur in colloquial English (see gh-12924).
-def simps(y, x=None, dx=1.0, axis=-1, even='avg'):
+def simps(y, x=None, dx=1.0, axis=-1, even=None):
     """An alias of `simpson`.
 
     `simps` is kept for backwards compatibility. For new code, prefer
@@ -531,7 +531,7 @@ def simps(y, x=None, dx=1.0, axis=-1, even='avg'):
     return simpson(y, x=x, dx=dx, axis=axis, even=even)
 
 
-def simpson(y, x=None, dx=1.0, axis=-1, even='avg'):
+def simpson(y, x=None, dx=1.0, axis=-1, even=None):
     """
     Integrate y(x) using samples along the given axis and the composite
     Simpson's rule. If x is None, spacing of dx is assumed.
@@ -551,10 +551,12 @@ def simpson(y, x=None, dx=1.0, axis=-1, even='avg'):
         `x` is None. Default is 1.
     axis : int, optional
         Axis along which to integrate. Default is the last axis.
-    even : str {'avg', 'first', 'last'}, optional
-        'avg' : Average two results:1) use the first N-2 intervals with
-                  a trapezoidal rule on the last interval and 2) use the last
-                  N-2 intervals with a trapezoidal rule on the first interval.
+    even : {None, 'simpson', 'avg', 'first', 'last'}, optional
+        'avg' : Average two results:
+            1) use the first N-2 intervals with
+               a trapezoidal rule on the last interval and
+            2) use the last
+               N-2 intervals with a trapezoidal rule on the first interval.
 
         'first' : Use Simpson's rule for the first N-2 intervals with
                 a trapezoidal rule on the last interval.
@@ -562,6 +564,25 @@ def simpson(y, x=None, dx=1.0, axis=-1, even='avg'):
         'last' : Use Simpson's rule for the last N-2 intervals with a
                trapezoidal rule on the first interval.
 
+        None : equivalent to 'simpson' (default)
+
+        'simpson' : Use Simpson's rule for the first N-2 intervals with the
+                  addition of a 3-point parabolic segment for the last
+                  interval using equations outlined by Cartwright [1]_.
+                  If the axis to be integrated over only has two points then
+                  the integration falls back to a trapezoidal integration.
+
+                  .. versionadded:: 1.11.0
+
+        .. versionchanged:: 1.11.0
+            The newly added 'simpson' option is now the default as it is more
+            accurate in most situations.
+
+        .. deprecated:: 1.11.0
+            Parameter `even` is deprecated and will be removed in SciPy
+            1.13.0. After this time the behaviour for an even number of
+            points will follow that of `even='simpson'`.
+
     Returns
     -------
     float
@@ -587,6 +608,12 @@ def simpson(y, x=None, dx=1.0, axis=-1, even='avg'):
     the samples are not equally spaced, then the result is exact only
     if the function is a polynomial of order 2 or less.
 
+    References
+    ----------
+    .. [1] Cartwright, Kenneth V. Simpson's Rule Cumulative Integration with
+           MS Excel and Irregularly-spaced Data. Journal of Mathematical
+           Sciences and Mathematics Education. 12 (2): 1-9
+
     Examples
     --------
     >>> from scipy import integrate
@@ -599,7 +626,7 @@ def simpson(y, x=None, dx=1.0, axis=-1, even='avg'):
 
     >>> y = np.power(x, 3)
     >>> integrate.simpson(y, x)
-    1642.5
+    1640.5
     >>> integrate.quad(lambda x: x**3, 0, 9)[0]
     1640.25
 
@@ -627,26 +654,115 @@ def simpson(y, x=None, dx=1.0, axis=-1, even='avg'):
         if x.shape[axis] != N:
             raise ValueError("If given, length of x along axis must be the "
                              "same as y.")
+
+    # even keyword parameter is deprecated
+    if even is not None:
+        warnings.warn(
+            "The 'even' keyword is deprecated as of SciPy 1.11.0 and will be "
+            "removed in SciPy 1.13.0",
+            DeprecationWarning, stacklevel=2
+        )
+
     if N % 2 == 0:
         val = 0.0
         result = 0.0
-        slice1 = (slice(None),)*nd
-        slice2 = (slice(None),)*nd
-        if even not in ['avg', 'last', 'first']:
-            raise ValueError("Parameter 'even' must be "
-                             "'avg', 'last', or 'first'.")
+        slice_all = (slice(None),) * nd
+
+        # default is 'simpson'
+        even = even if even is not None else "simpson"
+
+        if even not in ['avg', 'last', 'first', 'simpson']:
+            raise ValueError(
+                "Parameter 'even' must be 'simpson', "
+                "'avg', 'last', or 'first'."
+            )
+
+        if N == 2:
+            # need at least 3 points in integration axis to form parabolic
+            # segment. If there are two points then any of 'avg', 'first',
+            # 'last' should give the same result.
+            slice1 = tupleset(slice_all, axis, -1)
+            slice2 = tupleset(slice_all, axis, -2)
+            if x is not None:
+                last_dx = x[slice1] - x[slice2]
+            val += 0.5 * last_dx * (y[slice1] + y[slice2])
+
+            # calculation is finished. Set `even` to None to skip other
+            # scenarios
+            even = None
+
+        if even == 'simpson':
+            # use Simpson's rule on first intervals
+            result = _basic_simpson(y, 0, N-3, x, dx, axis)
+
+            slice1 = tupleset(slice_all, axis, -1)
+            slice2 = tupleset(slice_all, axis, -2)
+            slice3 = tupleset(slice_all, axis, -3)
+
+            h = np.asfarray([dx, dx])
+            if x is not None:
+                # grab the last two spacings from the appropriate axis
+                hm2 = tupleset(slice_all, axis, slice(-2, -1, 1))
+                hm1 = tupleset(slice_all, axis, slice(-1, None, 1))
+
+                diffs = np.float64(np.diff(x, axis=axis))
+                h = [np.squeeze(diffs[hm2], axis=axis),
+                     np.squeeze(diffs[hm1], axis=axis)]
+
+            # This is the correction for the last interval according to
+            # Cartwright.
+            # However, I used the equations given at
+            # https://en.wikipedia.org/wiki/Simpson%27s_rule#Composite_Simpson's_rule_for_irregularly_spaced_data
+            # A footnote on Wikipedia says:
+            # Cartwright 2017, Equation 8. The equation in Cartwright is
+            # calculating the first interval whereas the equations in the
+            # Wikipedia article are adjusting for the last integral. If the
+            # proper algebraic substitutions are made, the equation results in
+            # the values shown.
+            num = 2 * h[1] ** 2 + 3 * h[0] * h[1]
+            den = 6 * (h[1] + h[0])
+            alpha = np.true_divide(
+                num,
+                den,
+                out=np.zeros_like(den),
+                where=den != 0
+            )
+
+            num = h[1] ** 2 + 3.0 * h[0] * h[1]
+            den = 6 * h[0]
+            beta = np.true_divide(
+                num,
+                den,
+                out=np.zeros_like(den),
+                where=den != 0
+            )
+
+            num = 1 * h[1] ** 3
+            den = 6 * h[0] * (h[0] + h[1])
+            eta = np.true_divide(
+                num,
+                den,
+                out=np.zeros_like(den),
+                where=den != 0
+            )
+
+            result += alpha*y[slice1] + beta*y[slice2] - eta*y[slice3]
+
+        # The following code (down to result=result+val) can be removed
+        # once the 'even' keyword is removed.
+
         # Compute using Simpson's rule on first intervals
         if even in ['avg', 'first']:
-            slice1 = tupleset(slice1, axis, -1)
-            slice2 = tupleset(slice2, axis, -2)
+            slice1 = tupleset(slice_all, axis, -1)
+            slice2 = tupleset(slice_all, axis, -2)
             if x is not None:
                 last_dx = x[slice1] - x[slice2]
             val += 0.5*last_dx*(y[slice1]+y[slice2])
             result = _basic_simpson(y, 0, N-3, x, dx, axis)
         # Compute using Simpson's rule on last set of intervals
         if even in ['avg', 'last']:
-            slice1 = tupleset(slice1, axis, 0)
-            slice2 = tupleset(slice2, axis, 1)
+            slice1 = tupleset(slice_all, axis, 0)
+            slice2 = tupleset(slice_all, axis, 1)
             if x is not None:
                 first_dx = x[tuple(slice2)] - x[tuple(slice1)]
             val += 0.5*first_dx*(y[slice2]+y[slice1])


Test patch:
diff --git a/scipy/integrate/tests/test_quadrature.py b/scipy/integrate/tests/test_quadrature.py
--- a/scipy/integrate/tests/test_quadrature.py
+++ b/scipy/integrate/tests/test_quadrature.py
@@ -142,6 +142,8 @@ def test_newton_cotes2(self):
         numeric_integral = np.dot(wts, y)
         assert_almost_equal(numeric_integral, exact_integral)
 
+    # ignore the DeprecationWarning emitted by the even kwd
+    @pytest.mark.filterwarnings('ignore::DeprecationWarning')
     def test_simpson(self):
         y = np.arange(17)
         assert_equal(simpson(y), 128)
@@ -154,30 +156,86 @@ def test_simpson(self):
         assert_equal(simpson(y, x=x, even='first'), 13.75)
         assert_equal(simpson(y, x=x, even='last'), 14)
 
+        # `even='simpson'`
+        # integral should be exactly 21
+        x = np.linspace(1, 4, 4)
+        def f(x):
+            return x**2
+
+        assert_allclose(simpson(f(x), x=x, even='simpson'), 21.0)
+        assert_allclose(simpson(f(x), x=x, even='avg'), 21 + 1/6)
+
+        # integral should be exactly 114
+        x = np.linspace(1, 7, 4)
+        assert_allclose(simpson(f(x), dx=2.0, even='simpson'), 114)
+        assert_allclose(simpson(f(x), dx=2.0, even='avg'), 115 + 1/3)
+
+        # `even='simpson'`, test multi-axis behaviour
+        a = np.arange(16).reshape(4, 4)
+        x = np.arange(64.).reshape(4, 4, 4)
+        y = f(x)
+        for i in range(3):
+            r = simpson(y, x=x, even='simpson', axis=i)
+            it = np.nditer(a, flags=['multi_index'])
+            for _ in it:
+                idx = list(it.multi_index)
+                idx.insert(i, slice(None))
+                integral = x[tuple(idx)][-1]**3 / 3 - x[tuple(idx)][0]**3 / 3
+                assert_allclose(r[it.multi_index], integral)
+
+        # test when integration axis only has two points
+        x = np.arange(16).reshape(8, 2)
+        y = f(x)
+        for even in ['simpson', 'avg', 'first', 'last']:
+            r = simpson(y, x=x, even=even, axis=-1)
+
+            integral = 0.5 * (y[:, 1] + y[:, 0]) * (x[:, 1] - x[:, 0])
+            assert_allclose(r, integral)
+
+        # odd points, test multi-axis behaviour
+        a = np.arange(25).reshape(5, 5)
+        x = np.arange(125).reshape(5, 5, 5)
+        y = f(x)
+        for i in range(3):
+            r = simpson(y, x=x, axis=i)
+            it = np.nditer(a, flags=['multi_index'])
+            for _ in it:
+                idx = list(it.multi_index)
+                idx.insert(i, slice(None))
+                integral = x[tuple(idx)][-1]**3 / 3 - x[tuple(idx)][0]**3 / 3
+                assert_allclose(r[it.multi_index], integral)
+
         # Tests for checking base case
         x = np.array([3])
         y = np.power(x, 2)
-        assert_equal(simpson(y, x=x, axis=0), 0.0)
-        assert_equal(simpson(y, x=x, axis=-1), 0.0)
+        assert_allclose(simpson(y, x=x, axis=0), 0.0)
+        assert_allclose(simpson(y, x=x, axis=-1), 0.0)
 
         x = np.array([3, 3, 3, 3])
         y = np.power(x, 2)
-        assert_equal(simpson(y, x=x, axis=0), 0.0)
-        assert_equal(simpson(y, x=x, axis=-1), 0.0)
+        assert_allclose(simpson(y, x=x, axis=0), 0.0)
+        assert_allclose(simpson(y, x=x, axis=-1), 0.0)
 
         x = np.array([[1, 2, 4, 8], [1, 2, 4, 8], [1, 2, 4, 8]])
         y = np.power(x, 2)
         zero_axis = [0.0, 0.0, 0.0, 0.0]
-        default_axis = [175.75, 175.75, 175.75]
-        assert_equal(simpson(y, x=x, axis=0), zero_axis)
-        assert_equal(simpson(y, x=x, axis=-1), default_axis)
+        default_axis = [170 + 1/3] * 3   # 8**3 / 3 - 1/3
+        assert_allclose(simpson(y, x=x, axis=0), zero_axis)
+        # the following should be exact for even='simpson'
+        assert_allclose(simpson(y, x=x, axis=-1), default_axis)
 
         x = np.array([[1, 2, 4, 8], [1, 2, 4, 8], [1, 8, 16, 32]])
         y = np.power(x, 2)
         zero_axis = [0.0, 136.0, 1088.0, 8704.0]
-        default_axis = [175.75, 175.75, 11292.25]
-        assert_equal(simpson(y, x=x, axis=0), zero_axis)
-        assert_equal(simpson(y, x=x, axis=-1), default_axis)
+        default_axis = [170 + 1/3, 170 + 1/3, 32**3 / 3 - 1/3]
+        assert_allclose(simpson(y, x=x, axis=0), zero_axis)
+        assert_allclose(simpson(y, x=x, axis=-1), default_axis)
+
+    def test_simpson_even_is_deprecated(self):
+        x = np.linspace(0, 3, 4)
+        y = x**2
+        with pytest.deprecated_call():
+            simpson(y, x=x, even='first')
 
     @pytest.mark.parametrize('droplast', [False, True])
     def test_simpson_2d_integer_no_x(self, droplast):
@@ -193,10 +251,12 @@ def test_simpson_2d_integer_no_x(self, droplast):
 
     def test_simps(self):
         # Basic coverage test for the alias
-        y = np.arange(4)
+        y = np.arange(5)
         x = 2**y
-        assert_equal(simpson(y, x=x, dx=0.5, even='first'),
-                     simps(y, x=x, dx=0.5, even='first'))
+        assert_allclose(
+            simpson(y, x=x, dx=0.5),
+            simps(y, x=x, dx=0.5)
+        )
 
 
 class TestCumulative_trapezoid: