Skip to content

Commit be0fd8a

Browse files
Add causal filtering to filter.py (#3172)
1 parent 694f862 commit be0fd8a

4 files changed

Lines changed: 254 additions & 12 deletions

File tree

doc/api.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,7 @@ spikeinterface.preprocessing
171171
.. autofunction:: interpolate_bad_channels
172172
.. autofunction:: normalize_by_quantile
173173
.. autofunction:: notch_filter
174+
.. autofunction:: causal_filter
174175
.. autofunction:: phase_shift
175176
.. autofunction:: rectify
176177
.. autofunction:: remove_artifacts

src/spikeinterface/preprocessing/filter.py

Lines changed: 116 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,12 @@
2424

2525
class FilterRecording(BasePreprocessor):
2626
"""
27-
Generic filter class based on:
28-
29-
* scipy.signal.iirfilter
30-
* scipy.signal.filtfilt or scipy.signal.sosfilt
27+
A generic filter class based on:
28+
For filter coefficient generation:
29+
* scipy.signal.iirfilter
30+
For filter application:
31+
* scipy.signal.filtfilt or scipy.signal.sosfiltfilt when direction = "forward-backward"
32+
* scipy.signal.lfilter or scipy.signal.sosfilt when direction = "forward" or "backward"
3133
3234
BandpassFilterRecording is built on top of it.
3335
@@ -56,6 +58,11 @@ class FilterRecording(BasePreprocessor):
5658
- numerator/denominator : ("ba")
5759
ftype : str, default: "butter"
5860
Filter type for `scipy.signal.iirfilter` e.g. "butter", "cheby1".
61+
direction : "forward" | "backward" | "forward-backward", default: "forward-backward"
62+
Direction of filtering:
63+
- "forward" - filter is applied to the timeseries in one direction, creating phase shifts
64+
- "backward" - the timeseries is reversed, the filter is applied and filtered timeseries reversed again. Creates phase shifts in the opposite direction to "forward"
65+
- "forward-backward" - Applies the filter in the forward and backward direction, resulting in zero-phase filtering. Note this doubles the effective filter order.
5966
6067
Returns
6168
-------
@@ -75,6 +82,7 @@ def __init__(
7582
add_reflect_padding=False,
7683
coeff=None,
7784
dtype=None,
85+
direction="forward-backward",
7886
):
7987
import scipy.signal
8088

@@ -106,7 +114,13 @@ def __init__(
106114
for parent_segment in recording._recording_segments:
107115
self.add_recording_segment(
108116
FilterRecordingSegment(
109-
parent_segment, filter_coeff, filter_mode, margin, dtype, add_reflect_padding=add_reflect_padding
117+
parent_segment,
118+
filter_coeff,
119+
filter_mode,
120+
margin,
121+
dtype,
122+
add_reflect_padding=add_reflect_padding,
123+
direction=direction,
110124
)
111125
)
112126

@@ -121,14 +135,25 @@ def __init__(
121135
margin_ms=margin_ms,
122136
add_reflect_padding=add_reflect_padding,
123137
dtype=dtype.str,
138+
direction=direction,
124139
)
125140

126141

127142
class FilterRecordingSegment(BasePreprocessorSegment):
128-
def __init__(self, parent_recording_segment, coeff, filter_mode, margin, dtype, add_reflect_padding=False):
143+
def __init__(
144+
self,
145+
parent_recording_segment,
146+
coeff,
147+
filter_mode,
148+
margin,
149+
dtype,
150+
add_reflect_padding=False,
151+
direction="forward-backward",
152+
):
129153
BasePreprocessorSegment.__init__(self, parent_recording_segment)
130154
self.coeff = coeff
131155
self.filter_mode = filter_mode
156+
self.direction = direction
132157
self.margin = margin
133158
self.add_reflect_padding = add_reflect_padding
134159
self.dtype = dtype
@@ -150,11 +175,24 @@ def get_traces(self, start_frame, end_frame, channel_indices):
150175

151176
import scipy.signal
152177

153-
if self.filter_mode == "sos":
154-
filtered_traces = scipy.signal.sosfiltfilt(self.coeff, traces_chunk, axis=0)
155-
elif self.filter_mode == "ba":
156-
b, a = self.coeff
157-
filtered_traces = scipy.signal.filtfilt(b, a, traces_chunk, axis=0)
178+
if self.direction == "forward-backward":
179+
if self.filter_mode == "sos":
180+
filtered_traces = scipy.signal.sosfiltfilt(self.coeff, traces_chunk, axis=0)
181+
elif self.filter_mode == "ba":
182+
b, a = self.coeff
183+
filtered_traces = scipy.signal.filtfilt(b, a, traces_chunk, axis=0)
184+
else:
185+
if self.direction == "backward":
186+
traces_chunk = np.flip(traces_chunk, axis=0)
187+
188+
if self.filter_mode == "sos":
189+
filtered_traces = scipy.signal.sosfilt(self.coeff, traces_chunk, axis=0)
190+
elif self.filter_mode == "ba":
191+
b, a = self.coeff
192+
filtered_traces = scipy.signal.lfilter(b, a, traces_chunk, axis=0)
193+
194+
if self.direction == "backward":
195+
filtered_traces = np.flip(filtered_traces, axis=0)
158196

159197
if right_margin > 0:
160198
filtered_traces = filtered_traces[left_margin:-right_margin, :]
@@ -289,6 +327,73 @@ def __init__(self, recording, freq=3000, q=30, margin_ms=5.0, dtype=None):
289327
notch_filter = define_function_from_class(source_class=NotchFilterRecording, name="notch_filter")
290328
highpass_filter = define_function_from_class(source_class=HighpassFilterRecording, name="highpass_filter")
291329

330+
331+
def causal_filter(
332+
recording,
333+
direction="forward",
334+
band=[300.0, 6000.0],
335+
btype="bandpass",
336+
filter_order=5,
337+
ftype="butter",
338+
filter_mode="sos",
339+
margin_ms=5.0,
340+
add_reflect_padding=False,
341+
coeff=None,
342+
dtype=None,
343+
):
344+
"""
345+
Generic causal filter built on top of the filter function.
346+
347+
Parameters
348+
----------
349+
recording : Recording
350+
The recording extractor to be re-referenced
351+
direction : "forward" | "backward", default: "forward"
352+
Direction of causal filter. The "backward" option flips the traces in time before applying the filter
353+
and then flips them back.
354+
band : float or list, default: [300.0, 6000.0]
355+
If float, cutoff frequency in Hz for "highpass" filter type
356+
If list. band (low, high) in Hz for "bandpass" filter type
357+
btype : "bandpass" | "highpass", default: "bandpass"
358+
Type of the filter
359+
margin_ms : float, default: 5.0
360+
Margin in ms on border to avoid border effect
361+
coeff : array | None, default: None
362+
Filter coefficients in the filter_mode form.
363+
dtype : dtype or None, default: None
364+
The dtype of the returned traces. If None, the dtype of the parent recording is used
365+
add_reflect_padding : Bool, default False
366+
If True, uses a left and right margin during calculation.
367+
filter_order : order
368+
The order of the filter for `scipy.signal.iirfilter`
369+
filter_mode : "sos" | "ba", default: "sos"
370+
Filter form of the filter coefficients for `scipy.signal.iirfilter`:
371+
- second-order sections ("sos")
372+
- numerator/denominator : ("ba")
373+
ftype : str, default: "butter"
374+
Filter type for `scipy.signal.iirfilter` e.g. "butter", "cheby1".
375+
376+
Returns
377+
-------
378+
filter_recording : FilterRecording
379+
The causal-filtered recording extractor object
380+
"""
381+
assert direction in ["forward", "backward"], "Direction must be either 'forward' or 'backward'"
382+
return filter(
383+
recording=recording,
384+
direction=direction,
385+
band=band,
386+
btype=btype,
387+
filter_order=filter_order,
388+
ftype=ftype,
389+
filter_mode=filter_mode,
390+
margin_ms=margin_ms,
391+
add_reflect_padding=add_reflect_padding,
392+
coeff=coeff,
393+
dtype=dtype,
394+
)
395+
396+
292397
bandpass_filter.__doc__ = bandpass_filter.__doc__.format(_common_filter_docs)
293398
highpass_filter.__doc__ = highpass_filter.__doc__.format(_common_filter_docs)
294399

src/spikeinterface/preprocessing/preprocessinglist.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
notch_filter,
1313
HighpassFilterRecording,
1414
highpass_filter,
15+
causal_filter,
1516
)
1617
from .filter_gaussian import GaussianFilterRecording, gaussian_filter
1718
from .normalize_scale import (

src/spikeinterface/preprocessing/tests/test_filter.py

Lines changed: 136 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,140 @@
44
from spikeinterface.core import generate_recording
55
from spikeinterface import NumpyRecording, set_global_tmp_folder
66

7-
from spikeinterface.preprocessing import filter, bandpass_filter, notch_filter
7+
from spikeinterface.preprocessing import filter, bandpass_filter, notch_filter, causal_filter
8+
9+
10+
class TestCausalFilter:
11+
"""
12+
The only thing that is not tested (JZ, as of 23/07/2024) is the
13+
propagation of margin kwargs, these are general filter params
14+
and can be tested in an upcoming PR.
15+
"""
16+
17+
@pytest.fixture(scope="session")
18+
def recording_and_data(self):
19+
recording = generate_recording(durations=[1])
20+
raw_data = recording.get_traces()
21+
22+
return (recording, raw_data)
23+
24+
def test_causal_filter_main_kwargs(self, recording_and_data):
25+
"""
26+
Perform a test that expected output is returned under change
27+
of all key filter-related kwargs. First run the filter in
28+
the forward direction with options and compare it
29+
to the expected output from scipy.
30+
31+
Next, change every filter-related kwarg and set in the backwards
32+
direction. Again check it matches expected scipy output.
33+
"""
34+
from scipy.signal import lfilter, sosfilt
35+
36+
recording, raw_data = recording_and_data
37+
38+
# First, check in the forward direction with
39+
# the default set of kwargs
40+
options = self._get_filter_options()
41+
42+
sos = self._run_iirfilter(options, recording)
43+
44+
test_data = sosfilt(sos, raw_data, axis=0)
45+
test_data.astype(recording.dtype)
46+
47+
filt_data = causal_filter(recording, direction="forward", **options, margin_ms=0).get_traces()
48+
49+
assert np.allclose(test_data, filt_data, rtol=0, atol=1e-6)
50+
51+
# Then, change all kwargs to ensure they are propagated
52+
# and check the backwards version.
53+
options["band"] = [671]
54+
options["btype"] = "highpass"
55+
options["filter_order"] = 8
56+
options["ftype"] = "bessel"
57+
options["filter_mode"] = "ba"
58+
options["dtype"] = np.float16
59+
60+
b, a = self._run_iirfilter(options, recording)
61+
62+
flip_raw = np.flip(raw_data, axis=0)
63+
test_data = lfilter(b, a, flip_raw, axis=0)
64+
test_data = np.flip(test_data, axis=0)
65+
test_data = test_data.astype(options["dtype"])
66+
67+
filt_data = causal_filter(recording, direction="backward", **options, margin_ms=0).get_traces()
68+
69+
assert np.allclose(test_data, filt_data, rtol=0, atol=1e-6)
70+
71+
def test_causal_filter_custom_coeff(self, recording_and_data):
72+
"""
73+
A different path is taken when custom coeff is selected.
74+
Therefore, explicitly test the expected outputs are obtained
75+
when passing custom coeff, under the "ba" and "sos" conditions.
76+
"""
77+
from scipy.signal import lfilter, sosfilt
78+
79+
recording, raw_data = recording_and_data
80+
81+
options = self._get_filter_options()
82+
options["filter_mode"] = "ba"
83+
options["coeff"] = (np.array([0.1, 0.2, 0.3]), np.array([0.4, 0.5, 0.6]))
84+
85+
# Check the custom coeff are propagated in both modes.
86+
# First, in "ba" mode
87+
test_data = lfilter(options["coeff"][0], options["coeff"][1], raw_data, axis=0)
88+
test_data = test_data.astype(recording.get_dtype())
89+
90+
filt_data = causal_filter(recording, direction="forward", **options, margin_ms=0).get_traces()
91+
92+
assert np.allclose(test_data, filt_data, rtol=0, atol=1e-6, equal_nan=True)
93+
94+
# Next, in "sos" mode
95+
options["filter_mode"] = "sos"
96+
options["coeff"] = np.ones((2, 6))
97+
98+
test_data = sosfilt(options["coeff"], raw_data, axis=0)
99+
test_data = test_data.astype(recording.get_dtype())
100+
101+
filt_data = causal_filter(recording, direction="forward", **options, margin_ms=0).get_traces()
102+
103+
assert np.allclose(test_data, filt_data, rtol=0, atol=1e-6, equal_nan=True)
104+
105+
def test_causal_kwarg_error_raised(self, recording_and_data):
106+
"""
107+
Test that passing the "forward-backward" direction results in
108+
an error. It is is critical this error is raised,
109+
otherwise the filter will no longer be causal.
110+
"""
111+
recording, raw_data = recording_and_data
112+
113+
with pytest.raises(BaseException) as e:
114+
filt_data = causal_filter(recording, direction="forward-backward")
115+
116+
def _run_iirfilter(self, options, recording):
117+
"""
118+
Convenience function to convert Si kwarg
119+
names to Scipy.
120+
"""
121+
from scipy.signal import iirfilter
122+
123+
return iirfilter(
124+
N=options["filter_order"],
125+
Wn=options["band"],
126+
btype=options["btype"],
127+
ftype=options["ftype"],
128+
output=options["filter_mode"],
129+
fs=recording.get_sampling_frequency(),
130+
)
131+
132+
def _get_filter_options(self):
133+
return {
134+
"band": [300.0, 6000.0],
135+
"btype": "bandpass",
136+
"filter_order": 5,
137+
"ftype": "butter",
138+
"filter_mode": "sos",
139+
"coeff": None,
140+
}
8141

9142

10143
def test_filter():
@@ -28,6 +161,8 @@ def test_filter():
28161
# other filtering types
29162
rec3 = filter(rec, band=500.0, btype="highpass", filter_mode="ba", filter_order=2)
30163
rec4 = notch_filter(rec, freq=3000, q=30, margin_ms=5.0)
164+
rec5 = causal_filter(rec, direction="forward")
165+
rec6 = causal_filter(rec, direction="backward")
31166

32167
# filter from coefficients
33168
from scipy.signal import iirfilter

0 commit comments

Comments
 (0)