8000 ENH: signal.filtfilt supports complex-coefficient filters by roryyorke · Pull Request #22014 · scipy/scipy · GitHub
[go: up one dir, main page]

Skip to content
/ scipy Public

ENH: signal.filtfilt supports complex-coefficient filters #22014

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 10 commits into
base: main
Choose a base branch
from

Conversation

roryyorke
Copy link
Contributor

Reference issue

Closes gh-17877.

What does this implement/fix?

Correctly handle complex-coefficient filters in filtfilt for both default and Gustafsson's method.

Additional information

Arguably a fix, not an enhancement.

I'm not sure about the .. versionchanged directives I added:

  • should they be there at all?
  • should they rather be separate from the arguments under Notes or similar?

I haven't addressed the need for possibly different symmetry in imaginary part, nor making the Gustafsson's unit test more efficient (see gh-17877 for remarks on both of these).

Added unit test for padded (default) and Gustafsson's method.

Fixes scipygh-17877.
@github-actions github-actions bot added scipy.signal enhancement A new feature or improvement labels Dec 7, 2024
@roryyorke
Copy link
Contributor Author

I mistakenly thought sosfiltfilt was implemented in Cython, but I see sosfiltfilt is pure Python; it's sosfilt that's Cython. So the corresponding change to sosfiltfilt is quite simple (see below).

scipy.signal doesn't support zpk->sos for complex-coefficient filters, though. The diff below tests sosfiltfilt using a custom, naive zpk->sos for the sosfiltfilt tests. That's good from the point-of-view that sosfiltfilt gets the right update, but the capability would be hard to use: users would need to compute their own second-order-sections.

diff --git a/scipy/signal/_signaltools.py b/scipy/signal/_signaltools.py
index de4bbbb82..4cab95dca 100644
--- a/scipy/signal/_signaltools.py
+++ b/scipy/signal/_signaltools.py
@@ -4835,7 +4835,7 @@ def sosfiltfilt(sos, x, axis=-1, padtype='odd', padlen=None):
     x_0 = axis_slice(ext, stop=1, axis=axis)
     (y, zf) = sosfilt(sos, ext, axis=axis, zi=zi * x_0)
     y_0 = axis_slice(y, start=-1, axis=axis)
-    (y, zf) = sosfilt(sos, axis_reverse(y, axis=axis), axis=axis, zi=zi * y_0)
+    (y, zf) = sosfilt(sos.conj(), axis_reverse(y, axis=axis), axis=axis, zi=zi.conj() * y_0)
     y = axis_reverse(y, axis=axis)
     if edge > 0:
         y = axis_slice(y, start=edge, stop=-edge, axis=axis)
diff --git a/scipy/signal/tests/test_signaltools.py b/scipy/signal/tests/test_signaltools.py
index 8013d824d..80aae9131 100644
--- a/scipy/signal/tests/test_signaltools.py
+++ b/scipy/signal/tests/test_signaltools.py
@@ -2307,6 +2307,27 @@ class TestLFilterZI:
         assert_equal(np.real(signal.lfilter_zi(b, a)).dtype, dtype)
 
 
+def pairwise(flat):
+    """Make a list pairwise, with 0-tail padding"""
+    pairs = list(zip(flat[::2],flat[1::2]))
+    if len(flat) % 2 == 1:
+        pairs.append((flat[-1],0))
+    return pairs
+
+
+def zpk2sos_cplx(z, p, k):
+    """Naive zpk -> sos transformation for complex-coefficient filters"""
+    pairz = pairwise(z)
+    pairp = pairwise(p)
+    order = max(len(pairz), len(pairp))
+    pairz.extend([(0,0)] * (order - len(pairz)))
+    pairp.extend([(0,0)] * (order - len(pairp)))
+    soslist = []
+    for z, p in zip(pairz, pairp, strict=True):
+        soslist.append(np.hstack(signal.zpk2tf(z, p, 1)))
+    soslist[0][:3] *= k
+    return np.array(soslist)
+
 class TestFiltFilt:
     filtfilt_kind = 'tf'
 
@@ -2316,7 +2337,10 @@ class TestFiltFilt:
             b, a = zpk2tf(*zpk)
             return filtfilt(b, a, x, axis, padtype, padlen, method, irlen)
         elif self.filtfilt_kind == 'sos':
-            sos = zpk2sos(*zpk)
+            try:
+                sos = zpk2sos(*zpk)
+            except ValueError:
+                sos = zpk2sos_cplx(*zpk)
             return sosfiltfilt(sos, x, axis, padtype, padlen)
 
     def test_basic(self):
@@ -2405,8 +2429,6 @@ class TestFiltFilt:
 
     def test_complex_coeff(self):
         # gh-17877: complex coefficient filters
-        if self.filtfilt_kind != 'tf':
-            pytest.skip('complex coefficents only for tf')
 
         # centre frequency for filter [/time]
         fcentre = 50

@j-bowhay j-bowhay requested a review from DietBru December 8, 2024 16:54
@roryyorke
Copy link
Contributor Author

The merge conflict itself wasn't complicated. I updated the new test to use xp. everywhere instead of np. - I don't know if that's the right thing.

@roryyorke
Copy link
Contributor Author

the failures are in TestFiltFilt.test_complex_coeff[array_api_strict], TestFiltFilt.test_complex_coeff[jax.numpy], and TestFiltFilt.test_complex_coeff[jax.numpy], with the first two being to do with using xp.exp; and the latter being for xp.testing.

I can easily enough change back to np., but I (1) don't want to burn CI time, and (2) don't know when to use xp and when to use np, so I'll leave this as-is until I have time to research, or get feedback.

______________ TestFiltFilt.test_complex_coeff[array_api_strict] _______________
scipy/signal/tests/test_signaltools.py:2787: in test_complex_coeff
    z *= xp.exp(2j * xp.pi * fcentre/fs)
        fcentre    = 50
        fs         = 1000.0
        fwidth     = 5
        k          = np.float64(0.0022747565743493963)
        p          = array([0.93031353+0.06513695j, 0.93031353-0.06513695j])
        self       = <scipy.signal.tests.test_signaltools.TestFiltFilt object at 0x7fed96297f90>
        xp         = <module 'array_api_strict' from '/opt/hostedtoolcache/Python/3.11.11/x64/lib/python3.11/site-packages/array_api_strict/__init__.py'>
        z          = array([-1.+0.j, -1.+0.j])
/opt/hostedtoolcache/Python/3.11.11/x64/lib/python3.11/site-packages/array_api_strict/_elementwise_functions.py:422: in exp
    if x.dtype not in _floating_dtypes:
E   AttributeError: 'complex' object has no attribute 'dtype'
        x          = 0.3141592653589793j
____________________ TestFiltFilt.test_complex_coeff[torch] ____________________
scipy/signal/tests/test_signaltools.py:2787: in test_complex_coeff
    z *= xp.exp(2j * xp.pi * fcentre/fs)
E   TypeError: exp(): argument 'input' (position 1) must be Tensor, not complex
        fcentre    = 50
        fs         = 1000.0
        fwidth     = 5
        k          = np.float64(0.0022747565743493963)
        p          = array([0.93031353+0.06513695j, 0.93031353-0.06513695j])
        self       = <scipy.signal.tests.test_signaltools.TestFiltFilt object at 0x7fed96298250>
        xp         = <module 'torch' from '/opt/hostedtoolcache/Python/3.11.11/x64/lib/python3.11/site-packages/torch/__init__.py'>
        z          = array([-1.+0.j, -1.+0.j])
__________________ TestFiltFilt.test_complex_coeff[jax.numpy] __________________
scipy/signal/tests/test_signaltools.py:2810: in test_complex_coeff
    xp.testing.assert_array_less(abs(y - yref)[ntrans:-ntrans], 1e-2)
        a          = array([ 1.        +0.j        , -1.7695615 -0.57496538j,
        0.70362319+0.51121217j])
        b          = array([0.00227476+0.j        , 0.00432684+0.00140588j,
       0.00184032+0.00133707j])
        fcentre    = 50
        fs         = 1000.0
        fwidth     = 5
        k          = np.float64(0.0022747565743493963)
        ntrans     = 75
        p          = Array([0.86465232+0.34943162j, 0.90490917+0.22553377j], dtype=complex128)
        self       = <scipy.signal.tests.test_signaltools.TestFiltFilt object at 0x7fed962984d0>
        t          = Array([0.   , 0.001, 0.002, 0.003, 0.004, 0.005, 0.006, 0.007, 0.008,
       0.009, 0.01 , 0.011, 0.012, 0.013, 0.014,...88, 0.489, 0.49 , 0.491, 0.492, 0.493, 0.494,
       0.495, 0.496, 0.497, 0.498, 0.499], dtype=float64, weak_type=True)
        u          = Array([ 2.00000000e+00,  1.90211303e+00,  1.61803399e+00,  1.17557050e+00,
        6.18033989e-01,  1.22464680e-16, -6...24e-14,
        6.18033989e-01,  1.17557050e+00,  1.61803399e+00,  1.90211303e+00],      dtype=float64, weak_type=True)
        xp         = <module 'jax.numpy' from '/opt/hostedtoolcache/Python/3.11.11/x64/lib/python3.11/site-packages/jax/numpy/__init__.py'>
        y          = Array([ 1.81503083e-01+2.60283149e-01j,  1.45046267e-01+3.21498067e-01j,
        8.43437735e-02+3.81686759e-01j, -2.75...8e-04-3.60584087e-01j,
        7.21937214e-02-3.11107054e-01j,  1.18328030e-01-2.53455152e-01j],      dtype=complex128)
        yref       = Array([ 1.00000000e+00+0.00000000e+00j,  9.51056516e-01+3.09016994e-01j,
        8.09016994e-01+5.87785252e-01j,  5.87...e-01j,
        8.09016994e-01-5.87785252e-01j,  9.51056516e-01-3.09016994e-01j],      dtype=complex128, weak_type=True)
        z          = Array([-0.95105652-0.30901699j, -0.95105652-0.30901699j], dtype=complex128)
/opt/hostedtoolcache/Python/3.11.11/x64/lib/python3.11/site-packages/jax/_src/deprecations.py:55: in getattr
    raise AttributeError(f"module {module!r} has no attribute {name!r}")
E   AttributeError: module 'jax.numpy' has no attribute 'testing'
        deprecations = {'round_': ('jnp.round_ is deprecated; use jnp.round instead.', <PjitFunction of <function round at 0x7fedd3e73b00>>)}
        module     = 'jax.numpy'
        name       = 'testing'

Copy link
Member
@ev-br ev-br left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We probably want an opinion of @larsoner and/or @ilayn since complex-valued story is scipy.signal is a bit tricky.
I personally have no opinion and would be happy to go with what you three agree on :-).

Most of my comments below are purely janitorial and assume you wanted to make the new test you added array api compatible. If not, it's fine to decorate the test with @skip_xp_backends(np_only=True) + a code comment to remind us that it's just lack of time not an inherent reason it cannot run on alternative array backends.

Also note that this part of scipy.signal is in a bit of a flux w.r.t. array api compatibility, and domain expert help in converting it would be great, actually. This is a bit baffling at first but is no rocket science and we will help :-).

filtfilt code itself is a bit weird: it's all numpy + xp.asarray at the very end because lfilter is numpy-only compiled code. So there's little reason to generalize filtfilt internals, they can keep being what they are.

@@ -4331,8 +4331,16 @@ def _filtfilt_gust(b, a, x, axis=-1, irlen=None):
----------
b : scalar or 1-D ndarray
Numerator coefficients of the filter.

..versionchanged:: 1.15.0
Complex-valued `b` is correctly handled.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TBH, "is correctly handled" does not tell a reader much. Maybe this needs a longer explanation in the Notes section which you refer the reader from here.

Also, is this change backwards compatibile? If not, is it a bug fix or a behavior change --- in the latter case you migh need to argue about a deprecation process.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TBH, "is correctly handled" does not tell a reader much. Maybe this needs a longer explanation in the Notes section which you refer the reader from here.

Thanks, that is a good idea.

Also, is this change backwards compatibile? If not, is it a bug fix or a behavior change --- in the latter case you migh need to argue about a deprecation process

A good point.

It's backwardly compatible for real-coefficient filters, but not for complex-coefficient filters. I can't believe anyone is relying on the current behaviour for complex-coefficient filters, but it's remotely conceivable. The effect of the unchanged code is to apply the filter and its conjugate, but only once each.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds like it's clear a bugfix then, right?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds like it's clear a bugfix then, right?

Yes. Shall I change the PR title?

Here's what I've done for the "version changed" wording:

--- a/scipy/signal/_signaltools.py
+++ b/scipy/signal/_signaltools.py
@@ -4523,15 +4523,15 @@ def filtfilt(b, a, x, axis=-1, padtype='odd', padlen=None, method='pad',
     b : (N,) array_like
         The numerator coefficient vector of the filter.
 
-        .. versionchanged:: 1.15.0
-           Complex `b` is correctly handled.
+        .. versionchanged:: 1.16.0
+           Change in behaviour for complex `b`, see ``Notes``.
 
     a : (N,) array_like
         The denominator coefficient vector of the filter.  If ``a[0]``
         is not 1, then both `a` and `b` are normalized by ``a[0]``.
 
-        .. versionchanged:: 1.15.0
-           Complex `a` is correctly handled.
+        .. versionchanged:: 1.16.0
+           Change in behaviour for complex `a`, see ``Notes``.
 
     x : array_like
         The array of data to be filtered.
@@ -4586,6 +4586,13 @@ def filtfilt(b, a, x, axis=-1, padtype='odd', padlen=None, method='pad',
 
     The option to use Gustaffson's method was added in scipy version 0.16.0.
 
+    .. versionchanged:: 1.16.0
+
+       `b` and `a` are complex-conjugated when applying the backward
+       filter.  This conjugation reflects the filter's frequency
+       spectrum through zero, corresponding to the effect on the
+       signal's frequency spectrum when it is reversed in time.
+
     References
     ----------
     .. [1] F. Gustaffson, "Determining the initial states in forward-backward

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks reasonable to me!

One last test idea, would it be worth testing some simple complex filter (2-tap IIR?) on a short signal (5 samples?) with MATLAB or Octave with simple edge padding (constant?) and hard-coding the result in tests to check against?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One last test idea, would it be worth testing some simple complex filter (2-tap IIR?) on a short signal (5 samples?) with MATLAB or Octave with simple edge padding (constant?) and hard-coding the result in tests to check against?

Good idea. I'll see if Octave supports complex-coefficient filters.

# sample rate [/time]
fs = 1e3

z, p, k = signal.butter(2, 2*xp.pi*fwidth/2, output='zpk', fs=fs)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

as long as butter is numpy-only , you need to wrap z and p into xp.asarray(). Please also add a comment # XXX while butter is numpy-only so that we can search-and-remove when butter is converted.

@ev-br
Copy link
Member
ev-br commented Dec 14, 2024

when to use xp and when to use np

#22014 (comment)

To expand that comment:

TL;DR:

  • always add an xp argument to test functions
  • use xp_assert_* assertions (see elsewhere in test_signaltools.py for examples)
  • if you want to only run tests under numpy as the backend, add @skip_xp_backends(np_only=True) decorator
  • it's actually preferable to make tests array backend-agnostic. Then you use xp.-namespaced functions.

EDIT:

don't want to burn CI time

Locally, use the -b all argument to dev.py to run tests with all backends that you have installed (it's very useful to install array_api_strict, it will complain about all kinds of non-compliant usage):

$ python dev.py test -t scipy/signal/tests/test_signaltools.py -b all

If you want to experiment in ipython, add the env variable:

$ SCIPY_ARRAY_API=1 python dev.py ipython

@ilayn
Copy link
Member
ilayn commented Dec 14, 2024

I lost track of this so if you folks are happy with it, I'm OK with it, @DietBru wanna take a look?

@ev-br
Copy link
Member
ev-br commented Dec 14, 2024
____________________ TestFiltFilt.test_complex_coeff[torch] ____________________
scipy/signal/tests/test_signaltools.py:2787: in test_complex_coeff
    z *= xp.exp(2j * xp.pi * fcentre/fs)
E   TypeError: exp(): argument 'input' (position 1) must be Tensor, not complex
        fcentre    = 50
        fs         = 1000.0

TL;DR: Since you defined fcentre as python scalar, use cmath.exp instead of xp.exp.

In general, numpy is an odd one out which allows python scalars in and returns arrays. The next revision of the spec will most likely allow python PODs in binary functions (so that you will be able to xp.add(2, xp.arange(3)) etc) but for unary functions, no---those will keep refusing the temptation to guess the target dtype and device.

@j-bowhay j-bowhay added this to the 1.16.0 milestone Dec 15, 2024
@roryyorke
Copy link
Contributor Author

Thanks @ev-br for all the help.

Locally, use the -b all argument to dev.py to run tests with all backends that you have installed (it's very useful to install array_api_strict, it will complain about all kinds of non-compliant usage):

This was a good tip.

We still need to resolve the backward-compatibility issue, and I will meanwhile reword the explanation of the change.

@DietBru
Copy link
Contributor
DietBru commented Dec 17, 2024

All in all, this PR looks quite plausible to me. I have to admit that I do not fully understand Gustaffson's method and its implementation. Toying with a simple filter gives:

b, a = np.array([1j]), np.array([1, -0.5])
x = np.array([0, 0, 0, 0, 1, 0, 0, 0, 0], dtype=complex)


fig1, (ax10, ax11) = plt.subplots(2, 1, sharex='all', tight_layout=True)
ax10.set(title=f"Response for {b=}, {a=}", ylabel="Real")
ax11.set(xlabel="Samples", ylabel="Imag")
for n_ in ('pad', 'gust'):
    y = filtfilt(b, a, x, method=n_)
    ax10.step(y.real, '.-', where='post', alpha=0.5, label=n_)
    ax11.step(y.imag, '.-', where='post', alpha=0.5, label=n_)

for ax_ in (ax10, ax11):
    ax_.grid()
    ax_.legend()
plt.show()

image
I find the 'pad' method to be plausible and I would expect that 'gust' should produce the same result
(for real b, the results are identical).

@roryyorke
Copy link
Contributor Author

I find the 'pad' method to be plausible and I would expect that 'gust' should produce the same result
(for real b, the results are identical).

Interesting test case, thanks; I'll take a closer look.

@roryyorke
Copy link
Contributor Author

@larsoner wrote:

One last test idea, would it be worth testing some simple complex filter (2-tap IIR?) on a short signal (5 samples?) with MATLAB or Octave with simple edge padding (constant?) and hard-coding the result in tests to check against?

I don't have access to Matlab. I read their documentation for filtfilt https://www.mathworks.com/help/signal/ref/filtfilt.html, which was interesting but said nothing specific about complex-coefficient filters, and looking through the first few results of internet search for "matlab filtfilt complex coefficient" and "matlab filtfilt conjugate" revealed nothing of use.

Both Octave's signal and Julia's DSP.jl have the same behaviour as "unfixed" Scipy -- I tested using @DietBru 's test case, see below. Scilab doesn't have filtfilt https://gitlab.com/scilab/scilab/-/issues/15520 .

Although I'm fairly sure my proposed change is correct, this overall lack of support could point to some convention I'm ignorant of, though it's also plausible this is just too narrow a use case to come up often.

Octave:

$ octave
GNU Octave, version 7.3.0
Copyright (C) 1993-2022 The Octave Project Developers.
This is free software; see the source code for copying conditions.
There is ABSOLUTELY NO WARRANTY; not even for MERCHANTABILITY or
FITNESS FOR A PARTICULAR PURPOSE.  For details, type 'warranty'.

Octave was configured for "x86_64-pc-linux-gnu".

Additional information about Octave is available at https://www.octave.org.

Please contribute if you find this software useful.
For more information, visit https://www
9E88
.octave.org/get-involved.html

Read https://www.octave.org/bugs.html to learn how to submit bug reports.
For information about changes from previous versions, type 'news'.

octave:1> pkg load signal
octave:2> filtfilt([1j], [1,-0.5], [0,0,0,0,1,0,0,0,0])
ans =

  -0.083336  -0.166672  -0.333344  -0.666687  -1.333374  -0.666748  -0.333496  -0.166992  -0.083984

Julia:

$ julia
               _
   _       _ _(_)_     |  Documentation: https://docs.julialang.org
  (_)     | (_) (_)    |
   _ _   _| |_  __ _   |  Type "?" for help, "]?" for Pkg help.
  | | | | | | |/ _` |  |
  | | |_| | | | (_| |  |  Version 1.11.2 (2024-12-01)
 _/ |\__'_|_|_|\__'_|  |  Official https://julialang.org/ release
|__/                   |

julia> import DSP

julia> DSP.filtfilt(DSP.Filters.PolynomialRatio([1im],[1,-0.5]), [0,0,0,0,1,0,0,0,0])
9-element Vector{ComplexF64}:
 -0.08333587646484375 + 0.0im
  -0.1666717529296875 + 0.0im
   -0.333343505859375 + 0.0im
    -0.66668701171875 + 0.0im
     -1.3333740234375 + 0.0im
      -0.666748046875 + 0.0im
       -0.33349609375 + 0.0im
        -0.1669921875 + 0.0im
         -0.083984375 + 0.0im

@roryyorke
Copy link
Contributor Author

Summary: I think the Gustafsson result is correct.

@DietBru wrote:

I find the 'pad' method to be plausible and I would expect that 'gust' should produce the same result

Gustafsson's method finds filter initial conditions such that one gets the same output whether the forward filter is run first, and then the reverse, or the reverse and then the forward. As I understand, these initial conditions are unique, assuming filter input longer than filter order (or thereabouts: it may be filter order + 1, or - 1).

From the script below this forward-reverse condition is satisfied by the PR code, but the result is not the same as the pad method. The gust output is symmetric: the real part has even symmetry, and the imaginary part odd symmetry. The pad output real part even symmetry is not as exact as that of gust. I imagine the symmetry is a combination of gust and the symmetry of the input.

# gh-22014 discussion re gustafsson vs pad result

import numpy as np
from scipy import signal
from scipy.signal._signaltools import _filtfilt_gust

b, a = np.array([-1j]), np.array([1, -0.5])
x = np.array([0, 0, 0, 0, 1, 0, 0, 0, 0])

ygust, x0, x1 = _filtfilt_gust(b, a, x)

# forward then reverse
yfr = signal.lfilter(b.conj(), a.conj(), 
                     signal.lfilter(b, a, x, zi=x0)[0][::-1], zi=x1)[0][::-1]
# compare against filtfilt output; computed in slightly different way
print('y-forward-reverse vs ygust')
print(f'  {max(abs(yfr-ygust))=}')

# reverse then forward
yrf = signal.lfilter(b, a, 
                     signal.lfilter(b.conj(), a.conj(), 
                                    x[::-1], zi=x1)[0][::-1], zi=x0)[0]

# compare forward and reverse
print('y-forward-reverse vs y-reverse-forward')
print(f'  {max(abs(yrf-yfr))=}')

print()
print('symmetry of ygust')
print(f'  {ygust[:4].real - ygust[5:][::-1].real=}')
print(f'  {ygust[:4].imag + ygust[5:][::-1].imag=}')

# symmetry of ypad
ypad = signal.filtfilt(b, a, x)
print()
print('symmetry of ypad')
print(f'  {ypad[:4].real - ypad[5:][::-1].real=}')

output

y-forward-reverse vs ygust
  max(abs(yfr-ygust))=np.float64(1.1102230246251565e-16)
y-forward-reverse vs y-reverse-forward
  max(abs(yrf-yfr))=np.float64(1.1102569054260746e-16)

symmetry of ygust
  ygust[:4].real - ygust[5:][::-1].real=array([0., 0., 0., 0.])
  ygust[:4].imag + ygust[5:][::-1].imag=array([-1.73472348e-18, -8.67361738e-19, -4.33680869e-19, -2.16840434e-19])

symmetry of ypad
  ypad[:4].real - ypad[5:][::-1].real=array([0.0025839 , 0.00127675, 0.00060798, 0.00024319])

@DietBru
Copy link
Contributor
DietBru commented Jan 2, 2025

From the script below this forward-reverse condition is satisfied by the PR code, but the result is not the same as the pad method. The gust output is symmetric: the real part has even symmetry, and the imaginary part odd symmetry. The pad output real part even symmetry is not as exact as that of gust. I imagine the symmetry is a combination of gust and the symmetry of the input.

Looking at your little script, I was able to reverse the symmetry by make the input x purely imaginary, i.e.,

b, a = np.array([-1j]), np.array([1, -0.5])
x = np.array([0, 0, 0, 0, 1j, 0, 0, 0, 0])

It smells to me like a complex conjugation is missing or misplaced somewhere. I do not know if the problem is in Gustavsson's paper or in the implementation. I am sorry that currently I do not have the bandwidth to dive deeper into Gustavsson's method to write you something more meaningful.

@roryyorke
Copy link
Contributor Author
roryyorke commented Jan 3, 2025

Thanks for your patience -- this is a obscure corner of a technical topic.

Looking at your little script, I was able to reverse the symmetry by make the input x purely imaginary,

(For anyone else reading, real part of output is now odd & imaginary part is now even.)

I think filtfilt, Gustafsson or otherwise, should be linear in the input signal, which is the case for this input (and other inputs and scale factors, see below); this is consistent with this pure-imaginary input result.

Why I think the current method is right (this is not a watertight argument):

1. Conjugating the coefficients makes sense from theory, and gives the right result, ignoring edge transients
2. I've used the same conjugation of coefficients for Gustafsson's method
3. The result satisfies the order-of-operation symmetry, which is all Gustafsson's method promises

If I'm right, there's a is counter-intuitive consequence: the filtfilt operation doesn't scale with the filter. That is, if filter $G$ applied to input $u$ gives output $y$, then filter $kG$ applied to filter input $u$ doesn't give output $|k|^2 y$. It would be even stranger if such a scaling relationship held for real but not complex $k$.

filtfilt output scaling with input:

# filtfilt linear in input signal

import numpy as np
from scipy import signal


def test_lin(b, a, u, uk, method):
    y0 = signal.filtfilt(b, a, u, method=method)
    y1 = signal.filtfilt(b, a, uk*u, method=method)
    return max(abs(uk*y0 - y1))


b, a = np.array([-1j]), np.array([1, -0.5])
u1 = np.array([0,0,0,0,1j,0,0,0,0])

print('symmetric impulse input, g(z) = -1j/(z - 0.5), k=1j')
print(f'{test_lin(b, a, u1, 1j, "gust")             = :.4g}')
print(f'{test_lin(b, a, u1, 1j, "pad")              = :.4g}')

print('\nsymmetric impulse input, g(z) = -1j/(z - 0.5), k=1.234+7.89j')
print(f'{test_lin(b, a, u1, 1.234 + 7.89j, "gust")  = :.4g}')
print(f'{test_lin(b, a, u1, 1.234 + 7.89j, "pad")   = :.4g}')

ur = np.random.normal(size=(100,))

print('\nrandom input, g(z) = -1j/(z - 0.5), k=1.234+7.89j')
print(f'{test_lin(b, a, ur, 1.234 + 7.89j, "gust")  = :.4g}')
print(f'{test_lin(b, a, ur, 1.234 + 7.89j, "pad")   = :.4g}')

nnum = 6 # number of zeros
nden = 6 # number of poles

# random zeros
z = np.random.normal(size=(nnum,)) + 1j * np.random.normal(size=(nnum,))

# random stable poles
p = np.random.normal(size=(nden,)) + 1j * np.random.normal(size=(nden,))
unst = abs(p) > 1
p[unst] = 1/p[unst]

# arbitrary k
k = 1

br, ar = signal.zpk2tf(z, p, k)

print('\nrandom input, random stable g(z), k=1.234+7.89j')
print(f'{test_lin(br, ar, ur, 1.234 + 7.89j, "gust") = :.4g}')
print(f'{test_lin(br, ar, ur, 1.234 + 7.89j, "pad")  = :.4g}')

Results, using this branch:

symmetric impulse input, g(z) = -1j/(z - 0.5), k=1j
test_lin(b, a, u1, 1j, "gust")             = 0
test_lin(b, a, u1, 1j, "pad")              = 0

symmetric impulse input, g(z) = -1j/(z - 0.5), k=1.234+7.89j
test_lin(b, a, u1, 1.234 + 7.89j, "gust")  = 1.79e-15
test_lin(b, a, u1, 1.234 + 7.89j, "pad")   = 1.776e-15

random input, g(z) = -1j/(z - 0.5), k=1.234+7.89j
test_lin(b, a, ur, 1.234 + 7.89j, "gust")  = 7.324e-15
test_lin(b, a, ur, 1.234 + 7.89j, "pad")   = 7.161e-15

random input, random stable g(z), k=1.234+7.89j
test_lin(br, ar, ur, 1.234 + 7.89j, "gust") = 9.687e-11
test_lin(br, ar, ur, 1.234 + 7.89j, "pad")  = 1.151e-10

@DietBru
Copy link
Contributor
DietBru commented Mar 27, 2025

I finally found the time to study Gustafsson paper a bit, but there are still quite a few question marks for me: I do not understand how to integrate the conjugation into the following properties:
image

E.g., the second line A[::-1, :].conj() == B[::-1, :].conj() @ B does not hold. Though the last line A[::-1, ::1].conj() == A.T.conj() holds (if A is Toeplitz).

Hence, how does the conjugation of b and a for reversing fit in?

Also Matlab's filtfilt does not produce the same result:

>> b = 1i; a = [-1, 0.5];
>> x = [0, 0, 0, 0, 1, 0, 0, 0, 0];
>> y = filtfilt(b, a, x)
y =

   -0.0833   -0.1667   -0.3333   -0.6667   -1.3334   -0.6667   -0.3335   -0.1670   -0.0840

PS: You can use Matlab online for 20h per month for free, if you register.

@ev-br
Copy link
Member
ev-br commented May 16, 2025

There seems to be an ongoing discussion between @roryyorke and @DietBru , and it's unlikely the discussion resolves before branching 1.16. Am bumping the milestone. Would be great to arrive at a conclusion, ideally early in the 1.17 cycle, so that a potentially good change does not keep lingering.

@ev-br ev-br modified the milestones: 1.16.0, 1.17.0 May 16, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement A new feature or improvement scipy.signal
Projects
None yet
Development

Successfully merging this pull request may close these issues.

BUG: filtfilt doesn't work with complex coefficient filters
6 participants
0