Skip to content

Commit bb034ad

Browse files
ENH: Add Forward-based projection reconstruction (#14235)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent b410a9c commit bb034ad

4 files changed

Lines changed: 271 additions & 37 deletions

File tree

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 standard MNE rank handling, by `Hamza Abdelhedi`_.

mne/_fiff/proj.py

Lines changed: 51 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+
_check_rank,
1617
_validate_type,
1718
fill_doc,
1819
logger,
@@ -561,7 +562,17 @@ def plot_projs_topomap(
561562
)
562563
return fig
563564

564-
def reconstruct_proj(self, *, projs=None, mode="accurate", origin="auto"):
565+
@verbose
566+
def reconstruct_proj(
567+
self,
568+
*,
569+
projs=None,
570+
mode="accurate",
571+
origin="auto",
572+
forward=None,
573+
rank=None,
574+
verbose=None,
575+
):
565576
"""Apply SSP projectors and reconstruct the resulting signal in sensor space.
566577
567578
Operates in place.
@@ -574,18 +585,48 @@ def reconstruct_proj(self, *, projs=None, mode="accurate", origin="auto"):
574585
``None``, all projectors attached to the instance are used.
575586
mode : str
576587
Either ``'accurate'`` or ``'fast'``, determines the quality of the
577-
Legendre polynomial expansion used for reconstruction.
588+
Legendre polynomial expansion used for geometry-based
589+
reconstruction. Ignored when ``forward`` is provided.
578590
origin : array-like, shape (3,) | str
579591
Origin of the sphere in the head coordinate frame and in meters.
580592
Can be ``'auto'`` (default), which means a head-digitization-based
581-
origin fit.
593+
origin fit. Used for geometry-based reconstruction and ignored when
594+
``forward`` is provided.
595+
forward : instance of Forward | None
596+
Forward model used to construct the reconstruction field mapping.
597+
If ``None`` (default), use the geometry-based field mapping model.
598+
%(rank)s
599+
600+
Only used when ``forward`` is provided, where the default ``None``
601+
estimates the rank of the projected field covariance. ``'full'`` is
602+
not supported, as that covariance is rank-deficient after
603+
projection. Without ``forward``, ``rank`` must be ``None``, and the
604+
geometry-based field mapping uses its own internal truncation
605+
rather than a rank estimated from the data.
606+
%(verbose)s
582607
583608
Returns
584609
-------
585610
self : same type as the input data
586611
The modified instance.
612+
613+
Notes
614+
-----
615+
When ``forward`` is provided, ``rank`` controls the sensor-space rank
616+
of the projected Forward field covariance.
587617
"""
588-
from ..forward import _map_meg_or_eeg_channels
618+
from ..forward import Forward, _map_meg_or_eeg_channels
619+
620+
if forward is not None:
621+
_validate_type(forward, Forward, "forward")
622+
rank = _check_rank(rank)
623+
if forward is None and rank is not None:
624+
raise ValueError("rank can only be used when forward is provided")
625+
if forward is not None and rank == "full":
626+
raise ValueError(
627+
"rank='full' is incompatible with Forward reconstruction "
628+
"after projection; use rank='info', None, or an explicit rank dict"
629+
)
589630

590631
if projs is None:
591632
if len(self.info["projs"]) == 0:
@@ -626,7 +667,12 @@ def reconstruct_proj(self, *, projs=None, mode="accurate", origin="auto"):
626667
make_eeg_average_ref_proj(info_to, verbose=False)
627668
]
628669
mapping = _map_meg_or_eeg_channels(
629-
info_from, info_to, mode=mode, origin=origin
670+
info_from,
671+
info_to,
672+
mode=mode,
673+
origin=origin,
674+
forward=forward,
675+
rank=rank,
630676
)
631677
self.data[..., picks, :] = np.matmul(mapping, self.data[..., picks, :])
632678
return self

mne/forward/_field_interpolation.py

Lines changed: 72 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -12,31 +12,43 @@
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
18-
from ..cov import make_ad_hoc_cov
18+
from ..cov import Covariance, make_ad_hoc_cov
1919
from ..epochs import BaseEpochs, EpochsArray
2020
from ..evoked import Evoked, EvokedArray
21-
from ..fixes import _safe_svd
21+
from ..rank import _compute_rank_int
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
@@ -52,12 +64,26 @@ def _compute_mapping_matrix(fmd, info):
5264
whitener = np.diag(1.0 / np.sqrt(noise_cov["data"].ravel()))
5365
whitened_dots = np.dot(whitener.T, np.dot(proj_dots, whitener))
5466

55-
# SVD is numerically better than the eigenvalue composition even if
56-
# mat is supposed to be symmetric and positive definite
67+
# whitened_dots is symmetric and positive semi-definite, so _reg_pinv (which
68+
# requires square Hermitian input) can do the truncated pseudoinversion
5769
if fmd.get("pinv_method", "tsvd") == "tsvd":
58-
inv, fmd["nest"] = _pinv_trunc(whitened_dots, fmd["miss"])
70+
n = len(whitened_dots)
71+
if rank is None:
72+
# truncate at most "miss" fraction of the singular value energy
73+
s = np.linalg.svd(whitened_dots, compute_uv=False, hermitian=True)
74+
varexp = np.cumsum(s)
75+
varexp /= varexp[-1]
76+
rank = np.where(varexp >= 1.0 - fmd["miss"])[0][0] + 1
77+
logger.info(
78+
f" Truncating at {rank}/{n} components to omit less than "
79+
f"{fmd['miss']:g} ({1.0 - varexp[rank - 1]:0.2g})"
80+
)
81+
else:
82+
logger.info(f" Truncating at {rank}/{n} components")
83+
inv, _, fmd["nest"] = _reg_pinv(whitened_dots, reg=0, rank=rank)
5984
else:
6085
assert fmd["pinv_method"] == "tikhonov", fmd["pinv_method"]
86+
assert rank is None, rank # only the tsvd path supports an explicit rank
6187
inv, fmd["nest"] = _pinv_tikhonov(whitened_dots, fmd["miss"])
6288

6389
# Sandwich with the whitener
@@ -81,26 +107,6 @@ def _compute_mapping_matrix(fmd, info):
81107
return mapping_mat
82108

83109

84-
def _pinv_trunc(x, miss):
85-
"""Compute pseudoinverse, truncating at most "miss" fraction of varexp."""
86-
u, s, v = _safe_svd(x, full_matrices=False)
87-
88-
# Eigenvalue truncation
89-
varexp = np.cumsum(s)
90-
varexp /= varexp[-1]
91-
n = np.where(varexp >= (1.0 - miss))[0][0] + 1
92-
logger.info(
93-
" Truncating at %d/%d components to omit less than %g (%0.2g)",
94-
n,
95-
len(s),
96-
miss,
97-
1.0 - varexp[n - 1],
98-
)
99-
s = 1.0 / s[:n]
100-
inv = ((u[:, :n] * s) @ v[:n]).T
101-
return inv, n
102-
103-
104110
def _pinv_tikhonov(x, reg):
105111
# _reg_pinv requires square Hermitian, which we have here
106112
inv, _, n = _reg_pinv(x, reg=reg, rank=None)
@@ -110,7 +116,9 @@ def _pinv_tikhonov(x, reg):
110116
return inv, n
111117

112118

113-
def _map_meg_or_eeg_channels(info_from, info_to, mode, *, origin, miss=None):
119+
def _map_meg_or_eeg_channels(
120+
info_from, info_to, mode, *, origin, miss=None, forward=None, rank=None
121+
):
114122
"""Find mapping from one set of channels to another.
115123
116124
Parameters
@@ -127,14 +135,18 @@ def _map_meg_or_eeg_channels(info_from, info_to, mode, *, origin, miss=None):
127135
Origin of the sphere in the head coordinate frame and in meters.
128136
Can be ``'auto'``, which means a head-digitization-based origin
129137
fit.
138+
forward : instance of Forward | None
139+
Forward model used instead of geometry-based field interpolation.
140+
rank : None | 'info' | dict
141+
Rank specification for Forward-based reconstruction, where ``None``
142+
estimates it from the projected field covariance. Must be ``None`` for
143+
the geometry-based path, which uses its own truncation instead.
130144
131145
Returns
132146
-------
133147
mapping : array, shape (n_to, n_from)
134148
A mapping matrix.
135149
"""
136-
assert origin is not None # should be assured elsewhere
137-
138150
# no need to apply trans because both from and to coils are in device
139151
# coordinates
140152
info_kinds = set(ch["kind"] for ch in info_to["chs"])
@@ -150,6 +162,34 @@ def _map_meg_or_eeg_channels(info_from, info_to, mode, *, origin, miss=None):
150162
)
151163
kind = "eeg" if info_kinds[0] == FIFF.FIFFV_EEG_CH else "meg"
152164

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

0 commit comments

Comments
 (0)