Skip to content

interpolate_to (EEG spline): source positions are origin-centred but target positions are not — reconstructions at low electrodes can come back sign-inverted #14153

Description

@offirlaufer-cyber

Description

Raw.interpolate_to(..., method="spline") produces badly wrong reconstructions at electrodes low on the head — up to exact sign inversion (r = −1 against ground truth) — where interpolate_bads reconstructs the same channels from the same data correctly. Observed in 1.12.1; the code on current main is unchanged.

Root cause

In mne/channels/interpolation.py::_interpolate_to_eeg (spline branch), the fitted sphere origin is subtracted from the source positions but not from the target positions:

pos_from = inst.info._get_channel_positions(picks_good_eeg) - origin_val
pos_to = np.stack(list(ch_pos.values()), axis=0)          # origin NOT subtracted
...
mapping = _make_interpolation_matrix(pos_from, pos_to, alpha=reg)

_make_interpolation_matrix then unit-normalises both sets, so source and target directions are measured from different centres. With origin="auto" the fitted origin sits well above the head-frame origin (~36 mm for the GSN-HydroCel templates), so target unit vectors are systematically wrong relative to the sources — worst for low-lying electrodes, where the spline effectively evaluates the field at a displaced scalp location. If that displaced location crosses a sign boundary of the field (e.g. after linked-mastoid referencing), the reconstruction is a negation of the truth.

_interpolate_bads_eeg subtracts the origin from both sets (pos_good = pos[goods_idx_pos] - origin; pos_bad = pos[bads_idx_pos] - origin), which is why the two entry points disagree.

Reproduction

Hold a channel out, reconstruct it at its own position, compare with what it recorded:

import numpy as np
import mne

montage = mne.channels.make_standard_montage("GSN-HydroCel-129")
positions = montage.get_positions()
ch_pos = positions["ch_pos"]
names = montage.ch_names

# Smooth synthetic field: amplitude graded with electrode height. 20 uV, 10 Hz.
t = np.arange(1000) / 250.0
wave = 20e-6 * np.sin(2 * np.pi * 10.0 * t)
z = np.array([float(ch_pos[n][2]) for n in names])
weights = 0.5 + (z - z.min()) / np.ptp(z)          # 0.5 .. 1.5, smooth in space
data = weights[:, None] * wave

raw = mne.io.RawArray(data, mne.create_info(names, 250.0, "eeg"), verbose=False)
raw.set_montage(montage, verbose=False)
raw.set_eeg_reference(ref_channels=["E57", "E100"], verbose=False)  # linked mastoids

for chan in ("E11", "E62", "E38", "E121", "E56", "E107"):
    truth = raw.get_data(picks=[chan])[0]
    single = mne.channels.make_dig_montage(
        ch_pos={chan: ch_pos[chan]}, nasion=positions["nasion"],
        lpa=positions["lpa"], rpa=positions["rpa"], coord_frame="head")
    est_to = (raw.copy().drop_channels([chan])
              .interpolate_to(single, method="spline").get_data()[0])
    raw_bads = raw.copy()
    raw_bads.info["bads"] = [chan]
    est_bads = raw_bads.interpolate_bads(reset_bads=False).get_data(picks=[chan])[0]
    print(f"{chan:>5}  interpolate_to r={np.corrcoef(truth, est_to)[0,1]:+.3f}   "
          f"interpolate_bads r={np.corrcoef(truth, est_bads)[0,1]:+.3f}")

Output (MNE 1.12.1):

  E11  interpolate_to r=+1.000   interpolate_bads r=+1.000
  E62  interpolate_to r=+1.000   interpolate_bads r=+1.000
  E38  interpolate_to r=-1.000   interpolate_bads r=+1.000
 E121  interpolate_to r=-1.000   interpolate_bads r=+1.000
  E56  interpolate_to r=+1.000   interpolate_bads r=+1.000
 E107  interpolate_to r=+1.000   interpolate_bads r=+1.000

E38/E121 (lower frontal ring) reconstruct as the exact negation of a spatially smooth field. On real EEG we first noticed this as r ≈ −0.99 at temporal electrodes when validating montage reduction — physically implausible values that vanish once the frame is fixed.

Expected behaviour

interpolate_to at a held-out channel's own position should agree with interpolate_bads (and with the recorded data): r = +1.000 for all channels above.

Suggested fix

pos_to = np.stack(list(ch_pos.values()), axis=0) - origin_val

We validated this correction externally: with both position sets centred on the same fitted origin, held-out reconstruction on all three GSN nets returns r > 0.999 at every 10-20 target for smooth synthetic fields, and the anomalous negative correlations on real recordings disappear.

Secondary note: interpolate_to defaults to reg=0.0 while interpolate_bads uses alpha=1e-5; aligning the defaults would make the two entry points consistent once the frame is fixed.

Environment

MNE 1.12.1, Python 3.x, Windows 11 (also inspected main_interpolate_to_eeg unchanged).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions