Add antialiased decimation and allow non-integer resampling rates - #4621
Add antialiased decimation and allow non-integer resampling rates#4621grahamfindlay wants to merge 13 commits into
Conversation
Resolved resample.py by hand: kept dev's gap-handling (gap_tolerance_ms, section-wise resampling) and layered the feature's balanced multipass anti-aliased decimation on top. The non-gapped integer path uses get_antialiased_decimated_traces (exact margins); the gapped section path now cascades the same balanced sub-13 factors; non-integer rates relaxed; q>13 warning narrowed to unsplittable primes.
d1f1709 to
e0f1d44
Compare
Adds antialised decimation to both `DecimateRecording` and `ResampleRecording`. Opt-in for `DecimateRecording` with the `antialias` parameter. For `ResampleRecording`, antialiasing is always applied.
e0f1d44 to
bfe7f53
Compare
There was a problem hiding this comment.
Hi @grahamfindlay this looks great! Some small suggestions on the code. I didn't have a chance to play around locally and look at the tests but will do that early next week! In the meantime just some general questions:
-
Do you think it is worth putting a warning on
antialias=Falsewith possibly a timeline to switching the default toTrue? I would be tempted to change it for105but it's such a big change it might trip people up! But I guess it is important for people to use the AA filter especially if going from AP -> LFP band which I guess is the most common use of the decimate function? -
What do you think of resample_poly? Would solve a few issues (more predictable artefact than FFT method, natively handles large decimation factors, can funnel all resample calls to it rather than splitting decimate by integer/non-integer)) but maybe tricky to always find nice
up/downarguments from the resample ratio depending on the sampling rates. -
Currently it warns on
NaNif found after decimate, but should we error? I think (?) thatNaNin traces are not supported in downstream methods e.g.np.nan...methods are not typically used. -
The methods to handle time-gaps in the recording is really elegant. In the wider-picture of SI though, I wonder if it is necessary to support this mode, and if the module can become more readable by stripping it out. The reason being that AFAIK SI makes no guarantees on supporting gaps in the
time_vectorfor preprocessing, and most steps do not consider it nor work properly for it (e.g. filtering steps, or evendecimate.py). My understanding is that in SI time gaps should be handled by the user by using segments or separate recordings. I think in theresample.pycase we could just check for time gaps and error accordingly. That being said maybe there are important use cases for this I am overlooking.
Cheers!
| # single, potentially unstable, pass and warns. | ||
| if self._orig_samp_freq % resample_rate == 0: | ||
| decimation_factor = int(self._orig_samp_freq / resample_rate) | ||
| decimation_factors = get_balanced_decimation_factors(decimation_factor) |
There was a problem hiding this comment.
Could this check be centralised in _decimation_tools.py and also called from decimate.py? (and/or antialias_factors there called decimation_factors for consistencey)
There was a problem hiding this comment.
yes, I would prefer that -- will do
| self._dtype, | ||
| ) | ||
|
|
||
| # Non-integer ratio, no gaps: FFT-based resampling with proportional margins. |
There was a problem hiding this comment.
| # Non-integer ratio, no gaps: FFT-based resampling with proportional margins. | |
| # Non-integer ratio downsampling or upsampling with no gaps: FFT-based resampling with proportional margins. |
| # Original code path: no gaps (or no time_vector) | ||
| # get parent traces with margin | ||
| if self._decimation_factors is not None: | ||
| # Integer ratio, no gaps: anti-aliased multi-pass decimation. |
There was a problem hiding this comment.
| # Integer ratio, no gaps: anti-aliased multi-pass decimation. | |
| # Integer ratio downsampling, no gaps: anti-aliased multi-pass decimation. |
| if self._decimation_factors is not None: | ||
| # Integer ratio, no gaps: anti-aliased multi-pass decimation. | ||
| decimation_factor = int(self._parent_rate / self._resample_rate) | ||
| return get_antialiased_decimated_traces( |
There was a problem hiding this comment.
Here if Nan detected we warn, but if Nan detected in the section implementation we resample. Can these align?
| start_drop = left_margin // q | ||
| n_out = end_frame - start_frame | ||
| decimated_traces = decimated_traces[start_drop : start_drop + n_out] | ||
| if np.issubdtype(np.dtype(dtype), np.integer): |
There was a problem hiding this comment.
Is it worth doing something like:
np.round(decimated_traces, out=decimated_traces)
info = np.iinfo(dtype)
np.clip(decimated_traces, info.min, info.max, out=decimated_traces)
just in case overflow, although I guess the risk is very low with low-pass filter unless we encounter ringing artefact
| # fix_dtype doesn't always returns the str, make sure it does | ||
| dtype = fix_dtype(recording, dtype).str | ||
|
|
||
| antialias_factors = get_balanced_decimation_factors(decimation_factor) |
There was a problem hiding this comment.
This function is build around the 13 recommendation of Scipy. Is it easy to / worth generalizing, and passing _MAX_SINGLE_PASS_DECIMATION as an argument?
| ) | ||
|
|
||
| # Margin (in parent samples) to limit anti-aliasing filter edge effects. | ||
| margin = int(margin_ms * self._orig_samp_freq / 1000) |
There was a problem hiding this comment.
This margin seems a good catch-all. I guess the main use case of this will be going from AP band to LFP band? I think this margin will only be a problem based on going to very low sampling rates:
(this plot was generated by ChatGPT using the below sensible code to measure the impulse response from the corresponding 8th order Bessel). I guess we could add a warning for low reample_rate and/or perform a check similar to the one below directly from the filter (probably overkill)?
Details
import numpy as np
from scipy import signal
def estimate_iir_margin(fs, fout, eps=1e-6):
q = fs / fout
assert np.isclose(q, round(q))
q = int(round(q))
# Same default IIR design used by scipy.signal.decimate
sos = signal.cheby1(
8,
0.05,
0.8 / q,
output="sos",
)
# Long impulse, far away from either edge
n = 200_000
x = np.zeros(n)
centre = n // 2
x[centre] = 1.0
# Effective zero-phase filter
h = signal.sosfiltfilt(sos, x)
threshold = eps * np.max(np.abs(h))
indices = np.flatnonzero(np.abs(h) > threshold)
radius_samples = max(
centre - indices[0],
indices[-1] - centre,
)
margin_ms = radius_samples / fs * 1000
return q, radius_samples, margin_ms
fs = 30_000
for fout in [2500, 1000, 500, 250, 100]:
q, samples, margin_ms = estimate_iir_margin(fs, fout)
print(
f"{fs} -> {fout:4} Hz "
f"q={q:3}, "
f"margin={samples:5} samples = {margin_ms:.1f} ms"
)
There was a problem hiding this comment.
I think I just copied this margin code from what was already used elsewhere in the codebase, for consistency.
Yes, it is definitely worth checking. I think the code there that produced the table uses Chebyshev type I, not Bessel, and measures a single stage rather than the cascade. But in any case, I would test the actual cascade and then choose an automatic margin (or a warning threshold). Probably also makes sense to retain some kind of override. Will do.
| self._orig_samp_freq = recording.get_sampling_frequency() | ||
| self._resample_rate = resample_rate | ||
| self._sampling_frequency = resample_rate | ||
| # When the parent rate is an exact integer multiple of resample_rate, get_traces uses |
There was a problem hiding this comment.
| # When the parent rate is an exact integer multiple of resample_rate, get_traces uses | |
| # When the parent rate is an exact integer multiple of resample_rate (i.e. downsampling with an integer factor), get_traces uses |
| return primes_desc | ||
|
|
||
|
|
||
| def get_antialiased_decimated_traces( |
There was a problem hiding this comment.
Could this move to decimate.py? (if it can be imported from resample.py). Not a huge thing but I think the convention is for get-traces like functionality to live in next to the segments.
Yeah, I think that a transition warning is a good idea. I might distinguish an omitted argument from an explicit
I definitely would support the use of
Yes, I agree. Actually, I think it's even worse than
My (and other people's) chronic, continuous, multi-day recordings commonly have small gaps. I am very much open to representing continuous stretches as segments or separate recordings, as long as it provides a supported route through preprocessing, sorting, postprocessing, analyzer objects, etc. Last I checked, adding limited qualified support for gaps within a segment was by far the easiest and least disruptive way to achieve this, even though SI does generally expect contiguous segments. It is probably worth mentioning that this expectation is rarely if ever checked or enforced, and there is explicit support for and discussion of missing frames and irregularly-sampled data scattered all over the place, e.g. in tutorials, tests, in the NWB recording extractor, the Blackrock reader, etc. I guess that is part of why the time vector exists, rather than always assuming a start time + fixed sample rate. So I would not say there is any contract in SI that says a segment must be gap-free and regularly-sampled, even if that is asserted in the docs once or twice. It is maybe most accurate to say that all operations are expected to work for gap-free, regularly-sampled segments, and support for imperfect segments depends on the operation. It is also worth mentioning that motion estimation, lots of sorter-specific processing, and timing restoration/persistence (e.g. from analyzers), and exporters (e.g. Phy) are not currently able to correctly deal with time-gapped multisegment recordings. Using separate recordings is technically possible, but then there needs to be machinery to handle independently assigned unit IDs across recordings, and many other things. All this is just to say that, yes, supporting a segment with gaps here is a little bit of an anti-pattern, and in my ideal world we would have a bit more clarity about what a segment actually is and isn't, but if we stop handling segments with gaps correctly in the few places where it is currently needed, that opens up a much wider discussion about how to add support back for these kinds of data. Footnotes
|
Reuses the same trace processing for gapped sections and standalone recordings, removing duplicate padding logic and the sneaky silent FFT retry.
…hange. I decided that this doc is more of a tutorial than a reference, so this doesn't really belong here.
Share scipy.signal.resample_poly between resample() and antialiased decimate(), replacing the IIR / FFT approaches. Derive chunk margins from FIR support. Given a target sample rate, select the closest rational ratio under max_denominator, report the achieved sampling rate, and warn when it differs from the request.
for more information, see https://pre-commit.ci
|
@JoeZiminski Just pushed a batch of commits that hopefully address all of the above. The first 9 commits (through 7d1f053) keep the IIR/FFT filtering/resampling. The 10th and final commit clobbers most of that and replaces it with the FIR/polyphase resampling. So the last commit could be rejected if someone is reluctant to change. |
Implements #4605 : Adds antialised decimation (multipass with balanced factors, when possible) to both
DecimateRecordingandResampleRecording. Opt-in forDecimateRecordingwith theantialiasparameter (preserves existing default behavior). ForResampleRecording, antialiasing is always applied. For a future release where existing default behavior can be changed, I would recommend makingantalias=Truethe default.For simplicity, we could just remove the fast decimation path from
ResampleRecordingentirely and instead, if an integer resampling ratio is detected, just suggest to the user that they useDecimateRecordinginstead. But, that would change existing behavior, and I don't think this is too terribly complicated as-is.