Skip to content

Commit 3643fc3

Browse files
larsonerPragnyaKhandelwal
authored andcommitted
ENH: Add in_argmax mode to better match older MaxFilter (#14059)
1 parent f29a4d4 commit 3643fc3

8 files changed

Lines changed: 287 additions & 58 deletions

File tree

doc/changes/dev/14059.bugfix.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Fixed two bugs in how SSS information is stored and read: :func:`mne.preprocessing.maxwell_filter` recorded which multipole moments it had kept in the wrong order (the filtered data itself was unaffected), and :func:`mne.compute_rank` reported the full MEG rank rather than the reduced SSS rank for data processed with MaxFilter™-3.0, by `Eric Larson`_.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Added ``regularize="in_argmax"`` to :func:`mne.preprocessing.maxwell_filter` to allow component selection more similar to that of MaxFilter™-2.2, by `Eric Larson`_.

doc/references.bib

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1025,6 +1025,24 @@ @article{KnuutilaEtAl1993
10251025
year = {1993}
10261026
}
10271027

1028+
@inproceedings{NenonenEtAl2004,
1029+
author = {Nenonen, Jukka and Kajola, Matti and Simola, Juha and Ahonen, Antti},
1030+
booktitle = {Proceedings of the 14th International Conference on Biomagnetism (BIOMAG2004)},
1031+
pages = {630-631},
1032+
title = {Total Information of Multichannel {{MEG}} Sensor Arrays},
1033+
year = {2004}
1034+
}
1035+
1036+
@article{NenonenEtAl2007,
1037+
author = {Nenonen, Jukka and Taulu, Samu and Kajola, Matti and Ahonen, Antti},
1038+
doi = {10.1016/j.ics.2007.01.058},
1039+
journal = {International Congress Series},
1040+
pages = {245-248},
1041+
title = {Total Information Extracted from {{MEG}} Measurements},
1042+
volume = {1300},
1043+
year = {2007}
1044+
}
1045+
10281046
@article{Koles1991,
10291047
author = {Koles, Zoltan J.},
10301048
doi = {10.1016/0013-4694(91)90163-X},

mne/datasets/config.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@
8787
# update the checksum in the MNE_DATASETS dict below, and change version
8888
# here: ↓↓↓↓↓↓↓↓
8989
RELEASES = dict(
90-
testing="0.175",
90+
testing="0.176",
9191
misc="0.27",
9292
phantom_kit="0.2",
9393
ucl_opm_auditory="0.2",
@@ -115,7 +115,7 @@
115115
# Testing and misc are at the top as they're updated most often
116116
MNE_DATASETS["testing"] = dict(
117117
archive_name=f"{TESTING_VERSIONED}.tar.gz",
118-
hash="md5:0934f219e59aa727988b95352cfc1fcb",
118+
hash="md5:4e5f515c71b6897766934170d1d94a7f",
119119
url=(
120120
"https://codeload.github.com/mne-tools/mne-testing-data/"
121121
f"tar.gz/{RELEASES['testing']}"

mne/preprocessing/maxwell.py

Lines changed: 95 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1423,9 +1423,14 @@ def _regularize(
14231423
n_in = _get_n_moments(int_order)
14241424
n_out = S_decomp.shape[1] - n_in
14251425
t_str = f"{t:8.3f}"
1426-
if regularize is not None: # regularize='in'
1426+
if regularize is not None: # regularize='in' or 'in_argmax'
14271427
in_removes, out_removes = _regularize_in(
1428-
int_order, ext_order, S_decomp, mag_or_fine, extended_remove
1428+
int_order,
1429+
ext_order,
1430+
S_decomp,
1431+
mag_or_fine,
1432+
extended_remove,
1433+
argmax=regularize == "in_argmax",
14291434
)
14301435
else:
14311436
in_removes = []
@@ -1510,10 +1515,9 @@ def _get_mf_picks_fix_mags(info, int_order, ext_order, ignore_ref=False, verbose
15101515

15111516
def _check_regularize(regularize):
15121517
"""Ensure regularize is valid."""
1513-
if not (
1514-
regularize is None or (isinstance(regularize, str) and regularize in ("in",))
1515-
):
1516-
raise ValueError('regularize must be None or "in"')
1518+
_validate_type(regularize, (str, None), "regularize")
1519+
if regularize is not None:
1520+
_check_option("regularize", regularize, ("in", "in_argmax"), extra="when str")
15171521

15181522

15191523
def _check_usable(inst, ignore_ref):
@@ -1735,7 +1739,6 @@ def _sss_basis(exp, all_coils):
17351739
sin_az = rmags[:, 1] / r_xy # sin(phi)
17361740
sin_az[z_only] = 0.0
17371741
# Appropriate vector spherical harmonics terms
1738-
# JNE 2012-02-08: modified alm -> 2*alm, blm -> -2*blm
17391742
r_nn2 = r_n.copy()
17401743
r_nn1 = 1.0 / (r_n * r_n)
17411744
S_tot = np.empty((n_coils, n_in + n_out), np.float64)
@@ -1789,7 +1792,13 @@ def _sss_basis(exp, all_coils):
17891792
sin_order = np.sin(ord_phi)
17901793
cos_order = np.cos(ord_phi)
17911794
mult /= np.sqrt((degree - order + 1) * (degree + order))
1792-
factor = mult * np.sqrt(2) # equivalence fix (MF uses 2.)
1795+
# √2 keeps the real basis orthonormal. MaxFilter's real
1796+
# ("even-odd") coefficients instead absorb a factor of 2 relative to
1797+
# the complex ones, so our order != 0 columns are its columns
1798+
# divided by √2. Being a per-column scaling, this cancels in
1799+
# S_in @ pinv(S_tot); it only matters where column norms do, i.e.
1800+
# when regularizing (see _regularize_in).
1801+
factor = mult * np.sqrt(2)
17931802

17941803
# Real
17951804
idx = _deg_ord_idx(degree, order)
@@ -1952,6 +1961,25 @@ def _get_degrees_orders(order):
19521961
return degrees, orders
19531962

19541963

1964+
def _mne_ord_to_mf_idx(order):
1965+
"""Get indices reordering a moment array from our order into MaxFilter's.
1966+
1967+
We index moments by signed order ascending within each degree (see
1968+
``_deg_ord_idx``, i.e. -degree, ..., 0, ..., +degree). MaxFilter instead
1969+
walks its even-odd formulation, m = 0, ..., 2 * degree, where m = 0 is the
1970+
zero order, odd m is the real part of order (m + 1) // 2, and even m > 0 is
1971+
the imaginary part (our negative order).
1972+
"""
1973+
return np.array(
1974+
[
1975+
_deg_ord_idx(degree, order_)
1976+
for degree in range(1, order + 1)
1977+
for order_ in [0, *(s * m for m in range(1, degree + 1) for s in (1, -1))]
1978+
],
1979+
int,
1980+
)
1981+
1982+
19551983
def _alegendre_deriv(order, degree, val):
19561984
"""Compute the derivative of the associated Legendre polynomial at a value.
19571985
@@ -1996,8 +2024,11 @@ def _bases_complex_to_real(complex_tot, int_order, ext_order):
19962024
idx_neg = _deg_ord_idx(deg, -order)
19972025
real[:, idx_pos] = _sh_complex_to_real(comp[:, idx_pos], order)
19982026
if order != 0:
1999-
# This extra mult factor baffles me a bit, but it works
2000-
# in round-trip testing, so we'll keep it :(
2027+
# idx_neg holds (-1)**order * conj(comp[:, idx_pos]) (the
2028+
# conjugation property), and _sh_complex_to_real takes
2029+
# √2·imag for order < 0, flipping the sign again. This
2030+
# mult == -(-1)**order undoes both, leaving
2031+
# real[:, idx_neg] == √2 · imag(comp[:, idx_pos]).
20012032
mult = -1 if order % 2 == 0 else 1
20022033
real[:, idx_neg] = mult * _sh_complex_to_real(
20032034
comp[:, idx_neg], -order
@@ -2097,6 +2128,14 @@ def _update_sss_info(
20972128
raw.info["maxshield"] = False
20982129
components = np.zeros(n_in + n_out + len(extended_proj)).astype("int32")
20992130
components[reg_moments] = 1
2131+
# MaxFilter lays the moments out in its even-odd order rather than ours, so
2132+
# reindex both expansions before writing them out (nfree is a count, so it
2133+
# is unaffected). Any extended (eSSS) entries are appended after the two
2134+
# expansions and have no degree/order structure, so they stay put.
2135+
components[:n_in] = components[:n_in][_mne_ord_to_mf_idx(int_order)]
2136+
components[n_in : n_in + n_out] = components[n_in : n_in + n_out][
2137+
_mne_ord_to_mf_idx(ext_order)
2138+
]
21002139
sss_info_dict = dict(
21012140
in_order=int_order,
21022141
out_order=ext_order,
@@ -2400,7 +2439,9 @@ def _regularize_out(int_order, ext_order, mag_or_fine, extended_remove):
24002439
return list(range(n_in, n_in + 3 * remove_homog)) + extended_remove
24012440

24022441

2403-
def _regularize_in(int_order, ext_order, S_decomp, mag_or_fine, extended_remove):
2442+
def _regularize_in(
2443+
int_order, ext_order, S_decomp, mag_or_fine, extended_remove, *, argmax=False
2444+
):
24042445
"""Regularize basis set using idealized SNR measure."""
24052446
n_in, n_out = _get_n_moments([int_order, ext_order])
24062447

@@ -2424,22 +2465,7 @@ def _regularize_in(int_order, ext_order, S_decomp, mag_or_fine, extended_remove)
24242465
# if plot:
24252466
# import matplotlib.pyplot as plt
24262467
# fig, axs = plt.subplots(3, figsize=[6, 12])
2427-
# plot_ord = np.empty(n_in, int)
2428-
# plot_ord.fill(-1)
2429-
# count = 0
2430-
# # Reorder plot to match MF
2431-
# for degree in range(1, int_order + 1):
2432-
# for order in range(0, degree + 1):
2433-
# assert plot_ord[count] == -1
2434-
# plot_ord[count] = _deg_ord_idx(degree, order)
2435-
# count += 1
2436-
# if order > 0:
2437-
# assert plot_ord[count] == -1
2438-
# plot_ord[count] = _deg_ord_idx(degree, -order)
2439-
# count += 1
2440-
# assert count == n_in
2441-
# assert (plot_ord >= 0).all()
2442-
# assert len(np.unique(plot_ord)) == n_in
2468+
# plot_ord = _mne_ord_to_mf_idx(int_order) # reorder plot to match MF
24432469
noise_lev = 5e-13 # noise level in T/m
24442470
noise_lev *= noise_lev # effectively what would happen by earlier multiply
24452471
for ii in range(n_in):
@@ -2455,8 +2481,20 @@ def _regularize_in(int_order, ext_order, S_decomp, mag_or_fine, extended_remove)
24552481
eta_lm_sq = eta_lm_sq.sum(axis=1)
24562482
eta_lm_sq *= noise_lev
24572483

2458-
# Mysterious scale factors to match MF, likely due to differences
2459-
# in the basis normalizations...
2484+
# Together these scale snr by 400 (order != 0) / 200 (order == 0). Only
2485+
# the ratio of 2 is derivable: our order != 0 columns are MaxFilter's
2486+
# divided by √2 (see _sss_basis), making eta_lm_sq (a squared pinv)
2487+
# 2x its value there, which doubling order == 0 evens out.
2488+
#
2489+
# The shared factor of 100 is empirical, and puts I_tots in the ~480
2490+
# bits/sample range reported for this array in Nenonen et al. 2007 (Int
2491+
# Congr Ser 1300:245). It cannot affect *which* components are dropped,
2492+
# since remove_order below uses argmin(snr), but it does move the cut:
2493+
# every snr here is >> 1, so I_tots ~ 0.5*sum(log2(snr)) and a global
2494+
# scale adds 0.5*n_keepers*log2(scale) -- a ramp, not an offset, as
2495+
# n_keepers shrinks each iteration. It is nonetheless well enough
2496+
# calibrated that we reproduce MaxFilter 3.0's selection; see
2497+
# test_regularization_mf3.
24602498
eta_lm_sq[orders[in_keepers] == 0] *= 2
24612499
eta_lm_sq *= 0.0025
24622500
snr = a_lm_sq[in_keepers] / eta_lm_sq
@@ -2474,12 +2512,17 @@ def _regularize_in(int_order, ext_order, S_decomp, mag_or_fine, extended_remove)
24742512
# axs[1].set(ylabel='Information', xlabel='Iteration')
24752513
# axs[2].plot(eigs[:, 0] / eigs[:, 1])
24762514
# axs[2].set(ylabel='Condition', xlabel='Iteration')
2477-
# Pick the components that give at least 98% of max info
2478-
# This is done because the curves can be quite flat, and we err on the
2479-
# side of including rather than excluding components
2515+
# argmax is what MaxFilter 2.2 does, and what Nenonen et al. 2007 (Int Congr
2516+
# Ser 1300:245) describes: "Iteration is stopped when the maximum Itot is
2517+
# found". Over 27 expansion origins it reproduces 2.2's nfree exactly 16/27
2518+
# times against 4/27 for the 98% rule, which is 3.0's and which we track
2519+
# closely instead (see test_regularization_mf3).
24802520
if n_in:
24812521
max_info = np.max(I_tots)
2482-
lim_idx = np.where(I_tots >= 0.98 * max_info)[0][0]
2522+
if argmax:
2523+
lim_idx = np.argmax(I_tots)
2524+
else:
2525+
lim_idx = np.where(I_tots >= 0.98 * max_info)[0][0]
24832526
in_removes = remove_order[:lim_idx]
24842527
for ii, ri in enumerate(in_removes):
24852528
eig = eigs[ii]
@@ -2514,10 +2557,29 @@ def _compute_sphere_activation_in(degrees):
25142557
rho_i : float
25152558
The current density.
25162559
2560+
Notes
2561+
-----
2562+
The model is that of :footcite:`NenonenEtAl2004`, which is also where the
2563+
constants below and ``mag_scale=100`` come from.
2564+
25172565
References
25182566
----------
25192567
.. footbibliography::
25202568
"""
2569+
# Per NenonenEtAl2004: rho_i is Knuutila's 0.6 µA/m²/√Hz random brain
2570+
# current density (the 100 fT below inverts to 0.591), r_in is its
2571+
# "integration radius of 80 mm", and mag_scale=100 is its noise ratio,
2572+
# 3 fT/√Hz mag vs 4.8 fT/√Hz grad over a 16 mm baseline (3e-15 T
2573+
# vs 3.0e-13 T/m). rho_i and the noise are both spectral densities, so this
2574+
# is bandwidth free -- the 100 fT is only the RMS it gives over 0.1-30 Hz,
2575+
# and applying √bandwidth to one but not the other measurably hurts.
2576+
#
2577+
# We use the *surface* rather than the volume variant, though that paper
2578+
# describes a volume current density: the two differ by an l-dependent
2579+
# R_in²/(l+3)², and surface reproduces MaxFilter's component selection far
2580+
# better. r_in=0.080 is likewise a sharp empirical optimum across expansion
2581+
# origins, and making the sphere aware of the origin's offset from the head
2582+
# centre does not help.
25212583
r_in = 0.080 # radius of the randomly-activated sphere
25222584

25232585
# set the observation point r=r_s, az=el=0, so we can just look at m=0 term

0 commit comments

Comments
 (0)