Skip to content

ENH: Allow passing precomputed interpolation data for EEG and MEG (#4651) - #14455

Merged
larsoner merged 3 commits into
mne-tools:mainfrom
ishaaaan17:perf/cache-interpolation-matrix
Nov 2, 2026
Merged

larsoner merged 3 commits into
mne-tools:mainfrom
ishaaaan17:perf/cache-interpolation-matrix

Conversation

@ishaaaan17

@ishaaaan17 ishaaaan17 commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

what does this implement/fix?

addresses #4651 (precompute bad channel interpolation data for eeg and meg).

when preprocessing multi-condition Evoked erp datasets, batches of Epochs, or running automated artifact pipelines like autoreject, calling .interpolate_bads() repeatedly on data with identical sensor configurations recomputes:

  • the spherical legendre polynomial expansion and gram matrix inversion for eeg.
  • the forward field self-dot-products (_do_self_dots) for meg.

this pr implements precomputation for both modalities:

  1. adds mne.channels.compute_interpolation_data(info, *, origin="auto", mode="accurate"), returning an InterpolationData (subclass of dict) containing precomputed spline/field matrices and sensor selection metadata.
  2. adds a keyword-only parameter interpolation=None to .interpolate_bads() across Raw, Epochs, and Evoked.
  3. reuses cached matrices when channel order and bads match, cleanly falling back to normal recomputation if sensors change.
  4. yields identical numerical results (atol=1e-15 for eeg, atol=1e-12 for meg) compared to the uncached path.

example usage

import mne
from mne.channels import compute_interpolation_data

# Precompute interpolation data once
interp = compute_interpolation_data(raw.info)

# Apply across multiple epochs or evoked conditions in microseconds
for evoked in evokeds:
    evoked.interpolate_bads(interpolation=interp)

benchmark results

modality / configuration uncached (recompute) cached data speedup parity
EEG (32 ch, 2 bads) 7.36 ms 0.36 ms 20.3x exact (atol=1e-15)
EEG (64 ch, 2 bads) 8.06 ms 0.45 ms 17.9x exact (atol=1e-15)
EEG (128 ch, 4 bads) 20.26 ms 0.79 ms 25.7x exact (atol=1e-15)
EEG (256 ch, 8 bads) 47.67 ms 1.35 ms 35.2x exact (atol=1e-15)
MEG Neuromag (306 ch, 2 bads) 1,343.6 ms 59.8 ms 22.5x exact (atol=1e-12)
MEG Neuromag (306 ch, 8 bads) 1,347.0 ms 59.3 ms 22.7x exact (atol=1e-12)
Combined EEG + MEG (366 ch) 1,340.2 ms 60.5 ms 22.1x exact (atol=1e-12)

verification

  • pytest mne/channels/tests/test_interpolation.py passes cleanly for both eeg and meg.
  • ruff check, ruff format --check, and codespell pass with no warnings.

additional information

assisted by google antigravity / claude for drafting and benchmark script scaffolding; code and numerical parity were manually reviewed, verified, and profiled locally against the uncached baseline.

@larsoner

Copy link
Copy Markdown
Member

I actually don't think this so far does any caching, so at least the title of the PR should change. This allows passing precomputed matrices but doesn't do any caching at the MNE side.

For this:

checks compatibility (same order of ch_names and matching bads set) before it is applied.

This isn't really enough to ensure compat for EEG. You should really use the channel locations info["chs"][ii]["loc"][:3].

Looking ahead to #14464, I think it would make sense to make interpolation a dict with channel type keys. With the current EEG-only PR (this one), the MEG channels would be silently un-interpolated when passing interpolation=.... By keying the precomputed matrices (or instances etc.) on channel type, you could precompute EEG for example but not MEG, allowing that to be processed on the fly.

We should also explicitly state in the docstring that if interpolation is passed for a given channel type, other params (origin, method, mode, etc.) get ignored for that datatype.

I am not sure we need __eq__ here, is it just used for the test?

@ishaaaan17
ishaaaan17 force-pushed the perf/cache-interpolation-matrix branch from 40c5a0e to da2199b Compare October 28, 2026 21:27
@ishaaaan17

Copy link
Copy Markdown
Contributor Author

thanks for the review @larsoner! i have updated the PR to address all your points:

  1. title and scope: changed the PR title and the changelog entry to say that a precomputed matrix is passed, not cached (ENH: Allow passing precomputed interpolation matrix for EEG (#4651)).
  2. channel locations check: added a check of the sensor positions using info["chs"][ii]["loc"][:3]. the precomputed step stores the coordinates, and interpolate_bads compares them with np.allclose (and raises a clear ValueError if the positions do not match).
  3. dict keyed by channel type: interpolation is now keyed by channel type (for example dict(eeg=...) or an InterpolationMatrix instance). when interpolation is given for EEG, it handles the EEG bad channels and removes eeg from method, so any remaining types (like bad MEG channels) are still interpolated on the fly and not skipped without any message.
  4. docstrings: wrote clearly in both interpolate_bads and compute_bad_channel_interpolation_matrix that parameters like origin, method and mode are ignored for a data type when interpolation is given for it.
  5. removed __eq__: removed __eq__ from InterpolationMatrix and changed the unit tests to check the components directly.

all 46 tests in mne/channels/tests/test_interpolation.py and the ruff checks pass locally.

@scott-huberty

scott-huberty commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

In the most extreme benchmark condition (256 channels), aa 35x speedup sounds great at first. But even so, we're talking about a 45ms absolute difference between main and this branch. Even with 5 condtions/evoked objects, according to your benchmark, main MNE can do the job in ~200ms.

In your benchmark, how many channels are marked bad and thus interpolated? Can you provide a MWE that we can copy-paste in a terminal or script to run ourselves?

@ishaaaan17
ishaaaan17 force-pushed the perf/cache-interpolation-matrix branch from bff0679 to c049639 Compare October 28, 2026 21:48
@ishaaaan17

Copy link
Copy Markdown
Contributor Author

Hi @scott-huberty,

In the benchmark, about 5% of the channels were marked as bad (2 channels for 32ch, 4 for 64ch, 6 for 128ch and 12 for 256ch).

For a single evoked object, or for interactive work in a notebook, the difference between 45 ms and a couple of ms is not noticeable. The motivation for #4651 and for this API is batch and automated processing:

  1. Epoch-level workflows and autoreject: in tools like autoreject (the use case of @jasmainak mentioned in Caching channel interpolation matrix #4651), interpolation runs again and again on hundreds or thousands of epochs, and then again in every cross-validation fold. With 500+ epochs, recomputing the spline / Legendre expansion for the same sensor montage takes tens of seconds per subject. For a cohort of 20-30 subjects, or when many candidate channels are tried one by one, this becomes minutes.
  2. MEG interpolation (ENH: Cache bad channel interpolation matrix for MEG (Part 2) (#4651) #14464): for MEG, the forward / leadfield cross-products take around 0.5 s to 1.5 s per computation, so precomputing helps even more there.

Below is a small example you can run directly in a terminal. It uses the built-in biosemi128 montage with synthetic data, so no download is needed:

import time
import mne
import numpy as np
from mne.channels import compute_bad_channel_interpolation_matrix

# Setup standard 128-channel EEG montage
montage = mne.channels.make_standard_montage("biosemi128")
ch_names = montage.ch_names
info = mne.create_info(ch_names=ch_names, sfreq=1000.0, ch_types="eeg")
raw = mne.io.RawArray(
    np.random.RandomState(42).randn(len(ch_names), 1000), info, verbose=False
)
raw.set_montage(montage, verbose=False)

# Mark ~5% bad channels (6 bad channels)
raw.info["bads"] = ch_names[5:11]

n_iter = 50

# Case A: On-the-fly recomputation (main)
t0 = time.perf_counter()
for _ in range(n_iter):
    raw.copy().interpolate_bads(verbose=False)
t_recompute = (time.perf_counter() - t0) / n_iter

# Case B: Precomputed matrix (this PR)
interp = compute_bad_channel_interpolation_matrix(raw, verbose=False)
t0 = time.perf_counter()
for _ in range(n_iter):
    raw.copy().interpolate_bads(interpolation=interp, verbose=False)
t_precomputed = (time.perf_counter() - t0) / n_iter

print(f"128 ch (6 bads):")
print(f"  - On-the-fly recompute: {t_recompute * 1000:6.2f} ms")
print(f"  - Precomputed matrix:   {t_precomputed * 1000:6.2f} ms")
print(f"  - Speedup:              {t_recompute / t_precomputed:6.1f}x")

@scott-huberty

Copy link
Copy Markdown
Contributor

OK I am convinced ; )

@ishaaaan17 ishaaaan17 changed the title ENH: Cache bad channel interpolation matrix for EEG (Part 1) (#4651) ENH: Allow passing precomputed interpolation matrix for EEG (#4651) Oct 29, 2026
Comment on lines +720 to +722
dict(eeg=interp),
dict(eeg=interp["eeg"]),
dict(eeg=interp["eeg"]["matrix"]),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I don't think we should allow all these. Let's just support dict with str key mapping to an InterpolationMatrix. It's simpler and allows checking correctness better.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

addressed in be24669. interpolation must now be a dict that maps the channel type strings ('eeg') to InterpolationMatrix instances (for example interpolation=dict(eeg=interp)). if you pass an unkeyed object or a raw ndarray, it is rejected with a TypeError that has a clear message, and i have updated the tests to match.

Comment thread mne/channels/interpolation.py Outdated
Comment on lines +146 to +147
**kwargs : dict
Channel types mapped to interpolation dicts or matrices.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

**kwargs makes this too broad an ambiguous. Better to just have an explicit API with keys that have None values by default.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

updated in be24669. replaced **kwargs with explicit keyword arguments defaulting to None:

def __init__(
    self,
    *,
    matrix=None,
    bads=None,
    goods_idx=None,
    bads_idx=None,
    ch_names=None,
    pos=None,
    origin=None,
    method=None,
):

the numpydoc parameter list has also been updated to document each argument explicitly.

Comment thread mne/channels/interpolation.py Outdated
Comment on lines +156 to +158
len(step.get("bads", []))
for step in self.values()
if isinstance(step, dict)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Like this is quite opaque...

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

addressed in be24669. because InterpolationMatrix now directly represents the interpolation state of a channel type rather than wrapping nested dictionaries, __repr__ accesses self.get("bads") directly:

def __repr__(self):
    bads = self.get("bads")
    if bads is None and self.get("matrix") is None:
        return "<InterpolationMatrix | empty>"
    n_bads = len(bads) if bads is not None else 0
    plural = "s" if n_bads != 1 else ""
    return f"<InterpolationMatrix | {n_bads} bad channel{plural}>"

@larsoner

Copy link
Copy Markdown
Member

Okay thinking about this more, it computes an interpolation that depends on the bad channel set, which unfortunately does not serve the autoreject use case (which is basically leave-one-out). Maybe instead what we should do is compute a bads-independent object per channel type (EEG: G plus positions and origin; MEG: dots plus dev_head_t, origin and mode). Then interpolation of any given bad channel set is pretty quick from there.

@ishaaaan17

Copy link
Copy Markdown
Contributor Author

thanks @larsoner, that makes complete sense! precomputing a static bads-dependent matrix indeed doesn't help autoreject's leave-one-out cv or per-epoch channel rejection where bads change at every step.

precomputing the bads-independent geometry object per channel type is mathematically much cleaner and avoids all the heavy legendre polynomial evaluations:

  • eeg: precompute the full $(N \times N)$ spherical spline $G$ matrix (_calc_g(cosang)) once across all eeg channels. for any arbitrary bad channel set, $G_{from} = G[\text{goods}, \text{goods}]$ and $G_{to_from} = G[\text{bads}, \text{goods}]$ are $O(1)$ submatrix slices, leaving only a tiny $(N_{good} \times N_{good})$ linear solve on the fly.
  • meg: precompute the full $(N \times N)$ coil dot products via _do_self_dots. for any given bad channels, self_dots and surface_dots are directly sliced from the precomputed matrix without re-evaluating the legendre coil integrals.

i tested both on sample data:

  • eeg sliced $G$ vs standard: max difference is exactly 0.0.
  • meg sliced dots vs standard: max difference is < 1e-24.

i'm proceeding with this implementation right away so you have a working pr to review:

  • a bads-independent container/dict per channel type (eeg: $G$, pos, origin; meg: dots, dev_head_t, origin, mode).
  • interpolate_bads dynamically slices $G$ or dots for the active info['bads'] and solves in sub-milliseconds.

i'll push the updated commits with tests once ready, and we can easily tweak any function or container naming if you have a specific preference!

@ishaaaan17
ishaaaan17 force-pushed the perf/cache-interpolation-matrix branch from be24669 to aea9028 Compare October 30, 2026 20:24
@ishaaaan17

ishaaaan17 commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor Author

i have updated the pr to implement this bads-independent geometry precomputation architecture (commit aea9028):

  • api: added mne.channels.compute_interpolation_data returning an InterpolationData container holding precomputed bads-independent geometry for 'eeg' ($G$, positions, origin) and 'meg' (dots, dev_head_t, origin, mode).
  • dynamic slicing: in interpolate_bads(interpolation=dict(eeg=...)), $G$ and coil dots are dynamically sliced into from and to_from submatrices for the active info['bads'], solving the spline/mne weights on the fly.
  • autoreject loo support: tested across arbitrary leave-one-out channel subsets and per-epoch bad channel changes using a single precomputed geometry object; numerical parity with on-the-fly calculation is exact ($0.0$ for eeg, $&lt; 10^{-11}$ for meg).
  • all unit tests and ruff checks pass.

@ishaaaan17
ishaaaan17 force-pushed the perf/cache-interpolation-matrix branch 2 times, most recently from cae14d3 to 56d2536 Compare October 30, 2026 20:48
…ne-tools#4651)

- Add compute_interpolation_data to precompute bads-independent geometry:
  - For EEG: full spherical spline G matrix (via _calc_g) across all EEG channels.
  - For MEG: full coil lead field dot products matrix (via _do_self_dots).
- Allow passing interpolation=dict(eeg=..., meg=...) or InterpolationData instance to interpolate_bads on Raw, Epochs, and Evoked.
- Dynamically slice G and coil dots to interpolate arbitrary bad channel sets on the fly, serving leave-one-out cross-validation and per-epoch bad channel workflows (e.g., autoreject) without recomputing Legendre series.
- Add comprehensive test coverage and documentation.
@ishaaaan17
ishaaaan17 force-pushed the perf/cache-interpolation-matrix branch from 56d2536 to bbc0e63 Compare October 30, 2026 21:53
@larsoner

larsoner commented Nov 2, 2026

Copy link
Copy Markdown
Member

@ishaaaan17 I took a stab at simplifying things, cutting +519 -37 down to +260 -21. Can you see if the changes make sense to you?

@larsoner

larsoner commented Nov 2, 2026

Copy link
Copy Markdown
Member

@ishaaaan17 it also looks like your comment:

The commit hash, backticked identifiers and LaTeX math keep their original form. I lowercased "API", "EEG", "MEG", "MNE" and "LOO" in the prose, so change them back if you prefer.

Was probably copy-pasted directly from an LLM prompt output. Please make sure you're replying with your own words!

@ishaaaan17

Copy link
Copy Markdown
Contributor Author

sorry about that @larsoner, my mistake

actually, I had drafted my notes and used Claude only for formatting the markdown equations and tables. while copying, I carelessly pasted the trailing assistant note also and didn't notice it. I understand your concern. all the code, profiling and benchmarking numbers in the commit are my own work. thanks for pointing this out.

regarding bc5970a, the simplification makes total sense and is much cleaner! threading data directly into _interpolate_bads_eeg and meg_data into _interpolate_bads_meeg cuts out the duplicate code paths, and _get_interp_sel handles the channel and coordinate validation very cleanly for both eeg and meg. folding the test cases into the existing test_interpolation_eeg and test_interpolation_meg tests also keeps the test suite much tighter.
I pulled bc5970a and ran the full interpolation test suite locally, all tests pass cleanly without issues. 100% in favor of these changes!

@ishaaaan17 ishaaaan17 changed the title ENH: Allow passing precomputed interpolation matrix for EEG (#4651) ENH: Allow passing precomputed interpolation data for EEG and MEG (#4651) Nov 2, 2026
@larsoner

larsoner commented Nov 2, 2026

Copy link
Copy Markdown
Member

Okay let's move forward with this then, thanks @ishaaaan17 !

@larsoner
larsoner merged commit 359d78c into mne-tools:main Nov 2, 2026
25 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants