Skip to content

Commit a7f712f

Browse files
BUG: Cleanup cHPI filtering using smooth interpolation (#14112)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent adc8584 commit a7f712f

4 files changed

Lines changed: 130 additions & 45 deletions

File tree

doc/changes/dev/14112.bugfix.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
:func:`mne.chpi.filter_chpi` now smoothly interpolates the fitted cHPI/line amplitudes by default (``interp="hann"``), by `Eric Larson`_.

mne/chpi.py

Lines changed: 91 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
pick_types,
4141
)
4242
from ._fiff.proj import Projection, setup_proj
43+
from ._ola import _Interp2
4344
from .bem import ConductorModel
4445
from .channels.channels import _get_meg_system
4546
from .cov import compute_whitener, make_ad_hoc_cov
@@ -1548,6 +1549,8 @@ def filter_chpi(
15481549
t_window="auto",
15491550
ext_order=1,
15501551
allow_line_only=False,
1552+
*,
1553+
interp="hann",
15511554
verbose=None,
15521555
):
15531556
"""Remove cHPI and line noise from data.
@@ -1570,6 +1573,14 @@ def filter_chpi(
15701573
which only allows the function to run when cHPI information is present.
15711574
15721575
.. versionadded:: 0.20
1576+
interp : str | None
1577+
How the fitted cHPI/line amplitude envelope is interpolated between the
1578+
fitting windows before subtraction. ``"hann"`` (default) smoothly interpolates,
1579+
avoiding small step discontinuities (and associated broadband spectral
1580+
leakage) produced by ``None``, which holds each window's amplitudes constant
1581+
over its central ``t_step``. ``"linear"`` interpolation is also supported.
1582+
1583+
.. versionadded:: 1.13
15731584
%(verbose)s
15741585
15751586
Returns
@@ -1607,55 +1618,99 @@ def filter_chpi(
16071618
verbose=_verbose_safe_false(),
16081619
)
16091620

1610-
fit_idxs = np.arange(0, len(raw.times) + hpi["n_window"] // 2, n_step)
1621+
_check_option("interp", interp, (None, "hann", "cos2", "linear"))
16111622
n_freqs = len(hpi["freqs"])
16121623
n_remove = 2 * n_freqs
16131624
meg_picks = pick_types(raw.info, meg=True, exclude=()) # filter all chs
1614-
n_times = len(raw.times)
16151625

16161626
msg = f"Removing {n_freqs} cHPI"
16171627
if include_line:
16181628
n_remove += 2 * len(hpi["line_freqs"])
16191629
msg += f" and {len(hpi['line_freqs'])} line harmonic"
16201630
msg += f" frequencies from {len(meg_picks)} MEG channels"
1621-
1622-
recon = (hpi["model"][:, :n_remove] @ hpi["inv_model"][:n_remove]).T
16231631
logger.info(msg)
1624-
chunks = list() # the chunks to subtract
1625-
last_endpt = 0
1626-
pb = ProgressBar(fit_idxs, mesg="Filtering")
1627-
for ii, midpt in enumerate(pb):
1628-
left_edge = midpt - hpi["n_window"] // 2
1629-
time_sl = slice(
1630-
max(left_edge, 0), min(left_edge + hpi["n_window"], len(raw.times))
1631-
)
1632-
this_len = time_sl.stop - time_sl.start
1633-
if this_len == hpi["n_window"]:
1634-
this_recon = recon
1635-
else: # first or last window
1636-
model = hpi["model"][:this_len]
1637-
inv_model = np.linalg.pinv(model)
1638-
this_recon = (model[:, :n_remove] @ inv_model[:n_remove]).T
1639-
this_data = raw._data[meg_picks, time_sl]
1640-
subt_pt = min(midpt + n_step, n_times)
1641-
if last_endpt != subt_pt:
1642-
fit_left_edge = left_edge - time_sl.start + hpi["n_window"] // 2
1643-
fit_sl = slice(fit_left_edge, fit_left_edge + (subt_pt - last_endpt))
1644-
chunks.append((subt_pt, this_data @ this_recon[:, fit_sl]))
1645-
last_endpt = subt_pt
1646-
1647-
# Consume (trailing) chunks that are now safe to remove because
1648-
# our windows will no longer touch them
1649-
if ii < len(fit_idxs) - 1:
1650-
next_left_edge = fit_idxs[ii + 1] - hpi["n_window"] // 2
1651-
else:
1652-
next_left_edge = np.inf
1653-
while len(chunks) > 0 and chunks[0][0] <= next_left_edge:
1654-
right_edge, chunk = chunks.pop(0)
1655-
raw._data[meg_picks, right_edge - chunk.shape[1] : right_edge] -= chunk
1632+
1633+
# interp=None reproduces the pre-1.13 zero-order-hold subtraction
1634+
_subtract_chpi(
1635+
raw,
1636+
hpi,
1637+
meg_picks,
1638+
n_step,
1639+
n_remove,
1640+
include_line,
1641+
"zero" if interp is None else interp,
1642+
)
16561643
return raw
16571644

16581645

1646+
def _chpi_fit_coeffs(
1647+
midpt, *, raw, meg_picks, hpi, freqs, n_freqs, n_line, n_remove, inv_cache
1648+
):
1649+
"""Fit absolute-phase sin/cos amplitudes for one control point."""
1650+
n_window = hpi["n_window"]
1651+
n_times = len(raw.times)
1652+
sfreq = raw.info["sfreq"]
1653+
# window centered on the control point, clamped (shorter) at the edges, to
1654+
# match the legacy per-window fit so that interp="zero" reproduces it
1655+
left_edge = midpt - n_window // 2
1656+
start = max(left_edge, 0)
1657+
stop = min(left_edge + n_window, n_times)
1658+
this_len = stop - start
1659+
if this_len not in inv_cache:
1660+
inv_cache[this_len] = np.linalg.pinv(hpi["model"][:this_len])[:n_remove]
1661+
coef = inv_cache[this_len] @ raw._data[meg_picks, start:stop].T # (n_remove, n_ch)
1662+
# model columns of the removable subspace: [sin_hpi, cos_hpi, sin_line, cos_line]
1663+
sin_sl = slice(0, n_freqs)
1664+
sin_sl_line = slice(2 * n_freqs, 2 * n_freqs + n_line)
1665+
cos_sl = slice(n_freqs, 2 * n_freqs)
1666+
cos_sl_line = slice(2 * n_freqs + n_line, 2 * n_freqs + 2 * n_line)
1667+
sin_c = np.concatenate([coef[sin_sl], coef[sin_sl_line]])
1668+
cos_c = np.concatenate([coef[cos_sl], coef[cos_sl_line]])
1669+
# rotate window-relative phase (0 at window start) to absolute time
1670+
theta0 = (2 * np.pi * freqs * start / sfreq)[:, np.newaxis]
1671+
# one-element list (for _Interp2) with array of shape ``(2, n_freq, n_ch)``
1672+
s_t_0 = np.sin(theta0)
1673+
c_t_0 = np.cos(theta0)
1674+
return [np.stack([sin_c * c_t_0 + cos_c * s_t_0, cos_c * c_t_0 - sin_c * s_t_0])]
1675+
1676+
1677+
def _subtract_chpi(raw, hpi, meg_picks, n_step, n_remove, include_line, interp):
1678+
"""Subtract cHPI/line noise from the data."""
1679+
# Fits amplitudes on then uses _Interp2 to interpolate the amplitude envelope
1680+
# before reconstructing and subtracting.
1681+
sfreq = raw.info["sfreq"]
1682+
n_times = len(raw.times)
1683+
n_freqs = len(hpi["freqs"])
1684+
n_line = len(hpi["line_freqs"]) if include_line else 0
1685+
freqs = np.concatenate([hpi["freqs"], hpi["line_freqs"][:n_line]])
1686+
fit = partial(
1687+
_chpi_fit_coeffs,
1688+
raw=raw,
1689+
meg_picks=meg_picks,
1690+
hpi=hpi,
1691+
freqs=freqs,
1692+
n_freqs=n_freqs,
1693+
n_line=n_line,
1694+
n_remove=n_remove,
1695+
inv_cache={},
1696+
)
1697+
interp2 = _Interp2(np.arange(0, n_times, n_step), fit, interp=interp)
1698+
chunk = max(2 * hpi["n_window"], 1000)
1699+
prev = None # (start, stop, recon) subtracted one chunk late (see docstring)
1700+
for start in ProgressBar(range(0, n_times, chunk), mesg="Filtering"):
1701+
stop = min(start + chunk, n_times)
1702+
vals = interp2.feed(stop - start)[0] # (2, n_freq, n_ch, n_pts)
1703+
phase = 2 * np.pi * np.outer(freqs, np.arange(start, stop)) / sfreq
1704+
recon = np.einsum("fct,ft->ct", vals[0], np.sin(phase)) + np.einsum(
1705+
"fct,ft->ct", vals[1], np.cos(phase)
1706+
)
1707+
if prev is not None:
1708+
raw._data[meg_picks, prev[0] : prev[1]] -= prev[2]
1709+
prev = (start, stop, recon)
1710+
if prev is not None:
1711+
raw._data[meg_picks, prev[0] : prev[1]] -= prev[2]
1712+
1713+
16591714
def _compute_good_distances(hpi_coil_dists, new_pos, dist_limit=0.005):
16601715
"""Compute good coils based on distances."""
16611716
these_dists = cdist(new_pos, new_pos)

mne/tests/test_chpi.py

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import pytest
99
from numpy.testing import assert_allclose, assert_array_equal, assert_array_less
1010
from scipy.interpolate import interp1d
11+
from scipy.signal import welch
1112
from scipy.spatial.distance import cdist
1213

1314
from mne import pick_info, pick_types
@@ -730,7 +731,6 @@ def test_calculate_chpi_coil_locs_artemis():
730731
def assert_suppressed(new, old, suppressed, retained):
731732
"""Assert that some frequencies are suppressed and others aren't."""
732733
__tracebackhide__ = True
733-
from scipy.signal import welch
734734

735735
picks = pick_types(new.info, meg="grad")
736736
sfreq = new.info["sfreq"]
@@ -756,19 +756,48 @@ def test_chpi_subtraction_filter_chpi():
756756
raw.info["bads"] = ["MEG0111"]
757757
raw.del_proj()
758758
raw_orig = raw.copy().crop(0, 16)
759+
# remove cHPI status chans
760+
raw_c = read_raw_fif(sss_hpisubt_fname).crop(0, 16).load_data()
761+
raw_c.pick(["meg", "eeg", "eog", "ecg", "stim", "misc"])
762+
# interp=None reproduces the pre-1.13 zero-order-hold subtraction, which
763+
# closely matches MaxFilter (which also does not remove line freqs here)
764+
raw_zoh = raw.copy()
759765
with catch_logging() as log:
760-
filter_chpi(raw, include_line=False, t_window=0.2, verbose=True)
766+
filter_chpi(
767+
raw_zoh, include_line=False, t_window=0.2, interp=None, verbose=True
768+
)
761769
log = log.getvalue()
762770
assert "No average EEG" not in log
763771
assert "5 cHPI" in log
772+
raw_zoh.crop(0, 16)
764773
# MaxFilter doesn't do quite as well as our algorithm with the last bit
765-
raw.crop(0, 16)
766-
# remove cHPI status chans
767-
raw_c = read_raw_fif(sss_hpisubt_fname).crop(0, 16).load_data()
768-
raw_c.pick(["meg", "eeg", "eog", "ecg", "stim", "misc"])
769-
assert_meg_snr(raw, raw_c, 143, 624)
774+
assert_meg_snr(raw_zoh, raw_c, 143, 624)
770775
# cHPI suppressed but not line freqs (or others)
776+
assert_suppressed(raw_zoh, raw_orig, np.arange(83, 324, 60), [30, 60, 150])
777+
# the default interp="hann" smoothly interpolates the amplitude envelope,
778+
# avoiding zero-order-hold step discontinuities and removing cHPI at least
779+
# as well (thereby improving on, and diverging from, MaxFilter)
780+
filter_chpi(raw, include_line=False, t_window=0.2, verbose=True)
781+
raw.crop(0, 16)
771782
assert_suppressed(raw, raw_orig, np.arange(83, 324, 60), [30, 60, 150])
783+
# hann also suppresses the broadband "step" artifacts that the zero-order
784+
# hold (interp=None) injects: with the default (auto) window, the leakage in
785+
# a band between the removed line harmonics (360 and 420 Hz), where only that
786+
# artifact appears, drops by ~10 dB.
787+
raw_hann = raw_orig.copy()
788+
filter_chpi(raw_hann, verbose="error")
789+
raw_zoh_auto = raw_orig.copy()
790+
filter_chpi(raw_zoh_auto, interp=None, verbose="error")
791+
picks = pick_types(raw_orig.info, meg=True)
792+
orig = raw_orig.get_data(picks)
793+
kwargs = dict(fs=raw_orig.info["sfreq"], window="hann", nperseg=1024)
794+
f, p_hann = welch(orig - raw_hann.get_data(picks), **kwargs)
795+
_, p_zoh = welch(orig - raw_zoh_auto.get_data(picks), **kwargs)
796+
fb = (f >= 370) & (f <= 410) # between the removed 360 and 420 Hz harmonics
797+
leak_db = 10 * np.log10(
798+
np.median(p_hann[:, fb], axis=0).mean() / np.median(p_zoh[:, fb], axis=0).mean()
799+
)
800+
assert -15 < leak_db < -8, leak_db # observed ≈ -11.5 dB
772801
raw = raw_orig.copy()
773802
with catch_logging() as log:
774803
filter_chpi(raw, include_line=True, t_window=0.2, verbose=True)

tutorials/preprocessing/60_maxwell_filtering_sss.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55
Signal-space separation (SSS) and Maxwell filtering
66
===================================================
77
8-
This tutorial covers reducing environmental noise and compensating for head
9-
movement with SSS and Maxwell filtering.
8+
This tutorial covers reducing environmental noise and compensating for head movement
9+
with SSS and Maxwell filtering.
1010
1111
As usual, we'll start by importing the modules we need, loading some
1212
:ref:`example data <sample-dataset>`, and cropping it to save on memory:

0 commit comments

Comments
 (0)