@@ -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
15111516def _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
15191523def _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+
19551983def _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