Skip to content

Commit cdbdf92

Browse files
Aniketsydrammock
andauthored
ENH: Add variance regularization to F-statistic in f_oneway (#13698)
Co-authored-by: Daniel McCloy <dan@mccloy.info>
1 parent 84cabed commit cdbdf92

3 files changed

Lines changed: 76 additions & 2 deletions

File tree

doc/changes/dev/13698.other.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add optional low-variance ("hat") regularization to :func:`mne.stats.f_oneway` via new ``sigma`` and ``method`` parameters, by `Aniket Singh Yadav`_.

mne/stats/parametric.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ def ttest_ind_no_p(a, b, equal_var=True, sigma=0.0):
111111
return t
112112

113113

114-
def f_oneway(*args):
114+
def f_oneway(*args, sigma=0.0, method="relative"):
115115
"""Perform a 1-way ANOVA.
116116
117117
The one-way ANOVA tests the null hypothesis that 2 or more groups have
@@ -125,6 +125,20 @@ def f_oneway(*args):
125125
----------
126126
*args : array_like
127127
The sample measurements should be given as arguments.
128+
sigma : float
129+
Regularization parameter (>= 0) added to the within-group mean
130+
square to mitigate F-statistic inflation under low-variance
131+
conditions. ``0`` (default) disables correction.
132+
133+
.. versionadded:: 1.12
134+
method : str
135+
How *sigma* is interpreted when ``sigma > 0``. Can be
136+
``'relative'`` (default) or ``'absolute'``.
137+
``'relative'`` multiplies *sigma* by the median within-group
138+
mean square (scale-invariant, recommended).
139+
``'absolute'`` uses *sigma* directly as a raw sigma squared.
140+
141+
.. versionadded:: 1.12
128142
129143
Returns
130144
-------
@@ -151,6 +165,9 @@ def f_oneway(*args):
151165
----------
152166
.. footbibliography::
153167
"""
168+
_check_option("method", method, ["absolute", "relative"])
169+
if sigma < 0:
170+
raise ValueError(f"sigma must be >= 0, got {sigma}")
154171
n_classes = len(args)
155172
n_samples_per_class = np.array([len(a) for a in args])
156173
n_samples = np.sum(n_samples_per_class)
@@ -168,6 +185,12 @@ def f_oneway(*args):
168185
dfwn = n_samples - n_classes
169186
msb = ssbn / float(dfbn)
170187
msw = sswn / float(dfwn)
188+
if sigma > 0.0:
189+
if method == "relative":
190+
sigma_sq = sigma * np.median(msw)
191+
else:
192+
sigma_sq = float(sigma)
193+
msw = (sswn + sigma_sq) / float(dfwn)
171194
f = msb / msw
172195
return f
173196

mne/stats/tests/test_parametric.py

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from numpy.testing import assert_allclose, assert_array_almost_equal, assert_array_less
1212

1313
import mne
14-
from mne.stats.parametric import _map_effects, f_mway_rm, f_threshold_mway_rm
14+
from mne.stats.parametric import _map_effects, f_mway_rm, f_oneway, f_threshold_mway_rm
1515

1616
# hardcoded external test results, manually transferred
1717
test_external = {
@@ -175,3 +175,53 @@ def theirs(*a, **kw):
175175
# something to the divisor (var)
176176
assert_allclose(got, want, rtol=2e-1, atol=1e-2)
177177
assert_array_less(np.abs(got), np.abs(want))
178+
179+
180+
@pytest.mark.parametrize("sigma", [0.0, 1e-3])
181+
@pytest.mark.parametrize("method", ["absolute", "relative"])
182+
@pytest.mark.parametrize("seed", [0, 42, 1337])
183+
def test_f_oneway_hat(sigma, method, seed):
184+
"""Test f_oneway hat (low-variance) regularization."""
185+
rng = np.random.default_rng(seed)
186+
X1 = rng.standard_normal(size=(10, 50))
187+
X2 = rng.standard_normal(size=(10, 50))
188+
189+
f_ours = f_oneway(X1, X2, sigma=0.0, method=method)
190+
f_scipy = scipy.stats.f_oneway(X1, X2)[0]
191+
assert_allclose(f_ours, f_scipy, rtol=1e-7, atol=1e-6)
192+
193+
if sigma > 0:
194+
f_reg = f_oneway(X1, X2, sigma=sigma, method=method)
195+
f_unreg = f_oneway(X1, X2, sigma=0.0)
196+
pos = f_unreg > 0
197+
assert_array_less(f_reg[pos], f_unreg[pos])
198+
199+
200+
def test_f_oneway_hat_small_variance():
201+
"""Test that f_oneway hat stabilizes F-values for near-zero variance."""
202+
rng = np.random.RandomState(0)
203+
X1 = rng.normal(0, 1e-6, (10, 100))
204+
X2 = rng.normal(1, 1e-6, (10, 100))
205+
206+
f_unreg = f_oneway(X1, X2, sigma=0.0)
207+
f_abs = f_oneway(X1, X2, sigma=1e-3, method="absolute")
208+
f_rel = f_oneway(X1, X2, sigma=1e-3, method="relative")
209+
210+
assert np.median(f_unreg) > 1e6
211+
assert np.median(f_abs) < np.median(f_unreg)
212+
assert np.median(f_rel) < np.median(f_unreg)
213+
assert np.all(np.isfinite(f_abs))
214+
assert np.all(np.isfinite(f_rel))
215+
216+
217+
def test_f_oneway_hat_input_validation():
218+
"""Test f_oneway input validation for sigma and method."""
219+
rng = np.random.RandomState(0)
220+
X1 = rng.randn(5, 10)
221+
X2 = rng.randn(5, 10)
222+
223+
with pytest.raises(ValueError, match="sigma must be >= 0"):
224+
f_oneway(X1, X2, sigma=-0.1)
225+
226+
with pytest.raises(ValueError, match="method"):
227+
f_oneway(X1, X2, sigma=1e-3, method="invalid")

0 commit comments

Comments
 (0)