Repository: scipy/scipy

Issue:
Rewrite sosfilt to make a single loop over the input?
`signal.sosfilt` is currently implemented as a loop that calls `signal.lfilter`. The drawback of doing things this way is that a complete loop is made over the input data for each section.  I've got a branch (not currently on github) that creates a dedicated sosfilt function in C.  Using this I find a factor of 4 improvement for small inputs and a factor of 2 for larger inputs.  Right now, my code doesn't handle filtering along an axis of an nd array, but before I worked on that I thought I would see what people thought.   Some timings are below.  Note that the `signal.sosfilt` numbers include the improvements from #4628, which have already improved things.  With my new code, using the `sos` representation is as fast as using the `ba` representation.

```
In [1]: run test.py

In [2]: t, y = make_y(1000)

In [3]: sos = signal.butter(6, [0.1575, 0.1625], 'band', output='sos')

In [4]: y1 = signal.sosfilt(sos, y)

In [5]: y2 = signal.sigtools._sosfilt(sos, y)

In [6]: all(y1 == y2)
Out[6]: True

In [7]: timeit y1 = signal.sosfilt(sos, y)
10000 loops, best of 3: 79.7 µs per loop

In [8]: timeit y2 = signal.sigtools._sosfilt(sos, y)
100000 loops, best of 3: 19.8 µs per loop

In [9]: t, y = make_y(1000000)

In [10]: timeit y1 = signal.sosfilt(sos, y)
10 loops, best of 3: 37 ms per loop

In [11]: timeit y2 = signal.sigtools._sosfilt(sos, y)
10 loops, best of 3: 20 ms per loop

In [12]: sos = signal.butter(20, [0.1575, 0.1625], 'band', output='sos')

In [13]: timeit y1 = signal.sosfilt(sos, y)
10 loops, best of 3: 124 ms per loop

In [14]: timeit y2 = signal.sigtools._sosfilt(sos, y)
10 loops, best of 3: 64 ms per loop

In [15]: b,a = signal.butter(20, [0.1575, 0.1625], 'band', output='ba')

In [16]: timeit y3 = signal.lfilter(b, a, y)
10 loops, best of 3: 64.5 ms per loop

```



Discussion:
@ewmoore what do you think about using a Cython implementation rather than pure C? I think it might be easier to maintain if we can manage to get (near-)equivalent performance to the pure C code. Though perhaps to do that we may need to make the Cython code look a lot like C code anyway...?
I have no issues at all with moving to Cython.
@ewmoore can you push your changes to GitHub? I (or someone else) could take a stab at Cython-izing the C code.
Sorry, I don’t think I have that code anymore. 

Resolving patch:
diff --git a/benchmarks/benchmarks/signal_filtering.py b/benchmarks/benchmarks/signal_filtering.py
--- a/benchmarks/benchmarks/signal_filtering.py
+++ b/benchmarks/benchmarks/signal_filtering.py
@@ -70,3 +70,18 @@ def time_sosfilt(self, n_samples, threads):
             futures.append(pool.submit(sosfilt, self.filt, self.chunks[i]))
 
         wait(futures)
+
+
+class Sosfilt(Benchmark):
+    param_names = ['n_samples', 'order']
+    params = [
+        [1000, 1000000],
+        [6, 20]
+    ]
+
+    def setup(self, n_samples, order):
+        self.sos = butter(order, [0.1575, 0.1625], 'band', output='sos')
+        self.y = np.random.RandomState(0).randn(n_samples)
+
+    def time_sosfilt_basic(self, n_samples, order):
+        sosfilt(self.sos, self.y)
diff --git a/scipy/signal/setup.py b/scipy/signal/setup.py
--- a/scipy/signal/setup.py
+++ b/scipy/signal/setup.py
@@ -20,11 +20,16 @@ def configuration(parent_package='', top_path=None):
                          include_dirs=['.'],
                          **numpy_nodepr_api)
 
-    config.add_extension('_spectral', sources=['_spectral.c'])
-    config.add_extension('_max_len_seq_inner', sources=['_max_len_seq_inner.c'])
-    config.add_extension('_peak_finding_utils',
-                         sources=['_peak_finding_utils.c'])
-    config.add_extension('_upfirdn_apply', sources=['_upfirdn_apply.c'])
+    config.add_extension(
+        '_spectral', sources=['_spectral.c'])
+    config.add_extension(
+        '_max_len_seq_inner', sources=['_max_len_seq_inner.c'])
+    config.add_extension(
+        '_peak_finding_utils', sources=['_peak_finding_utils.c'])
+    config.add_extension(
+        '_sosfilt', sources=['_sosfilt.c'])
+    config.add_extension(
+        '_upfirdn_apply', sources=['_upfirdn_apply.c'])
     spline_src = ['splinemodule.c', 'S_bspline_util.c', 'D_bspline_util.c',
                   'C_bspline_util.c', 'Z_bspline_util.c', 'bspline_util.c']
     config.add_extension('spline', sources=spline_src, **numpy_nodepr_api)
diff --git a/scipy/signal/signaltools.py b/scipy/signal/signaltools.py
--- a/scipy/signal/signaltools.py
+++ b/scipy/signal/signaltools.py
@@ -19,6 +19,7 @@
 from ._arraytools import axis_slice, axis_reverse, odd_ext, even_ext, const_ext
 from .filter_design import cheby1, _validate_sos
 from .fir_filter_design import firwin
+from ._sosfilt import _sosfilt
 
 if sys.version_info >= (3, 5):
     from math import gcd
@@ -1763,7 +1764,7 @@ def lfilter(b, a, x, axis=-1, zi=None):
         a = np.asarray(a)
         if b.ndim != 1 and a.ndim != 1:
             raise ValueError('object of too small depth for desired array')
-        x = np.asarray(x)
+        x = _validate_x(x)
         inputs = [b, a, x]
         if zi is not None:
             # _linear_filter does not broadcast zi, but does do expansion of
@@ -3772,13 +3773,19 @@ def _validate_pad(padtype, padlen, x, axis, ntaps):
     return edge, ext
 
 
+def _validate_x(x):
+    x = np.asarray(x)
+    if x.ndim == 0:
+        raise ValueError('x must be at least 1D')
+    return x
+
+
 def sosfilt(sos, x, axis=-1, zi=None):
     """
     Filter data along one dimension using cascaded second-order sections.
 
     Filter a data sequence, `x`, using a digital IIR filter defined by
-    `sos`. This is implemented by performing `lfilter` for each
-    second-order section.  See `lfilter` for details.
+    `sos`.
 
     Parameters
     ----------
@@ -3843,28 +3850,45 @@ def sosfilt(sos, x, axis=-1, zi=None):
     >>> plt.show()
 
     """
-    x = np.asarray(x)
+    x = _validate_x(x)
     sos, n_sections = _validate_sos(sos)
-    use_zi = zi is not None
-    if use_zi:
-        zi = np.asarray(zi)
-        x_zi_shape = list(x.shape)
-        x_zi_shape[axis] = 2
-        x_zi_shape = tuple([n_sections] + x_zi_shape)
+    x_zi_shape = list(x.shape)
+    x_zi_shape[axis] = 2
+    x_zi_shape = tuple([n_sections] + x_zi_shape)
+    inputs = [sos, x]
+    if zi is not None:
+        inputs.append(np.asarray(zi))
+    dtype = np.result_type(*inputs)
+    if dtype.char not in 'fdgFDGO':
+        raise NotImplementedError("input type '%s' not supported" % dtype)
+    if zi is not None:
+        zi = np.array(zi, dtype)  # make a copy so that we can operate in place
         if zi.shape != x_zi_shape:
             raise ValueError('Invalid zi shape. With axis=%r, an input with '
                              'shape %r, and an sos array with %d sections, zi '
                              'must have shape %r, got %r.' %
                              (axis, x.shape, n_sections, x_zi_shape, zi.shape))
-        zf = np.zeros_like(zi)
-
-    for section in range(n_sections):
-        if use_zi:
-            x, zf[section] = lfilter(sos[section, :3], sos[section, 3:],
-                                     x, axis, zi=zi[section])
-        else:
-            x = lfilter(sos[section, :3], sos[section, 3:], x, axis)
-    out = (x, zf) if use_zi else x
+        return_zi = True
+    else:
+        zi = np.zeros(x_zi_shape, dtype=dtype)
+        return_zi = False
+    axis = axis % x.ndim  # make positive
+    x = np.moveaxis(x, axis, -1)
+    zi = np.moveaxis(zi, [0, axis + 1], [-2, -1])
+    x_shape, zi_shape = x.shape, zi.shape
+    x = np.reshape(x, (-1, x.shape[-1]))
+    x = np.array(x, dtype, order='C')  # make a copy, can modify in place
+    zi = np.ascontiguousarray(np.reshape(zi, (-1, n_sections, 2)))
+    sos = sos.astype(dtype, copy=False)
+    _sosfilt(sos, x, zi)
+    x.shape = x_shape
+    x = np.moveaxis(x, -1, axis)
+    if return_zi:
+        zi.shape = zi_shape
+        zi = np.moveaxis(zi, [-2, -1], [0, axis + 1])
+        out = (x, zi)
+    else:
+        out = x
     return out
 
 
@@ -3957,6 +3981,7 @@ def sosfiltfilt(sos, x, axis=-1, padtype='odd', padlen=None):
 
     """
     sos, n_sections = _validate_sos(sos)
+    x = _validate_x(x)
 
     # `method` is "pad"...
     ntaps = 2 * n_sections + 1


Test patch:
diff --git a/scipy/signal/tests/test_signaltools.py b/scipy/signal/tests/test_signaltools.py
--- a/scipy/signal/tests/test_signaltools.py
+++ b/scipy/signal/tests/test_signaltools.py
@@ -2823,29 +2823,74 @@ def test_negative_period_ValueError(self):
         assert_raises(ValueError, vectorstrength, events, period)
 
 
+def cast_tf2sos(b, a):
+    """Convert TF2SOS, casting to complex128 and back to the original dtype."""
+    # tf2sos does not support all of the dtypes that we want to check, e.g.:
+    #
+    #     TypeError: array type complex256 is unsupported in linalg
+    #
+    # so let's cast, convert, and cast back -- should be fine for the
+    # systems and precisions we are testing.
+    dtype = np.asarray(b).dtype
+    b = np.array(b, np.complex128)
+    a = np.array(a, np.complex128)
+    return tf2sos(b, a).astype(dtype)
+
+
+def assert_allclose_cast(actual, desired, rtol=1e-7, atol=0):
+    """Wrap assert_allclose while casting object arrays."""
+    if actual.dtype.kind == 'O':
+        dtype = np.array(actual.flat[0]).dtype
+        actual, desired = actual.astype(dtype), desired.astype(dtype)
+    assert_allclose(actual, desired, rtol, atol)
+
+
+@pytest.mark.parametrize('func', (sosfilt, lfilter))
+def test_nonnumeric_dtypes(func):
+    x = [Decimal(1), Decimal(2), Decimal(3)]
+    b = [Decimal(1), Decimal(2), Decimal(3)]
+    a = [Decimal(1), Decimal(2), Decimal(3)]
+    x = np.array(x)
+    assert x.dtype.kind == 'O'
+    desired = lfilter(np.array(b, float), np.array(a, float), x.astype(float))
+    if func is sosfilt:
+        actual = sosfilt([b + a], x)
+    else:
+        actual = lfilter(b, a, x)
+    assert all(isinstance(x, Decimal) for x in actual)
+    assert_allclose(actual.astype(float), desired.astype(float))
+    # Degenerate cases
+    if func is lfilter:
+        args = [1., 1.]
+    else:
+        args = [tf2sos(1., 1.)]
+    with pytest.raises(NotImplementedError,
+                       match='input type .* not supported'):
+        func(*args, x=['foo'])
+    with pytest.raises(ValueError, match='must be at least 1D'):
+        func(*args, x=1.)
+
+
+@pytest.mark.parametrize('dt', 'fdgFDGO')
 class TestSOSFilt(object):
 
-    # For sosfilt we only test a single datatype. Since sosfilt wraps
-    # to lfilter under the hood, it's hopefully good enough to ensure
-    # lfilter is extensively tested.
-    dt = np.float64
-
     # The test_rank* tests are pulled from _TestLinearFilter
-    def test_rank1(self):
-        x = np.linspace(0, 5, 6).astype(self.dt)
-        b = np.array([1, -1]).astype(self.dt)
-        a = np.array([0.5, -0.5]).astype(self.dt)
+    def test_rank1(self, dt):
+        x = np.linspace(0, 5, 6).astype(dt)
+        b = np.array([1, -1]).astype(dt)
+        a = np.array([0.5, -0.5]).astype(dt)
 
         # Test simple IIR
-        y_r = np.array([0, 2, 4, 6, 8, 10.]).astype(self.dt)
-        assert_array_almost_equal(sosfilt(tf2sos(b, a), x), y_r)
+        y_r = np.array([0, 2, 4, 6, 8, 10.]).astype(dt)
+        sos = cast_tf2sos(b, a)
+        assert_array_almost_equal(sosfilt(cast_tf2sos(b, a), x), y_r)
 
         # Test simple FIR
-        b = np.array([1, 1]).astype(self.dt)
+        b = np.array([1, 1]).astype(dt)
         # NOTE: This was changed (rel. to TestLinear...) to add a pole @zero:
-        a = np.array([1, 0]).astype(self.dt)
-        y_r = np.array([0, 1, 3, 5, 7, 9.]).astype(self.dt)
-        assert_array_almost_equal(sosfilt(tf2sos(b, a), x), y_r)
+        a = np.array([1, 0]).astype(dt)
+        y_r = np.array([0, 1, 3, 5, 7, 9.]).astype(dt)
+        assert_array_almost_equal(sosfilt(cast_tf2sos(b, a), x), y_r)
 
         b = [1, 1, 0]
         a = [1, 0, 0]
@@ -2855,40 +2900,40 @@ def test_rank1(self):
         y = sosfilt(sos, x)
         assert_allclose(y, [1, 2, 2, 2, 2, 2, 2, 2])
 
-    def test_rank2(self):
+    def test_rank2(self, dt):
         shape = (4, 3)
         x = np.linspace(0, np.prod(shape) - 1, np.prod(shape)).reshape(shape)
-        x = x.astype(self.dt)
+        x = x.astype(dt)
 
-        b = np.array([1, -1]).astype(self.dt)
-        a = np.array([0.5, 0.5]).astype(self.dt)
+        b = np.array([1, -1]).astype(dt)
+        a = np.array([0.5, 0.5]).astype(dt)
 
         y_r2_a0 = np.array([[0, 2, 4], [6, 4, 2], [0, 2, 4], [6, 4, 2]],
-                           dtype=self.dt)
+                           dtype=dt)
 
         y_r2_a1 = np.array([[0, 2, 0], [6, -4, 6], [12, -10, 12],
-                            [18, -16, 18]], dtype=self.dt)
+                            [18, -16, 18]], dtype=dt)
 
-        y = sosfilt(tf2sos(b, a), x, axis=0)
+        y = sosfilt(cast_tf2sos(b, a), x, axis=0)
         assert_array_almost_equal(y_r2_a0, y)
 
-        y = sosfilt(tf2sos(b, a), x, axis=1)
+        y = sosfilt(cast_tf2sos(b, a), x, axis=1)
         assert_array_almost_equal(y_r2_a1, y)
 
-    def test_rank3(self):
+    def test_rank3(self, dt):
         shape = (4, 3, 2)
         x = np.linspace(0, np.prod(shape) - 1, np.prod(shape)).reshape(shape)
 
-        b = np.array([1, -1]).astype(self.dt)
-        a = np.array([0.5, 0.5]).astype(self.dt)
+        b = np.array([1, -1]).astype(dt)
+        a = np.array([0.5, 0.5]).astype(dt)
 
         # Test last axis
-        y = sosfilt(tf2sos(b, a), x)
+        y = sosfilt(cast_tf2sos(b, a), x)
         for i in range(x.shape[0]):
             for j in range(x.shape[1]):
                 assert_array_almost_equal(y[i, j], lfilter(b, a, x[i, j]))
 
-    def test_initial_conditions(self):
+    def test_initial_conditions(self, dt):
         b1, a1 = signal.butter(2, 0.25, 'low')
         b2, a2 = signal.butter(2, 0.75, 'low')
         b3, a3 = signal.butter(2, 0.75, 'low')
@@ -2896,24 +2941,24 @@ def test_initial_conditions(self):
         a = np.convolve(np.convolve(a1, a2), a3)
         sos = np.array((np.r_[b1, a1], np.r_[b2, a2], np.r_[b3, a3]))
 
-        x = np.random.rand(50)
+        x = np.random.rand(50).astype(dt)
 
         # Stopping filtering and continuing
         y_true, zi = lfilter(b, a, x[:20], zi=np.zeros(6))
         y_true = np.r_[y_true, lfilter(b, a, x[20:], zi=zi)[0]]
-        assert_allclose(y_true, lfilter(b, a, x))
+        assert_allclose_cast(y_true, lfilter(b, a, x))
 
         y_sos, zi = sosfilt(sos, x[:20], zi=np.zeros((3, 2)))
         y_sos = np.r_[y_sos, sosfilt(sos, x[20:], zi=zi)[0]]
-        assert_allclose(y_true, y_sos)
+        assert_allclose_cast(y_true, y_sos)
 
         # Use a step function
         zi = sosfilt_zi(sos)
-        x = np.ones(8)
+        x = np.ones(8, dt)
         y, zf = sosfilt(sos, x, zi=zi)
 
-        assert_allclose(y, np.ones(8))
-        assert_allclose(zf, zi)
+        assert_allclose_cast(y, np.ones(8))
+        assert_allclose_cast(zf, zi)
 
         # Initial condition shape matching
         x.shape = (1, 1) + x.shape  # 3D
@@ -2923,14 +2968,15 @@ def test_initial_conditions(self):
         assert_raises(ValueError, sosfilt, sos, x,
                       zi=zi_nd[:, :, :, [0, 1, 1]])
         y, zf = sosfilt(sos, x, zi=zi_nd)
-        assert_allclose(y[0, 0], np.ones(8))
-        assert_allclose(zf[:, 0, 0, :], zi)
+        assert_allclose_cast(y[0, 0], np.ones(8))
+        assert_allclose_cast(zf[:, 0, 0, :], zi)
 
-    def test_initial_conditions_3d_axis1(self):
+    def test_initial_conditions_3d_axis1(self, dt):
         # Test the use of zi when sosfilt is applied to axis 1 of a 3-d input.
 
         # Input array is x.
         x = np.random.RandomState(159).randint(0, 5, size=(2, 15, 3))
+        x = x.astype(dt)
 
         # Design a filter in ZPK format and convert to SOS
         zpk = signal.butter(6, 0.35, output='zpk')
@@ -2955,8 +3001,8 @@ def test_initial_conditions_3d_axis1(self):
 
         # y should equal yf, and z2 should equal zf.
         y = np.concatenate((y1, y2), axis=axis)
-        assert_allclose(y, yf, rtol=1e-10, atol=1e-13)
-        assert_allclose(z2, zf, rtol=1e-10, atol=1e-13)
+        assert_allclose_cast(y, yf, rtol=1e-10, atol=1e-13)
+        assert_allclose_cast(z2, zf, rtol=1e-10, atol=1e-13)
 
         # let's try the "step" initial condition
         zi = sosfilt_zi(sos)
@@ -2969,26 +3015,34 @@ def test_initial_conditions_3d_axis1(self):
         zi.shape = [1, zi.size, 1]
         zi = zi * x[:, 0:1, :]
         y_tf = lfilter(b, a, x, axis=axis, zi=zi)[0]
-        assert_allclose(y, y_tf, rtol=1e-10, atol=1e-13)
+        assert_allclose_cast(y, y_tf, rtol=1e-10, atol=1e-13)
 
-    def test_bad_zi_shape(self):
+    def test_bad_zi_shape(self, dt):
         # The shape of zi is checked before using any values in the
         # arguments, so np.empty is fine for creating the arguments.
-        x = np.empty((3, 15, 3))
-        sos = np.empty((4, 6))
+        x = np.empty((3, 15, 3), dt)
+        sos = np.zeros((4, 6))
         zi = np.empty((4, 3, 3, 2))  # Correct shape is (4, 3, 2, 3)
-        assert_raises(ValueError, sosfilt, sos, x, zi=zi, axis=1)
+        with pytest.raises(ValueError, match='should be all ones'):
+            sosfilt(sos, x, zi=zi, axis=1)
+        sos[:, 3] = 1.
+        with pytest.raises(ValueError, match='Invalid zi shape'):
+            sosfilt(sos, x, zi=zi, axis=1)
 
-    def test_sosfilt_zi(self):
+    def test_sosfilt_zi(self, dt):
         sos = signal.butter(6, 0.2, output='sos')
         zi = sosfilt_zi(sos)
 
-        y, zf = sosfilt(sos, np.ones(40), zi=zi)
-        assert_allclose(zf, zi, rtol=1e-13)
+        y, zf = sosfilt(sos, np.ones(40, dt), zi=zi)
+        assert_allclose_cast(zf, zi, rtol=1e-13)
 
         # Expected steady state value of the step response of this filter:
         ss = np.prod(sos[:, :3].sum(axis=-1) / sos[:, 3:].sum(axis=-1))
-        assert_allclose(y, ss, rtol=1e-13)
+        assert_allclose_cast(y, ss, rtol=1e-13)
+
+        # zi as array-like
+        _, zf = sosfilt(sos, np.ones(40, dt), zi=zi.tolist())
+        assert_allclose_cast(zf, zi, rtol=1e-13)
 
 
 class TestDeconvolve(object):