Skip to content

Commit 5c3f140

Browse files
committed
ENH: Add Forward-based projection reconstruction
1 parent a0eb925 commit 5c3f140

4 files changed

Lines changed: 189 additions & 13 deletions

File tree

doc/changes/dev/newfeature.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Projection reconstruction can now use an explicit :class:`~mne.Forward` model and integer spatial rank, by `Hamza Abdelhedi`_.

mne/_fiff/proj.py

Lines changed: 47 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from ..fixes import _safe_svd
1414
from ..utils import (
1515
_check_option,
16+
_ensure_int,
1617
_validate_type,
1718
fill_doc,
1819
logger,
@@ -561,7 +562,15 @@ def plot_projs_topomap(
561562
)
562563
return fig
563564

564-
def reconstruct_proj(self, *, projs=None, mode="accurate", origin="auto"):
565+
def reconstruct_proj(
566+
self,
567+
*,
568+
projs=None,
569+
mode="accurate",
570+
origin="auto",
571+
forward=None,
572+
rank=None,
573+
):
565574
"""Apply SSP projectors and reconstruct the resulting signal in sensor space.
566575
567576
Operates in place.
@@ -574,18 +583,46 @@ def reconstruct_proj(self, *, projs=None, mode="accurate", origin="auto"):
574583
``None``, all projectors attached to the instance are used.
575584
mode : str
576585
Either ``'accurate'`` or ``'fast'``, determines the quality of the
577-
Legendre polynomial expansion used for reconstruction.
586+
Legendre polynomial expansion used for geometry-based
587+
reconstruction. Ignored when ``forward`` is provided.
578588
origin : array-like, shape (3,) | str
579589
Origin of the sphere in the head coordinate frame and in meters.
580590
Can be ``'auto'`` (default), which means a head-digitization-based
581-
origin fit.
591+
origin fit. Used for geometry-based reconstruction and ignored when
592+
``forward`` is provided.
593+
forward : instance of Forward | None
594+
Forward model used to construct the reconstruction field mapping.
595+
If ``None`` (default), use the geometry-based field mapping model.
596+
If provided, ``rank`` must also be specified.
597+
rank : int | None
598+
Number of spatial modes to retain when reconstructing with
599+
``forward``. This is a sensor-space reconstruction rank, not a
600+
number of sources or dipoles. Must be provided together with
601+
``forward``.
602+
603+
Notes
604+
-----
605+
When ``forward`` is provided, the reconstruction uses the sensor-space
606+
field covariance formed from the Forward gain matrix. ``rank`` specifies
607+
the number of sensor-space modes retained in the reconstruction.
582608
583609
Returns
584610
-------
585611
self : same type as the input data
586612
The modified instance.
587613
"""
588-
from ..forward import _map_meg_or_eeg_channels
614+
from ..forward import Forward, _map_meg_or_eeg_channels
615+
616+
if forward is None:
617+
if rank is not None:
618+
raise ValueError("rank can only be used when forward is provided")
619+
else:
620+
_validate_type(forward, Forward, "forward")
621+
if rank is None:
622+
raise ValueError("rank must be provided when forward is provided")
623+
rank = _ensure_int(rank, "rank")
624+
if rank <= 0:
625+
raise ValueError(f"rank must be positive, got {rank}")
589626

590627
if projs is None:
591628
if len(self.info["projs"]) == 0:
@@ -626,7 +663,12 @@ def reconstruct_proj(self, *, projs=None, mode="accurate", origin="auto"):
626663
make_eeg_average_ref_proj(info_to, verbose=False)
627664
]
628665
mapping = _map_meg_or_eeg_channels(
629-
info_from, info_to, mode=mode, origin=origin
666+
info_from,
667+
info_to,
668+
mode=mode,
669+
origin=origin,
670+
forward=forward,
671+
rank=rank,
630672
)
631673
self.data[..., picks, :] = np.matmul(mapping, self.data[..., picks, :])
632674
return self

mne/forward/_field_interpolation.py

Lines changed: 42 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212

1313
from .._fiff.constants import FIFF
1414
from .._fiff.meas_info import _simplify_info
15-
from .._fiff.pick import pick_info, pick_types
15+
from .._fiff.pick import pick_channels_forward, pick_info, pick_types
1616
from .._fiff.proj import _has_eeg_average_ref_proj, make_projector
1717
from ..bem import _check_origin
1818
from ..cov import make_ad_hoc_cov
@@ -21,22 +21,34 @@
2121
from ..fixes import _safe_svd
2222
from ..surface import get_head_surf, get_meg_helmet_surf
2323
from ..transforms import _find_trans, transform_surface_to
24-
from ..utils import _check_fname, _check_option, _pl, _reg_pinv, logger, verbose
24+
from ..utils import (
25+
_check_fname,
26+
_check_option,
27+
_pl,
28+
_reg_pinv,
29+
logger,
30+
verbose,
31+
)
2532
from ._lead_dots import _do_cross_dots, _do_self_dots, _do_surface_dots, _get_legen_fun
2633
from ._make_forward import _create_eeg_els, _create_meg_coils, _read_coil_defs
2734

2835

2936
def _setup_dots(mode, info, coils, ch_type):
3037
"""Set up dot products."""
3138
int_rad = 0.06
32-
noise = make_ad_hoc_cov(info, dict(mag=20e-15, grad=5e-13, eeg=1e-6))
39+
noise = _make_field_mapping_noise(info)
3340
# "fast" uses a coarser (n_coeff=50) Legendre series than "accurate" (n_coeff=100)
3441
n_coeff = 50 if mode == "fast" else 100
3542
leg_fun, n_fact = _get_legen_fun(ch_type, False, n_coeff)
3643
return int_rad, noise, leg_fun, n_fact
3744

3845

39-
def _compute_mapping_matrix(fmd, info):
46+
def _make_field_mapping_noise(info):
47+
"""Create the ad hoc noise covariance used for field mapping."""
48+
return make_ad_hoc_cov(info, dict(mag=20e-15, grad=5e-13, eeg=1e-6))
49+
50+
51+
def _compute_mapping_matrix(fmd, info, *, rank=None):
4052
"""Do the hairy computations."""
4153
logger.info(" Preparing the mapping matrix...")
4254
# assemble a projector and apply it to the data
@@ -54,7 +66,9 @@ def _compute_mapping_matrix(fmd, info):
5466

5567
# SVD is numerically better than the eigenvalue composition even if
5668
# mat is supposed to be symmetric and positive definite
57-
if fmd.get("pinv_method", "tsvd") == "tsvd":
69+
if rank is not None:
70+
inv, _, _ = _reg_pinv(whitened_dots, reg=0, rank=rank)
71+
elif fmd.get("pinv_method", "tsvd") == "tsvd":
5872
inv, fmd["nest"] = _pinv_trunc(whitened_dots, fmd["miss"])
5973
else:
6074
assert fmd["pinv_method"] == "tikhonov", fmd["pinv_method"]
@@ -110,7 +124,9 @@ def _pinv_tikhonov(x, reg):
110124
return inv, n
111125

112126

113-
def _map_meg_or_eeg_channels(info_from, info_to, mode, *, origin, miss=None):
127+
def _map_meg_or_eeg_channels(
128+
info_from, info_to, mode, *, origin, miss=None, forward=None, rank=None
129+
):
114130
"""Find mapping from one set of channels to another.
115131
116132
Parameters
@@ -133,8 +149,6 @@ def _map_meg_or_eeg_channels(info_from, info_to, mode, *, origin, miss=None):
133149
mapping : array, shape (n_to, n_from)
134150
A mapping matrix.
135151
"""
136-
assert origin is not None # should be assured elsewhere
137-
138152
# no need to apply trans because both from and to coils are in device
139153
# coordinates
140154
info_kinds = set(ch["kind"] for ch in info_to["chs"])
@@ -150,6 +164,26 @@ def _map_meg_or_eeg_channels(info_from, info_to, mode, *, origin, miss=None):
150164
)
151165
kind = "eeg" if info_kinds[0] == FIFF.FIFFV_EEG_CH else "meg"
152166

167+
if forward is not None:
168+
forward = pick_channels_forward(
169+
forward, include=info_from["ch_names"], ordered=True
170+
)
171+
assert forward["sol"]["row_names"] == info_from["ch_names"]
172+
lead_field = forward["sol"]["data"]
173+
# Form the sensor-space field covariance from the Forward gain matrix.
174+
# As with any Gram representation, very weak modes can be numerically unstable.
175+
dots = lead_field @ lead_field.T
176+
fmd = dict(
177+
kind=kind,
178+
ch_names=info_from["ch_names"],
179+
noise=_make_field_mapping_noise(info_from),
180+
self_dots=dots,
181+
surface_dots=dots,
182+
)
183+
return _compute_mapping_matrix(fmd, info_from, rank=rank)
184+
185+
assert origin is not None # should be assured elsewhere
186+
153187
#
154188
# Step 1. Prepare the coil definitions
155189
#

mne/tests/test_proj.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,14 @@
1919
compute_raw_covariance,
2020
convert_forward_solution,
2121
create_info,
22+
make_forward_solution,
23+
make_sphere_model,
24+
pick_channels_forward,
2225
pick_types,
2326
read_events,
2427
read_forward_solution,
2528
read_source_estimate,
29+
read_source_spaces,
2630
sensitivity_map,
2731
)
2832
from mne._fiff.proj import (
@@ -209,6 +213,101 @@ def test_reconstruct_proj(raw_orig, events):
209213
)
210214

211215

216+
@pytest.fixture(scope="module")
217+
def eeg_forward():
218+
"""Create a small local EEG forward for projection reconstruction tests."""
219+
raw = read_raw_fif(raw_fname, preload=False, verbose=False).pick(picks="eeg")
220+
raw.pick(raw.ch_names[:8])
221+
src = read_source_spaces(base_dir / "small-src.fif.gz", verbose=False)
222+
sphere = make_sphere_model(verbose=False)
223+
return make_forward_solution(
224+
raw.info,
225+
trans=None,
226+
src=src,
227+
bem=sphere,
228+
meg=False,
229+
eeg=True,
230+
mindist=0.0,
231+
verbose=False,
232+
)
233+
234+
235+
def _direct_forward_reconstruction(evoked, forward, rank):
236+
"""Compute the independent direct lead-field reconstruction."""
237+
projector = make_projector(evoked.info["projs"], evoked.ch_names)[0]
238+
forward = pick_channels_forward(forward, include=evoked.ch_names, ordered=True)
239+
lead_field = forward["sol"]["data"]
240+
u, s, vh = np.linalg.svd(projector @ lead_field, full_matrices=False)
241+
mapping = lead_field @ (vh[:rank].T / s[:rank]) @ u[:, :rank].T
242+
if _has_eeg_average_ref_proj(evoked.info):
243+
mapping -= mapping.mean(axis=0)
244+
return mapping @ (projector @ evoked.data)
245+
246+
247+
def test_reconstruct_proj_forward(raw_orig, eeg_forward):
248+
"""Test Forward reconstruction and channel handling."""
249+
raw = raw_orig.copy().pick(picks="eeg")
250+
raw.pick(raw.ch_names[:8])
251+
evoked = EvokedArray(raw.get_data()[:, :10], raw.info, tmin=0.0)
252+
evoked.add_proj(
253+
_make_test_proj(
254+
evoked.ch_names,
255+
np.arange(1.0, len(evoked.ch_names) + 1.0),
256+
"Forward reconstruction",
257+
),
258+
verbose=False,
259+
)
260+
rank = 3
261+
for average_ref in (False, True):
262+
this_evoked = evoked.copy()
263+
if average_ref:
264+
this_evoked.set_eeg_reference(projection=True)
265+
expected = _direct_forward_reconstruction(this_evoked, eeg_forward, rank)
266+
got = this_evoked.copy().reconstruct_proj(forward=eeg_forward, rank=rank).data
267+
assert_allclose(got, expected, rtol=1e-10, atol=1e-12)
268+
269+
reordered = pick_channels_forward(
270+
eeg_forward, include=eeg_forward.ch_names[::-1], ordered=True
271+
)
272+
got = evoked.copy().reconstruct_proj(forward=eeg_forward, rank=rank).data
273+
got_reordered = evoked.copy().reconstruct_proj(forward=reordered, rank=rank).data
274+
assert_allclose(got, got_reordered, rtol=1e-10, atol=1e-12)
275+
276+
evoked_bad = evoked.copy()
277+
evoked_bad.info["bads"] = [evoked_bad.ch_names[0]]
278+
got_bad = evoked_bad.reconstruct_proj(forward=eeg_forward, rank=rank).data
279+
assert_allclose(got_bad[0], evoked_bad.data[0])
280+
281+
282+
def test_reconstruct_proj_forward_validation(eeg_forward):
283+
"""Test validation of the explicit Forward reconstruction arguments."""
284+
info = create_info(eeg_forward["info"]["ch_names"], 100.0, "eeg")
285+
evoked = EvokedArray(np.zeros((len(info["ch_names"]), 1)), info, tmin=0.0)
286+
evoked.add_proj(
287+
_make_test_proj(
288+
evoked.ch_names,
289+
np.ones(len(evoked.ch_names)),
290+
"Forward reconstruction",
291+
),
292+
verbose=False,
293+
)
294+
with pytest.raises(ValueError, match="rank can only be used"):
295+
evoked.copy().reconstruct_proj(rank=1)
296+
with pytest.raises(ValueError, match="rank must be provided"):
297+
evoked.copy().reconstruct_proj(forward=eeg_forward)
298+
with pytest.raises(TypeError, match="forward must be an instance of Forward"):
299+
evoked.copy().reconstruct_proj(forward=[], rank=1)
300+
for rank in (0, -1):
301+
with pytest.raises(ValueError, match="rank must be positive"):
302+
evoked.copy().reconstruct_proj(forward=eeg_forward, rank=rank)
303+
for rank in (1.5, True):
304+
with pytest.raises(TypeError, match="rank must be an int"):
305+
evoked.copy().reconstruct_proj(forward=eeg_forward, rank=rank)
306+
rank = len(evoked.ch_names) + 1
307+
with pytest.raises(ValueError, match="Invalid value for the rank parameter"):
308+
evoked.copy().reconstruct_proj(forward=eeg_forward, rank=rank)
309+
310+
212311
@pytest.mark.parametrize("kind", ["raw", "epochs", "evoked"])
213312
def test_apply_proj_default(kind):
214313
"""Test that ``projs=None`` preserves legacy behavior."""

0 commit comments

Comments
 (0)