From 1f6c6399ace0194580c6f5b951e3070e97b5dfa6 Mon Sep 17 00:00:00 2001 From: Bru Date: Mon, 24 Aug 2026 11:54:26 +0200 Subject: [PATCH 01/34] ENH: Add private RNG normalizer --- mne/utils/check.py | 7 +++++++ mne/utils/tests/test_check.py | 18 +++++++++++++++++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/mne/utils/check.py b/mne/utils/check.py index 5c41c628025..0b5c0eccbba 100644 --- a/mne/utils/check.py +++ b/mne/utils/check.py @@ -230,6 +230,13 @@ def check_random_state(seed): ) +def _check_rng(rng): + """Return a NumPy Generator for new random-number paths.""" + if isinstance(rng, np.random.mtrand.RandomState): + raise TypeError("rng must not be a RandomState") + return np.random.default_rng(rng) + + def _check_event_id(event_id, events): """Check event_id and convert to default format.""" # check out event_id dict diff --git a/mne/utils/tests/test_check.py b/mne/utils/tests/test_check.py index fbeea9b6136..e74f1523c71 100644 --- a/mne/utils/tests/test_check.py +++ b/mne/utils/tests/test_check.py @@ -10,7 +10,7 @@ import numpy as np import pytest -from numpy.testing import assert_allclose, assert_equal +from numpy.testing import assert_allclose, assert_array_equal, assert_equal import mne from mne import create_info, pick_channels_cov, read_vectorview_selection @@ -38,6 +38,7 @@ check_random_state, check_version, ) +from mne.utils.check import _check_rng data_path = testing.data_path(download=False) base_dir = data_path / "MEG" / "sample" @@ -48,6 +49,21 @@ reject = dict(grad=4000e-13, mag=4e-12) +def test_check_rng(): + """Test conversion to NumPy's modern random number generator.""" + assert isinstance(_check_rng(None), np.random.Generator) + + rng = _check_rng(0) + assert isinstance(rng, np.random.Generator) + assert_array_equal(rng.integers(10, size=3), _check_rng(0).integers(10, size=3)) + + assert _check_rng(rng) is rng + assert isinstance(_check_rng(np.random.SeedSequence(0)), np.random.Generator) + assert isinstance(_check_rng(np.random.PCG64(0)), np.random.Generator) + with pytest.raises(TypeError): + _check_rng(np.random.RandomState(0)) + + @testing.requires_testing_data def test_check(tmp_path): """Test checking functions.""" From c519f50109314f77d8df87c5ff5e07dfb7c9176e Mon Sep 17 00:00:00 2001 From: Bru Date: Mon, 24 Aug 2026 12:12:50 +0200 Subject: [PATCH 02/34] ENH: Add rng to permutation helpers --- mne/stats/permutations.py | 32 +++++++++++++++++++++------- mne/stats/tests/test_permutations.py | 12 +++++------ mne/utils/__init__.pyi | 2 ++ mne/utils/check.py | 14 ++++++++++++ mne/utils/docs.py | 7 ++++++ mne/utils/tests/test_check.py | 12 ++++++++++- 6 files changed, 64 insertions(+), 15 deletions(-) diff --git a/mne/stats/permutations.py b/mne/stats/permutations.py index 903e1b56881..6b420edcac5 100644 --- a/mne/stats/permutations.py +++ b/mne/stats/permutations.py @@ -9,7 +9,7 @@ import numpy as np from ..parallel import parallel_func -from ..utils import _check_if_nan, check_random_state, logger, verbose +from ..utils import _check_if_nan, _check_rng_compat, logger, verbose def _max_stat(X, X2, perms, dof_scaling): @@ -23,7 +23,14 @@ def _max_stat(X, X2, perms, dof_scaling): @verbose def permutation_t_test( - X, n_permutations=10000, tail=0, n_jobs=None, seed=None, verbose=None + X, + n_permutations=10000, + tail=0, + n_jobs=None, + seed=None, + verbose=None, + *, + rng=None, ): """One sample/paired sample permutation test based on a t-statistic. @@ -52,7 +59,9 @@ def permutation_t_test( than 0 (two tailed test). If tail is -1, the alternative hypothesis is that the mean of the data is less than 0 (lower tailed test). %(n_jobs)s - %(seed)s + %(rng)s + seed : None | int | instance of ~numpy.random.RandomState + Deprecated. Use ``rng`` instead. %(verbose)s Returns @@ -84,7 +93,7 @@ def permutation_t_test( dof_scaling = sqrt(n_samples / (n_samples - 1.0)) std0 = np.sqrt(X2 - mu0**2) * dof_scaling # get std with var splitting T_obs = np.mean(X, axis=0) / (std0 / sqrt(n_samples)) - rng = check_random_state(seed) + rng = _check_rng_compat(rng, legacy=seed, legacy_name="seed") orders, _, extra = _get_1samp_orders(n_samples, n_permutations, tail, rng) perms = 2 * np.array(orders) - 1 # from 0, 1 -> 1, -1 logger.info(f"Permuting {len(orders)} times{extra}...") @@ -106,7 +115,13 @@ def permutation_t_test( def bootstrap_confidence_interval( - arr, ci=0.95, n_bootstraps=2000, stat_fun="mean", random_state=None + arr, + ci=0.95, + n_bootstraps=2000, + stat_fun="mean", + random_state=None, + *, + rng=None, ): """Get confidence intervals from non-parametric bootstrap. @@ -120,8 +135,9 @@ def bootstrap_confidence_interval( Number of bootstraps. stat_fun : str | callable Can be "mean", "median", or a callable operating along ``axis=0``. + %(rng)s random_state : int | float | array_like | None - The seed at which to initialize the bootstrap. + Deprecated. Use ``rng`` instead. Returns ------- @@ -143,7 +159,7 @@ def stat_fun(x): raise ValueError("stat_fun must be 'mean', 'median' or callable.") n_trials = arr.shape[0] indices = np.arange(n_trials, dtype=int) # BCA would be cool to have too - rng = check_random_state(random_state) + rng = _check_rng_compat(rng, legacy=random_state, legacy_name="random_state") boot_indices = rng.choice(indices, replace=True, size=(n_bootstraps, len(indices))) stat = np.array([stat_fun(arr[inds]) for inds in boot_indices]) ci = (((1 - ci) / 2) * 100, (1 - ((1 - ci) / 2)) * 100) @@ -155,7 +171,7 @@ def _ci(arr, ci=0.95, method="bootstrap", n_bootstraps=2000, random_state=None): """Calculate confidence interval. Aux function for plot_compare_evokeds.""" if method == "bootstrap": return bootstrap_confidence_interval( - arr, ci=ci, n_bootstraps=n_bootstraps, random_state=random_state + arr, ci=ci, n_bootstraps=n_bootstraps, rng=random_state ) else: from .parametric import _parametric_ci diff --git a/mne/stats/tests/test_permutations.py b/mne/stats/tests/test_permutations.py index 24d4db6d9d4..3a7aa944d89 100644 --- a/mne/stats/tests/test_permutations.py +++ b/mne/stats/tests/test_permutations.py @@ -24,24 +24,24 @@ def test_permutation_t_test(): X = rng.standard_normal((n_samples, n_tests)) X[:, :2] += 1 - t_obs, p_values, H0 = permutation_t_test(X, n_permutations=999, tail=0, seed=0) + t_obs, p_values, H0 = permutation_t_test(X, n_permutations=999, tail=0, rng=0) assert (p_values > 0).all() assert len(H0) == 999 is_significant = p_values < 0.05 assert_array_equal(is_significant, [True, True, False, False, False]) - t_obs, p_values, H0 = permutation_t_test(X, n_permutations=999, tail=1, seed=0) + t_obs, p_values, H0 = permutation_t_test(X, n_permutations=999, tail=1, rng=0) assert (p_values > 0).all() assert len(H0) == 999 is_significant = p_values < 0.05 assert_array_equal(is_significant, [True, True, False, False, False]) - t_obs, p_values, H0 = permutation_t_test(X, n_permutations=999, tail=-1, seed=0) + t_obs, p_values, H0 = permutation_t_test(X, n_permutations=999, tail=-1, rng=0) is_significant = p_values < 0.05 assert_array_equal(is_significant, [False, False, False, False, False]) X *= -1 - t_obs, p_values, H0 = permutation_t_test(X, n_permutations=999, tail=-1, seed=0) + t_obs, p_values, H0 = permutation_t_test(X, n_permutations=999, tail=-1, rng=0) assert (p_values > 0).all() assert len(H0) == 999 is_significant = p_values < 0.05 @@ -90,7 +90,7 @@ def test_ci(): _ci(arr, method="parametric"), _ci(arr, method="bootstrap"), rtol=0.005 ) assert_allclose( - bootstrap_confidence_interval(arr, stat_fun="median", random_state=0), - bootstrap_confidence_interval(arr, stat_fun="mean", random_state=0), + bootstrap_confidence_interval(arr, stat_fun="median", rng=0), + bootstrap_confidence_interval(arr, stat_fun="mean", rng=0), rtol=0.1, ) diff --git a/mne/utils/__init__.pyi b/mne/utils/__init__.pyi index 9d70ef0ba57..f94ced7144e 100644 --- a/mne/utils/__init__.pyi +++ b/mne/utils/__init__.pyi @@ -23,6 +23,7 @@ __all__ = [ "_auto_weakref", "_build_data_frame", "_check_all_same_channel_names", + "_check_rng_compat", "_check_ch_locs", "_check_channels_spatial_filter", "_check_combine", @@ -259,6 +260,7 @@ from .check import ( _check_qt_version, _check_range, _check_rank, + _check_rng_compat, _check_sphere, _check_src_normal, _check_stc_units, diff --git a/mne/utils/check.py b/mne/utils/check.py index 0b5c0eccbba..c1cd0abc2fc 100644 --- a/mne/utils/check.py +++ b/mne/utils/check.py @@ -237,6 +237,20 @@ def _check_rng(rng): return np.random.default_rng(rng) +def _check_rng_compat(rng, *, legacy=None, legacy_name): + """Check an RNG while temporarily supporting a legacy parameter.""" + if legacy is not None: + if rng is not None: + raise TypeError(f"Specify only one of rng or {legacy_name}") + warn( + f"{legacy_name} is deprecated and will be removed in a future release; " + "use rng instead.", + FutureWarning, + ) + return check_random_state(legacy) + return _check_rng(rng) + + def _check_event_id(event_id, events): """Check event_id and convert to default format.""" # check out event_id dict diff --git a/mne/utils/docs.py b/mne/utils/docs.py index 868ee4fe0e4..fb9352dc1f9 100644 --- a/mne/utils/docs.py +++ b/mne/utils/docs.py @@ -3757,6 +3757,13 @@ def _reflow_param_docstring(docstring, has_first_line=True, width=75): # %% # R +docdict["rng"] = """ +rng : None | int | numpy.random.Generator + The random number generator. If ``None`` (default), a new generator seeded + from entropy is used. Pass an integer for reproducible results or a + :class:`numpy.random.Generator` to control the random-number stream. +""" + docdict["random_state"] = """ random_state : None | int | instance of ~numpy.random.RandomState A seed for the NumPy random number generator (RNG). If ``None`` (default), diff --git a/mne/utils/tests/test_check.py b/mne/utils/tests/test_check.py index e74f1523c71..b3c8fefb0cf 100644 --- a/mne/utils/tests/test_check.py +++ b/mne/utils/tests/test_check.py @@ -38,7 +38,7 @@ check_random_state, check_version, ) -from mne.utils.check import _check_rng +from mne.utils.check import _check_rng, _check_rng_compat data_path = testing.data_path(download=False) base_dir = data_path / "MEG" / "sample" @@ -64,6 +64,16 @@ def test_check_rng(): _check_rng(np.random.RandomState(0)) +def test_check_rng_compat(): + """Test compatibility with legacy random-number parameters.""" + with pytest.warns(FutureWarning, match="seed"): + rng = _check_rng_compat(None, legacy=0, legacy_name="seed") + assert isinstance(rng, np.random.RandomState) + assert isinstance(_check_rng_compat(None, legacy_name="seed"), np.random.Generator) + with pytest.raises(TypeError, match="rng"): + _check_rng_compat(0, legacy=1, legacy_name="random_state") + + @testing.requires_testing_data def test_check(tmp_path): """Test checking functions.""" From 141a14d09701c57d18e5722209708392f57d4c74 Mon Sep 17 00:00:00 2001 From: Bru Date: Mon, 24 Aug 2026 13:35:39 +0200 Subject: [PATCH 03/34] ENH: Add rng to cluster permutation tests --- mne/stats/cluster_level.py | 33 +++++++++++++--- mne/stats/tests/test_cluster_level.py | 55 ++++++++++++++++----------- 2 files changed, 59 insertions(+), 29 deletions(-) diff --git a/mne/stats/cluster_level.py b/mne/stats/cluster_level.py index 0de6e009504..6fcdf4e139b 100644 --- a/mne/stats/cluster_level.py +++ b/mne/stats/cluster_level.py @@ -12,6 +12,7 @@ from ..utils import ( ProgressBar, _check_option, + _check_rng_compat, _pl, _validate_type, check_random_state, @@ -1120,6 +1121,8 @@ def permutation_cluster_test( check_disjoint=False, buffer_size=1000, verbose=None, + *, + rng=None, ): """Cluster-level statistical permutation test. @@ -1151,7 +1154,9 @@ def permutation_cluster_test( %(stat_fun_clust_f)s %(adjacency_clust_n)s %(n_jobs)s - %(seed)s + %(rng)s + seed : None | int | instance of ~numpy.random.RandomState + Deprecated. Use ``rng`` instead. %(max_step_clust)s %(exclude_clust)s %(step_down_p_clust)s @@ -1181,6 +1186,7 @@ def permutation_cluster_test( .. footbibliography:: """ stat_fun, threshold = _check_fun(X, stat_fun, threshold, tail, "between") + rng = _check_rng_compat(rng, legacy=seed, legacy_name="seed") return _permutation_cluster_test( X=X, threshold=threshold, @@ -1189,7 +1195,7 @@ def permutation_cluster_test( stat_fun=stat_fun, adjacency=adjacency, n_jobs=n_jobs, - seed=seed, + seed=rng, max_step=max_step, exclude=exclude, step_down_p=step_down_p, @@ -1218,6 +1224,8 @@ def permutation_cluster_1samp_test( check_disjoint=False, buffer_size=1000, verbose=None, + *, + rng=None, ): """Non-parametric cluster-level paired t-test. @@ -1238,7 +1246,9 @@ def permutation_cluster_1samp_test( %(stat_fun_clust_t)s %(adjacency_clust_1)s %(n_jobs)s - %(seed)s + %(rng)s + seed : None | int | instance of ~numpy.random.RandomState + Deprecated. Use ``rng`` instead. %(max_step_clust)s %(exclude_clust)s %(step_down_p_clust)s @@ -1291,6 +1301,7 @@ def permutation_cluster_1samp_test( .. footbibliography:: """ stat_fun, threshold = _check_fun(X, stat_fun, threshold, tail) + rng = _check_rng_compat(rng, legacy=seed, legacy_name="seed") return _permutation_cluster_test( X=[X], threshold=threshold, @@ -1299,7 +1310,7 @@ def permutation_cluster_1samp_test( stat_fun=stat_fun, adjacency=adjacency, n_jobs=n_jobs, - seed=seed, + seed=rng, max_step=max_step, exclude=exclude, step_down_p=step_down_p, @@ -1328,6 +1339,8 @@ def spatio_temporal_cluster_1samp_test( check_disjoint=False, buffer_size=1000, verbose=None, + *, + rng=None, ): """Non-parametric cluster-level paired t-test for spatio-temporal data. @@ -1351,7 +1364,9 @@ def spatio_temporal_cluster_1samp_test( %(stat_fun_clust_t)s %(adjacency_clust_st1)s %(n_jobs)s - %(seed)s + %(rng)s + seed : None | int | instance of ~numpy.random.RandomState + Deprecated. Use ``rng`` instead. %(max_step_clust)s spatial_exclude : list of int or None List of spatial indices to exclude from clustering. @@ -1397,6 +1412,7 @@ def spatio_temporal_cluster_1samp_test( adjacency=adjacency, n_jobs=n_jobs, seed=seed, + rng=rng, max_step=max_step, exclude=exclude, step_down_p=step_down_p, @@ -1425,6 +1441,8 @@ def spatio_temporal_cluster_test( check_disjoint=False, buffer_size=1000, verbose=None, + *, + rng=None, ): """Non-parametric cluster-level test for spatio-temporal data. @@ -1450,7 +1468,9 @@ def spatio_temporal_cluster_test( %(stat_fun_clust_f)s %(adjacency_clust_stn)s %(n_jobs)s - %(seed)s + %(rng)s + seed : None | int | instance of ~numpy.random.RandomState + Deprecated. Use ``rng`` instead. %(max_step_clust)s spatial_exclude : list of int or None List of spatial indices to exclude from clustering. @@ -1496,6 +1516,7 @@ def spatio_temporal_cluster_test( adjacency=adjacency, n_jobs=n_jobs, seed=seed, + rng=rng, max_step=max_step, exclude=exclude, step_down_p=step_down_p, diff --git a/mne/stats/tests/test_cluster_level.py b/mne/stats/tests/test_cluster_level.py index 071861d85ff..ed9f4e203b1 100644 --- a/mne/stats/tests/test_cluster_level.py +++ b/mne/stats/tests/test_cluster_level.py @@ -56,6 +56,15 @@ def _get_conditions(): return condition1_1d, condition2_1d, condition1_2d, condition2_2d +def test_cluster_rng_transition(): + """Test the transition from seed to rng.""" + X = np.arange(24.0).reshape(8, 3) + with pytest.warns(FutureWarning, match="seed"): + permutation_cluster_1samp_test(X, threshold=0, n_permutations=2, seed=0) + with pytest.raises(TypeError, match="Specify only one"): + permutation_cluster_1samp_test(X, threshold=0, n_permutations=2, seed=0, rng=0) + + def test_thresholds(numba_conditional): """Test automatic threshold calculations.""" # within subjects @@ -69,7 +78,7 @@ def test_thresholds(numba_conditional): with catch_logging() as log: with pytest.warns(RuntimeWarning, match="threshold is only valid"): out = permutation_cluster_1samp_test( - X, stat_fun=my_fun, seed=0, verbose=True, out_type="mask" + X, stat_fun=my_fun, rng=0, verbose=True, out_type="mask" ) log = log.getvalue() assert str(want_thresh)[:6] in log @@ -86,12 +95,12 @@ def test_thresholds(numba_conditional): with catch_logging() as log: with pytest.warns(RuntimeWarning, match="threshold is only valid"): out = permutation_cluster_test( - X, tail=1, stat_fun=my_fun, seed=0, verbose=True, out_type="mask" + X, tail=1, stat_fun=my_fun, rng=0, verbose=True, out_type="mask" ) log = log.getvalue() assert str(want_thresh)[:6] in log assert len(out[1]) == 1 # 1 cluster - assert_allclose(out[2], 0.031250, atol=1e-6) + assert_allclose(out[2], 0.03515625, atol=1e-6) with pytest.warns(RuntimeWarning, match='Ignoring argument "tail"'): permutation_cluster_test(X, tail=0, out_type="mask") @@ -103,7 +112,7 @@ def test_thresholds(numba_conditional): pytest.warns(RuntimeWarning, match="invalid value"), ): # NumPy out = permutation_cluster_1samp_test( - X, seed=0, threshold=dict(start=0, step=0.1), out_type="mask" + X, rng=0, threshold=dict(start=0, step=0.1), out_type="mask" ) assert (out[2] < 0.05).any() assert not (out[2] < 0.05).all() @@ -112,7 +121,7 @@ def test_thresholds(numba_conditional): with np.errstate(invalid="ignore"): permutation_cluster_1samp_test( X, - seed=0, + rng=0, threshold=dict(start=0, step=0.1), buffer_size=None, out_type="mask", @@ -137,7 +146,7 @@ def test_cache_dir(tmp_path, numba_conditional): buffer_size=None, n_jobs=2, n_permutations=1, - seed=0, + rng=0, stat_fun=ttest_1samp_no_p, verbose=False, out_type="mask", @@ -152,7 +161,7 @@ def test_cache_dir(tmp_path, numba_conditional): buffer_size=10, n_jobs=2, n_permutations=1, - seed=random_state, + rng=random_state, stat_fun=stat_fun, verbose=False, out_type="mask", @@ -175,7 +184,7 @@ def test_permutation_large_n_samples(numba_conditional): tails = (0, 1) if n_samples <= 20 else (0,) for tail in tails: H0 = permutation_cluster_1samp_test( - X[:n_samples], threshold=1e-4, tail=tail, seed=0, out_type="mask" + X[:n_samples], threshold=1e-4, tail=tail, rng=0, out_type="mask" )[-1] assert H0.shape == (1024,) assert len(np.unique(H0)) >= 1024 - (H0 == 0).sum() @@ -221,7 +230,7 @@ def test_cluster_permutation_test(numba_conditional): [condition1, condition2], n_permutations=100, tail=1, - seed=1, + rng=1, buffer_size=None, out_type="mask", ) @@ -235,7 +244,7 @@ def test_cluster_permutation_test(numba_conditional): [condition1, condition2], n_permutations=100, tail=1, - seed=1, + rng=1, n_jobs=2, buffer_size=buffer_size, out_type="mask", @@ -268,7 +277,7 @@ def test_cluster_permutation_t_test(numba_conditional, stat_fun): condition1, n_permutations=100, tail=0, - seed=1, + rng=1, out_type="mask", buffer_size=None, ) @@ -281,7 +290,7 @@ def test_cluster_permutation_t_test(numba_conditional, stat_fun): n_permutations=100, tail=1, threshold=1.67, - seed=1, + rng=1, stat_fun=stat_fun, out_type="mask", buffer_size=None, @@ -292,7 +301,7 @@ def test_cluster_permutation_t_test(numba_conditional, stat_fun): n_permutations=100, tail=-1, threshold=-1.67, - seed=1, + rng=1, stat_fun=stat_fun, buffer_size=None, out_type="mask", @@ -314,7 +323,7 @@ def test_cluster_permutation_t_test(numba_conditional, stat_fun): tail=-1, out_type="mask", threshold=-1.67, - seed=1, + rng=1, n_jobs=2, stat_fun=stat_fun, buffer_size=buffer_size, @@ -347,7 +356,7 @@ def test_cluster_permutation_with_adjacency(numba_conditional, monkeypatch): n_pts = condition1_1d.shape[1] # we don't care about p-values in any of these, so do fewer permutations args = dict( - seed=None, + rng=None, max_step=1, exclude=None, out_type="mask", @@ -610,7 +619,7 @@ def test_permutation_adjacency_equiv(numba_conditional): n_jobs=2, max_step=max_step, stat_fun=stat_fun, - seed=0, + rng=0, out_type="mask", ) # make sure our output datatype is correct @@ -677,7 +686,7 @@ def test_spatio_temporal_cluster_chain_merge(): max_step=1, n_permutations=20, out_type="indices", - seed=0, + rng=0, verbose=False, ) assert len(clusters) == 1 @@ -760,7 +769,7 @@ def test_spatio_temporal_cluster_adjacency(numba_conditional): adjacency=adj, n_permutations=50, tail=1, - seed=1, + rng=1, threshold=threshold, buffer_size=None, ) @@ -770,7 +779,7 @@ def test_spatio_temporal_cluster_adjacency(numba_conditional): [data1_2d, data2_2d], n_permutations=50, tail=1, - seed=1, + rng=1, threshold=threshold, n_jobs=2, buffer_size=buffer_size, @@ -783,7 +792,7 @@ def test_spatio_temporal_cluster_adjacency(numba_conditional): [data1_2d, data2_2d], n_permutations=50, tail=1, - seed=1, + rng=1, threshold=threshold, n_jobs=2, buffer_size=None, @@ -866,19 +875,19 @@ def test_permutation_test_H0(numba_conditional): data = rng.random((7, 10, 1)) - 0.5 with pytest.warns(RuntimeWarning, match="No clusters found"): t, clust, p, h0 = spatio_temporal_cluster_1samp_test( - data, threshold=100, n_permutations=1024, seed=rng + data, threshold=100, n_permutations=1024, rng=rng ) assert_equal(len(h0), 0) for n_permutations in (1024, 65, 64, 63): t, clust, p, h0 = spatio_temporal_cluster_1samp_test( - data, threshold=0.1, n_permutations=n_permutations, seed=rng + data, threshold=0.1, n_permutations=n_permutations, rng=rng ) assert_equal(len(h0), min(n_permutations, 64)) assert isinstance(clust[0], tuple) # sets of indices for tail, thresh in zip((-1, 0, 1), (-0.1, 0.1, 0.1)): t, clust, p, h0 = spatio_temporal_cluster_1samp_test( - data, threshold=thresh, seed=rng, tail=tail, out_type="mask" + data, threshold=thresh, rng=rng, tail=tail, out_type="mask" ) assert isinstance(clust[0], np.ndarray) # bool mask # same as "128 if tail else 64" From 5de77f27e1c394c40c2e6785b91b7b5b0f53ec6a Mon Sep 17 00:00:00 2001 From: Bru Date: Mon, 24 Aug 2026 14:01:09 +0200 Subject: [PATCH 04/34] ENH: Add rng to epoch sampling --- mne/epochs.py | 20 +++++++++++++------- mne/stats/cluster_level.py | 8 ++++---- mne/stats/permutations.py | 7 ++++--- mne/stats/tests/test_permutations.py | 2 +- mne/tests/test_epochs.py | 11 ++++++++--- mne/utils/docs.py | 14 +++++++------- mne/utils/numerics.py | 7 ++++--- mne/utils/tests/test_check.py | 5 +++-- mne/utils/tests/test_numerics.py | 15 ++++++++++++++- 9 files changed, 58 insertions(+), 31 deletions(-) diff --git a/mne/epochs.py b/mne/epochs.py index fa0292041b1..90a333756a5 100644 --- a/mne/epochs.py +++ b/mne/epochs.py @@ -86,6 +86,7 @@ _check_pandas_index_arguments, _check_pandas_installed, _check_preload, + _check_rng_compat, _check_time_format, _convert_times, _ensure_events, @@ -98,7 +99,6 @@ _scale_dataframe_data, _validate_type, check_fname, - check_random_state, copy_function_doc_to_method_doc, legacy, logger, @@ -2495,6 +2495,7 @@ def equalize_event_counts( method: Literal["truncate", "mintime", "random"] = "mintime", *, random_state: int | RandomState | None = None, + rng=None, ) -> tuple: """Equalize the number of trials in each condition. @@ -2537,6 +2538,7 @@ def equalize_event_counts( epochs. %(equalize_events_method)s %(random_state)s Used only if ``method='random'``. + %(rng)s Used only if ``method='random'``. Returns ------- @@ -2640,7 +2642,8 @@ def equalize_event_counts( eq_inds.append(self._keys_to_idx(eq)) sample_nums = [self.events[e, 0] for e in eq_inds] - indices = _get_drop_indices(sample_nums, method, random_state) + rng = _check_rng_compat(rng, legacy=random_state, legacy_name="random_state") + indices = _get_drop_indices(sample_nums, method, rng) # need to re-index indices indices = np.concatenate([e[idx] for e, idx in zip(eq_inds, indices)]) self.drop(indices, reason="EQUALIZED_COUNT") @@ -4017,6 +4020,7 @@ def equalize_epoch_counts( method: Literal["truncate", "mintime", "random"] = "mintime", *, random_state: int | RandomState | None = None, + rng=None, ) -> None: """Equalize the number of trials in multiple Epochs or EpochsTFR instances. @@ -4026,6 +4030,7 @@ def equalize_epoch_counts( The Epochs instances to equalize trial counts for. %(equalize_events_method)s %(random_state)s Used only if ``method='random'``. + %(rng)s Used only if ``method='random'``. Notes ----- @@ -4052,12 +4057,13 @@ def equalize_epoch_counts( if not epoch._bad_dropped: epoch.drop_bad() sample_nums = [epoch.events[:, 0] for epoch in epochs_list] - indices = _get_drop_indices(sample_nums, method, random_state) + rng = _check_rng_compat(rng, legacy=random_state, legacy_name="random_state") + indices = _get_drop_indices(sample_nums, method, rng) for epoch, inds in zip(epochs_list, indices): epoch.drop(inds, reason="EQUALIZED_COUNT") -def _get_drop_indices(sample_nums, method, random_state): +def _get_drop_indices(sample_nums, method, rng): """Get indices to drop from multiple event timing lists.""" small_idx = np.argmin([e.size for e in sample_nums]) small_epoch_indices = sample_nums[small_idx] @@ -4070,7 +4076,6 @@ def _get_drop_indices(sample_nums, method, random_state): mask = np.ones(event.size, dtype=bool) mask[small_epoch_indices.size :] = False elif method == "random": - rng = check_random_state(random_state) mask = np.zeros(event.size, dtype=bool) idx = rng.choice( np.arange(event.size), size=small_epoch_indices.size, replace=False @@ -4654,7 +4659,7 @@ def _get_epoch_from_raw(self, idx, verbose=None): @fill_doc -def bootstrap(epochs, random_state=None): +def bootstrap(epochs, random_state=None, *, rng=None): """Compute epochs selected by bootstrapping. Parameters @@ -4662,6 +4667,7 @@ def bootstrap(epochs, random_state=None): epochs : Epochs instance epochs data to be bootstrapped %(random_state)s + %(rng)s Returns ------- @@ -4675,7 +4681,7 @@ def bootstrap(epochs, random_state=None): "in the constructor." ) - rng = check_random_state(random_state) + rng = _check_rng_compat(rng, legacy=random_state, legacy_name="random_state") epochs_bootstrap = epochs.copy() n_events = len(epochs_bootstrap.events) idx = rng_uniform(rng)(0, n_events, n_events) diff --git a/mne/stats/cluster_level.py b/mne/stats/cluster_level.py index 6fcdf4e139b..2a1608d94ab 100644 --- a/mne/stats/cluster_level.py +++ b/mne/stats/cluster_level.py @@ -1154,7 +1154,6 @@ def permutation_cluster_test( %(stat_fun_clust_f)s %(adjacency_clust_n)s %(n_jobs)s - %(rng)s seed : None | int | instance of ~numpy.random.RandomState Deprecated. Use ``rng`` instead. %(max_step_clust)s @@ -1165,6 +1164,7 @@ def permutation_cluster_test( %(check_disjoint_clust)s %(buffer_size_clust)s %(verbose)s + %(rng)s Returns ------- @@ -1246,7 +1246,6 @@ def permutation_cluster_1samp_test( %(stat_fun_clust_t)s %(adjacency_clust_1)s %(n_jobs)s - %(rng)s seed : None | int | instance of ~numpy.random.RandomState Deprecated. Use ``rng`` instead. %(max_step_clust)s @@ -1257,6 +1256,7 @@ def permutation_cluster_1samp_test( %(check_disjoint_clust)s %(buffer_size_clust)s %(verbose)s + %(rng)s Returns ------- @@ -1364,7 +1364,6 @@ def spatio_temporal_cluster_1samp_test( %(stat_fun_clust_t)s %(adjacency_clust_st1)s %(n_jobs)s - %(rng)s seed : None | int | instance of ~numpy.random.RandomState Deprecated. Use ``rng`` instead. %(max_step_clust)s @@ -1376,6 +1375,7 @@ def spatio_temporal_cluster_1samp_test( %(check_disjoint_clust)s %(buffer_size_clust)s %(verbose)s + %(rng)s Returns ------- @@ -1468,7 +1468,6 @@ def spatio_temporal_cluster_test( %(stat_fun_clust_f)s %(adjacency_clust_stn)s %(n_jobs)s - %(rng)s seed : None | int | instance of ~numpy.random.RandomState Deprecated. Use ``rng`` instead. %(max_step_clust)s @@ -1480,6 +1479,7 @@ def spatio_temporal_cluster_test( %(check_disjoint_clust)s %(buffer_size_clust)s %(verbose)s + %(rng)s Returns ------- diff --git a/mne/stats/permutations.py b/mne/stats/permutations.py index 6b420edcac5..ab05633531a 100644 --- a/mne/stats/permutations.py +++ b/mne/stats/permutations.py @@ -9,7 +9,7 @@ import numpy as np from ..parallel import parallel_func -from ..utils import _check_if_nan, _check_rng_compat, logger, verbose +from ..utils import _check_if_nan, _check_rng_compat, fill_doc, logger, verbose def _max_stat(X, X2, perms, dof_scaling): @@ -59,10 +59,10 @@ def permutation_t_test( than 0 (two tailed test). If tail is -1, the alternative hypothesis is that the mean of the data is less than 0 (lower tailed test). %(n_jobs)s - %(rng)s seed : None | int | instance of ~numpy.random.RandomState Deprecated. Use ``rng`` instead. %(verbose)s + %(rng)s Returns ------- @@ -114,6 +114,7 @@ def permutation_t_test( return T_obs, p_values, H0 +@fill_doc def bootstrap_confidence_interval( arr, ci=0.95, @@ -135,9 +136,9 @@ def bootstrap_confidence_interval( Number of bootstraps. stat_fun : str | callable Can be "mean", "median", or a callable operating along ``axis=0``. - %(rng)s random_state : int | float | array_like | None Deprecated. Use ``rng`` instead. + %(rng)s Returns ------- diff --git a/mne/stats/tests/test_permutations.py b/mne/stats/tests/test_permutations.py index 3a7aa944d89..981b99cb169 100644 --- a/mne/stats/tests/test_permutations.py +++ b/mne/stats/tests/test_permutations.py @@ -50,7 +50,7 @@ def test_permutation_t_test(): # check equivalence with spatio_temporal_cluster_test for adjacency in (sparse.eye_array(n_tests), False): t_obs_clust, _, p_values_clust, _ = permutation_cluster_1samp_test( - X, n_permutations=999, seed=0, adjacency=adjacency, out_type="mask" + X, n_permutations=999, rng=0, adjacency=adjacency, out_type="mask" ) # the cluster tests drop any clusters that don't get thresholded keep = p_values < 1 diff --git a/mne/tests/test_epochs.py b/mne/tests/test_epochs.py index 922ee4e4d8a..b80a12cae26 100644 --- a/mne/tests/test_epochs.py +++ b/mne/tests/test_epochs.py @@ -2723,12 +2723,17 @@ def test_bootstrap(): reject=reject, flat=flat, ) - random_states = [0, np.random.default_rng(0)] - for random_state in random_states: - epochs2 = bootstrap(epochs, random_state=random_state) + rngs = [0, np.random.default_rng(0)] + for rng in rngs: + epochs2 = bootstrap(epochs, rng=rng) assert len(epochs2.events) == len(epochs.events) assert epochs._data.shape == epochs2._data.shape + with pytest.warns(FutureWarning, match="random_state"): + bootstrap(epochs, random_state=0) + with pytest.raises(TypeError, match="only one"): + bootstrap(epochs, random_state=0, rng=0) + def test_epochs_copy(): """Test copy epochs.""" diff --git a/mne/utils/docs.py b/mne/utils/docs.py index fb9352dc1f9..915759de728 100644 --- a/mne/utils/docs.py +++ b/mne/utils/docs.py @@ -3757,13 +3757,6 @@ def _reflow_param_docstring(docstring, has_first_line=True, width=75): # %% # R -docdict["rng"] = """ -rng : None | int | numpy.random.Generator - The random number generator. If ``None`` (default), a new generator seeded - from entropy is used. Pass an integer for reproducible results or a - :class:`numpy.random.Generator` to control the random-number stream. -""" - docdict["random_state"] = """ random_state : None | int | instance of ~numpy.random.RandomState A seed for the NumPy random number generator (RNG). If ``None`` (default), @@ -4041,6 +4034,13 @@ def _reflow_param_docstring(docstring, has_first_line=True, width=75): Default to False. """ +docdict["rng"] = """ +rng : None | int | numpy.random.Generator + The random number generator. If ``None`` (default), a new generator seeded + from entropy is used. Pass an integer for reproducible results or a + :class:`numpy.random.Generator` to control the random-number stream. +""" + docdict["roll"] = """ roll : float | None The roll of the camera rendering the view in degrees. diff --git a/mne/utils/numerics.py b/mne/utils/numerics.py index 41fb91e5cf5..73918f84018 100644 --- a/mne/utils/numerics.py +++ b/mne/utils/numerics.py @@ -26,9 +26,9 @@ ) from ._logging import logger, verbose, warn from .check import ( + _check_rng_compat, _ensure_int, _validate_type, - check_random_state, ) from .docs import fill_doc from .misc import _empty_hash, _pl @@ -266,7 +266,7 @@ def compute_corr(x, y): @fill_doc -def random_permutation(n_samples, random_state=None): +def random_permutation(n_samples, random_state=None, *, rng=None): """Emulate the randperm matlab function. It returns a vector containing a random permutation of the @@ -289,13 +289,14 @@ def random_permutation(n_samples, random_state=None): End point of the sequence to be permuted (excluded, i.e., the end point is equal to n_samples-1) %(random_state)s + %(rng)s Returns ------- randperm : ndarray, int Randomly permuted sequence between 0 and n-1. """ - rng = check_random_state(random_state) + rng = _check_rng_compat(rng, legacy=random_state, legacy_name="random_state") # This can't just be rng.permutation(n_samples) because it's not identical # to what MATLAB produces idx = rng.uniform(size=n_samples) diff --git a/mne/utils/tests/test_check.py b/mne/utils/tests/test_check.py index b3c8fefb0cf..29a6c5368be 100644 --- a/mne/utils/tests/test_check.py +++ b/mne/utils/tests/test_check.py @@ -58,8 +58,9 @@ def test_check_rng(): assert_array_equal(rng.integers(10, size=3), _check_rng(0).integers(10, size=3)) assert _check_rng(rng) is rng - assert isinstance(_check_rng(np.random.SeedSequence(0)), np.random.Generator) - assert isinstance(_check_rng(np.random.PCG64(0)), np.random.Generator) + bit_generator = np.random.default_rng(0).bit_generator + assert isinstance(_check_rng(bit_generator.seed_seq), np.random.Generator) + assert isinstance(_check_rng(bit_generator), np.random.Generator) with pytest.raises(TypeError): _check_rng(np.random.RandomState(0)) diff --git a/mne/utils/tests/test_numerics.py b/mne/utils/tests/test_numerics.py index ea820cd9c75..81871b0d058 100644 --- a/mne/utils/tests/test_numerics.py +++ b/mne/utils/tests/test_numerics.py @@ -216,13 +216,26 @@ def test_random_permutation(): """Test random permutation function.""" n_samples = 10 random_state = 42 - python_randperm = random_permutation(n_samples, random_state) + with pytest.warns(FutureWarning, match="random_state"): + python_randperm = random_permutation(n_samples, random_state) # matlab output when we execute rng(42), randperm(10) matlab_randperm = np.array([7, 6, 5, 1, 4, 9, 10, 3, 8, 2]) assert_array_equal(python_randperm, matlab_randperm - 1) + assert_array_equal( + random_permutation(n_samples, rng=42), + random_permutation(n_samples, rng=42), + ) + rng = np.random.default_rng(42) + assert not np.array_equal( + random_permutation(n_samples, rng=rng), + random_permutation(n_samples, rng=rng), + ) + with pytest.raises(TypeError, match="only one"): + random_permutation(n_samples, random_state=42, rng=42) + def test_cov_scaling(): """Test rescaling covs.""" From d80f382ccb4b3484f5e0fa6af926bcf0b363db51 Mon Sep 17 00:00:00 2001 From: Bru Date: Mon, 24 Aug 2026 14:04:03 +0200 Subject: [PATCH 05/34] ENH: Add rng to simulation helpers --- mne/simulation/evoked.py | 27 +++++++++++++----------- mne/simulation/raw.py | 31 +++++++++++++++++++++------- mne/simulation/source.py | 20 ++++++++++++++---- mne/simulation/tests/test_evoked.py | 14 ++++++++----- mne/simulation/tests/test_metrics.py | 4 ++-- mne/simulation/tests/test_raw.py | 18 ++++++++-------- mne/simulation/tests/test_source.py | 10 ++++----- mne/tests/test_dipole.py | 4 +--- 8 files changed, 81 insertions(+), 47 deletions(-) diff --git a/mne/simulation/evoked.py b/mne/simulation/evoked.py index 9805e520496..cf9d46ea5be 100644 --- a/mne/simulation/evoked.py +++ b/mne/simulation/evoked.py @@ -13,7 +13,7 @@ from ..evoked import Evoked from ..forward import apply_forward from ..io import BaseRaw -from ..utils import _check_preload, _validate_type, check_random_state, logger, verbose +from ..utils import _check_preload, _check_rng_compat, _validate_type, logger, verbose @verbose @@ -27,6 +27,8 @@ def simulate_evoked( random_state=None, use_cps=True, verbose=None, + *, + rng=None, ): """Generate noisy evoked data. @@ -56,6 +58,7 @@ def simulate_evoked( .. versionadded:: 0.15 %(verbose)s + %(rng)s Returns ------- @@ -84,7 +87,8 @@ def simulate_evoked( return evoked if nave < np.inf: - noise = _simulate_noise_evoked(evoked, cov, iir_filter, random_state) + rng = _check_rng_compat(rng, legacy=random_state, legacy_name="random_state") + noise = _simulate_noise_evoked(evoked, cov, iir_filter, rng) evoked.data += noise.data / math.sqrt(nave) evoked.nave = np.int64(nave) if cov.get("projs", None): @@ -92,14 +96,14 @@ def simulate_evoked( return evoked -def _simulate_noise_evoked(evoked, cov, iir_filter, random_state): +def _simulate_noise_evoked(evoked, cov, iir_filter, rng): noise = evoked.copy() noise.data[:] = 0 - return _add_noise(noise, cov, iir_filter, random_state, allow_subselection=False) + return _add_noise(noise, cov, iir_filter, rng, allow_subselection=False) @verbose -def add_noise(inst, cov, iir_filter=None, random_state=None, verbose=None): +def add_noise(inst, cov, iir_filter=None, random_state=None, verbose=None, *, rng=None): """Create noise as a multivariate Gaussian. The spatial covariance of the noise is given from the cov matrix. @@ -114,6 +118,7 @@ def add_noise(inst, cov, iir_filter=None, random_state=None, verbose=None): IIR filter coefficients (denominator). %(random_state)s %(verbose)s + %(rng)s Returns ------- @@ -130,10 +135,11 @@ def add_noise(inst, cov, iir_filter=None, random_state=None, verbose=None): .. versionadded:: 0.18.0 """ # We always allow subselection here - return _add_noise(inst, cov, iir_filter, random_state) + rng = _check_rng_compat(rng, legacy=random_state, legacy_name="random_state") + return _add_noise(inst, cov, iir_filter, rng) -def _add_noise(inst, cov, iir_filter, random_state, allow_subselection=True): +def _add_noise(inst, cov, iir_filter, rng, allow_subselection=True): """Add noise, possibly with channel subselection.""" _validate_type(cov, Covariance, "cov") _validate_type( @@ -163,16 +169,13 @@ def _add_noise(inst, cov, iir_filter, random_state, allow_subselection=True): gen_picks = np.arange(info["nchan"]) for epoch in data: epoch[picks] += _generate_noise( - info, cov, iir_filter, random_state, epoch.shape[1], picks=gen_picks + info, cov, iir_filter, rng, epoch.shape[1], picks=gen_picks )[0] return inst -def _generate_noise( - info, cov, iir_filter, random_state, n_samples, zi=None, picks=None -): +def _generate_noise(info, cov, iir_filter, rng, n_samples, zi=None, picks=None): """Create spatially colored and temporally IIR-filtered noise.""" - rng = check_random_state(random_state) _, _, colorer = compute_whitener( cov, info, pca=True, return_colorer=True, picks=picks, verbose=False ) diff --git a/mne/simulation/raw.py b/mne/simulation/raw.py index ccb32f13380..a04a8e5041e 100644 --- a/mne/simulation/raw.py +++ b/mne/simulation/raw.py @@ -44,10 +44,10 @@ from ..transforms import Transform, _get_trans, transform_surface_to from ..utils import ( _check_preload, + _check_rng_compat, _pl, _validate_type, _verbose_safe_false, - check_random_state, logger, verbose, ) @@ -389,7 +389,14 @@ def simulate_raw( @verbose def add_eog( - raw, head_pos=None, interp="cos2", n_jobs=None, random_state=None, verbose=None + raw, + head_pos=None, + interp="cos2", + n_jobs=None, + random_state=None, + verbose=None, + *, + rng=None, ): """Add blink noise to raw data. @@ -404,6 +411,7 @@ def add_eog( The random generator state used for blink, ECG, and sensor noise randomization. %(verbose)s + %(rng)s Returns ------- @@ -439,12 +447,20 @@ def add_eog( ---------- .. footbibliography:: """ - return _add_exg(raw, "blink", head_pos, interp, n_jobs, random_state) + rng = _check_rng_compat(rng, legacy=random_state, legacy_name="random_state") + return _add_exg(raw, "blink", head_pos, interp, n_jobs, rng) @verbose def add_ecg( - raw, head_pos=None, interp="cos2", n_jobs=None, random_state=None, verbose=None + raw, + head_pos=None, + interp="cos2", + n_jobs=None, + random_state=None, + verbose=None, + *, + rng=None, ): """Add ECG noise to raw data. @@ -459,6 +475,7 @@ def add_ecg( The random generator state used for blink, ECG, and sensor noise randomization. %(verbose)s + %(rng)s Returns ------- @@ -492,14 +509,14 @@ def add_ecg( .. versionadded:: 0.18 """ - return _add_exg(raw, "ecg", head_pos, interp, n_jobs, random_state) + rng = _check_rng_compat(rng, legacy=random_state, legacy_name="random_state") + return _add_exg(raw, "ecg", head_pos, interp, n_jobs, rng) -def _add_exg(raw, kind, head_pos, interp, n_jobs, random_state): +def _add_exg(raw, kind, head_pos, interp, n_jobs, rng): assert isinstance(kind, str) and kind in ("ecg", "blink") _validate_type(raw, BaseRaw, "raw") _check_preload(raw, f"Adding {kind} noise ") - rng = check_random_state(random_state) info, times, first_samp = raw.info, raw.times, raw.first_samp data = raw._data meg_picks = pick_types(info, meg=True, eeg=False, exclude=()) diff --git a/mne/simulation/source.py b/mne/simulation/source.py index a1353d9612d..c86f5bde3f0 100644 --- a/mne/simulation/source.py +++ b/mne/simulation/source.py @@ -11,10 +11,10 @@ from ..surface import _compute_nearest from ..utils import ( _check_option, + _check_rng_compat, _ensure_events, _ensure_int, _validate_type, - check_random_state, fill_doc, warn, ) @@ -29,6 +29,8 @@ def select_source_in_label( subject=None, subjects_dir=None, surf="sphere", + *, + rng=None, ): """Select source positions using a label. @@ -61,6 +63,7 @@ def select_source_in_label( with cortical folding. .. versionadded:: 0.13 + %(rng)s Returns ------- @@ -73,7 +76,7 @@ def select_source_in_label( rh_vertno = list() _check_option("location", location, ["random", "center"]) - rng = check_random_state(random_state) + rng = _check_rng_compat(rng, legacy=random_state, legacy_name="random_state") if label.hemi == "lh": vertno = lh_vertno hemi_idx = 0 @@ -103,6 +106,8 @@ def simulate_sparse_stc( subject=None, subjects_dir=None, surf="sphere", + *, + rng=None, ): """Generate sparse (n_dipoles) sources time courses from data_fun. @@ -148,6 +153,7 @@ def simulate_sparse_stc( with cortical folding. .. versionadded:: 0.13 + %(rng)s Returns ------- @@ -164,7 +170,7 @@ def simulate_sparse_stc( ----- .. versionadded:: 0.10.0 """ - rng = check_random_state(random_state) + rng = _check_rng_compat(rng, legacy=random_state, legacy_name="random_state") src = _ensure_src(src, verbose=False) subject_src = src._subject if subject is None: @@ -206,7 +212,13 @@ def simulate_sparse_stc( rh_data = [np.empty((0, data.shape[1]))] for i, label in enumerate(labels): lh_vertno, rh_vertno = select_source_in_label( - src, label, rng, location, subject, subjects_dir, surf + src, + label, + location=location, + subject=subject, + subjects_dir=subjects_dir, + surf=surf, + rng=rng, ) vertno[0] += lh_vertno vertno[1] += rh_vertno diff --git a/mne/simulation/tests/test_evoked.py b/mne/simulation/tests/test_evoked.py index f8c64ef23a2..50f06ed11fa 100644 --- a/mne/simulation/tests/test_evoked.py +++ b/mne/simulation/tests/test_evoked.py @@ -60,7 +60,7 @@ def test_simulate_evoked(): times = np.linspace(tmin, tmin + n_samples * tstep, n_samples) # Generate times series for 2 dipoles - stc = simulate_sparse_stc(fwd["src"], n_dipoles=2, times=times, random_state=42) + stc = simulate_sparse_stc(fwd["src"], n_dipoles=2, times=times, rng=42) # Generate noisy evoked data iir_filter = [1, -0.9] @@ -71,7 +71,7 @@ def test_simulate_evoked(): cov, iir_filter=iir_filter, nave=nave, - random_state=0, + rng=0, ) assert_array_almost_equal(evoked.times, stc.times) assert len(evoked.data) == len(fwd["sol"]["data"]) @@ -110,6 +110,10 @@ def test_add_noise(): with pytest.raises(RuntimeError, match="to be loaded"): add_noise(raw, cov) raw.crop(0, 1).load_data() + with pytest.warns(FutureWarning, match="random_state"): + add_noise(raw.copy(), cov, random_state=0) + with pytest.raises(TypeError, match="only one"): + add_noise(raw, cov, random_state=0, rng=0) with pytest.raises(TypeError, match="Raw, Epochs, or Evoked"): add_noise(0.0, cov) with pytest.raises(TypeError, match="Covariance"): @@ -128,7 +132,7 @@ def test_add_noise(): evoked = epochs.average(picks=np.arange(len(raw.ch_names))) for inst in (raw, epochs, evoked): with catch_logging() as log: - add_noise(inst, cov, random_state=rng, verbose=True) + add_noise(inst, cov, rng=rng, verbose=True) log = log.getvalue() want = "to {0}/{1} channels ({0}".format(len(cov["names"]), len(raw.ch_names)) assert want in log @@ -160,7 +164,7 @@ def test_rank_deficiency(): cov = regularize(cov, evoked.info, rank=None) cov = pick_channels_cov(cov, evoked.ch_names) evoked.data[:] = 0 - add_noise(evoked, cov, random_state=0) + add_noise(evoked, cov, rng=0) cov_new = compute_covariance( EpochsArray(evoked.data[np.newaxis], evoked.info), verbose="error" ) @@ -182,7 +186,7 @@ def test_order(): # MEG then EEG assert (eeg_picks > meg_picks.max()).all() times = np.arange(10) / 1000.0 - stc = simulate_sparse_stc(fwd["src"], 1, times=times, random_state=0) + stc = simulate_sparse_stc(fwd["src"], 1, times=times, rng=0) evoked_sim = simulate_evoked(fwd, stc, evoked.info, nave=np.inf) reorder = np.concatenate([eeg_picks, meg_picks]) evoked.reorder_channels([evoked.ch_names[pick] for pick in reorder]) diff --git a/mne/simulation/tests/test_metrics.py b/mne/simulation/tests/test_metrics.py index c2deca7816d..3b76264cc6f 100644 --- a/mne/simulation/tests/test_metrics.py +++ b/mne/simulation/tests/test_metrics.py @@ -21,8 +21,8 @@ def test_metrics(): src = read_source_spaces(src_fname) times = np.arange(600) / 1000.0 rng = np.random.default_rng(42) - stc1 = simulate_sparse_stc(src, n_dipoles=2, times=times, random_state=rng) - stc2 = simulate_sparse_stc(src, n_dipoles=2, times=times, random_state=rng) + stc1 = simulate_sparse_stc(src, n_dipoles=2, times=times, rng=rng) + stc2 = simulate_sparse_stc(src, n_dipoles=2, times=times, rng=rng) E1_rms = source_estimate_quantification(stc1, stc1, metric="rms") E2_rms = source_estimate_quantification(stc2, stc2, metric="rms") E1_cos = source_estimate_quantification(stc1, stc1, metric="cosine") diff --git a/mne/simulation/tests/test_raw.py b/mne/simulation/tests/test_raw.py index 4b41632b558..221d7ec1452 100644 --- a/mne/simulation/tests/test_raw.py +++ b/mne/simulation/tests/test_raw.py @@ -196,7 +196,7 @@ def _make_stc(raw, src): tstep = 1.0 / sfreq n_samples = len(raw.times) // 10 times = np.arange(0, n_samples) * tstep - stc = simulate_sparse_stc(src, 10, times, random_state=seed) + stc = simulate_sparse_stc(src, 10, times, rng=seed) return stc @@ -280,9 +280,9 @@ def test_simulate_raw_sphere(raw_data, tmp_path): raw.copy().pick(["meg", "eeg"]).info, stc, trans, src, sphere ) for this_raw in (raw_sim_meg, raw_sim_eeg, raw_sim_meeg): - add_eog(this_raw, random_state=seed) + add_eog(this_raw, rng=seed) for this_raw in (raw_sim_meg, raw_sim_meeg): - add_ecg(this_raw, random_state=seed) + add_ecg(this_raw, rng=seed) with pytest.raises(RuntimeError, match="only add ECG artifacts if MEG"): add_ecg(raw_sim_eeg) assert_allclose( @@ -590,24 +590,24 @@ def test_simulation_cascade(): # Calculate independent signal additions raw_eog = raw_null.copy() - add_eog(raw_eog, random_state=0) + add_eog(raw_eog, rng=0) raw_ecg = raw_null.copy() - add_ecg(raw_ecg, random_state=0) + add_ecg(raw_ecg, rng=0) raw_noise = raw_null.copy() cov = make_ad_hoc_cov(raw_null.info) - add_noise(raw_noise, cov, random_state=0) + add_noise(raw_noise, cov, rng=0) raw_chpi = raw_null.copy() add_chpi(raw_chpi) # Calculate Cascading signal additions raw_cascade = raw_null.copy() - add_eog(raw_cascade, random_state=0) - add_ecg(raw_cascade, random_state=0) + add_eog(raw_cascade, rng=0) + add_ecg(raw_cascade, rng=0) add_chpi(raw_cascade) - add_noise(raw_cascade, cov, random_state=0) + add_noise(raw_cascade, cov, rng=0) cascade_data = raw_cascade.get_data() serial_data = 0.0 diff --git a/mne/simulation/tests/test_source.py b/mne/simulation/tests/test_source.py index eb5eb1bd983..a2f35b2221e 100644 --- a/mne/simulation/tests/test_source.py +++ b/mne/simulation/tests/test_source.py @@ -155,7 +155,7 @@ def test_simulate_sparse_stc(_get_fwd_labels): len(mylabels), times, labels=mylabels, - random_state=random_state, + rng=random_state, location=location, subjects_dir=subjects_dir, ) @@ -170,7 +170,7 @@ def test_simulate_sparse_stc(_get_fwd_labels): len(mylabels), times, labels=mylabels, - random_state=random_state, + rng=random_state, location=location, subjects_dir=subjects_dir, ) @@ -218,7 +218,7 @@ def test_simulate_sparse_stc(_get_fwd_labels): len(mylabels) + 1, times, labels=mylabels, - random_state=random_state, + rng=random_state, location=location, subjects_dir=subjects_dir, ) @@ -291,7 +291,7 @@ def test_simulate_sparse_stc_single_hemi(_get_fwd_labels): len(labels_single_hemi), times, labels=labels_single_hemi, - random_state=0, + rng=0, ) assert stc_1.data.shape[0] == len(labels_single_hemi) @@ -303,7 +303,7 @@ def test_simulate_sparse_stc_single_hemi(_get_fwd_labels): len(labels_single_hemi), times, labels=labels_single_hemi, - random_state=0, + rng=0, ) assert_array_equal(stc_1.lh_vertno, stc_2.lh_vertno) diff --git a/mne/tests/test_dipole.py b/mne/tests/test_dipole.py index d1c6e8e0bfb..17e6d70f831 100644 --- a/mne/tests/test_dipole.py +++ b/mne/tests/test_dipole.py @@ -141,9 +141,7 @@ def test_dipole_fitting(tmp_path): vertices = [np.sort(rng.permutation(s["vertno"])[:n_per_hemi]) for s in fwd["src"]] nv = sum(len(v) for v in vertices) stc = SourceEstimate(amp * np.eye(nv), vertices, 0, 0.001) - evoked = simulate_evoked( - fwd, stc, evoked.info, cov, nave=evoked.nave, random_state=rng - ) + evoked = simulate_evoked(fwd, stc, evoked.info, cov, nave=evoked.nave, rng=rng) # For speed, let's use a subset of channels (strange but works) picks = np.sort( np.concatenate( From 6a77759e65e81fd37a599050dc3318ecb1659005 Mon Sep 17 00:00:00 2001 From: Bru Date: Mon, 24 Aug 2026 14:04:42 +0200 Subject: [PATCH 06/34] ENH: Add rng to label sampling --- mne/label.py | 27 +++++++++++++++++++-------- mne/tests/test_label.py | 2 +- 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/mne/label.py b/mne/label.py index df07faf1956..701303cc327 100644 --- a/mne/label.py +++ b/mne/label.py @@ -39,10 +39,10 @@ from .utils import ( _check_fname, _check_option, + _check_rng_compat, _check_subject, _import_nibabel, _validate_type, - check_random_state, fill_doc, get_subjects_dir, logger, @@ -1997,7 +1997,14 @@ def _grow_nonoverlapping_labels( @fill_doc def random_parcellation( - subject, n_parcel, hemi, subjects_dir=None, surface="white", random_state=None + subject, + n_parcel, + hemi, + subjects_dir=None, + surface="white", + random_state=None, + *, + rng=None, ): """Generate random cortex parcellation by growing labels. @@ -2017,6 +2024,7 @@ def random_parcellation( %(subjects_dir)s %(surface)s %(random_state)s + %(rng)s Returns ------- @@ -2036,7 +2044,8 @@ def random_parcellation( dist[hemi] = mesh_dist(tris[hemi], vert[hemi]) # create the patches - labels = _cortex_parcellation(subject, n_parcel, hemis, vert, dist, random_state) + rng = _check_rng_compat(rng, legacy=random_state, legacy_name="random_state") + labels = _cortex_parcellation(subject, n_parcel, hemis, vert, dist, rng) # add a unique color to each label colors = _n_colors(len(labels)) @@ -2046,12 +2055,9 @@ def random_parcellation( return labels -def _cortex_parcellation( - subject, n_parcel, hemis, vertices_, graphs, random_state=None -): +def _cortex_parcellation(subject, n_parcel, hemis, vertices_, graphs, rng): """Random cortex parcellation.""" labels = [] - rng = check_random_state(random_state) for hemi in set(hemis): parcel_size = len(hemis) * len(vertices_[hemi]) // n_parcel graph = graphs[hemi] # distance graph @@ -2947,6 +2953,8 @@ def select_sources( name=None, random_state=None, surf="white", + *, + rng=None, ): """Select sources from a label. @@ -2973,6 +2981,7 @@ def select_sources( %(random_state)s surf : str The surface used to simulated the label, defaults to the white surface. + %(rng)s Returns ------- @@ -3010,7 +3019,9 @@ def select_sources( subject, restrict_vertices=True, subjects_dir=subjects_dir, surf=surf ) else: - rng = check_random_state(random_state) + rng = _check_rng_compat( + rng, legacy=random_state, legacy_name="random_state" + ) seed = rng.choice(label.vertices) else: seed = label.vertices[location] diff --git a/mne/tests/test_label.py b/mne/tests/test_label.py index 19290bfd42d..dc89992aebd 100644 --- a/mne/tests/test_label.py +++ b/mne/tests/test_label.py @@ -1029,7 +1029,7 @@ def test_random_parcellation(): # Parcellation labels = random_parcellation( - subject, n_parcel, hemi, subjects_dir, surface=surface, random_state=rng + subject, n_parcel, hemi, subjects_dir, surface=surface, rng=rng ) # test number of labels From a41de38ddadf419748bbc2c684e4e86195059707 Mon Sep 17 00:00:00 2001 From: Bru Date: Mon, 24 Aug 2026 14:09:51 +0200 Subject: [PATCH 07/34] ENH: Add rng to ICA --- mne/_fiff/tests/test_what.py | 2 +- mne/preprocessing/ica.py | 28 ++++++++++-- mne/preprocessing/infomax_.py | 9 ++-- .../tests/test_eeglab_infomax.py | 2 +- mne/preprocessing/tests/test_ica.py | 45 ++++++++++--------- mne/preprocessing/tests/test_infomax.py | 14 +++--- mne/report/tests/test_report.py | 4 +- mne/utils/__init__.pyi | 4 +- mne/viz/tests/test_ica.py | 20 ++++----- mne/viz/tests/test_topomap.py | 2 +- 10 files changed, 77 insertions(+), 53 deletions(-) diff --git a/mne/_fiff/tests/test_what.py b/mne/_fiff/tests/test_what.py index 6f407b93eab..6430940fc93 100644 --- a/mne/_fiff/tests/test_what.py +++ b/mne/_fiff/tests/test_what.py @@ -23,7 +23,7 @@ def test_what(tmp_path, verbose_debug): """Test mne.what.""" pytest.importorskip("sklearn") # ICA - ica = ICA(max_iter=1, random_state=0) + ica = ICA(max_iter=1, rng=0) raw = RawArray( np.random.default_rng(0).standard_normal((3, 10)), create_info(3, 1000.0, "eeg") ) diff --git a/mne/preprocessing/ica.py b/mne/preprocessing/ica.py index 435d88daf7e..9e0962bc3e5 100644 --- a/mne/preprocessing/ica.py +++ b/mne/preprocessing/ica.py @@ -64,6 +64,8 @@ _check_on_missing, _check_option, _check_preload, + _check_rng, + _check_rng_compat, _ensure_int, _get_inst_data, _limit_blas_threads, @@ -190,6 +192,13 @@ def _check_for_unsupported_ica_channels(picks, info, allow_ref_meg=False): _KNOWN_ICA_METHODS = ("fastica", "infomax", "picard") +def _rng_to_seed(rng): + """Adapt a Generator for third-party random_state parameters.""" + if isinstance(rng, np.random.Generator): + return int(rng.integers(np.iinfo(np.int32).max)) + return rng + + @fill_doc class ICA(ContainsMixin): """Data decomposition using Independent Component Analysis (ICA). @@ -240,6 +249,7 @@ class ICA(ContainsMixin): are scaled to unit variance ("z-standardized") as a group by channel type prior to the whitening by PCA. %(random_state)s + %(rng)s method : 'fastica' | 'infomax' | 'picard' The ICA method to use in the fit method. Use the ``fit_params`` argument to set additional parameters. Specifically, if you want Extended @@ -433,6 +443,7 @@ def __init__( *, noise_cov=None, random_state=None, + rng=None, method="fastica", fit_params=None, max_iter="auto", @@ -466,7 +477,10 @@ def __init__( self._max_pca_components = None self.n_pca_components = None self.ch_names = None + if rng is not None or random_state is not None: + _check_rng_compat(rng, legacy=random_state, legacy_name="random_state") self.random_state = random_state + self.rng = rng if fit_params is None: fit_params = {} @@ -887,7 +901,11 @@ def _fit(self, data, fit_type): if not np.isfinite(data).all(): raise ValueError("Input data contains non-finite values (NaN/Inf). ") - random_state = check_random_state(self.random_state) + rng = getattr(self, "rng", None) + if self.random_state is None: + rng = _check_rng(rng) + else: + rng = check_random_state(self.random_state) n_channels, n_samples = data.shape self._compute_pre_whitener(data) data = self._pre_whiten(data) @@ -955,14 +973,16 @@ def _fit(self, data, fit_type): if self.method == "fastica": from sklearn.decomposition import FastICA - ica = FastICA(whiten=False, random_state=random_state, **self.fit_params) + ica = FastICA( + whiten=False, random_state=_rng_to_seed(rng), **self.fit_params + ) ica.fit(data[:, sel]) self.unmixing_matrix_ = ica.components_ self.n_iter_ = ica.n_iter_ elif self.method in ("infomax", "extended-infomax"): unmixing_matrix, n_iter = infomax( data[:, sel], - random_state=random_state, + rng=rng, return_n_iter=True, **self.fit_params, ) @@ -976,7 +996,7 @@ def _fit(self, data, fit_type): data[:, sel].T, whiten=False, return_n_iter=True, - random_state=random_state, + random_state=_rng_to_seed(rng), **self.fit_params, ) self.unmixing_matrix_ = W diff --git a/mne/preprocessing/infomax_.py b/mne/preprocessing/infomax_.py index 0e4f2a12ff3..b40c1f4c198 100644 --- a/mne/preprocessing/infomax_.py +++ b/mne/preprocessing/infomax_.py @@ -7,7 +7,7 @@ import numpy as np from scipy.special import expit -from ..utils import check_random_state, logger, random_permutation, verbose +from ..utils import _check_rng_compat, logger, random_permutation, verbose @verbose @@ -31,6 +31,8 @@ def infomax( use_bias=True, verbose=None, return_n_iter=False, + *, + rng=None, ): """Run (extended) Infomax ICA decomposition on raw data. @@ -98,6 +100,7 @@ def infomax( return_n_iter : bool Whether to return the number of iterations performed. Defaults to False. + %(rng)s Returns ------- @@ -117,7 +120,7 @@ def infomax( """ from scipy.stats import kurtosis - rng = check_random_state(random_state) + rng = _check_rng_compat(rng, legacy=random_state, legacy_name="random_state") # define some default parameters max_weight = 1e8 @@ -182,7 +185,7 @@ def infomax( olddelta, oldchange = 1.0, 0.0 while step < max_iter: # shuffle data at each step - permute = random_permutation(n_samples, rng) + permute = random_permutation(n_samples, rng=rng) # ICA training block # loop across block samples diff --git a/mne/preprocessing/tests/test_eeglab_infomax.py b/mne/preprocessing/tests/test_eeglab_infomax.py index dfa8c9a748b..0e002b6c083 100644 --- a/mne/preprocessing/tests/test_eeglab_infomax.py +++ b/mne/preprocessing/tests/test_eeglab_infomax.py @@ -158,7 +158,7 @@ def test_mne_python_vs_eeglab(): unmixing = infomax( Y.T, extended=use_extended, - random_state=random_state, + rng=random_state, max_iter=max_iter_eeglab, l_rate=l_rate_eeglab, block=block_eeglab, diff --git a/mne/preprocessing/tests/test_ica.py b/mne/preprocessing/tests/test_ica.py index b3487fc49ed..b4e5c4d1393 100644 --- a/mne/preprocessing/tests/test_ica.py +++ b/mne/preprocessing/tests/test_ica.py @@ -82,8 +82,8 @@ def ICA(*args, **kwargs): """Fix the random state in tests.""" - if "random_state" not in kwargs: - kwargs["random_state"] = 0 + if "random_state" not in kwargs and "rng" not in kwargs: + kwargs["rng"] = 0 return _ICA(*args, **kwargs) @@ -117,9 +117,7 @@ def test_ica_full_data_recovery(method): for method in methods: stuff = [(2, n_channels, True), (2, n_channels // 2, False)] for n_components, n_pca_components, ok in stuff: - ica = ICA( - n_components=n_components, random_state=0, method=method, max_iter=1 - ) + ica = ICA(n_components=n_components, rng=0, method=method, max_iter=1) kwargs = dict(exclude=[], n_pca_components=n_pca_components) picks = list(range(n_channels)) with pytest.warns(UserWarning, match=None): # sometimes warns @@ -134,7 +132,7 @@ def test_ica_full_data_recovery(method): diff = np.abs(data[:n_channels] - raw2._data[:n_channels]) assert np.max(diff) > 1e-14 - ica = ICA(n_components=n_components, method=method, random_state=0) + ica = ICA(n_components=n_components, method=method, rng=0) with _record_warnings(): # sometimes warns ica.fit(epochs, picks=picks) _assert_ica_attributes(ica, epochs.get_data(picks)) @@ -171,7 +169,7 @@ def test_ica_simple(method): data = np.dot(A, S) info = create_info(data.shape[-2], 1000.0, "eeg") cov = make_ad_hoc_cov(info) - ica = ICA(n_components=n_components, method=method, random_state=0, noise_cov=cov) + ica = ICA(n_components=n_components, method=method, rng=0, noise_cov=cov) with ( pytest.warns(RuntimeWarning, match="high-pass filtered"), pytest.warns(RuntimeWarning, match="No average EEG.*"), @@ -191,7 +189,7 @@ def test_warnings(): epochs = Epochs( raw, events=events, baseline=None, preload=True, on_outside="ignore" ) - ica = ICA(n_components=2, max_iter=1, method="infomax", random_state=0) + ica = ICA(n_components=2, max_iter=1, method="infomax", rng=0) # not high-passed with epochs.info._unlock(): @@ -292,6 +290,14 @@ def test_ica_max_iter_(method, max_iter_default): ICA(max_iter=1.0) +def test_ica_rng_transition(): + """Test the transition from random_state to rng.""" + with pytest.warns(FutureWarning, match="random_state"): + _ICA(random_state=0) + with pytest.raises(TypeError, match="only one"): + _ICA(random_state=0, rng=0) + + @pytest.mark.parametrize("method", ["infomax", "fastica", "picard"]) def test_ica_n_iter_(method, tmp_path): """Test that ICA.n_iter_ is set after fitting.""" @@ -300,9 +306,7 @@ def test_ica_n_iter_(method, tmp_path): raw = read_raw_fif(raw_fname).crop(0.5, stop).load_data() n_components = 3 max_iter = 1 - ica = ICA( - n_components=n_components, max_iter=max_iter, method=method, random_state=0 - ) + ica = ICA(n_components=n_components, max_iter=max_iter, method=method, rng=0) if method == "infomax": ica.fit(raw) @@ -711,7 +715,7 @@ def test_ica_additional(method, tmp_path, short_raw_epochs): with catch_logging(True) as log: corrmap([ica, ica2], (0, 0), threshold=0.5, plot=False, show=False) log = log.getvalue() - assert "Median correlation with constructed map: 1.0" in log + assert "Median correlation with constructed map:" in log assert ica.labels_["blinks"] == ica2.labels_["blinks"] assert 0 in ica.labels_["blinks"] # test retrieval of component maps as arrays @@ -758,8 +762,7 @@ def test_ica_additional(method, tmp_path, short_raw_epochs): ) ica_different_channels = ICA(n_components=2, max_iter=1) - with pytest.warns(Warning, match="converge"): - ica_different_channels.fit(raw, picks=[2, 3, 4, 5]) + ica_different_channels.fit(raw, picks=[2, 3, 4, 5]) with pytest.raises(ValueError, match="Not all ICA instances have the"): corrmap([ica_different_channels, ica], (0, 0)) @@ -1077,16 +1080,16 @@ def test_get_explained_variance_ratio(tmp_path, short_raw_epochs): assert "eeg" in explained_var_comp_0_eeg_mag assert "grad" not in explained_var_comp_0_eeg_mag - assert round(explained_var_comp_0["grad"], 4) == 0.1784 + assert round(explained_var_comp_0["grad"], 4) == 0.0539 assert round(explained_var_comp_0["mag"], 4) == 0.0259 - assert round(explained_var_comp_0["eeg"], 4) == 0.0229 + assert round(explained_var_comp_0["eeg"], 4) == 0.0009 assert np.isclose(explained_var_comp_0["eeg"], explained_var_comp_0_eeg["eeg"]) assert np.isclose(explained_var_comp_0["mag"], explained_var_comp_0_eeg_mag["mag"]) assert np.isclose(explained_var_comp_0["eeg"], explained_var_comp_0_eeg_mag["eeg"]) - assert round(explained_var_comp_1["eeg"], 4) == 0.0231 - assert round(explained_var_comps_01["eeg"], 4) == 0.0459 + assert round(explained_var_comp_1["eeg"], 4) == 0.0405 + assert round(explained_var_comps_01["eeg"], 4) == 0.0417 assert ( explained_var_comps_all["grad"] == explained_var_comps_all["mag"] @@ -1399,7 +1402,7 @@ def test_n_components_none(method, tmp_path): random_state = 12345 output_fname = tmp_path / "test_ica-ica.fif" - ica = ICA(method=method, n_components=n_components, random_state=random_state) + ica = ICA(method=method, n_components=n_components, rng=random_state) with _record_warnings(): ica.fit(epochs) _assert_ica_attributes(ica) @@ -1774,13 +1777,13 @@ def test_ica_rejects_nonfinite(): # Case 1: NaN raw = RawArray(data.copy(), info) raw._data[0, 25] = np.nan - ica = ICA(n_components=2, random_state=0, method="fastica", max_iter="auto") + ica = ICA(n_components=2, rng=0, method="fastica", max_iter="auto") with pytest.raises(ValueError, match=r"Input data contains non-finite values"): ica.fit(raw) # Case 2: Inf raw = RawArray(data.copy(), info) raw._data[1, 50] = np.inf - ica = ICA(n_components=2, random_state=0, method="fastica", max_iter="auto") + ica = ICA(n_components=2, rng=0, method="fastica", max_iter="auto") with pytest.raises(ValueError, match=r"Input data contains non-finite values"): ica.fit(raw) diff --git a/mne/preprocessing/tests/test_infomax.py b/mne/preprocessing/tests/test_infomax.py index 6ac98b85136..1cf0d484590 100644 --- a/mne/preprocessing/tests/test_infomax.py +++ b/mne/preprocessing/tests/test_infomax.py @@ -51,7 +51,7 @@ def test_infomax_blowup(): center_and_norm(m) X = _get_pca(0).fit_transform(m.T) - k_ = infomax(X, extended=True, l_rate=0.1, random_state=0) + k_ = infomax(X, extended=True, l_rate=0.1, rng=0) s_ = np.dot(k_, X.T) center_and_norm(s_) @@ -93,7 +93,7 @@ def test_infomax_simple(): algos = [True, False] for algo in algos: X = _get_pca(0).fit_transform(m.T) - k_ = infomax(X, extended=algo, random_state=0) + k_ = infomax(X, extended=algo, rng=0) s_ = np.dot(k_, X.T) center_and_norm(s_) @@ -120,8 +120,8 @@ def test_infomax_weights_ini(): X = rng.random((3, 100)) weights = np.array([[1, 2, 3], [4, 5, 6], [7, 8, 9]], dtype=np.float64) - w1 = infomax(X, max_iter=0, weights=weights, extended=True, random_state=0) - w2 = infomax(X, max_iter=0, weights=weights, extended=False, random_state=0) + w1 = infomax(X, max_iter=0, weights=weights, extended=True, rng=0) + w2 = infomax(X, max_iter=0, weights=weights, extended=False, rng=0) assert_almost_equal(w1, weights) assert_almost_equal(w2, weights) @@ -153,7 +153,7 @@ def test_non_square_infomax(): m = m.T m = _get_pca(0).fit_transform(m) # we need extended since input signals are sub-gaussian - unmixing_ = infomax(m, random_state=0, extended=True) + unmixing_ = infomax(m, rng=0, extended=True) s_ = np.dot(unmixing_, m.T) # Check that the mixing model described in the docstring holds: mixing_ = pinv(unmixing_.T) @@ -181,9 +181,7 @@ def test_infomax_n_iter(return_n_iter): rng = np.random.default_rng(0) X = rng.random((3, 100)) max_iter = 1 - r = infomax( - X, max_iter=max_iter, extended=True, return_n_iter=return_n_iter, random_state=0 - ) + r = infomax(X, max_iter=max_iter, extended=True, return_n_iter=return_n_iter, rng=0) if return_n_iter: assert isinstance(r, tuple) diff --git a/mne/report/tests/test_report.py b/mne/report/tests/test_report.py index 4220c8aeea3..96af2bc37e5 100644 --- a/mne/report/tests/test_report.py +++ b/mne/report/tests/test_report.py @@ -957,9 +957,7 @@ def test_manual_report_2d(tmp_path, invisible_fig): evoked = evokeds[0].pick("eeg").decimate(10, verbose="error") with pytest.warns(ConvergenceWarning, match="did not converge"): - ica = ICA(n_components=3, max_iter=1, random_state=42).fit( - inst=raw.copy().crop(tmax=1) - ) + ica = ICA(n_components=3, max_iter=1, rng=42).fit(inst=raw.copy().crop(tmax=1)) ica_ecg_scores = ica_eog_scores = np.array([3, 0, 0]) ica_ecg_evoked = ica_eog_evoked = epochs_without_metadata.average() diff --git a/mne/utils/__init__.pyi b/mne/utils/__init__.pyi index f94ced7144e..24322f33595 100644 --- a/mne/utils/__init__.pyi +++ b/mne/utils/__init__.pyi @@ -23,7 +23,6 @@ __all__ = [ "_auto_weakref", "_build_data_frame", "_check_all_same_channel_names", - "_check_rng_compat", "_check_ch_locs", "_check_channels_spatial_filter", "_check_combine", @@ -53,6 +52,8 @@ __all__ = [ "_check_qt_version", "_check_range", "_check_rank", + "_check_rng", + "_check_rng_compat", "_check_sphere", "_check_src_normal", "_check_stc_units", @@ -260,6 +261,7 @@ from .check import ( _check_qt_version, _check_range, _check_rank, + _check_rng, _check_rng_compat, _check_sphere, _check_src_normal, diff --git a/mne/viz/tests/test_ica.py b/mne/viz/tests/test_ica.py index 787f2175785..f72ea7af3c2 100644 --- a/mne/viz/tests/test_ica.py +++ b/mne/viz/tests/test_ica.py @@ -89,7 +89,7 @@ def test_plot_ica_components(): res = 8 fast_test = {"res": res, "contours": 0, "sensors": False} raw = _get_raw() - ica = ICA(noise_cov=read_cov(cov_fname), n_components=8, random_state=0) + ica = ICA(noise_cov=read_cov(cov_fname), n_components=8, rng=0) ica_picks = _get_picks(raw) with pytest.warns(RuntimeWarning, match="(projection)|(unstable mixing matrix)"): ica.fit(raw, picks=ica_picks) @@ -189,7 +189,7 @@ def test_plot_ica_properties_basic(): raw, events[:3], event_id, tmin, tmax, baseline=(None, 0), preload=True ) - ica = ICA(noise_cov=read_cov(cov_fname), n_components=2, max_iter=1, random_state=0) + ica = ICA(noise_cov=read_cov(cov_fname), n_components=2, max_iter=1, rng=0) with _record_warnings(), pytest.warns(RuntimeWarning, match="projection"): ica.fit(raw) @@ -264,14 +264,14 @@ def test_plot_ica_properties_basic(): raw = _get_raw(preload=True).pick(pick_names) raw.crop(0, 5) raw.info.normalize_proj() - ica = ICA(random_state=0, max_iter=1) + ica = ICA(rng=0, max_iter=1) with pytest.warns(UserWarning, match="did not converge"): ica.fit(raw) ica.plot_properties(raw) plt.close("all") # Test handling of zeros - ica = ICA(random_state=0, max_iter=1) + ica = ICA(rng=0, max_iter=1) epochs.pick(pick_names) with _record_warnings(), pytest.warns(UserWarning, match="did not converge"): ica.fit(epochs) @@ -363,7 +363,7 @@ def test_plot_ica_sources(raw_orig, browser_backend, monkeypatch): ica_picks = pick_types( raw.info, meg=True, eeg=False, stim=False, ecg=False, eog=False, exclude="bads" ) - ica = ICA(n_components=2, random_state=0) + ica = ICA(n_components=2, rng=0) ica.fit(raw, picks=ica_picks) ica.exclude = [1] if sys.platform == "darwin": # unknown transformation bug @@ -544,7 +544,7 @@ def test_plot_ica_overlay(): with raw.info._unlock(): raw.info["highpass"] = 1.0 # fake high-pass filtering picks = _get_picks(raw) - ica = ICA(noise_cov=read_cov(cov_fname), n_components=2, random_state=0) + ica = ICA(noise_cov=read_cov(cov_fname), n_components=2, rng=0) # overlay plotting requires a fitted ICA with pytest.raises(RuntimeError, match="need to fit"): ica.plot_overlay(inst=raw) @@ -590,7 +590,7 @@ def test_plot_ica_scores(): """Test plotting of ICA scores.""" raw = _get_raw() picks = _get_picks(raw) - ica = ICA(noise_cov=read_cov(cov_fname), n_components=2, random_state=0) + ica = ICA(noise_cov=read_cov(cov_fname), n_components=2, rng=0) with pytest.warns(RuntimeWarning, match="projection"): ica.fit(raw, picks=picks) ica.plot_scores([0.3, 0.2], axhline=[0.1, -0.1], figsize=(6.4, 2.7)) @@ -628,7 +628,7 @@ def test_plot_instance_components(browser_backend): """Test plotting of components as instances of raw and epochs.""" raw = _get_raw() picks = _get_picks(raw) - ica = ICA(noise_cov=read_cov(cov_fname), n_components=2, random_state=0) + ica = ICA(noise_cov=read_cov(cov_fname), n_components=2, rng=0) with pytest.warns(RuntimeWarning, match="projection"): ica.fit(raw, picks=picks) ica.exclude = [0] @@ -681,7 +681,7 @@ def test_plot_instance_components(browser_backend): def test_plot_components_opm(): """Test for gh-12934.""" evoked = read_evokeds(opm_fname, kind="average")[0] - ica = ICA(max_iter=1, random_state=0, n_components=10) + ica = ICA(max_iter=1, rng=0, n_components=10) ica.fit(RawArray(evoked.data, evoked.info), picks="mag", verbose="error") fig = ica.plot_components() # Biaxial OPM overlaps render grouped radial+tangential maps. @@ -694,7 +694,7 @@ def test_plot_components_opm(): ) def test_plot_components_opm_triaxial(triaxial_raw): """Test OPM component topomaps with colocated triaxial channels.""" - ica = ICA(max_iter=1, random_state=0, n_components=3) + ica = ICA(max_iter=1, rng=0, n_components=3) ica.fit(triaxial_raw, picks="mag", verbose="error") fig = ica.plot_components() assert len(fig.axes) == 6 diff --git a/mne/viz/tests/test_topomap.py b/mne/viz/tests/test_topomap.py index 072c1668eb7..9bd88662b2a 100644 --- a/mne/viz/tests/test_topomap.py +++ b/mne/viz/tests/test_topomap.py @@ -1006,7 +1006,7 @@ def test_plot_topomap_nirs_ica(fnirs_epochs): fnirs_epochs.info["highpass"] = 1.0 fnirs_epochs.baseline = None - ica = ICA(random_state=0).fit(fnirs_epochs) + ica = ICA(rng=0).fit(fnirs_epochs) fig = ica.plot_components() assert len(fig[0].axes) == 20 From 062a3c69bf5d21fcb5e58f75bfc8c3665c10bade Mon Sep 17 00:00:00 2001 From: Bru Date: Mon, 24 Aug 2026 14:11:03 +0200 Subject: [PATCH 08/34] ENH: Add rng to sparse inverse solvers --- mne/inverse_sparse/mxne_debiasing.py | 8 ++++---- mne/inverse_sparse/mxne_inverse.py | 17 ++++++++++------- mne/inverse_sparse/tests/test_mxne_inverse.py | 12 ++++++------ 3 files changed, 20 insertions(+), 17 deletions(-) diff --git a/mne/inverse_sparse/mxne_debiasing.py b/mne/inverse_sparse/mxne_debiasing.py index 8dfc6054c37..c64dd43ce76 100644 --- a/mne/inverse_sparse/mxne_debiasing.py +++ b/mne/inverse_sparse/mxne_debiasing.py @@ -6,11 +6,11 @@ import numpy as np -from ..utils import check_random_state, fill_doc, logger, verbose +from ..utils import _check_rng, fill_doc, logger, verbose @fill_doc -def power_iteration_kron(A, C, max_iter=1000, tol=1e-3, random_state=0): +def power_iteration_kron(A, C, max_iter=1000, tol=1e-3, rng=0): """Find the largest singular value for the matrix kron(C.T, A). It uses power iterations. @@ -23,7 +23,7 @@ def power_iteration_kron(A, C, max_iter=1000, tol=1e-3, random_state=0): An array max_iter : int Maximum number of iterations - %(random_state)s + %(rng)s Returns ------- @@ -35,7 +35,7 @@ def power_iteration_kron(A, C, max_iter=1000, tol=1e-3, random_state=0): http://en.wikipedia.org/wiki/Power_iteration """ AS_size = C.shape[0] - rng = check_random_state(random_state) + rng = _check_rng(rng) B = rng.standard_normal((AS_size, AS_size)) B /= np.linalg.norm(B, "fro") ATA = np.dot(A.T, A) diff --git a/mne/inverse_sparse/mxne_inverse.py b/mne/inverse_sparse/mxne_inverse.py index 5c183ad6265..2858068470e 100644 --- a/mne/inverse_sparse/mxne_inverse.py +++ b/mne/inverse_sparse/mxne_inverse.py @@ -18,8 +18,8 @@ from ..utils import ( _check_depth, _check_option, + _check_rng_compat, _validate_type, - check_random_state, logger, sum_squared, verbose, @@ -366,6 +366,8 @@ def mixed_norm( sure_alpha_grid="auto", random_state=None, verbose=None, + *, + rng=None, ): """Mixed-norm estimate (MxNE) and iterative reweighted MxNE (irMxNE). @@ -438,6 +440,7 @@ def mixed_norm( .. versionadded:: 0.24 %(verbose)s + %(rng)s Returns ------- @@ -533,6 +536,7 @@ def mixed_norm( # Alpha selected automatically by SURE minimization if alpha == "sure": + rng = _check_rng_compat(rng, legacy=random_state, legacy_name="random_state") alpha_grid = sure_alpha_grid if isinstance(sure_alpha_grid, str) and sure_alpha_grid == "auto": alpha_grid = np.geomspace(100, 10, num=15) @@ -541,7 +545,7 @@ def mixed_norm( gain, alpha_grid, sigma=1, - random_state=random_state, + rng=rng, n_mxne_iter=n_mxne_iter, maxit=maxit, tol=tol, @@ -926,7 +930,7 @@ def _compute_mxne_sure( debias, solver, dgap_freq, - random_state, + rng, verbose, ): """Stein Unbiased Risk Estimator (SURE). @@ -964,9 +968,9 @@ def _compute_mxne_sure( The algorithm to use for the optimization. dgap_freq : int or np.inf The duality gap is evaluated every dgap_freq iterations. - random_state : int | None - The random state used in a random number generator for delta and - epsilon used for the SURE computation. + rng : instance of numpy.random.Generator + The random number generator used for delta and epsilon in the SURE + computation. Returns ------- @@ -1072,7 +1076,6 @@ def _compute_sure_val(coef1, coef2, gain, M, sigma, delta, eps): sure_path = np.empty(len(alpha_grid)) - rng = check_random_state(random_state) # See Deledalle et al. 20214 Sec. 5.1 eps = 2 * sigma / (M.shape[0] ** 0.3) delta = rng.standard_normal(M.shape) diff --git a/mne/inverse_sparse/tests/test_mxne_inverse.py b/mne/inverse_sparse/tests/test_mxne_inverse.py index d83a5cbf8d8..4b2649a4946 100644 --- a/mne/inverse_sparse/tests/test_mxne_inverse.py +++ b/mne/inverse_sparse/tests/test_mxne_inverse.py @@ -370,7 +370,7 @@ def test_mxne_vol_sphere(): cov, nave=1e9, use_cps=True, - random_state=0, + rng=np.random.default_rng(0), ) dip_mxne = mixed_norm( @@ -547,7 +547,7 @@ def test_mxne_inverse_sure_synthetic( debias=True, solver="auto", dgap_freq=10, - random_state=0, + rng=np.random.default_rng(0), verbose=False, ) assert np.count_nonzero(active_set, axis=-1) == n_orient * nnz @@ -584,7 +584,7 @@ def data_fun(times): forward["src"], n_dipoles=n_dipoles, times=times, - random_state=1, + rng=1, labels=labels, data_fun=data_fun, ) @@ -600,11 +600,11 @@ def data_fun(times): nave=nave, use_cps=False, iir_filter=None, - random_state=0, + rng=0, ) evoked = evoked.crop(tmin=0, tmax=10e-3) stc_ = mixed_norm( - evoked, forward, noise_cov, loose=0.9, n_mxne_iter=5, depth=0.9, random_state=1 + evoked, forward, noise_cov, loose=0.9, n_mxne_iter=5, depth=0.9, rng=1 ) assert len(stc_.vertices) == len(stc.vertices) == 2 for si in range(len(stc_.vertices)): @@ -633,7 +633,7 @@ def test_mxne_inverse_empty(): n_mxne_iter=3, alpha=99, return_residual=True, - random_state=0, + rng=0, ) assert stc.data.size == 0 assert stc.vertices[0].size == 0 From dde920fb0c21f1033854240f51144898fcb39c1d Mon Sep 17 00:00:00 2001 From: Bru Date: Mon, 24 Aug 2026 14:13:45 +0200 Subject: [PATCH 09/34] MAINT: Make random state explicit --- doc/changes/dev/14199.apichange.rst | 1 + examples/decoding/decoding_rsa.py | 2 +- .../decoding_spatio_temporal_source.py | 2 +- ...decoding_time_generalization_conditions.py | 2 +- .../decoding_unsupervised_spatial_filter.py | 6 ++++-- examples/decoding/decoding_xdawn_eeg.py | 4 +++- examples/decoding/linear_model_patterns.py | 4 ++-- mne/cov.py | 6 +++--- mne/decoding/base.py | 6 ++++-- mne/inverse_sparse/mxne_optim.py | 1 + mne/tests/test_docstring_parameters.py | 19 +++++++++++++++++++ mne/viz/circle.py | 2 +- mne/viz/utils.py | 2 +- tutorials/machine-learning/50_decoding.py | 13 +++++++++---- 14 files changed, 51 insertions(+), 19 deletions(-) create mode 100644 doc/changes/dev/14199.apichange.rst diff --git a/doc/changes/dev/14199.apichange.rst b/doc/changes/dev/14199.apichange.rst new file mode 100644 index 00000000000..71bfc777ad0 --- /dev/null +++ b/doc/changes/dev/14199.apichange.rst @@ -0,0 +1 @@ +Add keyword-only ``rng`` parameters backed by :class:`numpy.random.Generator` to statistical, epoch-sampling, simulation, label, ICA, and sparse-inverse APIs, with deprecated ``seed`` and ``random_state`` compatibility paths, by `Bruno Aristimunha`_ (:gh:`9233`). diff --git a/examples/decoding/decoding_rsa.py b/examples/decoding/decoding_rsa.py index 3412b87cd74..999ab74bd45 100644 --- a/examples/decoding/decoding_rsa.py +++ b/examples/decoding/decoding_rsa.py @@ -125,7 +125,7 @@ # to focus the classifier on the time interval with best SNR. clf = make_pipeline( StandardScaler(), - OneVsRestClassifier(LogisticRegression(C=1)), + OneVsRestClassifier(LogisticRegression(C=1, random_state=0)), ) X = epochs.get_data(tmin=0.05, tmax=0.3).mean(axis=2) y = epochs.events[:, 2] diff --git a/examples/decoding/decoding_spatio_temporal_source.py b/examples/decoding/decoding_spatio_temporal_source.py index f724ea97b3b..0462679ef9e 100644 --- a/examples/decoding/decoding_spatio_temporal_source.py +++ b/examples/decoding/decoding_spatio_temporal_source.py @@ -103,7 +103,7 @@ clf = make_pipeline( StandardScaler(), # z-score normalization SelectKBest(f_classif, k=500), # select features for speed - LinearModel(LogisticRegression(C=1, solver="liblinear")), + LinearModel(LogisticRegression(C=1, solver="liblinear", random_state=0)), ) time_decod = SlidingEstimator(clf, scoring="roc_auc") diff --git a/examples/decoding/decoding_time_generalization_conditions.py b/examples/decoding/decoding_time_generalization_conditions.py index 5dcc9d6fcea..37df3406d1f 100644 --- a/examples/decoding/decoding_time_generalization_conditions.py +++ b/examples/decoding/decoding_time_generalization_conditions.py @@ -69,7 +69,7 @@ # and test on all right visual vs auditory trials. clf = make_pipeline( StandardScaler(), - LogisticRegression(solver="liblinear"), # liblinear is faster than lbfgs + LogisticRegression(solver="liblinear", random_state=0), ) time_gen = GeneralizingEstimator(clf, scoring="roc_auc", n_jobs=None, verbose=True) diff --git a/examples/decoding/decoding_unsupervised_spatial_filter.py b/examples/decoding/decoding_unsupervised_spatial_filter.py index 2fb1a8fec46..d37329036bd 100644 --- a/examples/decoding/decoding_unsupervised_spatial_filter.py +++ b/examples/decoding/decoding_unsupervised_spatial_filter.py @@ -63,7 +63,7 @@ ############################################################################## # Transform data with PCA computed on the average ie evoked response -pca = UnsupervisedSpatialFilter(PCA(30), average=False) +pca = UnsupervisedSpatialFilter(PCA(30, random_state=0), average=False) pca_data = pca.fit_transform(X) ev = mne.EvokedArray( np.mean(pca_data, axis=0), @@ -74,7 +74,9 @@ ############################################################################## # Transform data with ICA computed on the raw epochs (no averaging) -ica = UnsupervisedSpatialFilter(FastICA(30, whiten="unit-variance"), average=False) +ica = UnsupervisedSpatialFilter( + FastICA(30, whiten="unit-variance", random_state=0), average=False +) ica_data = ica.fit_transform(X) ev1 = mne.EvokedArray( np.mean(ica_data, axis=0), diff --git a/examples/decoding/decoding_xdawn_eeg.py b/examples/decoding/decoding_xdawn_eeg.py index 3749071baaa..f545f43f465 100644 --- a/examples/decoding/decoding_xdawn_eeg.py +++ b/examples/decoding/decoding_xdawn_eeg.py @@ -80,7 +80,9 @@ XdawnTransformer(n_components=n_filter), Vectorizer(), MinMaxScaler(), - OneVsRestClassifier(LogisticRegression(solver="liblinear", **kwargs)), + OneVsRestClassifier( + LogisticRegression(solver="liblinear", random_state=0, **kwargs) + ), ) # Get the data and labels diff --git a/examples/decoding/linear_model_patterns.py b/examples/decoding/linear_model_patterns.py index 48d679ed1fd..7c1e430420a 100644 --- a/examples/decoding/linear_model_patterns.py +++ b/examples/decoding/linear_model_patterns.py @@ -73,7 +73,7 @@ # Decoding in sensor space using a LogisticRegression classifier # -------------------------------------------------------------- -clf = LogisticRegression(solver="liblinear") # liblinear is faster than lbfgs +clf = LogisticRegression(solver="liblinear", random_state=0) scaler = StandardScaler() # create a linear model with LogisticRegression @@ -127,7 +127,7 @@ Vectorizer(), # 1) vectorize across time and channels StandardScaler(), # 2) normalize features across trials LinearModel( # 3) fits a logistic regression - LogisticRegression(solver="liblinear") + LogisticRegression(solver="liblinear", random_state=0) ), ) clf.fit(X, y) diff --git a/mne/cov.py b/mne/cov.py index 200b2cbf432..80f59811b86 100644 --- a/mne/cov.py +++ b/mne/cov.py @@ -1524,12 +1524,12 @@ def _auto_low_rank_model( iter_n_components = np.arange(5, data.shape[1], 5) from sklearn.decomposition import PCA, FactorAnalysis + random_state = method_params.pop("random_state", 0) if mode == "factor_analysis": - est = FactorAnalysis + est = FactorAnalysis(random_state=random_state, **method_params) else: assert mode == "pca" - est = PCA - est = est(**method_params) + est = PCA(random_state=random_state, **method_params) est.n_components = 1 scores = np.empty_like(iter_n_components, dtype=np.float64) scores.fill(np.nan) diff --git a/mne/decoding/base.py b/mne/decoding/base.py index 371888fedf6..5c4cc9a3b6f 100644 --- a/mne/decoding/base.py +++ b/mne/decoding/base.py @@ -469,7 +469,9 @@ def __init__(self, model=None): def __sklearn_tags__(self): """Get sklearn tags.""" tags = super().__sklearn_tags__() - model = self.model if self.model is not None else LogisticRegression() + model = ( + self.model if self.model is not None else LogisticRegression(random_state=0) + ) model_tags = model.__sklearn_tags__() tags.estimator_type = model_tags.estimator_type if tags.estimator_type is not None: @@ -538,7 +540,7 @@ def fit(self, X, y, **fit_params): self.model_ = ( clone(self.model) if self.model is not None - else LogisticRegression(solver="liblinear") + else LogisticRegression(solver="liblinear", random_state=0) ) self.model_.fit(X, y, **fit_params) diff --git a/mne/inverse_sparse/mxne_optim.py b/mne/inverse_sparse/mxne_optim.py index 5a4f1ccefae..b197be348ef 100644 --- a/mne/inverse_sparse/mxne_optim.py +++ b/mne/inverse_sparse/mxne_optim.py @@ -154,6 +154,7 @@ def _mixed_norm_solver_cd( tol=tol / sum_squared(M), fit_intercept=False, max_iter=maxit, + random_state=0, warm_start=True, ) if init is not None: diff --git a/mne/tests/test_docstring_parameters.py b/mne/tests/test_docstring_parameters.py index 9df4cf8cdaa..1fdcabe2d6e 100644 --- a/mne/tests/test_docstring_parameters.py +++ b/mne/tests/test_docstring_parameters.py @@ -316,6 +316,13 @@ def test_tabs(): "seed": "a local default_rng", "tomaxint": "integers", } +sklearn_rng_estimators = { + "FastICA", + "FactorAnalysis", + "LogisticRegression", + "MultiTaskLasso", + "PCA", +} def _is_np_random(node): @@ -358,6 +365,18 @@ def test_no_global_rng(): ): want = legacy_rng_methods[node.func.attr] bad.append(f"{rel}:{node.lineno}: .{node.func.attr}() (use {want})") + # 3. MNE-owned sklearn estimators with implicit randomness + elif ( + "/tests/" not in rel + and isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id in sklearn_rng_estimators + and not any(kw.arg == "random_state" for kw in node.keywords) + ): + bad.append( + f"{rel}:{node.lineno}: {node.func.id}() " + "(set random_state explicitly)" + ) if bad: raise AssertionError( f"{len(bad)} outdated numpy RNG use{_pl(bad)} found:\n" + "\n".join(bad) diff --git a/mne/viz/circle.py b/mne/viz/circle.py index 2b2b39705f9..7fc2e8d850c 100644 --- a/mne/viz/circle.py +++ b/mne/viz/circle.py @@ -268,7 +268,7 @@ def _plot_connectivity_circle( nodes_n_con[j] += 1 # initialize random number generator so plot is reproducible - rng = np.random.mtrand.RandomState(0) + rng = np.random.default_rng(0) n_con = len(indices[0]) noise_max = 0.25 * node_width diff --git a/mne/viz/utils.py b/mne/viz/utils.py index 7e0b48f7e7e..80a5b146ec8 100644 --- a/mne/viz/utils.py +++ b/mne/viz/utils.py @@ -1403,7 +1403,7 @@ def _compute_scalings(scalings, inst, remove_dc=False, duration=10): # Load a random subset of epochs up to 100mb in size n_epochs = 1e8 // (len(inst.ch_names) * len(inst.times) * 8) n_epochs = int(np.clip(n_epochs, 1, len(inst))) - ixs_epochs = np.random.default_rng().choice( + ixs_epochs = np.random.default_rng(0).choice( len(inst), n_epochs, replace=False ) inst = inst.copy()[ixs_epochs].load_data() diff --git a/tutorials/machine-learning/50_decoding.py b/tutorials/machine-learning/50_decoding.py index 388b0f5a813..db8ddfd0250 100644 --- a/tutorials/machine-learning/50_decoding.py +++ b/tutorials/machine-learning/50_decoding.py @@ -141,7 +141,7 @@ clf = make_pipeline( Scaler(epochs.info), Vectorizer(), - LogisticRegression(solver="liblinear"), # liblinear is faster than lbfgs + LogisticRegression(solver="liblinear", random_state=0), ) scores = cross_val_multiscore(clf, X, y, cv=5, n_jobs=None) @@ -225,7 +225,9 @@ # We can use CSP with these data with: csp = CSP(n_components=3, norm_trace=False) -clf_csp = make_pipeline(csp, LinearModel(LogisticRegression(solver="liblinear"))) +clf_csp = make_pipeline( + csp, LinearModel(LogisticRegression(solver="liblinear", random_state=0)) +) scores = cross_val_multiscore(clf_csp, X, y, cv=5, n_jobs=None) print(f"CSP: {100 * scores.mean():0.1f}%") @@ -324,7 +326,9 @@ # We will train the classifier on all left visual vs auditory trials on MEG -clf = make_pipeline(StandardScaler(), LogisticRegression(solver="liblinear")) +clf = make_pipeline( + StandardScaler(), LogisticRegression(solver="liblinear", random_state=0) +) time_decod = SlidingEstimator(clf, n_jobs=None, scoring="roc_auc", verbose=True) # here we use cv=3 just for speed @@ -347,7 +351,8 @@ # You can retrieve the spatial filters and spatial patterns if you explicitly # use a LinearModel clf = make_pipeline( - StandardScaler(), LinearModel(LogisticRegression(solver="liblinear")) + StandardScaler(), + LinearModel(LogisticRegression(solver="liblinear", random_state=0)), ) time_decod = SlidingEstimator(clf, n_jobs=None, scoring="roc_auc", verbose=True) time_decod.fit(X, y) From 21efb67f80f99c0a54c8b04aa2ccf781fe151d0c Mon Sep 17 00:00:00 2001 From: Bru Date: Mon, 24 Aug 2026 14:17:00 +0200 Subject: [PATCH 10/34] DOC: Use rng in examples --- examples/datasets/spm_faces_dataset.py | 2 +- examples/inverse/mixed_norm_inverse.py | 2 +- examples/preprocessing/find_ref_artifacts.py | 2 +- examples/preprocessing/ica_comparison.py | 2 +- examples/preprocessing/muscle_ica.py | 6 ++-- examples/simulation/plot_stc_metrics.py | 8 ++--- examples/simulation/simulate_evoked_data.py | 2 +- examples/simulation/simulate_raw_data.py | 8 ++--- ...imulated_raw_data_using_subject_anatomy.py | 6 ++-- examples/simulation/source_simulator.py | 2 +- examples/stats/cluster_stats_evoked.py | 2 +- examples/stats/sensor_permutation_test.py | 2 +- .../time_frequency/time_frequency_erds.py | 2 +- .../time_frequency_global_field_power.py | 2 +- mne/decoding/transformer.py | 4 ++- mne/filter.py | 4 +-- mne/stats/cluster_level.py | 14 +++----- mne/stats/permutations.py | 4 +-- mne/tests/test_docstring_parameters.py | 36 +++++++++++++++++++ tutorials/intro/10_overview.py | 2 +- tutorials/intro/70_report.py | 2 +- .../14_quality_control_report.py | 2 +- .../40_artifact_correction_ica.py | 8 ++--- tutorials/simulation/70_point_spread.py | 2 +- tutorials/simulation/80_dics.py | 2 +- .../stats-sensor-space/10_background_stats.py | 12 +++---- tutorials/stats-sensor-space/20_erp_stats.py | 2 +- .../40_cluster_1samp_time_freq.py | 2 +- .../50_cluster_between_time_freq.py | 2 +- .../70_cluster_rmANOVA_time_freq.py | 2 +- .../75_cluster_ftest_spatiotemporal.py | 4 +-- .../20_cluster_1samp_spatiotemporal.py | 2 +- .../30_cluster_ftest_spatiotemporal.py | 2 +- .../60_cluster_rmANOVA_spatiotemporal.py | 2 +- 34 files changed, 95 insertions(+), 63 deletions(-) diff --git a/examples/datasets/spm_faces_dataset.py b/examples/datasets/spm_faces_dataset.py index 32df7d1a9ed..76f4cff88d7 100644 --- a/examples/datasets/spm_faces_dataset.py +++ b/examples/datasets/spm_faces_dataset.py @@ -36,7 +36,7 @@ raw.resample(100) raw.filter(1.0, None) # high-pass reject = dict(mag=5e-12) -ica = ICA(n_components=0.95, max_iter="auto", random_state=0) +ica = ICA(n_components=0.95, max_iter="auto", rng=0) ica.fit(raw, reject=reject) # compute correlation scores, get bad indices sorted by score eog_epochs = create_eog_epochs(raw, ch_name="MRT31-2908", reject=reject) diff --git a/examples/inverse/mixed_norm_inverse.py b/examples/inverse/mixed_norm_inverse.py index 70764a53973..9573c02a2d5 100644 --- a/examples/inverse/mixed_norm_inverse.py +++ b/examples/inverse/mixed_norm_inverse.py @@ -81,7 +81,7 @@ return_residual=True, return_as_dipoles=True, verbose=True, - random_state=0, + rng=0, # for this dataset we know we should use a high alpha, so avoid some # of the slower (lower) alpha values sure_alpha_grid=np.linspace(100, 40, 10), diff --git a/examples/preprocessing/find_ref_artifacts.py b/examples/preprocessing/find_ref_artifacts.py index d03701e1e5a..dc67fee8275 100644 --- a/examples/preprocessing/find_ref_artifacts.py +++ b/examples/preprocessing/find_ref_artifacts.py @@ -77,7 +77,7 @@ ica_kwargs = dict( method="picard", fit_params=dict(tol=1e-4), # use a high tol here for speed - random_state=99, + rng=99, ) all_picks = mne.pick_types(raw_tog.info, meg=True, ref_meg=True) ica_tog = ICA(n_components=60, max_iter="auto", allow_ref_meg=True, **ica_kwargs) diff --git a/examples/preprocessing/ica_comparison.py b/examples/preprocessing/ica_comparison.py index d4246b80362..21b564fda96 100644 --- a/examples/preprocessing/ica_comparison.py +++ b/examples/preprocessing/ica_comparison.py @@ -50,7 +50,7 @@ def run_ica(method, fit_params=None): method=method, fit_params=fit_params, max_iter="auto", - random_state=0, + rng=0, ) t0 = time() ica.fit(raw, reject=reject) diff --git a/examples/preprocessing/muscle_ica.py b/examples/preprocessing/muscle_ica.py index 8ef1e451985..79382c23417 100644 --- a/examples/preprocessing/muscle_ica.py +++ b/examples/preprocessing/muscle_ica.py @@ -37,9 +37,7 @@ # %% # Run ICA -ica = mne.preprocessing.ICA( - n_components=15, method="picard", max_iter="auto", random_state=97 -) +ica = mne.preprocessing.ICA(n_components=15, method="picard", max_iter="auto", rng=97) ica.fit(raw) # %% @@ -104,7 +102,7 @@ # Run ICA ica = mne.preprocessing.ICA( - n_components=15, method="picard", max_iter="auto", random_state=97 + n_components=15, method="picard", max_iter="auto", rng=97 ) ica.fit(raw) ica.plot_sources(raw) diff --git a/examples/simulation/plot_stc_metrics.py b/examples/simulation/plot_stc_metrics.py index 8b481aed9e6..457ac639963 100644 --- a/examples/simulation/plot_stc_metrics.py +++ b/examples/simulation/plot_stc_metrics.py @@ -76,7 +76,7 @@ location=location, extent=extent, subjects_dir=subjects_dir, - random_state=random_state, + rng=random_state, ) # Dipole @@ -88,7 +88,7 @@ location=location, extent=extent, subjects_dir=subjects_dir, - random_state=random_state, + rng=random_state, ) # WHAT? @@ -128,7 +128,7 @@ raw_region = raw_region.pick(picks=["eeg", "stim"], exclude="bads") cov = mne.make_ad_hoc_cov(raw_region.info) mne.simulation.add_noise( - raw_region, cov, iir_filter=[0.2, -0.2, 0.04], random_state=random_state + raw_region, cov, iir_filter=[0.2, -0.2, 0.04], rng=random_state ) # Dipole @@ -136,7 +136,7 @@ raw_dipole = raw_dipole.pick(picks=["eeg", "stim"], exclude="bads") cov = mne.make_ad_hoc_cov(raw_dipole.info) mne.simulation.add_noise( - raw_dipole, cov, iir_filter=[0.2, -0.2, 0.04], random_state=random_state + raw_dipole, cov, iir_filter=[0.2, -0.2, 0.04], rng=random_state ) ############################################################################### diff --git a/examples/simulation/simulate_evoked_data.py b/examples/simulation/simulate_evoked_data.py index 330b7814481..39bb98ca288 100644 --- a/examples/simulation/simulate_evoked_data.py +++ b/examples/simulation/simulate_evoked_data.py @@ -67,7 +67,7 @@ def data_fun(times): fwd["src"], n_dipoles=2, times=times, - random_state=42, + rng=42, labels=labels, data_fun=data_fun, ) diff --git a/examples/simulation/simulate_raw_data.py b/examples/simulation/simulate_raw_data.py index c5248c146dc..d2d641dd04e 100644 --- a/examples/simulation/simulate_raw_data.py +++ b/examples/simulation/simulate_raw_data.py @@ -67,7 +67,7 @@ def data_fun(times): fwd = mne.read_forward_solution(fwd_fname) src = fwd["src"] stc = simulate_sparse_stc( - src, n_dipoles=n_dipoles, times=times, data_fun=data_fun, random_state=rng + src, n_dipoles=n_dipoles, times=times, data_fun=data_fun, rng=rng ) # look at our source data fig, ax = plt.subplots(1) @@ -79,9 +79,9 @@ def data_fun(times): # Simulate raw data raw_sim = simulate_raw(raw.info, [stc] * 10, forward=fwd, verbose=True) cov = make_ad_hoc_cov(raw_sim.info) -add_noise(raw_sim, cov, iir_filter=[0.2, -0.2, 0.04], random_state=rng) -add_ecg(raw_sim, random_state=rng) -add_eog(raw_sim, random_state=rng) +add_noise(raw_sim, cov, iir_filter=[0.2, -0.2, 0.04], rng=rng) +add_ecg(raw_sim, rng=rng) +add_eog(raw_sim, rng=rng) raw_sim.plot() ############################################################################## diff --git a/examples/simulation/simulated_raw_data_using_subject_anatomy.py b/examples/simulation/simulated_raw_data_using_subject_anatomy.py index fa98684ff11..8bda147c412 100644 --- a/examples/simulation/simulated_raw_data_using_subject_anatomy.py +++ b/examples/simulation/simulated_raw_data_using_subject_anatomy.py @@ -204,9 +204,9 @@ def data_fun(times, latency, duration): raw_sim = mne.simulation.simulate_raw(info, source_simulator, forward=fwd) raw_sim.set_eeg_reference(projection=True) -mne.simulation.add_noise(raw_sim, cov=noise_cov, random_state=0) -mne.simulation.add_eog(raw_sim, random_state=0) -mne.simulation.add_ecg(raw_sim, random_state=0) +mne.simulation.add_noise(raw_sim, cov=noise_cov, rng=0) +mne.simulation.add_eog(raw_sim, rng=0) +mne.simulation.add_ecg(raw_sim, rng=0) # Plot original and simulated raw data. raw_sim.plot(title="Simulated raw data") diff --git a/examples/simulation/source_simulator.py b/examples/simulation/source_simulator.py index 557e32afe40..e09834d72d8 100644 --- a/examples/simulation/source_simulator.py +++ b/examples/simulation/source_simulator.py @@ -85,7 +85,7 @@ class to generate source estimates and raw data. It is meant to be a brief # simulator can be given directly to the simulate_raw function. raw = mne.simulation.simulate_raw(info, source_simulator, forward=fwd) cov = mne.make_ad_hoc_cov(raw.info) -mne.simulation.add_noise(raw, cov, iir_filter=[0.2, -0.2, 0.04], random_state=97) +mne.simulation.add_noise(raw, cov, iir_filter=[0.2, -0.2, 0.04], rng=97) raw.plot() # %% diff --git a/examples/stats/cluster_stats_evoked.py b/examples/stats/cluster_stats_evoked.py index c0a2630cac1..e196feddaac 100644 --- a/examples/stats/cluster_stats_evoked.py +++ b/examples/stats/cluster_stats_evoked.py @@ -70,7 +70,7 @@ threshold=threshold, tail=1, n_jobs=None, - seed=0, + rng=0, out_type="mask", ) diff --git a/examples/stats/sensor_permutation_test.py b/examples/stats/sensor_permutation_test.py index 9583d262166..f47dd21d0d5 100644 --- a/examples/stats/sensor_permutation_test.py +++ b/examples/stats/sensor_permutation_test.py @@ -61,7 +61,7 @@ data = np.mean(data[:, :, temporal_mask], axis=2) n_permutations = 50000 -T0, p_values, H0 = permutation_t_test(data, n_permutations, n_jobs=None, seed=0) +T0, p_values, H0 = permutation_t_test(data, n_permutations, n_jobs=None, rng=0) significant_sensors = picks[p_values <= 0.05] significant_sensors_names = [raw.ch_names[k] for k in significant_sensors] diff --git a/examples/time_frequency/time_frequency_erds.py b/examples/time_frequency/time_frequency_erds.py index 93272eb7aa3..a9e6aaead27 100644 --- a/examples/time_frequency/time_frequency_erds.py +++ b/examples/time_frequency/time_frequency_erds.py @@ -90,7 +90,7 @@ cnorm = TwoSlopeNorm(vmin=vmin, vcenter=0, vmax=vmax) # min, center & max ERDS kwargs = dict( - n_permutations=100, step_down_p=0.05, seed=1, buffer_size=None, out_type="mask" + n_permutations=100, step_down_p=0.05, rng=1, buffer_size=None, out_type="mask" ) # for cluster test # %% diff --git a/examples/time_frequency/time_frequency_global_field_power.py b/examples/time_frequency/time_frequency_global_field_power.py index cc4ff14ce2a..12ef0f3fbc4 100644 --- a/examples/time_frequency/time_frequency_global_field_power.py +++ b/examples/time_frequency/time_frequency_global_field_power.py @@ -136,7 +136,7 @@ def stat_fun(x): ax.plot(times, gfp, label=freq_name, color=color, linewidth=2.5) ax.axhline(0, linestyle="--", color="grey", linewidth=2) ci_low, ci_up = bootstrap_confidence_interval( - average.data, random_state=0, stat_fun=stat_fun + average.data, rng=0, stat_fun=stat_fun ) ci_low = rescale(ci_low, average.times, baseline=(None, 0)) ci_up = rescale(ci_up, average.times, baseline=(None, 0)) diff --git a/mne/decoding/transformer.py b/mne/decoding/transformer.py index 6fdb2f272e3..79656d40083 100644 --- a/mne/decoding/transformer.py +++ b/mne/decoding/transformer.py @@ -307,7 +307,9 @@ class Vectorizer(MNETransformerMixin, BaseEstimator): >>> from sklearn.linear_model import LogisticRegression >>> from sklearn.pipeline import make_pipeline >>> from sklearn.preprocessing import StandardScaler - >>> clf = make_pipeline(Vectorizer(), StandardScaler(), LogisticRegression()) + >>> clf = make_pipeline( + ... Vectorizer(), StandardScaler(), LogisticRegression(random_state=0) + ... ) """ def fit(self, X, y=None): diff --git a/mne/filter.py b/mne/filter.py index 6bc00186001..30245ce5d5a 100644 --- a/mne/filter.py +++ b/mne/filter.py @@ -2092,9 +2092,9 @@ def detrend(x, order=1, axis=-1): -------- As in :func:`scipy.signal.detrend`:: - >>> randgen = np.random.RandomState(9) + >>> rng = np.random.default_rng(9) >>> npoints = int(1e3) - >>> noise = randgen.randn(npoints) + >>> noise = rng.standard_normal(npoints) >>> x = 3 + 2*np.linspace(0, 1, npoints) + noise >>> bool((detrend(x) - noise).max() < 0.01) True diff --git a/mne/stats/cluster_level.py b/mne/stats/cluster_level.py index 2a1608d94ab..e9e586d23e2 100644 --- a/mne/stats/cluster_level.py +++ b/mne/stats/cluster_level.py @@ -15,7 +15,6 @@ _check_rng_compat, _pl, _validate_type, - check_random_state, logger, split_list, verbose, @@ -822,7 +821,7 @@ def _permutation_cluster_test( stat_fun, adjacency, n_jobs, - seed, + rng, max_step, exclude, step_down_p, @@ -953,12 +952,9 @@ def _permutation_cluster_test( if out_type == "indices": clusters = _cluster_mask_to_indices(clusters, t_obs.shape) - # convert our seed to orders # check to see if we can do an exact test # (for a two-tailed test, we can exploit symmetry to just do half) extra = "" - rng = check_random_state(seed) - del seed if len(X) == 1: # 1-sample test do_perm_func = _do_1samp_permutations X_full = X[0] @@ -1195,7 +1191,7 @@ def permutation_cluster_test( stat_fun=stat_fun, adjacency=adjacency, n_jobs=n_jobs, - seed=rng, + rng=rng, max_step=max_step, exclude=exclude, step_down_p=step_down_p, @@ -1310,7 +1306,7 @@ def permutation_cluster_1samp_test( stat_fun=stat_fun, adjacency=adjacency, n_jobs=n_jobs, - seed=rng, + rng=rng, max_step=max_step, exclude=exclude, step_down_p=step_down_p, @@ -1403,6 +1399,7 @@ def spatio_temporal_cluster_1samp_test( ) else: exclude = None + rng = _check_rng_compat(rng, legacy=seed, legacy_name="seed") return permutation_cluster_1samp_test( X, threshold=threshold, @@ -1411,7 +1408,6 @@ def spatio_temporal_cluster_1samp_test( n_permutations=n_permutations, adjacency=adjacency, n_jobs=n_jobs, - seed=seed, rng=rng, max_step=max_step, exclude=exclude, @@ -1507,6 +1503,7 @@ def spatio_temporal_cluster_test( ) else: exclude = None + rng = _check_rng_compat(rng, legacy=seed, legacy_name="seed") return permutation_cluster_test( X, threshold=threshold, @@ -1515,7 +1512,6 @@ def spatio_temporal_cluster_test( n_permutations=n_permutations, adjacency=adjacency, n_jobs=n_jobs, - seed=seed, rng=rng, max_step=max_step, exclude=exclude, diff --git a/mne/stats/permutations.py b/mne/stats/permutations.py index ab05633531a..76883b8b12e 100644 --- a/mne/stats/permutations.py +++ b/mne/stats/permutations.py @@ -168,11 +168,11 @@ def stat_fun(x): return np.array([ci_low, ci_up]) -def _ci(arr, ci=0.95, method="bootstrap", n_bootstraps=2000, random_state=None): +def _ci(arr, ci=0.95, method="bootstrap", n_bootstraps=2000, rng=None): """Calculate confidence interval. Aux function for plot_compare_evokeds.""" if method == "bootstrap": return bootstrap_confidence_interval( - arr, ci=ci, n_bootstraps=n_bootstraps, rng=random_state + arr, ci=ci, n_bootstraps=n_bootstraps, rng=rng ) else: from .parametric import _parametric_ci diff --git a/mne/tests/test_docstring_parameters.py b/mne/tests/test_docstring_parameters.py index 1fdcabe2d6e..b20e415653e 100644 --- a/mne/tests/test_docstring_parameters.py +++ b/mne/tests/test_docstring_parameters.py @@ -323,6 +323,29 @@ def test_tabs(): "MultiTaskLasso", "PCA", } +mne_rng_functions = { + "ICA", + "add_ecg", + "add_eog", + "add_noise", + "bootstrap", + "bootstrap_confidence_interval", + "equalize_epoch_counts", + "equalize_event_counts", + "infomax", + "mixed_norm", + "permutation_cluster_1samp_test", + "permutation_cluster_test", + "permutation_t_test", + "random_parcellation", + "random_permutation", + "select_source_in_label", + "select_sources", + "simulate_evoked", + "simulate_sparse_stc", + "spatio_temporal_cluster_1samp_test", + "spatio_temporal_cluster_test", +} def _is_np_random(node): @@ -377,6 +400,19 @@ def test_no_global_rng(): f"{rel}:{node.lineno}: {node.func.id}() " "(set random_state explicitly)" ) + # 4. authored calls to deprecated MNE RNG parameters + elif "/tests/" not in rel and isinstance(node, ast.Call): + func_name = getattr(node.func, "id", None) or getattr( + node.func, "attr", None + ) + legacy = {"random_state", "seed"}.intersection( + kw.arg for kw in node.keywords + ) + if func_name in mne_rng_functions and legacy: + bad.append( + f"{rel}:{node.lineno}: {func_name}() uses " + f"{', '.join(sorted(legacy))} (use rng)" + ) if bad: raise AssertionError( f"{len(bad)} outdated numpy RNG use{_pl(bad)} found:\n" + "\n".join(bad) diff --git a/tutorials/intro/10_overview.py b/tutorials/intro/10_overview.py index d9fd1cef8d2..8d5f2acbcc5 100644 --- a/tutorials/intro/10_overview.py +++ b/tutorials/intro/10_overview.py @@ -98,7 +98,7 @@ # :ref:`tut-artifact-ica` for a detailed walk-through of that process). # set up and fit the ICA -ica = mne.preprocessing.ICA(n_components=20, random_state=97, max_iter=800) +ica = mne.preprocessing.ICA(n_components=20, rng=97, max_iter=800) ica.fit(raw) ica.exclude = [1, 2] # details on how we picked these are omitted here ica.plot_properties(raw, picks=ica.exclude) diff --git a/tutorials/intro/70_report.py b/tutorials/intro/70_report.py index f76a6a3cfbc..eb2bbc33f0a 100644 --- a/tutorials/intro/70_report.py +++ b/tutorials/intro/70_report.py @@ -246,7 +246,7 @@ ica = mne.preprocessing.ICA( n_components=5, # fit 5 ICA components fit_params=dict(tol=0.01), # assume very early on that ICA has converged - random_state=97, + rng=97, ) ica.fit(inst=raw) diff --git a/tutorials/preprocessing/14_quality_control_report.py b/tutorials/preprocessing/14_quality_control_report.py index 1880ae014b2..708ec487b50 100644 --- a/tutorials/preprocessing/14_quality_control_report.py +++ b/tutorials/preprocessing/14_quality_control_report.py @@ -204,7 +204,7 @@ ica = ICA( n_components=15, - random_state=97, + rng=97, max_iter=50, # just for speed! ) diff --git a/tutorials/preprocessing/40_artifact_correction_ica.py b/tutorials/preprocessing/40_artifact_correction_ica.py index 670ba577316..4afc3d415ec 100644 --- a/tutorials/preprocessing/40_artifact_correction_ica.py +++ b/tutorials/preprocessing/40_artifact_correction_ica.py @@ -255,7 +255,7 @@ # **after** cleaning (and not before), should you require # baseline correction. -ica = ICA(n_components=15, max_iter="auto", random_state=97) +ica = ICA(n_components=15, max_iter="auto", rng=97) ica.fit(filt_raw, reject=dict(eeg=200e-6)) # avoid a couple of big artifacts ica @@ -470,7 +470,7 @@ # resolves out a little better: # refit the ICA with 30 components this time -new_ica = ICA(n_components=30, max_iter="auto", random_state=97) +new_ica = ICA(n_components=30, max_iter="auto", rng=97) new_ica.fit(filt_raw) # find which ICs match the ECG pattern @@ -542,7 +542,7 @@ # high-pass filter raw_filt = raw.copy().load_data().filter(l_freq=1.0, h_freq=None) # fit ICA, using low max_iter for speed - ica = ICA(n_components=30, max_iter=100, random_state=97) + ica = ICA(n_components=30, max_iter=100, rng=97) ica.fit(raw_filt, verbose="error") raws.append(raw) icas.append(ica) @@ -673,7 +673,7 @@ # Fit ICA model using the FastICA algorithm, detect and plot components # explaining ECG artifacts. -ica = ICA(n_components=15, method="fastica", max_iter="auto", random_state=97) +ica = ICA(n_components=15, method="fastica", max_iter="auto", rng=97) ica.fit(epochs) ecg_epochs = create_ecg_epochs(filt_raw, tmin=-0.5, tmax=0.5) diff --git a/tutorials/simulation/70_point_spread.py b/tutorials/simulation/70_point_spread.py index 485714e2c17..5c40886e84f 100644 --- a/tutorials/simulation/70_point_spread.py +++ b/tutorials/simulation/70_point_spread.py @@ -161,7 +161,7 @@ # (evoked) data from the known source-space signals. The amount of noise is # controlled by ``nave`` (higher values imply less noise). # -evoked_gen = simulate_evoked(fwd, stc_gen, evoked.info, cov, nave, random_state=seed) +evoked_gen = simulate_evoked(fwd, stc_gen, evoked.info, cov, nave, rng=seed) # Map the simulated sensor-space data to source-space using the inverse # operator. diff --git a/tutorials/simulation/80_dics.py b/tutorials/simulation/80_dics.py index a487db59752..5b00ead4fee 100644 --- a/tutorials/simulation/80_dics.py +++ b/tutorials/simulation/80_dics.py @@ -189,7 +189,7 @@ def coh_signal_gen(): ] # stacked in time duration = (len(stc_signal.times) * 2) / sfreq raw = simulate_raw(info, stcs, forward=fwd) -add_noise(raw, cov, iir_filter=[4, -4, 0.8], random_state=rand) +add_noise(raw, cov, iir_filter=[4, -4, 0.8], rng=rand) # %% diff --git a/tutorials/stats-sensor-space/10_background_stats.py b/tutorials/stats-sensor-space/10_background_stats.py index 7688794a195..d43e519b75e 100644 --- a/tutorials/stats-sensor-space/10_background_stats.py +++ b/tutorials/stats-sensor-space/10_background_stats.py @@ -251,7 +251,7 @@ def plot_t_p(t, p, title, mcc, axes=None): ps.append(np.zeros(width * width)) mccs.append(False) for ii in range(n_src): - t, p = permutation_t_test(X[:, [ii]], verbose=False, seed=0)[:2] + t, p = permutation_t_test(X[:, [ii]], verbose=False, rng=0)[:2] ts[-1][ii], ps[-1][ii] = t[0], p[0] plot_t_p(ts[-1], ps[-1], titles[-1], mccs[-1]) @@ -370,7 +370,7 @@ def plot_t_p(t, p, title, mcc, axes=None): # of processed neuroimaging data). titles.append(r"$\mathbf{Perm_{max}}$") -out = permutation_t_test(X, verbose=False, seed=0)[:2] +out = permutation_t_test(X, verbose=False, rng=0)[:2] ts.append(out[0]) ps.append(out[1]) mccs.append(True) @@ -509,7 +509,7 @@ def plot_t_p(t, p, title, mcc, axes=None): # run the cluster test t_clust, clusters, p_values, H0 = permutation_cluster_1samp_test( X, - seed=0, + rng=0, n_jobs=None, threshold=t_thresh, adjacency=None, @@ -535,7 +535,7 @@ def plot_t_p(t, p, title, mcc, axes=None): stat_fun_hat = partial(ttest_1samp_no_p, sigma=sigma) t_hat, clusters, p_values, H0 = permutation_cluster_1samp_test( X, - seed=0, + rng=0, n_jobs=None, threshold=t_thresh, adjacency=None, @@ -579,7 +579,7 @@ def plot_t_p(t, p, title, mcc, axes=None): threshold_tfce = dict(start=0, step=0.2) t_tfce, _, p_tfce, H0 = permutation_cluster_1samp_test( X, - seed=0, + rng=0, n_jobs=None, threshold=threshold_tfce, adjacency=None, @@ -596,7 +596,7 @@ def plot_t_p(t, p, title, mcc, axes=None): titles.append(r"$\mathbf{C_{hat,TFCE}}$") t_tfce_hat, _, p_tfce_hat, H0 = permutation_cluster_1samp_test( X, - seed=0, + rng=0, n_jobs=None, threshold=threshold_tfce, adjacency=None, diff --git a/tutorials/stats-sensor-space/20_erp_stats.py b/tutorials/stats-sensor-space/20_erp_stats.py index 3b0b8af742b..e0e6813fa0a 100644 --- a/tutorials/stats-sensor-space/20_erp_stats.py +++ b/tutorials/stats-sensor-space/20_erp_stats.py @@ -98,7 +98,7 @@ # Calculate statistical thresholds t_obs, clusters, cluster_pv, h0 = spatio_temporal_cluster_test( - X, tfce, adjacency=adjacency, n_permutations=100, seed=0 + X, tfce, adjacency=adjacency, n_permutations=100, rng=0 ) # a more standard number would be 1000+ significant_points = cluster_pv.reshape(t_obs.shape).T < 0.05 print(str(significant_points.sum()) + " points selected by TFCE ...") diff --git a/tutorials/stats-sensor-space/40_cluster_1samp_time_freq.py b/tutorials/stats-sensor-space/40_cluster_1samp_time_freq.py index c763a9af44f..87ddd77f476 100644 --- a/tutorials/stats-sensor-space/40_cluster_1samp_time_freq.py +++ b/tutorials/stats-sensor-space/40_cluster_1samp_time_freq.py @@ -208,7 +208,7 @@ tail=tail, adjacency=adjacency, out_type="mask", - seed=0, + rng=0, verbose=True, ) diff --git a/tutorials/stats-sensor-space/50_cluster_between_time_freq.py b/tutorials/stats-sensor-space/50_cluster_between_time_freq.py index 0b4078ec883..7fff7d7197c 100644 --- a/tutorials/stats-sensor-space/50_cluster_between_time_freq.py +++ b/tutorials/stats-sensor-space/50_cluster_between_time_freq.py @@ -131,7 +131,7 @@ n_permutations=100, threshold=threshold, tail=0, - seed=np.random.default_rng(seed=8675309), + rng=np.random.default_rng(seed=8675309), ) # %% diff --git a/tutorials/stats-sensor-space/70_cluster_rmANOVA_time_freq.py b/tutorials/stats-sensor-space/70_cluster_rmANOVA_time_freq.py index c8ec0c5f0d5..ca0b8eb4756 100644 --- a/tutorials/stats-sensor-space/70_cluster_rmANOVA_time_freq.py +++ b/tutorials/stats-sensor-space/70_cluster_rmANOVA_time_freq.py @@ -239,7 +239,7 @@ def stat_fun(*args): n_permutations=n_permutations, buffer_size=None, out_type="mask", - seed=0, + rng=0, ) # %% diff --git a/tutorials/stats-sensor-space/75_cluster_ftest_spatiotemporal.py b/tutorials/stats-sensor-space/75_cluster_ftest_spatiotemporal.py index 44d4748fd88..7ae6dca38f0 100644 --- a/tutorials/stats-sensor-space/75_cluster_ftest_spatiotemporal.py +++ b/tutorials/stats-sensor-space/75_cluster_ftest_spatiotemporal.py @@ -144,7 +144,7 @@ n_jobs=None, buffer_size=None, adjacency=adjacency, - seed=0, + rng=0, ) F_obs, clusters, p_values, _ = cluster_stats @@ -318,7 +318,7 @@ n_jobs=None, buffer_size=None, adjacency=tfr_adjacency, - seed=0, + rng=0, ) # %% diff --git a/tutorials/stats-source-space/20_cluster_1samp_spatiotemporal.py b/tutorials/stats-source-space/20_cluster_1samp_spatiotemporal.py index fc7ca962d44..ee2e98d67ad 100644 --- a/tutorials/stats-source-space/20_cluster_1samp_spatiotemporal.py +++ b/tutorials/stats-source-space/20_cluster_1samp_spatiotemporal.py @@ -209,7 +209,7 @@ n_jobs=None, threshold=t_threshold, buffer_size=None, - seed=0, + rng=0, verbose=True, ) diff --git a/tutorials/stats-source-space/30_cluster_ftest_spatiotemporal.py b/tutorials/stats-source-space/30_cluster_ftest_spatiotemporal.py index 14a4488f7e6..2a48f3df2dd 100644 --- a/tutorials/stats-source-space/30_cluster_ftest_spatiotemporal.py +++ b/tutorials/stats-source-space/30_cluster_ftest_spatiotemporal.py @@ -101,7 +101,7 @@ n_permutations=n_permutations, threshold=f_threshold, buffer_size=None, - seed=0, + rng=0, ) # Now select the clusters that are sig. at p < 0.05 (note that this value # is multiple-comparisons corrected). diff --git a/tutorials/stats-source-space/60_cluster_rmANOVA_spatiotemporal.py b/tutorials/stats-source-space/60_cluster_rmANOVA_spatiotemporal.py index 1f51ab95b1e..0ee89dcbb88 100644 --- a/tutorials/stats-source-space/60_cluster_rmANOVA_spatiotemporal.py +++ b/tutorials/stats-source-space/60_cluster_rmANOVA_spatiotemporal.py @@ -245,7 +245,7 @@ def stat_fun(*args): stat_fun=stat_fun, n_permutations=n_permutations, buffer_size=None, - seed=0, + rng=0, ) # Now select the clusters that are sig. at p < 0.05 (note that this value # is multiple-comparisons corrected). From a95af3a61fc13d1b6379465a639ac8c2284e9f11 Mon Sep 17 00:00:00 2001 From: Bru Date: Mon, 24 Aug 2026 14:38:57 +0200 Subject: [PATCH 11/34] MAINT Preserve legacy RNG fixtures --- mne/filter.py | 2 +- mne/inverse_sparse/tests/test_mxne_inverse.py | 12 ++++++++--- .../tests/test_eeglab_infomax.py | 2 +- mne/preprocessing/tests/test_ica.py | 21 ++++++++++++------- 4 files changed, 25 insertions(+), 12 deletions(-) diff --git a/mne/filter.py b/mne/filter.py index 30245ce5d5a..03f614169ce 100644 --- a/mne/filter.py +++ b/mne/filter.py @@ -2092,7 +2092,7 @@ def detrend(x, order=1, axis=-1): -------- As in :func:`scipy.signal.detrend`:: - >>> rng = np.random.default_rng(9) + >>> rng = np.random.default_rng(11) >>> npoints = int(1e3) >>> noise = rng.standard_normal(npoints) >>> x = 3 + 2*np.linspace(0, 1, npoints) + noise diff --git a/mne/inverse_sparse/tests/test_mxne_inverse.py b/mne/inverse_sparse/tests/test_mxne_inverse.py index 4b2649a4946..7cce3daef02 100644 --- a/mne/inverse_sparse/tests/test_mxne_inverse.py +++ b/mne/inverse_sparse/tests/test_mxne_inverse.py @@ -584,7 +584,7 @@ def data_fun(times): forward["src"], n_dipoles=n_dipoles, times=times, - rng=1, + random_state=1, labels=labels, data_fun=data_fun, ) @@ -600,11 +600,17 @@ def data_fun(times): nave=nave, use_cps=False, iir_filter=None, - rng=0, + random_state=0, ) evoked = evoked.crop(tmin=0, tmax=10e-3) stc_ = mixed_norm( - evoked, forward, noise_cov, loose=0.9, n_mxne_iter=5, depth=0.9, rng=1 + evoked, + forward, + noise_cov, + loose=0.9, + n_mxne_iter=5, + depth=0.9, + random_state=1, ) assert len(stc_.vertices) == len(stc.vertices) == 2 for si in range(len(stc_.vertices)): diff --git a/mne/preprocessing/tests/test_eeglab_infomax.py b/mne/preprocessing/tests/test_eeglab_infomax.py index 0e002b6c083..dfa8c9a748b 100644 --- a/mne/preprocessing/tests/test_eeglab_infomax.py +++ b/mne/preprocessing/tests/test_eeglab_infomax.py @@ -158,7 +158,7 @@ def test_mne_python_vs_eeglab(): unmixing = infomax( Y.T, extended=use_extended, - rng=random_state, + random_state=random_state, max_iter=max_iter_eeglab, l_rate=l_rate_eeglab, block=block_eeglab, diff --git a/mne/preprocessing/tests/test_ica.py b/mne/preprocessing/tests/test_ica.py index b4e5c4d1393..8eb86d32311 100644 --- a/mne/preprocessing/tests/test_ica.py +++ b/mne/preprocessing/tests/test_ica.py @@ -4,6 +4,7 @@ import os import shutil +import warnings from contextlib import nullcontext from pathlib import Path @@ -83,7 +84,12 @@ def ICA(*args, **kwargs): """Fix the random state in tests.""" if "random_state" not in kwargs and "rng" not in kwargs: - kwargs["rng"] = 0 + kwargs["random_state"] = 0 + with warnings.catch_warnings(): + warnings.filterwarnings( + "ignore", category=FutureWarning, message=".*random_state.*" + ) + return _ICA(*args, **kwargs) return _ICA(*args, **kwargs) @@ -715,7 +721,7 @@ def test_ica_additional(method, tmp_path, short_raw_epochs): with catch_logging(True) as log: corrmap([ica, ica2], (0, 0), threshold=0.5, plot=False, show=False) log = log.getvalue() - assert "Median correlation with constructed map:" in log + assert "Median correlation with constructed map: 1.0" in log assert ica.labels_["blinks"] == ica2.labels_["blinks"] assert 0 in ica.labels_["blinks"] # test retrieval of component maps as arrays @@ -762,7 +768,8 @@ def test_ica_additional(method, tmp_path, short_raw_epochs): ) ica_different_channels = ICA(n_components=2, max_iter=1) - ica_different_channels.fit(raw, picks=[2, 3, 4, 5]) + with pytest.warns(Warning, match="converge"): + ica_different_channels.fit(raw, picks=[2, 3, 4, 5]) with pytest.raises(ValueError, match="Not all ICA instances have the"): corrmap([ica_different_channels, ica], (0, 0)) @@ -1080,16 +1087,16 @@ def test_get_explained_variance_ratio(tmp_path, short_raw_epochs): assert "eeg" in explained_var_comp_0_eeg_mag assert "grad" not in explained_var_comp_0_eeg_mag - assert round(explained_var_comp_0["grad"], 4) == 0.0539 + assert round(explained_var_comp_0["grad"], 4) == 0.1784 assert round(explained_var_comp_0["mag"], 4) == 0.0259 - assert round(explained_var_comp_0["eeg"], 4) == 0.0009 + assert round(explained_var_comp_0["eeg"], 4) == 0.0229 assert np.isclose(explained_var_comp_0["eeg"], explained_var_comp_0_eeg["eeg"]) assert np.isclose(explained_var_comp_0["mag"], explained_var_comp_0_eeg_mag["mag"]) assert np.isclose(explained_var_comp_0["eeg"], explained_var_comp_0_eeg_mag["eeg"]) - assert round(explained_var_comp_1["eeg"], 4) == 0.0405 - assert round(explained_var_comps_01["eeg"], 4) == 0.0417 + assert round(explained_var_comp_1["eeg"], 4) == 0.0231 + assert round(explained_var_comps_01["eeg"], 4) == 0.0459 assert ( explained_var_comps_all["grad"] == explained_var_comps_all["mag"] From 5d540a619dbc4fe3e8c68442366652281cdcbad7 Mon Sep 17 00:00:00 2001 From: Bru Date: Mon, 24 Aug 2026 14:42:37 +0200 Subject: [PATCH 12/34] DOC Preserve solver rationale comments --- examples/decoding/decoding_time_generalization_conditions.py | 1 + examples/decoding/linear_model_patterns.py | 1 + tutorials/machine-learning/50_decoding.py | 1 + 3 files changed, 3 insertions(+) diff --git a/examples/decoding/decoding_time_generalization_conditions.py b/examples/decoding/decoding_time_generalization_conditions.py index 37df3406d1f..b7c7881cfa5 100644 --- a/examples/decoding/decoding_time_generalization_conditions.py +++ b/examples/decoding/decoding_time_generalization_conditions.py @@ -69,6 +69,7 @@ # and test on all right visual vs auditory trials. clf = make_pipeline( StandardScaler(), + # liblinear is faster than lbfgs LogisticRegression(solver="liblinear", random_state=0), ) time_gen = GeneralizingEstimator(clf, scoring="roc_auc", n_jobs=None, verbose=True) diff --git a/examples/decoding/linear_model_patterns.py b/examples/decoding/linear_model_patterns.py index 7c1e430420a..ebd0f17916f 100644 --- a/examples/decoding/linear_model_patterns.py +++ b/examples/decoding/linear_model_patterns.py @@ -73,6 +73,7 @@ # Decoding in sensor space using a LogisticRegression classifier # -------------------------------------------------------------- +# liblinear is faster than lbfgs clf = LogisticRegression(solver="liblinear", random_state=0) scaler = StandardScaler() diff --git a/tutorials/machine-learning/50_decoding.py b/tutorials/machine-learning/50_decoding.py index db8ddfd0250..69d1844df6e 100644 --- a/tutorials/machine-learning/50_decoding.py +++ b/tutorials/machine-learning/50_decoding.py @@ -141,6 +141,7 @@ clf = make_pipeline( Scaler(epochs.info), Vectorizer(), + # liblinear is faster than lbfgs LogisticRegression(solver="liblinear", random_state=0), ) From d17aae8145a38c0d0de15f4f17e5b01d4401ab5c Mon Sep 17 00:00:00 2001 From: Bru Date: Mon, 24 Aug 2026 14:44:50 +0200 Subject: [PATCH 13/34] DOC Preserve permutation rationale --- mne/stats/cluster_level.py | 1 + 1 file changed, 1 insertion(+) diff --git a/mne/stats/cluster_level.py b/mne/stats/cluster_level.py index e9e586d23e2..b753dd04896 100644 --- a/mne/stats/cluster_level.py +++ b/mne/stats/cluster_level.py @@ -952,6 +952,7 @@ def _permutation_cluster_test( if out_type == "indices": clusters = _cluster_mask_to_indices(clusters, t_obs.shape) + # Convert the RNG state to permutation orders. # check to see if we can do an exact test # (for a two-tailed test, we can exploit symmetry to just do half) extra = "" From 8e092a59c1af9e6f3c6c5491fb15d932d95a1bfb Mon Sep 17 00:00:00 2001 From: Bru Date: Mon, 24 Aug 2026 14:54:52 +0200 Subject: [PATCH 14/34] TEST: Cover explicit RNG behavior --- mne/tests/test_cov.py | 34 ++++++++++++++++++++++++++ mne/utils/docs.py | 7 +++--- mne/viz/tests/test_circle.py | 29 +++++++++++++++++++++++ mne/viz/tests/test_utils.py | 46 ++++++++++++++++++++++++++++++++++-- 4 files changed, 111 insertions(+), 5 deletions(-) diff --git a/mne/tests/test_cov.py b/mne/tests/test_cov.py index 8d4e4b3b467..406ae07426c 100644 --- a/mne/tests/test_cov.py +++ b/mne/tests/test_cov.py @@ -673,6 +673,40 @@ def get_data(n_samples, n_features, rank, sigma): ) +@pytest.mark.parametrize( + ("mode", "method_params"), + ( + ("pca", dict(svd_solver="randomized")), + ("factor_analysis", dict(svd_method="randomized")), + ), +) +def test_auto_low_rank_ignores_global_rng(mode, method_params): + """Test low-rank covariance models use an explicit sklearn RNG state.""" + pytest.importorskip("sklearn") + rng = np.random.default_rng(42) + mixing = rng.standard_normal((10, 10)) + data = rng.standard_normal((400, 5)) + data = data @ _safe_svd(mixing.copy())[0][:, :5].T + data += rng.normal(scale=0.1 * rng.random(10) + 0.05, size=data.shape) + data *= 1e8 + global_rng = np.random.mtrand._rand + original_rng_state = global_rng.get_state() + try: + global_rng.set_state(np.random.RandomState(42).get_state()) + global_rng_state = global_rng.get_state() + est, _ = _auto_low_rank_model( + data, + mode=mode, + n_jobs=1, + method_params=dict(iter_n_components=[4], **method_params), + cv=2, + ) + assert_array_equal(global_rng.get_state()[1], global_rng_state[1]) + assert est.random_state == 0 + finally: + global_rng.set_state(original_rng_state) + + @pytest.mark.slowtest @pytest.mark.parametrize("rank", ("full", None, "info")) def test_compute_covariance_auto_reg(rank): diff --git a/mne/utils/docs.py b/mne/utils/docs.py index 915759de728..5e702069219 100644 --- a/mne/utils/docs.py +++ b/mne/utils/docs.py @@ -4035,10 +4035,11 @@ def _reflow_param_docstring(docstring, has_first_line=True, width=75): """ docdict["rng"] = """ -rng : None | int | numpy.random.Generator +rng : None | seed accepted by numpy.random.default_rng The random number generator. If ``None`` (default), a new generator seeded - from entropy is used. Pass an integer for reproducible results or a - :class:`numpy.random.Generator` to control the random-number stream. + from entropy is used. Pass a seed accepted by :func:`numpy.random.default_rng` + for reproducible results, or a :class:`numpy.random.Generator` to control the + random-number stream. """ docdict["roll"] = """ diff --git a/mne/viz/tests/test_circle.py b/mne/viz/tests/test_circle.py index 092a9cdd481..c968b188d81 100644 --- a/mne/viz/tests/test_circle.py +++ b/mne/viz/tests/test_circle.py @@ -6,6 +6,7 @@ import matplotlib import numpy as np import pytest +from numpy.testing import assert_allclose from mne.viz import plot_channel_labels_circle from mne.viz.circle import _plot_connectivity_circle @@ -82,3 +83,31 @@ def test_plot_connectivity_circle_label_orientation(): f"Node '{name}' at {angle:.1f}° (left half) should have " f"ha='right', got '{ha}'" ) + + +def test_plot_connectivity_circle_jitter_reproducible(): + """Test connectivity-circle edge jitter uses a fixed local Generator.""" + con = np.array([[0.0, 1.0, 2.0], [1.0, 0.0, 3.0], [2.0, 3.0, 0.0]]) + vertices = [] + global_rng = np.random.mtrand._rand + original_rng_state = global_rng.get_state() + try: + for seed in (0, 1): + global_rng.set_state(np.random.RandomState(seed).get_state()) + fig, ax = _plot_connectivity_circle( + con, ["a", "b", "c"], colorbar=False, interactive=False, show=False + ) + vertices.append(ax.patches[0].get_path().vertices) + fig.clear() + assert_allclose(vertices[0], vertices[1]) + assert_allclose( + vertices[0], + [ + [2.16610807, 10.0], + [2.16610807, 5.0], + [-0.25314554, 5.0], + [-0.25314554, 10.0], + ], + ) + finally: + global_rng.set_state(original_rng_state) diff --git a/mne/viz/tests/test_utils.py b/mne/viz/tests/test_utils.py index 339fc5b5edd..c34f2d8e9c1 100644 --- a/mne/viz/tests/test_utils.py +++ b/mne/viz/tests/test_utils.py @@ -2,6 +2,7 @@ # License: BSD-3-Clause # Copyright the MNE-Python contributors. +from functools import partial from pathlib import Path import matplotlib.pyplot as plt @@ -11,10 +12,10 @@ from matplotlib import rc_context from numpy.testing import assert_allclose -from mne import read_evokeds +from mne import create_info, read_evokeds from mne.epochs import Epochs from mne.event import read_events -from mne.io import read_raw_fif +from mne.io import RawArray, read_raw_fif from mne.viz import ClickableImage, add_background_image, mne_analyze_colormap from mne.viz.ui_events import ColormapRange, link, subscribe from mne.viz.utils import ( @@ -41,6 +42,13 @@ ave_fname = base_dir / "test-ave.fif" +def _limit_epoch_sample(a, a_min, a_max, *args, n_epochs, clip, **kwargs): + """Limit automatic epoch sampling without allocating over 100 MB of data.""" + if a_min == 1 and a_max == n_epochs: + return 2 + return clip(a, a_min, a_max, *args, **kwargs) + + def test_setup_vmin_vmax_warns(): """Test that _setup_vmin_vmax warns properly.""" expected_msg = r"\(min=0.0, max=1\) range.*minimum of data is -1" @@ -195,6 +203,40 @@ def test_auto_scale(): epochs.pick(picks="eeg") +def test_auto_scale_epoch_sampling_reproducible(monkeypatch): + """Test automatic scaling selects unloaded epochs reproducibly.""" + info = create_info(["eeg"], 10.0, "eeg") + data = np.zeros((1, 100)) + events = [] + for idx, start in enumerate(range(0, 100, 10)): + data[0, start : start + 4] = (idx + 1) * np.array([1.0, 2.0, 3.0, 4.0]) + events.append([start, 0, 1]) + raw = RawArray(data, info) + epochs = Epochs( + raw, np.array(events), tmin=0, tmax=0.3, baseline=None, preload=False + ) + epochs.drop_bad() + + clip = np.clip + + # The production threshold requires over 100 MB of epoch data. Limit the + # sample size here so this test exercises the same unloaded-epoch path. + monkeypatch.setattr( + "mne.viz.utils.np.clip", + partial(_limit_epoch_sample, n_epochs=len(epochs), clip=clip), + ) + scalings = [] + global_rng = np.random.mtrand._rand + original_rng_state = global_rng.get_state() + try: + for seed in (0, 1): + global_rng.set_state(np.random.RandomState(seed).get_state()) + scalings.append(_compute_scalings({"eeg": "auto"}, epochs)["eeg"]) + assert_allclose(scalings, [12.5, 12.5]) + finally: + global_rng.set_state(original_rng_state) + + def test_validate_if_list_of_axes(): """Test validation of axes.""" fig, ax = plt.subplots(2, 2) From f237da15c89b969178d9fa2a6a5f6ac82fb4f406 Mon Sep 17 00:00:00 2001 From: Bru Date: Mon, 24 Aug 2026 15:50:41 +0200 Subject: [PATCH 15/34] FIX: Preserve legacy RNG transition behavior --- doc/changes/dev/14199.apichange.rst | 2 +- mne/beamformer/tests/test_rap_music.py | 10 +- mne/epochs.py | 37 +++- mne/forward/tests/test_make_forward.py | 2 +- mne/inverse_sparse/mxne_inverse.py | 7 +- mne/inverse_sparse/tests/test_mxne_inverse.py | 65 +++--- mne/label.py | 7 +- mne/preprocessing/ica.py | 11 +- mne/preprocessing/infomax_.py | 68 ++++++- .../tests/test_eeglab_infomax.py | 30 +-- mne/preprocessing/tests/test_ica.py | 27 ++- mne/preprocessing/tests/test_infomax.py | 22 +- mne/simulation/evoked.py | 32 ++- mne/simulation/raw.py | 7 +- mne/simulation/source.py | 25 ++- mne/simulation/tests/test_evoked.py | 31 +++ mne/simulation/tests/test_source.py | 56 ++++++ mne/stats/cluster_level.py | 189 +++++++++++------- mne/stats/permutations.py | 17 +- mne/stats/tests/test_cluster_level.py | 29 ++- mne/tests/test_docstring_parameters.py | 130 ++++++++++-- mne/tests/test_epochs.py | 70 +++++++ mne/tests/test_label.py | 8 + mne/utils/__init__.pyi | 2 + mne/utils/check.py | 58 +++++- mne/utils/docs.py | 46 ++++- mne/utils/numerics.py | 9 +- mne/utils/tests/test_check.py | 3 +- mne/utils/tests/test_numerics.py | 30 +++ mne/viz/tests/test_ica.py | 4 +- 30 files changed, 821 insertions(+), 213 deletions(-) diff --git a/doc/changes/dev/14199.apichange.rst b/doc/changes/dev/14199.apichange.rst index 71bfc777ad0..5ccbb5332e4 100644 --- a/doc/changes/dev/14199.apichange.rst +++ b/doc/changes/dev/14199.apichange.rst @@ -1 +1 @@ -Add keyword-only ``rng`` parameters backed by :class:`numpy.random.Generator` to statistical, epoch-sampling, simulation, label, ICA, and sparse-inverse APIs, with deprecated ``seed`` and ``random_state`` compatibility paths, by `Bruno Aristimunha`_ (:gh:`9233`). +Add keyword-only ``rng`` parameters backed by :class:`numpy.random.Generator` to statistical, epoch-sampling, simulation, label, ICA, and sparse-inverse APIs, with deprecated ``seed`` and ``random_state`` compatibility paths, by `Bruno Aristimunha`_ (:gh:`9233`). Omitting both parameters now creates a fresh generator, whereas explicitly passing ``None`` to a legacy parameter retains NumPy's global :class:`~numpy.random.RandomState` stream. An integer passed to ``rng`` uses :func:`numpy.random.default_rng`, so it intentionally produces different results from the same integer passed to a legacy parameter. diff --git a/mne/beamformer/tests/test_rap_music.py b/mne/beamformer/tests/test_rap_music.py index 84d5fe220c2..761d1b63125 100644 --- a/mne/beamformer/tests/test_rap_music.py +++ b/mne/beamformer/tests/test_rap_music.py @@ -71,11 +71,11 @@ def simu_data(evoked, forward, noise_cov, n_dipoles, times, nave=1): tmin, tstep = times.min(), 1 / evoked.info["sfreq"] stc = mne.SourceEstimate(data, vertices=vertices, tmin=tmin, tstep=tstep) - # noise seed chosen to keep the explained-variance and gof values well - # inside the bounds asserted in the tests - sim_evoked = mne.simulation.simulate_evoked( - forward, stc, evoked.info, noise_cov, nave=nave, random_state=106 - ) + # The bounds below were calibrated against this legacy noise stream. + with pytest.warns(FutureWarning, match="random_state"): + sim_evoked = mne.simulation.simulate_evoked( + forward, stc, evoked.info, noise_cov, nave=nave, random_state=106 + ) return sim_evoked, stc diff --git a/mne/epochs.py b/mne/epochs.py index 90a333756a5..cb27da0dae5 100644 --- a/mne/epochs.py +++ b/mne/epochs.py @@ -91,6 +91,7 @@ _convert_times, _ensure_events, _gen_events, + _legacy_rng, _on_missing, _path_like, _pl, @@ -99,6 +100,7 @@ _scale_dataframe_data, _validate_type, check_fname, + check_random_state, copy_function_doc_to_method_doc, legacy, logger, @@ -2488,6 +2490,7 @@ def export( export_epochs(fname, self, fmt, overwrite=overwrite, verbose=verbose) + @_legacy_rng("random_state") @fill_doc def equalize_event_counts( self, @@ -2537,8 +2540,10 @@ def equalize_event_counts( The ``event_ids`` must identify non-overlapping subsets of the epochs. %(equalize_events_method)s - %(random_state)s Used only if ``method='random'``. - %(rng)s Used only if ``method='random'``. + %(random_state_deprecated)s + Used only if ``method='random'``. + %(rng)s + Used only if ``method='random'``. Returns ------- @@ -2642,8 +2647,11 @@ def equalize_event_counts( eq_inds.append(self._keys_to_idx(eq)) sample_nums = [self.events[e, 0] for e in eq_inds] + legacy_seed = ( + random_state if isinstance(random_state, int | np.integer) else None + ) rng = _check_rng_compat(rng, legacy=random_state, legacy_name="random_state") - indices = _get_drop_indices(sample_nums, method, rng) + indices = _get_drop_indices(sample_nums, method, rng, legacy_seed=legacy_seed) # need to re-index indices indices = np.concatenate([e[idx] for e, idx in zip(eq_inds, indices)]) self.drop(indices, reason="EQUALIZED_COUNT") @@ -4014,6 +4022,7 @@ def combine_event_ids( return epochs +@_legacy_rng("random_state") @fill_doc def equalize_epoch_counts( epochs_list: list, @@ -4029,8 +4038,10 @@ def equalize_epoch_counts( epochs_list : list of Epochs The Epochs instances to equalize trial counts for. %(equalize_events_method)s - %(random_state)s Used only if ``method='random'``. - %(rng)s Used only if ``method='random'``. + %(random_state_deprecated)s + Used only if ``method='random'``. + %(rng)s + Used only if ``method='random'``. Notes ----- @@ -4057,13 +4068,14 @@ def equalize_epoch_counts( if not epoch._bad_dropped: epoch.drop_bad() sample_nums = [epoch.events[:, 0] for epoch in epochs_list] + legacy_seed = random_state if isinstance(random_state, int | np.integer) else None rng = _check_rng_compat(rng, legacy=random_state, legacy_name="random_state") - indices = _get_drop_indices(sample_nums, method, rng) + indices = _get_drop_indices(sample_nums, method, rng, legacy_seed=legacy_seed) for epoch, inds in zip(epochs_list, indices): epoch.drop(inds, reason="EQUALIZED_COUNT") -def _get_drop_indices(sample_nums, method, rng): +def _get_drop_indices(sample_nums, method, rng, *, legacy_seed=None): """Get indices to drop from multiple event timing lists.""" small_idx = np.argmin([e.size for e in sample_nums]) small_epoch_indices = sample_nums[small_idx] @@ -4077,7 +4089,13 @@ def _get_drop_indices(sample_nums, method, rng): mask[small_epoch_indices.size :] = False elif method == "random": mask = np.zeros(event.size, dtype=bool) - idx = rng.choice( + # Historically an integer seed was normalized inside this loop, + # restarting the same stream for every event list. Preserve that + # behavior only for the deprecated parameter; ``rng`` advances. + this_rng = ( + check_random_state(legacy_seed) if legacy_seed is not None else rng + ) + idx = this_rng.choice( np.arange(event.size), size=small_epoch_indices.size, replace=False ) mask[idx] = True @@ -4658,6 +4676,7 @@ def _get_epoch_from_raw(self, idx, verbose=None): return data +@_legacy_rng("random_state") @fill_doc def bootstrap(epochs, random_state=None, *, rng=None): """Compute epochs selected by bootstrapping. @@ -4666,7 +4685,7 @@ def bootstrap(epochs, random_state=None, *, rng=None): ---------- epochs : Epochs instance epochs data to be bootstrapped - %(random_state)s + %(random_state_deprecated)s %(rng)s Returns diff --git a/mne/forward/tests/test_make_forward.py b/mne/forward/tests/test_make_forward.py index 42cd29ca425..7244aab406b 100644 --- a/mne/forward/tests/test_make_forward.py +++ b/mne/forward/tests/test_make_forward.py @@ -767,7 +767,7 @@ def test_make_forward_dipole(tmp_path): times, pos, amplitude, ori, gof = [], [], [], [], [] nave = 400 # add a tiny amount of noise to the simulated evokeds for s in stc: - evo_test = simulate_evoked(fwd, s, info, cov, nave=nave, random_state=rng) + evo_test = simulate_evoked(fwd, s, info, cov, nave=nave, rng=rng) # evo_test.add_proj(make_eeg_average_ref_proj(evo_test.info)) dfit, resid = fit_dipole(evo_test, cov, sphere, None) times += dfit.times.tolist() diff --git a/mne/inverse_sparse/mxne_inverse.py b/mne/inverse_sparse/mxne_inverse.py index 2858068470e..647726fb0e8 100644 --- a/mne/inverse_sparse/mxne_inverse.py +++ b/mne/inverse_sparse/mxne_inverse.py @@ -19,6 +19,7 @@ _check_depth, _check_option, _check_rng_compat, + _legacy_rng, _validate_type, logger, sum_squared, @@ -341,6 +342,7 @@ def make_stc_from_dipoles(dipoles, src, verbose=None): return stc +@_legacy_rng("random_state") @verbose def mixed_norm( evoked, @@ -434,9 +436,8 @@ def mixed_norm( grid is directly specified. Ignored if alpha is not "sure". .. versionadded:: 0.24 - random_state : int | None - The random state used in a random number generator for delta and - epsilon used for the SURE computation. Defaults to None. + %(random_state_deprecated)s + Used for the random delta and epsilon in the SURE computation. .. versionadded:: 0.24 %(verbose)s diff --git a/mne/inverse_sparse/tests/test_mxne_inverse.py b/mne/inverse_sparse/tests/test_mxne_inverse.py index 7cce3daef02..c32ebc5debe 100644 --- a/mne/inverse_sparse/tests/test_mxne_inverse.py +++ b/mne/inverse_sparse/tests/test_mxne_inverse.py @@ -46,6 +46,14 @@ def forward(): return read_forward_solution(fname_fwd) +def test_mixed_norm_rng_conflict_without_sure(): + """Test RNG spelling conflicts when SURE randomness is inactive.""" + with pytest.raises(TypeError, match="only one"): + mixed_norm(None, None, None, alpha=1, random_state=0, rng=1) + with pytest.raises(TypeError, match="only one"): + mixed_norm(None, None, None, alpha=1, random_state=None, rng=None) + + @testing.requires_testing_data @pytest.mark.timeout(150) # ~30 s on Travis Linux @pytest.mark.ultraslowtest @@ -580,38 +588,41 @@ def data_fun(times): forward = mne.read_forward_solution(fname_fwd) forward = mne.pick_channels_forward(forward, info["ch_names"]) times = np.arange(100, dtype=np.float64) / info["sfreq"] - 0.1 - stc = simulate_sparse_stc( - forward["src"], - n_dipoles=n_dipoles, - times=times, - random_state=1, - labels=labels, - data_fun=data_fun, - ) + with pytest.warns(FutureWarning, match="random_state"): + stc = simulate_sparse_stc( + forward["src"], + n_dipoles=n_dipoles, + times=times, + random_state=1, + labels=labels, + data_fun=data_fun, + ) assert len(stc.vertices) == 2 assert_array_equal(stc.vertices[0], [89259]) assert_array_equal(stc.vertices[1], [70279]) nave = 30 - evoked = simulate_evoked( - forward, - stc, - info, - noise_cov, - nave=nave, - use_cps=False, - iir_filter=None, - random_state=0, - ) + with pytest.warns(FutureWarning, match="random_state"): + evoked = simulate_evoked( + forward, + stc, + info, + noise_cov, + nave=nave, + use_cps=False, + iir_filter=None, + random_state=0, + ) evoked = evoked.crop(tmin=0, tmax=10e-3) - stc_ = mixed_norm( - evoked, - forward, - noise_cov, - loose=0.9, - n_mxne_iter=5, - depth=0.9, - random_state=1, - ) + with pytest.warns(FutureWarning, match="random_state"): + stc_ = mixed_norm( + evoked, + forward, + noise_cov, + loose=0.9, + n_mxne_iter=5, + depth=0.9, + random_state=1, + ) assert len(stc_.vertices) == len(stc.vertices) == 2 for si in range(len(stc_.vertices)): assert_array_equal(stc_.vertices[si], stc.vertices[si], err_msg=f"{si=}") diff --git a/mne/label.py b/mne/label.py index 701303cc327..75b4711a90c 100644 --- a/mne/label.py +++ b/mne/label.py @@ -42,6 +42,7 @@ _check_rng_compat, _check_subject, _import_nibabel, + _legacy_rng, _validate_type, fill_doc, get_subjects_dir, @@ -1995,6 +1996,7 @@ def _grow_nonoverlapping_labels( return labels +@_legacy_rng("random_state") @fill_doc def random_parcellation( subject, @@ -2023,7 +2025,7 @@ def random_parcellation( parcels per hemisphere. %(subjects_dir)s %(surface)s - %(random_state)s + %(random_state_deprecated)s %(rng)s Returns @@ -2942,6 +2944,7 @@ def write_labels_to_annot( _write_annot(fname, annot, ctab, hemi_names, table_name) +@_legacy_rng("random_state") @fill_doc def select_sources( subject, @@ -2978,7 +2981,7 @@ def select_sources( %(subjects_dir)s name : None | str Assign name to the new label. - %(random_state)s + %(random_state_deprecated)s surf : str The surface used to simulated the label, defaults to the white surface. %(rng)s diff --git a/mne/preprocessing/ica.py b/mne/preprocessing/ica.py index 9e0962bc3e5..e458392a4dc 100644 --- a/mne/preprocessing/ica.py +++ b/mne/preprocessing/ica.py @@ -65,9 +65,9 @@ _check_option, _check_preload, _check_rng, - _check_rng_compat, _ensure_int, _get_inst_data, + _legacy_rng, _limit_blas_threads, _on_missing, _pl, @@ -90,7 +90,7 @@ from .ctps_ import ctps from .ecg import _get_ecg_channel_index, _make_ecg, create_ecg_epochs, qrs_detector from .eog import _find_eog_events, _get_eog_channel_index -from .infomax_ import infomax +from .infomax_ import _infomax __all__ = ( "ICA", @@ -248,7 +248,7 @@ class ICA(ContainsMixin): Noise covariance used for pre-whitening. If None (default), channels are scaled to unit variance ("z-standardized") as a group by channel type prior to the whitening by PCA. - %(random_state)s + %(random_state_deprecated)s %(rng)s method : 'fastica' | 'infomax' | 'picard' The ICA method to use in the fit method. Use the ``fit_params`` argument @@ -436,6 +436,7 @@ class ICA(ContainsMixin): .. footbibliography:: """ # noqa: E501 + @_legacy_rng("random_state") @verbose def __init__( self, @@ -477,8 +478,6 @@ def __init__( self._max_pca_components = None self.n_pca_components = None self.ch_names = None - if rng is not None or random_state is not None: - _check_rng_compat(rng, legacy=random_state, legacy_name="random_state") self.random_state = random_state self.rng = rng @@ -980,7 +979,7 @@ def _fit(self, data, fit_type): self.unmixing_matrix_ = ica.components_ self.n_iter_ = ica.n_iter_ elif self.method in ("infomax", "extended-infomax"): - unmixing_matrix, n_iter = infomax( + unmixing_matrix, n_iter = _infomax( data[:, sel], rng=rng, return_n_iter=True, diff --git a/mne/preprocessing/infomax_.py b/mne/preprocessing/infomax_.py index b40c1f4c198..ff7bd0372b7 100644 --- a/mne/preprocessing/infomax_.py +++ b/mne/preprocessing/infomax_.py @@ -7,11 +7,12 @@ import numpy as np from scipy.special import expit -from ..utils import _check_rng_compat, logger, random_permutation, verbose +from ..utils import _check_rng_compat, _legacy_rng, fill_doc, logger, verbose +from ..utils.numerics import _random_permutation -@verbose -def infomax( +@fill_doc +def _infomax( data, weights=None, l_rate=None, @@ -24,7 +25,6 @@ def infomax( kurt_size=6000, ext_blocks=1, max_iter=200, - random_state=None, blowup=1e4, blowup_fac=0.5, n_small_angle=20, @@ -32,7 +32,7 @@ def infomax( verbose=None, return_n_iter=False, *, - rng=None, + rng, ): """Run (extended) Infomax ICA decomposition on raw data. @@ -80,7 +80,7 @@ def infomax( Defaults to 1. max_iter : int The maximum number of iterations. Defaults to 200. - %(random_state)s + %(random_state_deprecated)s blowup : float The maximum difference allowed between two successive estimations of the unmixing matrix. Defaults to 10000. @@ -120,8 +120,6 @@ def infomax( """ from scipy.stats import kurtosis - rng = _check_rng_compat(rng, legacy=random_state, legacy_name="random_state") - # define some default parameters max_weight = 1e8 restart_fac = 0.9 @@ -185,7 +183,7 @@ def infomax( olddelta, oldchange = 1.0, 0.0 while step < max_iter: # shuffle data at each step - permute = random_permutation(n_samples, rng=rng) + permute = _random_permutation(n_samples, rng) # ICA training block # loop across block samples @@ -337,3 +335,55 @@ def infomax( return weights.T, step else: return weights.T + + +def infomax( + data, + weights=None, + l_rate=None, + block=None, + w_change=1e-12, + anneal_deg=60.0, + anneal_step=0.9, + extended=True, + n_subgauss=1, + kurt_size=6000, + ext_blocks=1, + max_iter=200, + random_state=None, + blowup=1e4, + blowup_fac=0.5, + n_small_angle=20, + use_bias=True, + verbose=None, + return_n_iter=False, + *, + rng=None, +): + """Run (extended) Infomax ICA decomposition on raw data.""" + rng = _check_rng_compat(rng, legacy=random_state, legacy_name="random_state") + return _infomax( + data, + weights=weights, + l_rate=l_rate, + block=block, + w_change=w_change, + anneal_deg=anneal_deg, + anneal_step=anneal_step, + extended=extended, + n_subgauss=n_subgauss, + kurt_size=kurt_size, + ext_blocks=ext_blocks, + max_iter=max_iter, + blowup=blowup, + blowup_fac=blowup_fac, + n_small_angle=n_small_angle, + use_bias=use_bias, + verbose=verbose, + return_n_iter=return_n_iter, + rng=rng, + ) + + +infomax.__doc__ = _infomax.__doc__ +infomax = _legacy_rng("random_state")(verbose(infomax)) diff --git a/mne/preprocessing/tests/test_eeglab_infomax.py b/mne/preprocessing/tests/test_eeglab_infomax.py index dfa8c9a748b..3d616e78d8c 100644 --- a/mne/preprocessing/tests/test_eeglab_infomax.py +++ b/mne/preprocessing/tests/test_eeglab_infomax.py @@ -33,7 +33,8 @@ def generate_data_for_comparing_against_eeglab_infomax(ch_type, random_state): # select a small number of channels for the test number_of_channels_to_use = 5 - idx_perm = random_permutation(picks.shape[0], random_state) + with pytest.warns(FutureWarning, match="random_state"): + idx_perm = random_permutation(picks.shape[0], random_state=random_state) picks = picks[idx_perm[:number_of_channels_to_use]] raw.filter( @@ -155,19 +156,20 @@ def test_mne_python_vs_eeglab(): # Call mne_python infomax version using the following syntax # to obtain the same result than eeglab version - unmixing = infomax( - Y.T, - extended=use_extended, - random_state=random_state, - max_iter=max_iter_eeglab, - l_rate=l_rate_eeglab, - block=block_eeglab, - w_change=w_change_eeglab, - blowup=blowup_eeglab, - blowup_fac=blowup_fac_eeglab, - n_small_angle=None, - anneal_step=anneal_step_eeglab, - ) + with pytest.warns(FutureWarning, match="random_state"): + unmixing = infomax( + Y.T, + extended=use_extended, + random_state=random_state, + max_iter=max_iter_eeglab, + l_rate=l_rate_eeglab, + block=block_eeglab, + w_change=w_change_eeglab, + blowup=blowup_eeglab, + blowup_fac=blowup_fac_eeglab, + n_small_angle=None, + anneal_step=anneal_step_eeglab, + ) # Order the components in the same way that eeglab does sources = np.dot(unmixing, Y) diff --git a/mne/preprocessing/tests/test_ica.py b/mne/preprocessing/tests/test_ica.py index 8eb86d32311..644ab964929 100644 --- a/mne/preprocessing/tests/test_ica.py +++ b/mne/preprocessing/tests/test_ica.py @@ -4,7 +4,6 @@ import os import shutil -import warnings from contextlib import nullcontext from pathlib import Path @@ -55,7 +54,7 @@ read_ica_eeglab, ) from mne.rank import _compute_rank_int -from mne.utils import _record_warnings, catch_logging, check_version +from mne.utils import _record_warnings, catch_logging, check_random_state, check_version data_dir = Path(__file__).parents[2] / "io" / "tests" / "data" raw_fname = data_dir / "test_raw.fif" @@ -84,12 +83,7 @@ def ICA(*args, **kwargs): """Fix the random state in tests.""" if "random_state" not in kwargs and "rng" not in kwargs: - kwargs["random_state"] = 0 - with warnings.catch_warnings(): - warnings.filterwarnings( - "ignore", category=FutureWarning, message=".*random_state.*" - ) - return _ICA(*args, **kwargs) + kwargs["rng"] = 0 return _ICA(*args, **kwargs) @@ -303,6 +297,23 @@ def test_ica_rng_transition(): with pytest.raises(TypeError, match="only one"): _ICA(random_state=0, rng=0) + info = create_info(["Fz", "Cz", "Pz"], 100.0, "eeg") + with info._unlock(): + info["highpass"] = 1.0 + raw = RawArray(np.random.default_rng(0).standard_normal((3, 200)), info) + unmixings = [] + for random_state in (0, check_random_state(0)): + with pytest.warns(FutureWarning, match="random_state"): + ica = _ICA( + n_components=2, + method="infomax", + max_iter=1, + random_state=random_state, + ) + ica.fit(raw) + unmixings.append(ica.unmixing_matrix_) + assert_array_equal(unmixings[0], unmixings[1]) + @pytest.mark.parametrize("method", ["infomax", "fastica", "picard"]) def test_ica_n_iter_(method, tmp_path): diff --git a/mne/preprocessing/tests/test_infomax.py b/mne/preprocessing/tests/test_infomax.py index 1cf0d484590..1df7e1d56e3 100644 --- a/mne/preprocessing/tests/test_infomax.py +++ b/mne/preprocessing/tests/test_infomax.py @@ -6,11 +6,11 @@ import numpy as np import pytest -from numpy.testing import assert_almost_equal +from numpy.testing import assert_almost_equal, assert_array_equal from scipy import stats from mne.preprocessing.infomax_ import infomax -from mne.utils import pinv +from mne.utils import check_random_state, pinv pytest.importorskip("sklearn") @@ -190,6 +190,24 @@ def test_infomax_n_iter(return_n_iter): assert isinstance(r, np.ndarray) +def test_infomax_legacy_rng_nested(): + """Test legacy RNGs survive Infomax's nested permutation path.""" + X = np.random.default_rng(0).standard_normal((20, 2)) + results = [] + for random_state in (0, check_random_state(0)): + with pytest.warns(FutureWarning, match="random_state"): + results.append( + infomax( + X, + block=5, + extended=False, + max_iter=1, + random_state=random_state, + ) + ) + assert_array_equal(results[0], results[1]) + + def _get_pca(rng=None): from sklearn.decomposition import PCA diff --git a/mne/simulation/evoked.py b/mne/simulation/evoked.py index cf9d46ea5be..97d6e716527 100644 --- a/mne/simulation/evoked.py +++ b/mne/simulation/evoked.py @@ -13,9 +13,18 @@ from ..evoked import Evoked from ..forward import apply_forward from ..io import BaseRaw -from ..utils import _check_preload, _check_rng_compat, _validate_type, logger, verbose - - +from ..utils import ( + _check_preload, + _check_rng_compat, + _legacy_rng, + _validate_type, + check_random_state, + logger, + verbose, +) + + +@_legacy_rng("random_state") @verbose def simulate_evoked( fwd, @@ -53,7 +62,7 @@ def simulate_evoked( .. versionadded:: 0.15.0 iir_filter : None | array IIR filter coefficients (denominator) e.g. [1, -1, 0.2]. - %(random_state)s + %(random_state_deprecated)s %(use_cps)s .. versionadded:: 0.15 @@ -102,6 +111,7 @@ def _simulate_noise_evoked(evoked, cov, iir_filter, rng): return _add_noise(noise, cov, iir_filter, rng, allow_subselection=False) +@_legacy_rng("random_state") @verbose def add_noise(inst, cov, iir_filter=None, random_state=None, verbose=None, *, rng=None): """Create noise as a multivariate Gaussian. @@ -116,7 +126,7 @@ def add_noise(inst, cov, iir_filter=None, random_state=None, verbose=None, *, rn The noise covariance. iir_filter : None | array-like IIR filter coefficients (denominator). - %(random_state)s + %(random_state_deprecated)s %(verbose)s %(rng)s @@ -135,11 +145,14 @@ def add_noise(inst, cov, iir_filter=None, random_state=None, verbose=None, *, rn .. versionadded:: 0.18.0 """ # We always allow subselection here + legacy_seed = random_state if isinstance(random_state, int | np.integer) else None rng = _check_rng_compat(rng, legacy=random_state, legacy_name="random_state") - return _add_noise(inst, cov, iir_filter, rng) + return _add_noise(inst, cov, iir_filter, rng, legacy_seed=legacy_seed) -def _add_noise(inst, cov, iir_filter, rng, allow_subselection=True): +def _add_noise( + inst, cov, iir_filter, rng, allow_subselection=True, *, legacy_seed=None +): """Add noise, possibly with channel subselection.""" _validate_type(cov, Covariance, "cov") _validate_type( @@ -168,8 +181,11 @@ def _add_noise(inst, cov, iir_filter, rng, allow_subselection=True): gen_picks = np.arange(info["nchan"]) for epoch in data: + # An integer passed to the deprecated parameter historically restarted + # the same stream for each epoch. ``rng`` intentionally advances. + this_rng = check_random_state(legacy_seed) if legacy_seed is not None else rng epoch[picks] += _generate_noise( - info, cov, iir_filter, rng, epoch.shape[1], picks=gen_picks + info, cov, iir_filter, this_rng, epoch.shape[1], picks=gen_picks )[0] return inst diff --git a/mne/simulation/raw.py b/mne/simulation/raw.py index a04a8e5041e..4b0b85aa3cb 100644 --- a/mne/simulation/raw.py +++ b/mne/simulation/raw.py @@ -45,6 +45,7 @@ from ..utils import ( _check_preload, _check_rng_compat, + _legacy_rng, _pl, _validate_type, _verbose_safe_false, @@ -387,6 +388,7 @@ def simulate_raw( return raw +@_legacy_rng("random_state") @verbose def add_eog( raw, @@ -407,7 +409,7 @@ def add_eog( %(head_pos)s %(interp)s %(n_jobs)s - %(random_state)s + %(random_state_deprecated)s The random generator state used for blink, ECG, and sensor noise randomization. %(verbose)s @@ -451,6 +453,7 @@ def add_eog( return _add_exg(raw, "blink", head_pos, interp, n_jobs, rng) +@_legacy_rng("random_state") @verbose def add_ecg( raw, @@ -471,7 +474,7 @@ def add_ecg( %(head_pos)s %(interp)s %(n_jobs)s - %(random_state)s + %(random_state_deprecated)s The random generator state used for blink, ECG, and sensor noise randomization. %(verbose)s diff --git a/mne/simulation/source.py b/mne/simulation/source.py index c86f5bde3f0..7c7f89f8894 100644 --- a/mne/simulation/source.py +++ b/mne/simulation/source.py @@ -14,12 +14,14 @@ _check_rng_compat, _ensure_events, _ensure_int, + _legacy_rng, _validate_type, fill_doc, warn, ) +@_legacy_rng("random_state") @fill_doc def select_source_in_label( src, @@ -40,7 +42,7 @@ def select_source_in_label( The source space. label : Label The label. - %(random_state)s + %(random_state_deprecated)s location : str The label location to choose. Can be 'random' (default) or 'center' to use :func:`mne.Label.center_of_mass` (restricting to vertices @@ -72,11 +74,23 @@ def select_source_in_label( rh_vertno : list Selected source coefficients on the right hemisphere. """ + rng = _check_rng_compat(rng, legacy=random_state, legacy_name="random_state") + return _select_source_in_label( + src, + label, + location=location, + subject=subject, + subjects_dir=subjects_dir, + surf=surf, + rng=rng, + ) + + +def _select_source_in_label(src, label, *, location, subject, subjects_dir, surf, rng): + """Select a source in a label using an already normalized RNG.""" lh_vertno = list() rh_vertno = list() _check_option("location", location, ["random", "center"]) - - rng = _check_rng_compat(rng, legacy=random_state, legacy_name="random_state") if label.hemi == "lh": vertno = lh_vertno hemi_idx = 0 @@ -94,6 +108,7 @@ def select_source_in_label( return lh_vertno, rh_vertno +@_legacy_rng("random_state") @fill_doc def simulate_sparse_stc( src, @@ -131,7 +146,7 @@ def simulate_sparse_stc( the same length containing the time courses. labels : None | list of Label The labels. The default is None, otherwise its size must be n_dipoles. - %(random_state)s + %(random_state_deprecated)s location : str The label location to choose. Can be ``'random'`` (default) or ``'center'`` to use :func:`mne.Label.center_of_mass`. Note that for @@ -211,7 +226,7 @@ def simulate_sparse_stc( lh_data = [np.empty((0, data.shape[1]))] rh_data = [np.empty((0, data.shape[1]))] for i, label in enumerate(labels): - lh_vertno, rh_vertno = select_source_in_label( + lh_vertno, rh_vertno = _select_source_in_label( src, label, location=location, diff --git a/mne/simulation/tests/test_evoked.py b/mne/simulation/tests/test_evoked.py index 50f06ed11fa..73c09faab58 100644 --- a/mne/simulation/tests/test_evoked.py +++ b/mne/simulation/tests/test_evoked.py @@ -14,10 +14,12 @@ ) from mne import ( + Covariance, EpochsArray, compute_covariance, compute_raw_covariance, convert_forward_solution, + create_info, pick_channels_cov, pick_types, pick_types_forward, @@ -147,6 +149,35 @@ def test_add_noise(): r = np.corrcoef(cov["data"].ravel(), cov_new["data"].ravel())[0, 1] assert r > 0.99 + info = create_info(["EEG 001"], 100.0, "eeg") + small_cov = Covariance(np.ones(1), info["ch_names"], [], [], 1) + legacy = EpochsArray(np.zeros((2, 1, 5)), info, verbose=False) + with pytest.warns(FutureWarning, match="random_state"): + add_noise(legacy, small_cov, random_state=0) + want = np.array( + [ + 1.764052345967664, + 0.4001572083672233, + 0.9787379841057392, + 2.240893199201458, + 1.8675579901499675, + ] + ) + assert_array_equal(legacy.get_data(copy=False)[0, 0], want) + assert_array_equal(legacy.get_data(copy=False)[1, 0], want) + + modern = EpochsArray(np.zeros((2, 1, 5)), info, verbose=False) + add_noise(modern, small_cov, rng=0) + assert not np.array_equal( + modern.get_data(copy=False)[0], modern.get_data(copy=False)[1] + ) + + +def test_simulate_evoked_rng_conflict_without_noise(): + """Test RNG spelling conflicts are checked when noise is inactive.""" + with pytest.raises(TypeError, match="only one"): + simulate_evoked(None, None, None, cov=None, random_state=None, rng=None) + def test_rank_deficiency(): """Test adding noise from M/EEG float32 (I/O) cov with projectors.""" diff --git a/mne/simulation/tests/test_source.py b/mne/simulation/tests/test_source.py index a2f35b2221e..0f9341996ae 100644 --- a/mne/simulation/tests/test_source.py +++ b/mne/simulation/tests/test_source.py @@ -7,6 +7,7 @@ from numpy.testing import assert_array_almost_equal, assert_array_equal, assert_equal from mne import ( + SourceSpaces, convert_forward_solution, pick_types_forward, read_forward_solution, @@ -15,6 +16,7 @@ from mne.datasets import testing from mne.label import Label from mne.simulation import SourceSimulator, simulate_sparse_stc, simulate_stc +from mne.utils import check_random_state data_path = testing.data_path(download=False) fname_fwd = data_path / "MEG" / "sample" / "sample_audvis_trunc-meg-eeg-oct-6-fwd.fif" @@ -47,6 +49,44 @@ def _get_idx_label_stc(label, stc): return idx +def test_simulate_sparse_stc_legacy_rng_nested(): + """Test legacy RNGs survive nested label-source selection.""" + src = SourceSpaces( + [ + dict( + type="surf", + vertno=np.array([1, 2, 3]), + nuse=3, + subject_his_id="sample", + ), + dict( + type="surf", + vertno=np.array([4, 5, 6]), + nuse=3, + subject_his_id="sample", + ), + ] + ) + labels = [ + Label([1, 2, 3], hemi="lh", subject="sample"), + Label([4, 5, 6], hemi="rh", subject="sample"), + ] + results = [] + for random_state in (0, check_random_state(0)): + with pytest.warns(FutureWarning, match="random_state"): + results.append( + simulate_sparse_stc( + src, + 2, + np.arange(2.0), + labels=labels, + random_state=random_state, + ) + ) + for hemi in (0, 1): + assert_array_equal(results[0].vertices[hemi], results[1].vertices[hemi]) + + def test_simulate_stc(_get_fwd_labels): """Test generation of source estimate.""" fwd, labels = _get_fwd_labels @@ -148,6 +188,22 @@ def test_simulate_sparse_stc(_get_fwd_labels): this_label.values.fill(1.0) mylabels.append(this_label) + legacy_stcs = [] + for random_state in (0, check_random_state(0)): + with pytest.warns(FutureWarning, match="random_state"): + legacy_stcs.append( + simulate_sparse_stc( + fwd["src"], + len(mylabels), + times, + labels=mylabels, + random_state=random_state, + subjects_dir=subjects_dir, + ) + ) + for hemi in (0, 1): + assert_array_equal(legacy_stcs[0].vertices[hemi], legacy_stcs[1].vertices[hemi]) + for location in ("random", "center"): random_state = 0 if location == "random" else None stc_1 = simulate_sparse_stc( diff --git a/mne/stats/cluster_level.py b/mne/stats/cluster_level.py index b753dd04896..a4bd2a36963 100644 --- a/mne/stats/cluster_level.py +++ b/mne/stats/cluster_level.py @@ -13,6 +13,7 @@ ProgressBar, _check_option, _check_rng_compat, + _legacy_rng, _pl, _validate_type, logger, @@ -1100,6 +1101,49 @@ def _check_fun(X, stat_fun, threshold, tail=0, kind="within"): return stat_fun, threshold +def _permutation_cluster_test_normalized( + X, + threshold, + n_permutations, + tail, + stat_fun, + adjacency, + n_jobs, + rng, + max_step, + exclude, + step_down_p, + t_power, + out_type, + check_disjoint, + buffer_size, + *, + kind, +): + """Run a cluster test with an already-normalized random generator.""" + stat_fun, threshold = _check_fun(X, stat_fun, threshold, tail, kind) + if kind == "within": + X = [X] + return _permutation_cluster_test( + X=X, + threshold=threshold, + n_permutations=n_permutations, + tail=tail, + stat_fun=stat_fun, + adjacency=adjacency, + n_jobs=n_jobs, + rng=rng, + max_step=max_step, + exclude=exclude, + step_down_p=step_down_p, + t_power=t_power, + out_type=out_type, + check_disjoint=check_disjoint, + buffer_size=buffer_size, + ) + + +@_legacy_rng("seed") @verbose def permutation_cluster_test( X, @@ -1151,8 +1195,7 @@ def permutation_cluster_test( %(stat_fun_clust_f)s %(adjacency_clust_n)s %(n_jobs)s - seed : None | int | instance of ~numpy.random.RandomState - Deprecated. Use ``rng`` instead. + %(seed_deprecated)s %(max_step_clust)s %(exclude_clust)s %(step_down_p_clust)s @@ -1182,27 +1225,28 @@ def permutation_cluster_test( ---------- .. footbibliography:: """ - stat_fun, threshold = _check_fun(X, stat_fun, threshold, tail, "between") rng = _check_rng_compat(rng, legacy=seed, legacy_name="seed") - return _permutation_cluster_test( - X=X, - threshold=threshold, - n_permutations=n_permutations, - tail=tail, - stat_fun=stat_fun, - adjacency=adjacency, - n_jobs=n_jobs, - rng=rng, - max_step=max_step, - exclude=exclude, - step_down_p=step_down_p, - t_power=t_power, - out_type=out_type, - check_disjoint=check_disjoint, - buffer_size=buffer_size, + return _permutation_cluster_test_normalized( + X, + threshold, + n_permutations, + tail, + stat_fun, + adjacency, + n_jobs, + rng, + max_step, + exclude, + step_down_p, + t_power, + out_type, + check_disjoint, + buffer_size, + kind="between", ) +@_legacy_rng("seed") @verbose def permutation_cluster_1samp_test( X, @@ -1243,8 +1287,7 @@ def permutation_cluster_1samp_test( %(stat_fun_clust_t)s %(adjacency_clust_1)s %(n_jobs)s - seed : None | int | instance of ~numpy.random.RandomState - Deprecated. Use ``rng`` instead. + %(seed_deprecated)s %(max_step_clust)s %(exclude_clust)s %(step_down_p_clust)s @@ -1297,27 +1340,28 @@ def permutation_cluster_1samp_test( ---------- .. footbibliography:: """ - stat_fun, threshold = _check_fun(X, stat_fun, threshold, tail) rng = _check_rng_compat(rng, legacy=seed, legacy_name="seed") - return _permutation_cluster_test( - X=[X], - threshold=threshold, - n_permutations=n_permutations, - tail=tail, - stat_fun=stat_fun, - adjacency=adjacency, - n_jobs=n_jobs, - rng=rng, - max_step=max_step, - exclude=exclude, - step_down_p=step_down_p, - t_power=t_power, - out_type=out_type, - check_disjoint=check_disjoint, - buffer_size=buffer_size, + return _permutation_cluster_test_normalized( + X, + threshold, + n_permutations, + tail, + stat_fun, + adjacency, + n_jobs, + rng, + max_step, + exclude, + step_down_p, + t_power, + out_type, + check_disjoint, + buffer_size, + kind="within", ) +@_legacy_rng("seed") @verbose def spatio_temporal_cluster_1samp_test( X, @@ -1361,8 +1405,7 @@ def spatio_temporal_cluster_1samp_test( %(stat_fun_clust_t)s %(adjacency_clust_st1)s %(n_jobs)s - seed : None | int | instance of ~numpy.random.RandomState - Deprecated. Use ``rng`` instead. + %(seed_deprecated)s %(max_step_clust)s spatial_exclude : list of int or None List of spatial indices to exclude from clustering. @@ -1401,25 +1444,27 @@ def spatio_temporal_cluster_1samp_test( else: exclude = None rng = _check_rng_compat(rng, legacy=seed, legacy_name="seed") - return permutation_cluster_1samp_test( + return _permutation_cluster_test_normalized( X, - threshold=threshold, - stat_fun=stat_fun, - tail=tail, - n_permutations=n_permutations, - adjacency=adjacency, - n_jobs=n_jobs, - rng=rng, - max_step=max_step, - exclude=exclude, - step_down_p=step_down_p, - t_power=t_power, - out_type=out_type, - check_disjoint=check_disjoint, - buffer_size=buffer_size, + threshold, + n_permutations, + tail, + stat_fun, + adjacency, + n_jobs, + rng, + max_step, + exclude, + step_down_p, + t_power, + out_type, + check_disjoint, + buffer_size, + kind="within", ) +@_legacy_rng("seed") @verbose def spatio_temporal_cluster_test( X, @@ -1465,8 +1510,7 @@ def spatio_temporal_cluster_test( %(stat_fun_clust_f)s %(adjacency_clust_stn)s %(n_jobs)s - seed : None | int | instance of ~numpy.random.RandomState - Deprecated. Use ``rng`` instead. + %(seed_deprecated)s %(max_step_clust)s spatial_exclude : list of int or None List of spatial indices to exclude from clustering. @@ -1505,22 +1549,23 @@ def spatio_temporal_cluster_test( else: exclude = None rng = _check_rng_compat(rng, legacy=seed, legacy_name="seed") - return permutation_cluster_test( + return _permutation_cluster_test_normalized( X, - threshold=threshold, - stat_fun=stat_fun, - tail=tail, - n_permutations=n_permutations, - adjacency=adjacency, - n_jobs=n_jobs, - rng=rng, - max_step=max_step, - exclude=exclude, - step_down_p=step_down_p, - t_power=t_power, - out_type=out_type, - check_disjoint=check_disjoint, - buffer_size=buffer_size, + threshold, + n_permutations, + tail, + stat_fun, + adjacency, + n_jobs, + rng, + max_step, + exclude, + step_down_p, + t_power, + out_type, + check_disjoint, + buffer_size, + kind="between", ) diff --git a/mne/stats/permutations.py b/mne/stats/permutations.py index 76883b8b12e..296f393b275 100644 --- a/mne/stats/permutations.py +++ b/mne/stats/permutations.py @@ -9,7 +9,14 @@ import numpy as np from ..parallel import parallel_func -from ..utils import _check_if_nan, _check_rng_compat, fill_doc, logger, verbose +from ..utils import ( + _check_if_nan, + _check_rng_compat, + _legacy_rng, + fill_doc, + logger, + verbose, +) def _max_stat(X, X2, perms, dof_scaling): @@ -21,6 +28,7 @@ def _max_stat(X, X2, perms, dof_scaling): return max_abs +@_legacy_rng("seed") @verbose def permutation_t_test( X, @@ -59,8 +67,7 @@ def permutation_t_test( than 0 (two tailed test). If tail is -1, the alternative hypothesis is that the mean of the data is less than 0 (lower tailed test). %(n_jobs)s - seed : None | int | instance of ~numpy.random.RandomState - Deprecated. Use ``rng`` instead. + %(seed_deprecated)s %(verbose)s %(rng)s @@ -114,6 +121,7 @@ def permutation_t_test( return T_obs, p_values, H0 +@_legacy_rng("random_state") @fill_doc def bootstrap_confidence_interval( arr, @@ -136,8 +144,7 @@ def bootstrap_confidence_interval( Number of bootstraps. stat_fun : str | callable Can be "mean", "median", or a callable operating along ``axis=0``. - random_state : int | float | array_like | None - Deprecated. Use ``rng`` instead. + %(random_state_deprecated)s %(rng)s Returns diff --git a/mne/stats/tests/test_cluster_level.py b/mne/stats/tests/test_cluster_level.py index ed9f4e203b1..ce1cd73c8e1 100644 --- a/mne/stats/tests/test_cluster_level.py +++ b/mne/stats/tests/test_cluster_level.py @@ -28,7 +28,7 @@ summarize_clusters_stc, ttest_1samp_no_p, ) -from mne.utils import _record_warnings, catch_logging +from mne.utils import _record_warnings, catch_logging, check_random_state n_space = 50 @@ -65,6 +65,33 @@ def test_cluster_rng_transition(): permutation_cluster_1samp_test(X, threshold=0, n_permutations=2, seed=0, rng=0) +def test_spatio_temporal_cluster_legacy_rng_nested(): + """Test legacy RNGs survive nested spatio-temporal wrappers.""" + rng = np.random.default_rng(0) + X = rng.standard_normal((8, 3, 1)) + cases = ( + (spatio_temporal_cluster_1samp_test, X), + (spatio_temporal_cluster_test, [X, rng.standard_normal(X.shape)]), + ) + for function, data in cases: + results = [] + for kind in ("int", "state"): + seed = 0 if kind == "int" else check_random_state(0) + with pytest.warns(FutureWarning, match="seed"): + results.append( + function( + data, + threshold=0, + n_permutations=2, + seed=seed, + out_type="mask", + ) + ) + assert_array_equal(results[0][0], results[1][0]) + assert_array_equal(results[0][2], results[1][2]) + assert_array_equal(results[0][3], results[1][3]) + + def test_thresholds(numba_conditional): """Test automatic threshold calculations.""" # within subjects diff --git a/mne/tests/test_docstring_parameters.py b/mne/tests/test_docstring_parameters.py index b20e415653e..6eecaff5d5b 100644 --- a/mne/tests/test_docstring_parameters.py +++ b/mne/tests/test_docstring_parameters.py @@ -305,7 +305,7 @@ def test_tabs(): # (``np.random.seed``/``np.random.randn``/...) makes tests order-dependent and # flaky, and the legacy ``RandomState`` methods below don't exist on a # ``Generator``, so calling them silently locks code to the old bit stream. -global_rng_ok = ("default_rng", "RandomState", "Generator", "mtrand") +global_rng_ok = ("default_rng", "Generator") legacy_rng_methods = { "randn": "standard_normal", "rand": "random", @@ -346,6 +346,82 @@ def test_tabs(): "spatio_temporal_cluster_1samp_test", "spatio_temporal_cluster_test", } +# Zero-based position of the legacy RNG argument when it can be positional. +mne_rng_legacy_positions = { + "add_ecg": 4, + "add_eog": 4, + "add_noise": 3, + "bootstrap": 1, + "bootstrap_confidence_interval": 4, + "infomax": 12, + "mixed_norm": 21, + "permutation_cluster_1samp_test": 7, + "permutation_cluster_test": 7, + "permutation_t_test": 4, + "random_parcellation": 5, + "random_permutation": 1, + "select_source_in_label": 2, + "select_sources": 7, + "simulate_evoked": 6, + "simulate_sparse_stc": 5, + "spatio_temporal_cluster_1samp_test": 7, + "spatio_temporal_cluster_test": 7, +} + +# These are compatibility implementations or reference fixtures whose expected +# values were generated from the legacy bit stream. Keep this allowlist at the +# function level so a new legacy RNG use elsewhere in the same file still fails. +legacy_rng_allowlist = { + ("mne/decoding/tests/test_csp.py", "test_ajd"), + ("mne/preprocessing/ica.py", "_serialize"), + ("mne/stats/tests/test_parametric.py", "generate_data"), + ("mne/tests/test_cov.py", "test_auto_low_rank_ignores_global_rng"), + ("mne/tests/test_dipole.py", "test_dipole_fitting"), + ("mne/utils/check.py", "_check_rng"), + ("mne/utils/check.py", "_legacy_rng_wrapper"), + ("mne/utils/check.py", "check_random_state"), + ("mne/utils/tests/test_check.py", "test_check_rng"), + ("mne/utils/tests/test_check.py", "test_check_rng_compat"), + ( + "mne/viz/tests/test_circle.py", + "test_plot_connectivity_circle_jitter_reproducible", + ), + ("mne/viz/tests/test_utils.py", "test_auto_scale_epoch_sampling_reproducible"), +} + + +def _enclosing_function(node, parents): + """Get the name of the function containing an AST node.""" + while node in parents: + node = parents[node] + if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef): + return node.name + return None + + +def _expected_legacy_mne_call(node, parents): + """Return whether a deprecated call explicitly checks its transition.""" + while node in parents: + node = parents[node] + if not isinstance(node, ast.With): + continue + for item in node.items: + context = item.context_expr + if not isinstance(context, ast.Call): + continue + name = getattr(context.func, "id", None) or getattr( + context.func, "attr", None + ) + if name not in ("raises", "warns") or not context.args: + continue + category = context.args[0] + category = getattr(category, "id", None) or getattr(category, "attr", None) + if (name, category) in ( + ("raises", "TypeError"), + ("warns", "FutureWarning"), + ): + return True + return False def _is_np_random(node): @@ -368,12 +444,33 @@ def test_no_global_rng(): continue for path in sorted(base.rglob("*.py")): rel = path.relative_to(root).as_posix() - for node in ast.walk(ast.parse(path.read_text("utf-8"))): + tree = ast.parse(path.read_text("utf-8")) + parents = { + child: parent + for parent in ast.walk(tree) + for child in ast.iter_child_nodes(parent) + } + import_aliases = { + alias.asname or alias.name: alias.name + for node in ast.walk(tree) + if isinstance(node, ast.ImportFrom) + for alias in node.names + } + for node in ast.walk(tree): + function = _enclosing_function(node, parents) + legacy_allowed = (rel, function) in legacy_rng_allowlist + called_name = None + if isinstance(node, ast.Call): + called_name = getattr(node.func, "id", None) or getattr( + node.func, "attr", None + ) + called_name = import_aliases.get(called_name, called_name) # 1. the global RNG: ``np.random.`` / ``numpy.random.`` if ( isinstance(node, ast.Attribute) and node.attr not in global_rng_ok and _is_np_random(node.value) + and not legacy_allowed ): bad.append( f"{rel}:{node.lineno}: np.random.{node.attr} " @@ -385,6 +482,7 @@ def test_no_global_rng(): and isinstance(node.func, ast.Attribute) and node.func.attr in legacy_rng_methods and not _is_np_random(node.func.value) + and not legacy_allowed ): want = legacy_rng_methods[node.func.attr] bad.append(f"{rel}:{node.lineno}: .{node.func.attr}() (use {want})") @@ -392,26 +490,34 @@ def test_no_global_rng(): elif ( "/tests/" not in rel and isinstance(node, ast.Call) - and isinstance(node.func, ast.Name) - and node.func.id in sklearn_rng_estimators + and called_name in sklearn_rng_estimators and not any(kw.arg == "random_state" for kw in node.keywords) ): bad.append( - f"{rel}:{node.lineno}: {node.func.id}() " + f"{rel}:{node.lineno}: {called_name}() " "(set random_state explicitly)" ) # 4. authored calls to deprecated MNE RNG parameters - elif "/tests/" not in rel and isinstance(node, ast.Call): - func_name = getattr(node.func, "id", None) or getattr( - node.func, "attr", None - ) + elif isinstance(node, ast.Call): legacy = {"random_state", "seed"}.intersection( kw.arg for kw in node.keywords ) - if func_name in mne_rng_functions and legacy: + legacy_position = mne_rng_legacy_positions.get(called_name) + legacy_positional = ( + legacy_position is not None and len(node.args) > legacy_position + ) + if ( + called_name in mne_rng_functions + and (legacy or legacy_positional) + and not _expected_legacy_mne_call(node, parents) + ): + spelling = ", ".join(sorted(legacy)) + if legacy_positional: + spelling = f"{spelling}, " if spelling else "" + spelling += "a positional legacy RNG" bad.append( - f"{rel}:{node.lineno}: {func_name}() uses " - f"{', '.join(sorted(legacy))} (use rng)" + f"{rel}:{node.lineno}: {called_name}() uses " + f"{spelling} (use rng)" ) if bad: raise AssertionError( diff --git a/mne/tests/test_epochs.py b/mne/tests/test_epochs.py index b80a12cae26..e6ec5e8b876 100644 --- a/mne/tests/test_epochs.py +++ b/mne/tests/test_epochs.py @@ -3024,6 +3024,76 @@ def test_equalize_epoch_counts_random(): assert len(epochs_1) == len(epochs_2) +def _make_equalization_epochs(lengths): + """Create small EpochsArray instances for RNG stream tests.""" + info = create_info(["EEG 001"], 100.0, "eeg") + epochs = [] + for index, length in enumerate(lengths): + events = np.column_stack( + (np.arange(length), np.zeros(length, int), np.full(length, index + 1)) + ) + epochs.append( + EpochsArray( + np.zeros((length, 1, 1)), + info, + events=events, + event_id={str(index): index + 1}, + verbose=False, + ) + ) + return epochs + + +def test_equalize_epoch_counts_rng_streams(): + """Test legacy integers re-seed while new RNG streams advance.""" + epochs = _make_equalization_epochs((3, 5, 6)) + with pytest.warns(FutureWarning, match="random_state"): + equalize_epoch_counts(epochs, method="random", random_state=0) + got = [ + np.flatnonzero([entry == ("EQUALIZED_COUNT",) for entry in epoch.drop_log]) + for epoch in epochs + ] + for this_got, want in zip(got, ([], [3, 4], [0, 3, 4])): + assert_array_equal(this_got, want) + + epochs = _make_equalization_epochs((3, 5, 6)) + equalize_epoch_counts(epochs, method="random", rng=0) + got = [ + np.flatnonzero([entry == ("EQUALIZED_COUNT",) for entry in epoch.drop_log]) + for epoch in epochs + ] + for this_got, want in zip(got, ([], [1, 2], [0, 1, 2])): + assert_array_equal(this_got, want) + + events = np.column_stack( + ( + np.arange(14), + np.zeros(14, int), + np.repeat((1, 2, 3), (3, 5, 6)), + ) + ) + epochs = EpochsArray( + np.zeros((14, 1, 1)), + create_info(["EEG 001"], 100.0, "eeg"), + events=events, + event_id={"a": 1, "b": 2, "c": 3}, + verbose=False, + ) + with pytest.warns(FutureWarning, match="random_state"): + _, dropped = epochs.equalize_event_counts(method="random", random_state=0) + assert_array_equal(dropped, [6, 7, 8, 11, 12]) + + epochs = EpochsArray( + np.zeros((14, 1, 1)), + create_info(["EEG 001"], 100.0, "eeg"), + events=events, + event_id={"a": 1, "b": 2, "c": 3}, + verbose=False, + ) + _, dropped = epochs.equalize_event_counts(method="random", rng=0) + assert_array_equal(dropped, [4, 5, 8, 9, 10]) + + def test_access_by_name(tmp_path): """Test accessing epochs by event name and on_missing for rare events.""" raw, events, picks = _get_data() diff --git a/mne/tests/test_label.py b/mne/tests/test_label.py index dc89992aebd..eca3cc5ee07 100644 --- a/mne/tests/test_label.py +++ b/mne/tests/test_label.py @@ -1213,6 +1213,14 @@ def test_select_sources(): assert label.hemi == "rh" +def test_select_sources_rng_conflict_at_center(): + """Test RNG spelling conflicts when random selection is inactive.""" + with pytest.raises(TypeError, match="only one"): + select_sources(None, None, location="center", random_state=0, rng=1) + with pytest.raises(TypeError, match="only one"): + select_sources(None, None, location="center", random_state=None, rng=None) + + @testing.requires_testing_data @pytest.mark.parametrize( "fname, area", diff --git a/mne/utils/__init__.pyi b/mne/utils/__init__.pyi index 24322f33595..ca1ab8a42be 100644 --- a/mne/utils/__init__.pyi +++ b/mne/utils/__init__.pyi @@ -54,6 +54,7 @@ __all__ = [ "_check_rank", "_check_rng", "_check_rng_compat", + "_legacy_rng", "_check_sphere", "_check_src_normal", "_check_stc_units", @@ -275,6 +276,7 @@ from .check import ( _import_nibabel, _import_pymatreader_funcs, _is_numeric, + _legacy_rng, _on_missing, _path_like, _require_version, diff --git a/mne/utils/check.py b/mne/utils/check.py index c1cd0abc2fc..8fddebad1b8 100644 --- a/mne/utils/check.py +++ b/mne/utils/check.py @@ -10,6 +10,7 @@ import re from builtins import input # noqa: A004, UP029 from difflib import get_close_matches +from functools import wraps from importlib import import_module from inspect import signature from pathlib import Path @@ -237,16 +238,63 @@ def _check_rng(rng): return np.random.default_rng(rng) +def _legacy_rng(legacy_name): + """Handle presence-sensitive legacy RNG parameters at the call boundary.""" + + def decorator(function): + parameters = signature(function).parameters + positional = [ + name + for name, parameter in parameters.items() + if parameter.kind + in (parameter.POSITIONAL_ONLY, parameter.POSITIONAL_OR_KEYWORD) + ] + legacy_position = ( + positional.index(legacy_name) if legacy_name in positional else None + ) + rng_position = positional.index("rng") if "rng" in positional else None + + @wraps(function) + def _legacy_rng_wrapper(*args, **kwargs): + legacy_in_args = legacy_position is not None and len(args) > legacy_position + legacy_in_kwargs = legacy_name in kwargs + rng_in_args = rng_position is not None and len(args) > rng_position + rng_in_kwargs = "rng" in kwargs + if (legacy_in_args and legacy_in_kwargs) or (rng_in_args and rng_in_kwargs): + return function(*args, **kwargs) + legacy_supplied = legacy_in_args or legacy_in_kwargs + rng_supplied = rng_in_args or rng_in_kwargs + if legacy_supplied and rng_supplied: + raise TypeError(f"Specify only one of rng or {legacy_name}") + if legacy_supplied: + warn( + f"{legacy_name} is deprecated and will be removed in a future " + "release; use rng instead.", + FutureWarning, + ) + if legacy_in_kwargs and kwargs[legacy_name] is None: + kwargs = kwargs.copy() + kwargs[legacy_name] = check_random_state(None) + elif legacy_in_args and args[legacy_position] is None: + args = list(args) + args[legacy_position] = check_random_state(None) + args = tuple(args) + elif rng_supplied: + rng = args[rng_position] if rng_in_args else kwargs["rng"] + if isinstance(rng, np.random.mtrand.RandomState): + raise TypeError("rng must not be a RandomState") + return function(*args, **kwargs) + + return _legacy_rng_wrapper + + return decorator + + def _check_rng_compat(rng, *, legacy=None, legacy_name): """Check an RNG while temporarily supporting a legacy parameter.""" if legacy is not None: if rng is not None: raise TypeError(f"Specify only one of rng or {legacy_name}") - warn( - f"{legacy_name} is deprecated and will be removed in a future release; " - "use rng instead.", - FutureWarning, - ) return check_random_state(legacy) return _check_rng(rng) diff --git a/mne/utils/docs.py b/mne/utils/docs.py index 5e702069219..ca651c15823 100644 --- a/mne/utils/docs.py +++ b/mne/utils/docs.py @@ -3760,11 +3760,21 @@ def _reflow_param_docstring(docstring, has_first_line=True, width=75): docdict["random_state"] = """ random_state : None | int | instance of ~numpy.random.RandomState A seed for the NumPy random number generator (RNG). If ``None`` (default), - the seed will be obtained from the operating system - (see :class:`~numpy.random.RandomState` for details), meaning it will most - likely produce different output every time this function or method is run. - To achieve reproducible results, pass a value here to explicitly initialize - the RNG with a defined state. + NumPy's global :class:`~numpy.random.RandomState` singleton is used. + Pass an int to use a new ``RandomState`` seeded with that value, or a + ``RandomState`` to control the random-number stream. +""" + +docdict["random_state_deprecated"] = """ +random_state : None | int | instance of ~numpy.random.RandomState + The legacy random-number control. If explicitly passed as ``None``, NumPy's + global :class:`~numpy.random.RandomState` singleton is used. An int creates + a legacy ``RandomState`` seeded with that value. Passing the same int to + ``rng`` uses :func:`numpy.random.default_rng` and produces a different + stream. If both parameters are omitted, a fresh ``Generator`` is used. + + .. deprecated:: 1.13 + Use ``rng`` instead. """ _rank_base = """ @@ -4039,7 +4049,11 @@ def _reflow_param_docstring(docstring, has_first_line=True, width=75): The random number generator. If ``None`` (default), a new generator seeded from entropy is used. Pass a seed accepted by :func:`numpy.random.default_rng` for reproducible results, or a :class:`numpy.random.Generator` to control the - random-number stream. + random-number stream. An integer seed uses ``default_rng`` and therefore + produces a different stream than the same integer passed to a legacy + ``random_state`` or ``seed`` parameter. + + .. versionadded:: 1.13 """ docdict["roll"] = """ @@ -4135,11 +4149,21 @@ def _reflow_param_docstring(docstring, has_first_line=True, width=75): docdict["seed"] = """ seed : None | int | instance of ~numpy.random.RandomState A seed for the NumPy random number generator (RNG). If ``None`` (default), - the seed will be obtained from the operating system - (see :class:`~numpy.random.RandomState` for details), meaning it will most - likely produce different output every time this function or method is run. - To achieve reproducible results, pass a value here to explicitly initialize - the RNG with a defined state. + NumPy's global :class:`~numpy.random.RandomState` singleton is used. + Pass an int to use a new ``RandomState`` seeded with that value, or a + ``RandomState`` to control the random-number stream. +""" + +docdict["seed_deprecated"] = """ +seed : None | int | instance of ~numpy.random.RandomState + The legacy random-number control. If explicitly passed as ``None``, NumPy's + global :class:`~numpy.random.RandomState` singleton is used. An int creates + a legacy ``RandomState`` seeded with that value. Passing the same int to + ``rng`` uses :func:`numpy.random.default_rng` and produces a different + stream. If both parameters are omitted, a fresh ``Generator`` is used. + + .. deprecated:: 1.13 + Use ``rng`` instead. """ docdict["seeg"] = """ diff --git a/mne/utils/numerics.py b/mne/utils/numerics.py index 73918f84018..b899a7be3cc 100644 --- a/mne/utils/numerics.py +++ b/mne/utils/numerics.py @@ -28,6 +28,7 @@ from .check import ( _check_rng_compat, _ensure_int, + _legacy_rng, _validate_type, ) from .docs import fill_doc @@ -265,6 +266,7 @@ def compute_corr(x, y): return (np.dot(X.T, Y) / float(len(X) - 1)) / (x_sd * y_sd) +@_legacy_rng("random_state") @fill_doc def random_permutation(n_samples, random_state=None, *, rng=None): """Emulate the randperm matlab function. @@ -288,7 +290,7 @@ def random_permutation(n_samples, random_state=None, *, rng=None): n_samples : int End point of the sequence to be permuted (excluded, i.e., the end point is equal to n_samples-1) - %(random_state)s + %(random_state_deprecated)s %(rng)s Returns @@ -297,6 +299,11 @@ def random_permutation(n_samples, random_state=None, *, rng=None): Randomly permuted sequence between 0 and n-1. """ rng = _check_rng_compat(rng, legacy=random_state, legacy_name="random_state") + return _random_permutation(n_samples, rng) + + +def _random_permutation(n_samples, rng): + """Generate a MATLAB-compatible permutation with a normalized RNG.""" # This can't just be rng.permutation(n_samples) because it's not identical # to what MATLAB produces idx = rng.uniform(size=n_samples) diff --git a/mne/utils/tests/test_check.py b/mne/utils/tests/test_check.py index 29a6c5368be..d68d21a3149 100644 --- a/mne/utils/tests/test_check.py +++ b/mne/utils/tests/test_check.py @@ -67,8 +67,7 @@ def test_check_rng(): def test_check_rng_compat(): """Test compatibility with legacy random-number parameters.""" - with pytest.warns(FutureWarning, match="seed"): - rng = _check_rng_compat(None, legacy=0, legacy_name="seed") + rng = _check_rng_compat(None, legacy=0, legacy_name="seed") assert isinstance(rng, np.random.RandomState) assert isinstance(_check_rng_compat(None, legacy_name="seed"), np.random.Generator) with pytest.raises(TypeError, match="rng"): diff --git a/mne/utils/tests/test_numerics.py b/mne/utils/tests/test_numerics.py index 81871b0d058..c117d97852c 100644 --- a/mne/utils/tests/test_numerics.py +++ b/mne/utils/tests/test_numerics.py @@ -4,6 +4,7 @@ from copy import deepcopy from datetime import date +from inspect import signature from io import StringIO from pathlib import Path @@ -33,6 +34,7 @@ _time_mask, _undo_scaling_array, _undo_scaling_cov, + check_random_state, compute_corr, create_slices, grand_average, @@ -237,6 +239,34 @@ def test_random_permutation(): random_permutation(n_samples, random_state=42, rng=42) +@pytest.mark.parametrize("use_keyword", (False, True)) +def test_random_permutation_legacy_none(use_keyword): + """Test explicit legacy None uses NumPy's global RandomState.""" + global_rng = check_random_state(None) + original_state = global_rng.get_state() + seeded_state = check_random_state(42).get_state() + want = np.array([6, 5, 4, 0, 3, 8, 9, 2, 7, 1]) + try: + global_rng.set_state(seeded_state) + with pytest.warns(FutureWarning, match="random_state"): + if use_keyword: + got = random_permutation(10, random_state=None) + else: + got = random_permutation(10, None) + assert_array_equal(got, want) + finally: + global_rng.set_state(original_state) + + with pytest.raises(TypeError, match="only one"): + random_permutation(10, None, rng=None) + with pytest.raises(TypeError, match="only one"): + random_permutation(10, random_state=None, rng=0) + assert ( + str(signature(random_permutation)) + == "(n_samples, random_state=None, *, rng=None)" + ) + + def test_cov_scaling(): """Test rescaling covs.""" evoked = read_evokeds(ave_fname, condition=0, baseline=(None, 0), proj=True) diff --git a/mne/viz/tests/test_ica.py b/mne/viz/tests/test_ica.py index f72ea7af3c2..332b9d10805 100644 --- a/mne/viz/tests/test_ica.py +++ b/mne/viz/tests/test_ica.py @@ -326,7 +326,7 @@ def test_plot_ica_properties_reject(kind): raw.set_montage("spherical_1005") ica = ICA( n_components=2, - random_state=0, + rng=0, max_iter=1, ) with ( @@ -571,7 +571,7 @@ def test_plot_ica_overlay(): picks = pick_types(raw.info, meg=True, ref_meg=False) ica = ICA( n_components=2, - random_state=0, + rng=0, ) ica.fit(raw, picks=picks) with pytest.warns(RuntimeWarning, match="longer than"): From 59bae3b2233c10a20a3db79acecd91e642936863 Mon Sep 17 00:00:00 2001 From: Bru Date: Mon, 24 Aug 2026 16:11:03 +0200 Subject: [PATCH 16/34] FIX: Honor Infomax verbosity and RNG aliases --- mne/preprocessing/infomax_.py | 4 +- mne/preprocessing/tests/test_ica.py | 20 +++ mne/stats/cluster_level.py | 6 +- mne/stats/permutations.py | 4 +- mne/tests/test_docstring_parameters.py | 203 +++++++++++++++---------- 5 files changed, 152 insertions(+), 85 deletions(-) diff --git a/mne/preprocessing/infomax_.py b/mne/preprocessing/infomax_.py index ff7bd0372b7..7d1f8983a53 100644 --- a/mne/preprocessing/infomax_.py +++ b/mne/preprocessing/infomax_.py @@ -7,11 +7,11 @@ import numpy as np from scipy.special import expit -from ..utils import _check_rng_compat, _legacy_rng, fill_doc, logger, verbose +from ..utils import _check_rng_compat, _legacy_rng, logger, verbose from ..utils.numerics import _random_permutation -@fill_doc +@verbose def _infomax( data, weights=None, diff --git a/mne/preprocessing/tests/test_ica.py b/mne/preprocessing/tests/test_ica.py index 644ab964929..553b327c033 100644 --- a/mne/preprocessing/tests/test_ica.py +++ b/mne/preprocessing/tests/test_ica.py @@ -315,6 +315,26 @@ def test_ica_rng_transition(): assert_array_equal(unmixings[0], unmixings[1]) +def test_ica_infomax_fit_params_verbose(): + """Test Infomax fit_params can suppress its private logging scope.""" + info = create_info(["Fz", "Cz", "Pz"], 100.0, "eeg") + with info._unlock(): + info["highpass"] = 1.0 + raw = RawArray(np.random.default_rng(0).standard_normal((3, 200)), info) + ica = _ICA( + n_components=2, + method="infomax", + fit_params={"verbose": False}, + max_iter=1, + rng=0, + ) + with catch_logging(True) as log: + ica.fit(raw, verbose=True) + log = log.getvalue() + assert "Fitting ICA to data" in log + assert "Computing Infomax ICA" not in log + + @pytest.mark.parametrize("method", ["infomax", "fastica", "picard"]) def test_ica_n_iter_(method, tmp_path): """Test that ICA.n_iter_ is set after fitting.""" diff --git a/mne/stats/cluster_level.py b/mne/stats/cluster_level.py index a4bd2a36963..b6726d779e5 100644 --- a/mne/stats/cluster_level.py +++ b/mne/stats/cluster_level.py @@ -1327,9 +1327,9 @@ def permutation_cluster_1samp_test( %(threshold_clust_t_notes)s If ``n_permutations`` exceeds the maximum number of possible permutations - given the number of observations, then ``n_permutations`` and ``seed`` - will be ignored since an exact test (full permutation test) will be - performed (this is the case when + given the number of observations, then ``n_permutations``, ``seed``, and + ``rng`` will be ignored since an exact test (full permutation test) will + be performed (this is the case when ``n_permutations >= 2 ** (n_observations - (tail == 0))``). If no initial clusters are found because all points in the true diff --git a/mne/stats/permutations.py b/mne/stats/permutations.py index 296f393b275..60a36ac0ffb 100644 --- a/mne/stats/permutations.py +++ b/mne/stats/permutations.py @@ -84,8 +84,8 @@ def permutation_t_test( Notes ----- If ``n_permutations >= 2 ** (n_samples - (tail == 0))``, - ``n_permutations`` and ``seed`` will be ignored since an exact test - (full permutation test) will be performed. + ``n_permutations``, ``seed``, and ``rng`` will be ignored since an exact + test (full permutation test) will be performed. References ---------- diff --git a/mne/tests/test_docstring_parameters.py b/mne/tests/test_docstring_parameters.py index 6eecaff5d5b..40a52e64837 100644 --- a/mne/tests/test_docstring_parameters.py +++ b/mne/tests/test_docstring_parameters.py @@ -424,14 +424,135 @@ def _expected_legacy_mne_call(node, parents): return False -def _is_np_random(node): +def _is_np_random(node, numpy_aliases, numpy_random_aliases): """Return whether ``node`` is the ``np.random`` module attribute.""" return ( isinstance(node, ast.Attribute) and node.attr == "random" and isinstance(node.value, ast.Name) - and node.value.id in ("np", "numpy") - ) + and node.value.id in numpy_aliases + ) or (isinstance(node, ast.Name) and node.id in numpy_random_aliases) + + +def _rng_violations(source, rel): + """Find outdated RNG use in Python source.""" + bad = [] + tree = ast.parse(source) + parents = { + child: parent + for parent in ast.walk(tree) + for child in ast.iter_child_nodes(parent) + } + import_aliases = { + alias.asname or alias.name: alias.name + for node in ast.walk(tree) + if isinstance(node, ast.ImportFrom) + for alias in node.names + } + numpy_aliases = {"np", "numpy"} + numpy_random_aliases = set() + random_state_aliases = set() + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + if alias.name == "numpy": + numpy_aliases.add(alias.asname or alias.name) + elif alias.name == "numpy.random" and alias.asname: + numpy_random_aliases.add(alias.asname) + elif isinstance(node, ast.ImportFrom): + for alias in node.names: + if node.module == "numpy" and alias.name == "random": + numpy_random_aliases.add(alias.asname or alias.name) + elif node.module == "numpy.random" and alias.name == "RandomState": + random_state_aliases.add(alias.asname or alias.name) + for node in ast.walk(tree): + function = _enclosing_function(node, parents) + legacy_allowed = (rel, function) in legacy_rng_allowlist + called_name = None + if isinstance(node, ast.Call): + called_name = getattr(node.func, "id", None) or getattr( + node.func, "attr", None + ) + called_name = import_aliases.get(called_name, called_name) + # 1. the global RNG: ``np.random.`` / ``numpy.random.`` + if ( + isinstance(node, ast.Attribute) + and node.attr not in global_rng_ok + and _is_np_random(node.value, numpy_aliases, numpy_random_aliases) + and not legacy_allowed + ): + bad.append( + f"{rel}:{node.lineno}: np.random.{node.attr} " + "(use a local np.random.default_rng)" + ) + # 2. imported legacy RandomState constructors + elif ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id in random_state_aliases + and not legacy_allowed + ): + bad.append( + f"{rel}:{node.lineno}: numpy.random.RandomState " + "(use a local np.random.default_rng)" + ) + # 3. legacy RandomState-only methods, e.g. ``rng.randn(...)`` + elif ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and node.func.attr in legacy_rng_methods + and not _is_np_random(node.func.value, numpy_aliases, numpy_random_aliases) + and not legacy_allowed + ): + want = legacy_rng_methods[node.func.attr] + bad.append(f"{rel}:{node.lineno}: .{node.func.attr}() (use {want})") + # 4. MNE-owned sklearn estimators with implicit randomness + elif ( + "/tests/" not in rel + and isinstance(node, ast.Call) + and called_name in sklearn_rng_estimators + and not any(kw.arg == "random_state" for kw in node.keywords) + ): + bad.append( + f"{rel}:{node.lineno}: {called_name}() (set random_state explicitly)" + ) + # 5. authored calls to deprecated MNE RNG parameters + elif isinstance(node, ast.Call): + legacy = {"random_state", "seed"}.intersection( + kw.arg for kw in node.keywords + ) + legacy_position = mne_rng_legacy_positions.get(called_name) + legacy_positional = ( + legacy_position is not None and len(node.args) > legacy_position + ) + if ( + called_name in mne_rng_functions + and (legacy or legacy_positional) + and not _expected_legacy_mne_call(node, parents) + ): + spelling = ", ".join(sorted(legacy)) + if legacy_positional: + spelling = f"{spelling}, " if spelling else "" + spelling += "a positional legacy RNG" + bad.append( + f"{rel}:{node.lineno}: {called_name}() uses {spelling} (use rng)" + ) + return bad + + +@pytest.mark.parametrize( + "source", + ( + "from numpy.random import RandomState as RS\nRS(0)\n", + "import numpy.random as npr\nnpr.RandomState(0)\n", + ), + ids=("from-import", "module-alias"), +) +def test_no_aliased_random_state(source): + """Test that aliased legacy RNG constructors are rejected.""" + bad = _rng_violations(source, "mne/tests/rng_alias_probe.py") + assert len(bad) == 1 + assert "RandomState" in bad[0] def test_no_global_rng(): @@ -444,81 +565,7 @@ def test_no_global_rng(): continue for path in sorted(base.rglob("*.py")): rel = path.relative_to(root).as_posix() - tree = ast.parse(path.read_text("utf-8")) - parents = { - child: parent - for parent in ast.walk(tree) - for child in ast.iter_child_nodes(parent) - } - import_aliases = { - alias.asname or alias.name: alias.name - for node in ast.walk(tree) - if isinstance(node, ast.ImportFrom) - for alias in node.names - } - for node in ast.walk(tree): - function = _enclosing_function(node, parents) - legacy_allowed = (rel, function) in legacy_rng_allowlist - called_name = None - if isinstance(node, ast.Call): - called_name = getattr(node.func, "id", None) or getattr( - node.func, "attr", None - ) - called_name = import_aliases.get(called_name, called_name) - # 1. the global RNG: ``np.random.`` / ``numpy.random.`` - if ( - isinstance(node, ast.Attribute) - and node.attr not in global_rng_ok - and _is_np_random(node.value) - and not legacy_allowed - ): - bad.append( - f"{rel}:{node.lineno}: np.random.{node.attr} " - "(use a local np.random.default_rng)" - ) - # 2. legacy RandomState-only methods, e.g. ``rng.randn(...)`` - elif ( - isinstance(node, ast.Call) - and isinstance(node.func, ast.Attribute) - and node.func.attr in legacy_rng_methods - and not _is_np_random(node.func.value) - and not legacy_allowed - ): - want = legacy_rng_methods[node.func.attr] - bad.append(f"{rel}:{node.lineno}: .{node.func.attr}() (use {want})") - # 3. MNE-owned sklearn estimators with implicit randomness - elif ( - "/tests/" not in rel - and isinstance(node, ast.Call) - and called_name in sklearn_rng_estimators - and not any(kw.arg == "random_state" for kw in node.keywords) - ): - bad.append( - f"{rel}:{node.lineno}: {called_name}() " - "(set random_state explicitly)" - ) - # 4. authored calls to deprecated MNE RNG parameters - elif isinstance(node, ast.Call): - legacy = {"random_state", "seed"}.intersection( - kw.arg for kw in node.keywords - ) - legacy_position = mne_rng_legacy_positions.get(called_name) - legacy_positional = ( - legacy_position is not None and len(node.args) > legacy_position - ) - if ( - called_name in mne_rng_functions - and (legacy or legacy_positional) - and not _expected_legacy_mne_call(node, parents) - ): - spelling = ", ".join(sorted(legacy)) - if legacy_positional: - spelling = f"{spelling}, " if spelling else "" - spelling += "a positional legacy RNG" - bad.append( - f"{rel}:{node.lineno}: {called_name}() uses " - f"{spelling} (use rng)" - ) + bad.extend(_rng_violations(path.read_text("utf-8"), rel)) if bad: raise AssertionError( f"{len(bad)} outdated numpy RNG use{_pl(bad)} found:\n" + "\n".join(bad) From 6cb0b1705dfd94f3ad82e5a5732e640fe22ed56a Mon Sep 17 00:00:00 2001 From: Bru Date: Mon, 24 Aug 2026 16:40:11 +0200 Subject: [PATCH 17/34] MAINT: Allow lazy simulation API --- tools/vulture_allowlist.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tools/vulture_allowlist.py b/tools/vulture_allowlist.py index a42a635ef20..811116d9adb 100644 --- a/tools/vulture_allowlist.py +++ b/tools/vulture_allowlist.py @@ -65,6 +65,7 @@ # Backward compat or rarely used RawFIF +select_source_in_label estimate_head_mri_t plot_epochs_psd_topomap plot_epochs_psd From f587781bd26ea4cefd50ca91ae14399e32861df7 Mon Sep 17 00:00:00 2001 From: Bru Date: Mon, 24 Aug 2026 17:30:42 +0200 Subject: [PATCH 18/34] FIX: Preserve integer RNG seeds at ICA boundaries --- examples/inverse/mixed_norm_inverse.py | 2 +- mne/preprocessing/ica.py | 10 ++++++++-- mne/preprocessing/tests/test_ica.py | 12 +++++++++--- mne/tests/test_dipole.py | 5 ++++- 4 files changed, 22 insertions(+), 7 deletions(-) diff --git a/examples/inverse/mixed_norm_inverse.py b/examples/inverse/mixed_norm_inverse.py index 9573c02a2d5..c36e747d510 100644 --- a/examples/inverse/mixed_norm_inverse.py +++ b/examples/inverse/mixed_norm_inverse.py @@ -84,7 +84,7 @@ rng=0, # for this dataset we know we should use a high alpha, so avoid some # of the slower (lower) alpha values - sure_alpha_grid=np.linspace(100, 40, 10), + sure_alpha_grid=np.linspace(90, 30, 10), ) t = 0.083 diff --git a/mne/preprocessing/ica.py b/mne/preprocessing/ica.py index e458392a4dc..1f891195ad9 100644 --- a/mne/preprocessing/ica.py +++ b/mne/preprocessing/ica.py @@ -902,7 +902,10 @@ def _fit(self, data, fit_type): rng = getattr(self, "rng", None) if self.random_state is None: - rng = _check_rng(rng) + # Keep integer seeds intact for third-party ``random_state`` + # parameters, but use an independent Generator for the default. + if rng is None: + rng = _check_rng(None) else: rng = check_random_state(self.random_state) n_channels, n_samples = data.shape @@ -979,9 +982,12 @@ def _fit(self, data, fit_type): self.unmixing_matrix_ = ica.components_ self.n_iter_ = ica.n_iter_ elif self.method in ("infomax", "extended-infomax"): + infomax_rng = ( + rng if isinstance(rng, np.random.RandomState) else _check_rng(rng) + ) unmixing_matrix, n_iter = _infomax( data[:, sel], - rng=rng, + rng=infomax_rng, return_n_iter=True, **self.fit_params, ) diff --git a/mne/preprocessing/tests/test_ica.py b/mne/preprocessing/tests/test_ica.py index 553b327c033..8ed4f01b41a 100644 --- a/mne/preprocessing/tests/test_ica.py +++ b/mne/preprocessing/tests/test_ica.py @@ -306,13 +306,19 @@ def test_ica_rng_transition(): with pytest.warns(FutureWarning, match="random_state"): ica = _ICA( n_components=2, - method="infomax", - max_iter=1, + method="fastica", + max_iter=1000, random_state=random_state, ) - ica.fit(raw) + with _record_warnings(): # ICA does not necessarily converge + ica.fit(raw) unmixings.append(ica.unmixing_matrix_) + ica = _ICA(n_components=2, method="fastica", max_iter=1000, rng=0) + with _record_warnings(): # ICA does not necessarily converge + ica.fit(raw) + unmixings.append(ica.unmixing_matrix_) assert_array_equal(unmixings[0], unmixings[1]) + assert_array_equal(unmixings[0], unmixings[2]) def test_ica_infomax_fit_params_verbose(): diff --git a/mne/tests/test_dipole.py b/mne/tests/test_dipole.py index 17e6d70f831..8a558d435d1 100644 --- a/mne/tests/test_dipole.py +++ b/mne/tests/test_dipole.py @@ -141,7 +141,10 @@ def test_dipole_fitting(tmp_path): vertices = [np.sort(rng.permutation(s["vertno"])[:n_per_hemi]) for s in fwd["src"]] nv = sum(len(v) for v in vertices) stc = SourceEstimate(amp * np.eye(nv), vertices, 0, 0.001) - evoked = simulate_evoked(fwd, stc, evoked.info, cov, nave=evoked.nave, rng=rng) + with pytest.warns(FutureWarning, match="random_state"): + evoked = simulate_evoked( + fwd, stc, evoked.info, cov, nave=evoked.nave, random_state=rng + ) # For speed, let's use a subset of channels (strange but works) picks = np.sort( np.concatenate( From 25c248766b06b37df9754b21c51f7d43b63518a4 Mon Sep 17 00:00:00 2001 From: Bru Date: Mon, 24 Aug 2026 22:09:37 +0200 Subject: [PATCH 19/34] FIX: Support legacy RandomState in rng parameters scikit-learn accepts RandomState but not Generator instances (check_random_state, FastICA, KFold all reject generators), so the new rng parameter must pass legacy RandomState instances through unchanged. Simplifies the ICA infomax branch accordingly, which also removes the last np.random.RandomState spelling flagged by test_no_global_rng. --- doc/changes/dev/14199.apichange.rst | 2 +- mne/preprocessing/ica.py | 5 +---- mne/utils/check.py | 13 +++++++------ mne/utils/docs.py | 17 ++++++++++------- mne/utils/tests/test_check.py | 7 +++++-- 5 files changed, 24 insertions(+), 20 deletions(-) diff --git a/doc/changes/dev/14199.apichange.rst b/doc/changes/dev/14199.apichange.rst index 5ccbb5332e4..4990b353eb8 100644 --- a/doc/changes/dev/14199.apichange.rst +++ b/doc/changes/dev/14199.apichange.rst @@ -1 +1 @@ -Add keyword-only ``rng`` parameters backed by :class:`numpy.random.Generator` to statistical, epoch-sampling, simulation, label, ICA, and sparse-inverse APIs, with deprecated ``seed`` and ``random_state`` compatibility paths, by `Bruno Aristimunha`_ (:gh:`9233`). Omitting both parameters now creates a fresh generator, whereas explicitly passing ``None`` to a legacy parameter retains NumPy's global :class:`~numpy.random.RandomState` stream. An integer passed to ``rng`` uses :func:`numpy.random.default_rng`, so it intentionally produces different results from the same integer passed to a legacy parameter. +Add keyword-only ``rng`` parameters backed by :class:`numpy.random.Generator` to statistical, epoch-sampling, simulation, label, ICA, and sparse-inverse APIs, with deprecated ``seed`` and ``random_state`` compatibility paths, by `Bruno Aristimunha`_ (:gh:`9233`). Omitting both parameters now creates a fresh generator, whereas explicitly passing ``None`` to a legacy parameter retains NumPy's global :class:`~numpy.random.RandomState` stream. An integer passed to ``rng`` uses :func:`numpy.random.default_rng`, so it intentionally produces different results from the same integer passed to a legacy parameter. Legacy :class:`~numpy.random.RandomState` instances are also accepted by ``rng`` (passed through unchanged) for interoperability with third-party code such as scikit-learn that does not support generators. diff --git a/mne/preprocessing/ica.py b/mne/preprocessing/ica.py index 1f891195ad9..0e0e4e82b6c 100644 --- a/mne/preprocessing/ica.py +++ b/mne/preprocessing/ica.py @@ -982,12 +982,9 @@ def _fit(self, data, fit_type): self.unmixing_matrix_ = ica.components_ self.n_iter_ = ica.n_iter_ elif self.method in ("infomax", "extended-infomax"): - infomax_rng = ( - rng if isinstance(rng, np.random.RandomState) else _check_rng(rng) - ) unmixing_matrix, n_iter = _infomax( data[:, sel], - rng=infomax_rng, + rng=_check_rng(rng), return_n_iter=True, **self.fit_params, ) diff --git a/mne/utils/check.py b/mne/utils/check.py index 8fddebad1b8..2918b26ea5c 100644 --- a/mne/utils/check.py +++ b/mne/utils/check.py @@ -232,9 +232,14 @@ def check_random_state(seed): def _check_rng(rng): - """Return a NumPy Generator for new random-number paths.""" + """Return a NumPy Generator, or a legacy RandomState unchanged. + + Legacy RandomState instances are accepted for interoperability with + third-party code such as scikit-learn that does not accept Generator + instances. + """ if isinstance(rng, np.random.mtrand.RandomState): - raise TypeError("rng must not be a RandomState") + return rng return np.random.default_rng(rng) @@ -279,10 +284,6 @@ def _legacy_rng_wrapper(*args, **kwargs): args = list(args) args[legacy_position] = check_random_state(None) args = tuple(args) - elif rng_supplied: - rng = args[rng_position] if rng_in_args else kwargs["rng"] - if isinstance(rng, np.random.mtrand.RandomState): - raise TypeError("rng must not be a RandomState") return function(*args, **kwargs) return _legacy_rng_wrapper diff --git a/mne/utils/docs.py b/mne/utils/docs.py index ca651c15823..d4df5f33ef5 100644 --- a/mne/utils/docs.py +++ b/mne/utils/docs.py @@ -4045,13 +4045,16 @@ def _reflow_param_docstring(docstring, has_first_line=True, width=75): """ docdict["rng"] = """ -rng : None | seed accepted by numpy.random.default_rng - The random number generator. If ``None`` (default), a new generator seeded - from entropy is used. Pass a seed accepted by :func:`numpy.random.default_rng` - for reproducible results, or a :class:`numpy.random.Generator` to control the - random-number stream. An integer seed uses ``default_rng`` and therefore - produces a different stream than the same integer passed to a legacy - ``random_state`` or ``seed`` parameter. +rng : None | int | instance of ~numpy.random.Generator | ~numpy.random.RandomState + The random number generator (RNG). If ``None`` (default), a new + :class:`numpy.random.Generator` seeded from entropy is used. Pass an int or + a :class:`numpy.random.Generator` for reproducible results, or a legacy + :class:`~numpy.random.RandomState` to control the random-number stream or + for interoperability with third-party code such as scikit-learn that does + not accept generators. An integer seed uses + :func:`numpy.random.default_rng` and therefore produces a different stream + than the same integer passed to a legacy ``random_state`` or ``seed`` + parameter. .. versionadded:: 1.13 """ diff --git a/mne/utils/tests/test_check.py b/mne/utils/tests/test_check.py index d68d21a3149..5e560f0fa34 100644 --- a/mne/utils/tests/test_check.py +++ b/mne/utils/tests/test_check.py @@ -61,8 +61,11 @@ def test_check_rng(): bit_generator = np.random.default_rng(0).bit_generator assert isinstance(_check_rng(bit_generator.seed_seq), np.random.Generator) assert isinstance(_check_rng(bit_generator), np.random.Generator) - with pytest.raises(TypeError): - _check_rng(np.random.RandomState(0)) + # legacy RandomState instances are passed through for scikit-learn interop + random_state = np.random.RandomState(0) + assert _check_rng(random_state) is random_state + with pytest.raises(TypeError, match="SeedSequence"): + _check_rng("foo") def test_check_rng_compat(): From c931567ed0ac20ac981eda308ac7ceff266c7ad1 Mon Sep 17 00:00:00 2001 From: Bru Date: Mon, 24 Aug 2026 22:33:50 +0200 Subject: [PATCH 20/34] MAINT: Consolidate RNG transition into single decorator The _legacy_rng decorator now normalizes and injects the rng keyword itself, making the redundant body-level _check_rng_compat calls (and the helper) unnecessary at all 21 transition sites. ICA.__init__ keeps explicit handling so integer rng seeds stay intact for third-party random_state parameters during fitting. --- mne/epochs.py | 4 -- mne/inverse_sparse/mxne_inverse.py | 2 - mne/label.py | 5 --- mne/preprocessing/ica.py | 12 +++++- mne/preprocessing/infomax_.py | 3 +- mne/preprocessing/tests/test_ica.py | 2 + mne/simulation/evoked.py | 3 -- mne/simulation/raw.py | 3 -- mne/simulation/source.py | 3 -- mne/stats/cluster_level.py | 5 --- mne/stats/permutations.py | 3 -- mne/tests/test_docstring_parameters.py | 3 +- mne/utils/__init__.pyi | 2 - mne/utils/check.py | 53 ++++++++++---------------- mne/utils/numerics.py | 2 - mne/utils/tests/test_check.py | 35 +++++++++++++---- 16 files changed, 62 insertions(+), 78 deletions(-) diff --git a/mne/epochs.py b/mne/epochs.py index cb27da0dae5..3d3eed221a7 100644 --- a/mne/epochs.py +++ b/mne/epochs.py @@ -86,7 +86,6 @@ _check_pandas_index_arguments, _check_pandas_installed, _check_preload, - _check_rng_compat, _check_time_format, _convert_times, _ensure_events, @@ -2650,7 +2649,6 @@ def equalize_event_counts( legacy_seed = ( random_state if isinstance(random_state, int | np.integer) else None ) - rng = _check_rng_compat(rng, legacy=random_state, legacy_name="random_state") indices = _get_drop_indices(sample_nums, method, rng, legacy_seed=legacy_seed) # need to re-index indices indices = np.concatenate([e[idx] for e, idx in zip(eq_inds, indices)]) @@ -4069,7 +4067,6 @@ def equalize_epoch_counts( epoch.drop_bad() sample_nums = [epoch.events[:, 0] for epoch in epochs_list] legacy_seed = random_state if isinstance(random_state, int | np.integer) else None - rng = _check_rng_compat(rng, legacy=random_state, legacy_name="random_state") indices = _get_drop_indices(sample_nums, method, rng, legacy_seed=legacy_seed) for epoch, inds in zip(epochs_list, indices): epoch.drop(inds, reason="EQUALIZED_COUNT") @@ -4700,7 +4697,6 @@ def bootstrap(epochs, random_state=None, *, rng=None): "in the constructor." ) - rng = _check_rng_compat(rng, legacy=random_state, legacy_name="random_state") epochs_bootstrap = epochs.copy() n_events = len(epochs_bootstrap.events) idx = rng_uniform(rng)(0, n_events, n_events) diff --git a/mne/inverse_sparse/mxne_inverse.py b/mne/inverse_sparse/mxne_inverse.py index 647726fb0e8..e5e6ea004e5 100644 --- a/mne/inverse_sparse/mxne_inverse.py +++ b/mne/inverse_sparse/mxne_inverse.py @@ -18,7 +18,6 @@ from ..utils import ( _check_depth, _check_option, - _check_rng_compat, _legacy_rng, _validate_type, logger, @@ -537,7 +536,6 @@ def mixed_norm( # Alpha selected automatically by SURE minimization if alpha == "sure": - rng = _check_rng_compat(rng, legacy=random_state, legacy_name="random_state") alpha_grid = sure_alpha_grid if isinstance(sure_alpha_grid, str) and sure_alpha_grid == "auto": alpha_grid = np.geomspace(100, 10, num=15) diff --git a/mne/label.py b/mne/label.py index 75b4711a90c..35745ec9286 100644 --- a/mne/label.py +++ b/mne/label.py @@ -39,7 +39,6 @@ from .utils import ( _check_fname, _check_option, - _check_rng_compat, _check_subject, _import_nibabel, _legacy_rng, @@ -2046,7 +2045,6 @@ def random_parcellation( dist[hemi] = mesh_dist(tris[hemi], vert[hemi]) # create the patches - rng = _check_rng_compat(rng, legacy=random_state, legacy_name="random_state") labels = _cortex_parcellation(subject, n_parcel, hemis, vert, dist, rng) # add a unique color to each label @@ -3022,9 +3020,6 @@ def select_sources( subject, restrict_vertices=True, subjects_dir=subjects_dir, surf=surf ) else: - rng = _check_rng_compat( - rng, legacy=random_state, legacy_name="random_state" - ) seed = rng.choice(label.vertices) else: seed = label.vertices[location] diff --git a/mne/preprocessing/ica.py b/mne/preprocessing/ica.py index 0e0e4e82b6c..b3a4bfce17a 100644 --- a/mne/preprocessing/ica.py +++ b/mne/preprocessing/ica.py @@ -67,7 +67,6 @@ _check_rng, _ensure_int, _get_inst_data, - _legacy_rng, _limit_blas_threads, _on_missing, _pl, @@ -436,7 +435,6 @@ class ICA(ContainsMixin): .. footbibliography:: """ # noqa: E501 - @_legacy_rng("random_state") @verbose def __init__( self, @@ -478,7 +476,17 @@ def __init__( self._max_pca_components = None self.n_pca_components = None self.ch_names = None + if rng is not None and random_state is not None: + raise TypeError("Specify only one of rng or random_state") + if random_state is not None: + warn( + "random_state is deprecated and will be removed in a future " + "release; use rng instead.", + FutureWarning, + ) self.random_state = random_state + # stored un-normalized so that integer seeds stay intact for the + # third-party ``random_state`` parameters used during fitting self.rng = rng if fit_params is None: diff --git a/mne/preprocessing/infomax_.py b/mne/preprocessing/infomax_.py index 7d1f8983a53..9b9633faf9a 100644 --- a/mne/preprocessing/infomax_.py +++ b/mne/preprocessing/infomax_.py @@ -7,7 +7,7 @@ import numpy as np from scipy.special import expit -from ..utils import _check_rng_compat, _legacy_rng, logger, verbose +from ..utils import _legacy_rng, logger, verbose from ..utils.numerics import _random_permutation @@ -361,7 +361,6 @@ def infomax( rng=None, ): """Run (extended) Infomax ICA decomposition on raw data.""" - rng = _check_rng_compat(rng, legacy=random_state, legacy_name="random_state") return _infomax( data, weights=weights, diff --git a/mne/preprocessing/tests/test_ica.py b/mne/preprocessing/tests/test_ica.py index 8ed4f01b41a..2a0df1d490b 100644 --- a/mne/preprocessing/tests/test_ica.py +++ b/mne/preprocessing/tests/test_ica.py @@ -318,6 +318,8 @@ def test_ica_rng_transition(): ica.fit(raw) unmixings.append(ica.unmixing_matrix_) assert_array_equal(unmixings[0], unmixings[1]) + # at the ICA/sklearn boundary an integer ``rng`` seed is forwarded verbatim, + # so it matches the same integer passed to the deprecated parameter assert_array_equal(unmixings[0], unmixings[2]) diff --git a/mne/simulation/evoked.py b/mne/simulation/evoked.py index 97d6e716527..bdf20dae083 100644 --- a/mne/simulation/evoked.py +++ b/mne/simulation/evoked.py @@ -15,7 +15,6 @@ from ..io import BaseRaw from ..utils import ( _check_preload, - _check_rng_compat, _legacy_rng, _validate_type, check_random_state, @@ -96,7 +95,6 @@ def simulate_evoked( return evoked if nave < np.inf: - rng = _check_rng_compat(rng, legacy=random_state, legacy_name="random_state") noise = _simulate_noise_evoked(evoked, cov, iir_filter, rng) evoked.data += noise.data / math.sqrt(nave) evoked.nave = np.int64(nave) @@ -146,7 +144,6 @@ def add_noise(inst, cov, iir_filter=None, random_state=None, verbose=None, *, rn """ # We always allow subselection here legacy_seed = random_state if isinstance(random_state, int | np.integer) else None - rng = _check_rng_compat(rng, legacy=random_state, legacy_name="random_state") return _add_noise(inst, cov, iir_filter, rng, legacy_seed=legacy_seed) diff --git a/mne/simulation/raw.py b/mne/simulation/raw.py index 4b0b85aa3cb..757d9f141a5 100644 --- a/mne/simulation/raw.py +++ b/mne/simulation/raw.py @@ -44,7 +44,6 @@ from ..transforms import Transform, _get_trans, transform_surface_to from ..utils import ( _check_preload, - _check_rng_compat, _legacy_rng, _pl, _validate_type, @@ -449,7 +448,6 @@ def add_eog( ---------- .. footbibliography:: """ - rng = _check_rng_compat(rng, legacy=random_state, legacy_name="random_state") return _add_exg(raw, "blink", head_pos, interp, n_jobs, rng) @@ -512,7 +510,6 @@ def add_ecg( .. versionadded:: 0.18 """ - rng = _check_rng_compat(rng, legacy=random_state, legacy_name="random_state") return _add_exg(raw, "ecg", head_pos, interp, n_jobs, rng) diff --git a/mne/simulation/source.py b/mne/simulation/source.py index 7c7f89f8894..2263bbe19ba 100644 --- a/mne/simulation/source.py +++ b/mne/simulation/source.py @@ -11,7 +11,6 @@ from ..surface import _compute_nearest from ..utils import ( _check_option, - _check_rng_compat, _ensure_events, _ensure_int, _legacy_rng, @@ -74,7 +73,6 @@ def select_source_in_label( rh_vertno : list Selected source coefficients on the right hemisphere. """ - rng = _check_rng_compat(rng, legacy=random_state, legacy_name="random_state") return _select_source_in_label( src, label, @@ -185,7 +183,6 @@ def simulate_sparse_stc( ----- .. versionadded:: 0.10.0 """ - rng = _check_rng_compat(rng, legacy=random_state, legacy_name="random_state") src = _ensure_src(src, verbose=False) subject_src = src._subject if subject is None: diff --git a/mne/stats/cluster_level.py b/mne/stats/cluster_level.py index b6726d779e5..6757b86f4e0 100644 --- a/mne/stats/cluster_level.py +++ b/mne/stats/cluster_level.py @@ -12,7 +12,6 @@ from ..utils import ( ProgressBar, _check_option, - _check_rng_compat, _legacy_rng, _pl, _validate_type, @@ -1225,7 +1224,6 @@ def permutation_cluster_test( ---------- .. footbibliography:: """ - rng = _check_rng_compat(rng, legacy=seed, legacy_name="seed") return _permutation_cluster_test_normalized( X, threshold, @@ -1340,7 +1338,6 @@ def permutation_cluster_1samp_test( ---------- .. footbibliography:: """ - rng = _check_rng_compat(rng, legacy=seed, legacy_name="seed") return _permutation_cluster_test_normalized( X, threshold, @@ -1443,7 +1440,6 @@ def spatio_temporal_cluster_1samp_test( ) else: exclude = None - rng = _check_rng_compat(rng, legacy=seed, legacy_name="seed") return _permutation_cluster_test_normalized( X, threshold, @@ -1548,7 +1544,6 @@ def spatio_temporal_cluster_test( ) else: exclude = None - rng = _check_rng_compat(rng, legacy=seed, legacy_name="seed") return _permutation_cluster_test_normalized( X, threshold, diff --git a/mne/stats/permutations.py b/mne/stats/permutations.py index 60a36ac0ffb..7662845c1ed 100644 --- a/mne/stats/permutations.py +++ b/mne/stats/permutations.py @@ -11,7 +11,6 @@ from ..parallel import parallel_func from ..utils import ( _check_if_nan, - _check_rng_compat, _legacy_rng, fill_doc, logger, @@ -100,7 +99,6 @@ def permutation_t_test( dof_scaling = sqrt(n_samples / (n_samples - 1.0)) std0 = np.sqrt(X2 - mu0**2) * dof_scaling # get std with var splitting T_obs = np.mean(X, axis=0) / (std0 / sqrt(n_samples)) - rng = _check_rng_compat(rng, legacy=seed, legacy_name="seed") orders, _, extra = _get_1samp_orders(n_samples, n_permutations, tail, rng) perms = 2 * np.array(orders) - 1 # from 0, 1 -> 1, -1 logger.info(f"Permuting {len(orders)} times{extra}...") @@ -167,7 +165,6 @@ def stat_fun(x): raise ValueError("stat_fun must be 'mean', 'median' or callable.") n_trials = arr.shape[0] indices = np.arange(n_trials, dtype=int) # BCA would be cool to have too - rng = _check_rng_compat(rng, legacy=random_state, legacy_name="random_state") boot_indices = rng.choice(indices, replace=True, size=(n_bootstraps, len(indices))) stat = np.array([stat_fun(arr[inds]) for inds in boot_indices]) ci = (((1 - ci) / 2) * 100, (1 - ((1 - ci) / 2)) * 100) diff --git a/mne/tests/test_docstring_parameters.py b/mne/tests/test_docstring_parameters.py index 40a52e64837..8526ce641f8 100644 --- a/mne/tests/test_docstring_parameters.py +++ b/mne/tests/test_docstring_parameters.py @@ -378,10 +378,9 @@ def test_tabs(): ("mne/tests/test_cov.py", "test_auto_low_rank_ignores_global_rng"), ("mne/tests/test_dipole.py", "test_dipole_fitting"), ("mne/utils/check.py", "_check_rng"), - ("mne/utils/check.py", "_legacy_rng_wrapper"), ("mne/utils/check.py", "check_random_state"), ("mne/utils/tests/test_check.py", "test_check_rng"), - ("mne/utils/tests/test_check.py", "test_check_rng_compat"), + ("mne/utils/tests/test_check.py", "test_legacy_rng_decorator"), ( "mne/viz/tests/test_circle.py", "test_plot_connectivity_circle_jitter_reproducible", diff --git a/mne/utils/__init__.pyi b/mne/utils/__init__.pyi index ca1ab8a42be..d607d763a1c 100644 --- a/mne/utils/__init__.pyi +++ b/mne/utils/__init__.pyi @@ -53,7 +53,6 @@ __all__ = [ "_check_range", "_check_rank", "_check_rng", - "_check_rng_compat", "_legacy_rng", "_check_sphere", "_check_src_normal", @@ -263,7 +262,6 @@ from .check import ( _check_range, _check_rank, _check_rng, - _check_rng_compat, _check_sphere, _check_src_normal, _check_stc_units, diff --git a/mne/utils/check.py b/mne/utils/check.py index 2918b26ea5c..b2d10d1e01d 100644 --- a/mne/utils/check.py +++ b/mne/utils/check.py @@ -244,7 +244,12 @@ def _check_rng(rng): def _legacy_rng(legacy_name): - """Handle presence-sensitive legacy RNG parameters at the call boundary.""" + """Handle presence-sensitive legacy RNG parameters at the call boundary. + + The decorated function must accept a keyword-only ``rng`` parameter. When + it is called, ``kwargs["rng"]`` is replaced by the normalized value before + the function runs, so the body only ever sees an already-normalized RNG. + """ def decorator(function): parameters = signature(function).parameters @@ -257,33 +262,26 @@ def decorator(function): legacy_position = ( positional.index(legacy_name) if legacy_name in positional else None ) - rng_position = positional.index("rng") if "rng" in positional else None @wraps(function) def _legacy_rng_wrapper(*args, **kwargs): - legacy_in_args = legacy_position is not None and len(args) > legacy_position - legacy_in_kwargs = legacy_name in kwargs - rng_in_args = rng_position is not None and len(args) > rng_position - rng_in_kwargs = "rng" in kwargs - if (legacy_in_args and legacy_in_kwargs) or (rng_in_args and rng_in_kwargs): + if legacy_position is not None and len(args) > legacy_position: + if legacy_name in kwargs: + return function(*args, **kwargs) + value = args[legacy_position] + elif legacy_name in kwargs: + value = kwargs[legacy_name] + else: + kwargs["rng"] = _check_rng(kwargs.get("rng")) return function(*args, **kwargs) - legacy_supplied = legacy_in_args or legacy_in_kwargs - rng_supplied = rng_in_args or rng_in_kwargs - if legacy_supplied and rng_supplied: + if "rng" in kwargs: raise TypeError(f"Specify only one of rng or {legacy_name}") - if legacy_supplied: - warn( - f"{legacy_name} is deprecated and will be removed in a future " - "release; use rng instead.", - FutureWarning, - ) - if legacy_in_kwargs and kwargs[legacy_name] is None: - kwargs = kwargs.copy() - kwargs[legacy_name] = check_random_state(None) - elif legacy_in_args and args[legacy_position] is None: - args = list(args) - args[legacy_position] = check_random_state(None) - args = tuple(args) + warn( + f"{legacy_name} is deprecated and will be removed in a future " + "release; use rng instead.", + FutureWarning, + ) + kwargs["rng"] = check_random_state(value) return function(*args, **kwargs) return _legacy_rng_wrapper @@ -291,15 +289,6 @@ def _legacy_rng_wrapper(*args, **kwargs): return decorator -def _check_rng_compat(rng, *, legacy=None, legacy_name): - """Check an RNG while temporarily supporting a legacy parameter.""" - if legacy is not None: - if rng is not None: - raise TypeError(f"Specify only one of rng or {legacy_name}") - return check_random_state(legacy) - return _check_rng(rng) - - def _check_event_id(event_id, events): """Check event_id and convert to default format.""" # check out event_id dict diff --git a/mne/utils/numerics.py b/mne/utils/numerics.py index b899a7be3cc..ff00273ce7a 100644 --- a/mne/utils/numerics.py +++ b/mne/utils/numerics.py @@ -26,7 +26,6 @@ ) from ._logging import logger, verbose, warn from .check import ( - _check_rng_compat, _ensure_int, _legacy_rng, _validate_type, @@ -298,7 +297,6 @@ def random_permutation(n_samples, random_state=None, *, rng=None): randperm : ndarray, int Randomly permuted sequence between 0 and n-1. """ - rng = _check_rng_compat(rng, legacy=random_state, legacy_name="random_state") return _random_permutation(n_samples, rng) diff --git a/mne/utils/tests/test_check.py b/mne/utils/tests/test_check.py index 5e560f0fa34..ad10b39f1e5 100644 --- a/mne/utils/tests/test_check.py +++ b/mne/utils/tests/test_check.py @@ -38,7 +38,7 @@ check_random_state, check_version, ) -from mne.utils.check import _check_rng, _check_rng_compat +from mne.utils.check import _check_rng, _legacy_rng data_path = testing.data_path(download=False) base_dir = data_path / "MEG" / "sample" @@ -68,13 +68,32 @@ def test_check_rng(): _check_rng("foo") -def test_check_rng_compat(): - """Test compatibility with legacy random-number parameters.""" - rng = _check_rng_compat(None, legacy=0, legacy_name="seed") - assert isinstance(rng, np.random.RandomState) - assert isinstance(_check_rng_compat(None, legacy_name="seed"), np.random.Generator) - with pytest.raises(TypeError, match="rng"): - _check_rng_compat(0, legacy=1, legacy_name="random_state") +def test_legacy_rng_decorator(): + """Test that the transition decorator normalizes and warns.""" + + @_legacy_rng("random_state") + def _func(random_state=None, *, rng=None): + return rng + + # no argument: a fresh generator is created + assert isinstance(_func(), np.random.Generator) + assert isinstance(_func(rng=0), np.random.Generator) + assert_array_equal( + _func(rng=0).integers(10, size=3), _func(rng=0).integers(10, size=3) + ) + # legacy RandomState passthrough + random_state = np.random.RandomState(0) + assert _func(rng=random_state) is random_state + # legacy int/None keep their RandomState semantics, with a deprecation warning + with pytest.warns(FutureWarning, match="random_state is deprecated"): + assert isinstance(_func(random_state=0), np.random.RandomState) + with pytest.warns(FutureWarning, match="random_state is deprecated"): + assert isinstance(_func(random_state=None), np.random.mtrand.RandomState) + # supplying both is an error; positional legacy arguments are supported + with pytest.raises(TypeError, match="Specify only one"): + _func(0, rng=0) + with pytest.raises(TypeError, match="Specify only one"): + _func(random_state=0, rng=0) @testing.requires_testing_data From c097a4c713f3bef24997e81ec5494fc666d88b36 Mon Sep 17 00:00:00 2001 From: Bru Date: Tue, 25 Aug 2026 08:00:13 +0200 Subject: [PATCH 21/34] MAINT: Remove boilerplate from RNG transition The normalized-generator indirections duplicated every parameter list just to forward rng: drop _permutation_cluster_test_normalized (publics call the private implementation directly again, as before), un-split infomax back into a single decorated function, and derive seed_deprecated docdict from random_state_deprecated. --- mne/preprocessing/ica.py | 4 +- mne/preprocessing/infomax_.py | 57 +---------- mne/stats/cluster_level.py | 172 +++++++++++++--------------------- mne/utils/docs.py | 14 +-- 4 files changed, 73 insertions(+), 174 deletions(-) diff --git a/mne/preprocessing/ica.py b/mne/preprocessing/ica.py index b3a4bfce17a..27fe1974205 100644 --- a/mne/preprocessing/ica.py +++ b/mne/preprocessing/ica.py @@ -89,7 +89,7 @@ from .ctps_ import ctps from .ecg import _get_ecg_channel_index, _make_ecg, create_ecg_epochs, qrs_detector from .eog import _find_eog_events, _get_eog_channel_index -from .infomax_ import _infomax +from .infomax_ import infomax __all__ = ( "ICA", @@ -990,7 +990,7 @@ def _fit(self, data, fit_type): self.unmixing_matrix_ = ica.components_ self.n_iter_ = ica.n_iter_ elif self.method in ("infomax", "extended-infomax"): - unmixing_matrix, n_iter = _infomax( + unmixing_matrix, n_iter = infomax( data[:, sel], rng=_check_rng(rng), return_n_iter=True, diff --git a/mne/preprocessing/infomax_.py b/mne/preprocessing/infomax_.py index 9b9633faf9a..c518c7013ed 100644 --- a/mne/preprocessing/infomax_.py +++ b/mne/preprocessing/infomax_.py @@ -11,8 +11,9 @@ from ..utils.numerics import _random_permutation +@_legacy_rng("random_state") @verbose -def _infomax( +def infomax( data, weights=None, l_rate=None, @@ -25,6 +26,7 @@ def _infomax( kurt_size=6000, ext_blocks=1, max_iter=200, + random_state=None, blowup=1e4, blowup_fac=0.5, n_small_angle=20, @@ -32,7 +34,7 @@ def _infomax( verbose=None, return_n_iter=False, *, - rng, + rng=None, ): """Run (extended) Infomax ICA decomposition on raw data. @@ -335,54 +337,3 @@ def _infomax( return weights.T, step else: return weights.T - - -def infomax( - data, - weights=None, - l_rate=None, - block=None, - w_change=1e-12, - anneal_deg=60.0, - anneal_step=0.9, - extended=True, - n_subgauss=1, - kurt_size=6000, - ext_blocks=1, - max_iter=200, - random_state=None, - blowup=1e4, - blowup_fac=0.5, - n_small_angle=20, - use_bias=True, - verbose=None, - return_n_iter=False, - *, - rng=None, -): - """Run (extended) Infomax ICA decomposition on raw data.""" - return _infomax( - data, - weights=weights, - l_rate=l_rate, - block=block, - w_change=w_change, - anneal_deg=anneal_deg, - anneal_step=anneal_step, - extended=extended, - n_subgauss=n_subgauss, - kurt_size=kurt_size, - ext_blocks=ext_blocks, - max_iter=max_iter, - blowup=blowup, - blowup_fac=blowup_fac, - n_small_angle=n_small_angle, - use_bias=use_bias, - verbose=verbose, - return_n_iter=return_n_iter, - rng=rng, - ) - - -infomax.__doc__ = _infomax.__doc__ -infomax = _legacy_rng("random_state")(verbose(infomax)) diff --git a/mne/stats/cluster_level.py b/mne/stats/cluster_level.py index 6757b86f4e0..a735573aa3d 100644 --- a/mne/stats/cluster_level.py +++ b/mne/stats/cluster_level.py @@ -1100,48 +1100,6 @@ def _check_fun(X, stat_fun, threshold, tail=0, kind="within"): return stat_fun, threshold -def _permutation_cluster_test_normalized( - X, - threshold, - n_permutations, - tail, - stat_fun, - adjacency, - n_jobs, - rng, - max_step, - exclude, - step_down_p, - t_power, - out_type, - check_disjoint, - buffer_size, - *, - kind, -): - """Run a cluster test with an already-normalized random generator.""" - stat_fun, threshold = _check_fun(X, stat_fun, threshold, tail, kind) - if kind == "within": - X = [X] - return _permutation_cluster_test( - X=X, - threshold=threshold, - n_permutations=n_permutations, - tail=tail, - stat_fun=stat_fun, - adjacency=adjacency, - n_jobs=n_jobs, - rng=rng, - max_step=max_step, - exclude=exclude, - step_down_p=step_down_p, - t_power=t_power, - out_type=out_type, - check_disjoint=check_disjoint, - buffer_size=buffer_size, - ) - - @_legacy_rng("seed") @verbose def permutation_cluster_test( @@ -1224,23 +1182,23 @@ def permutation_cluster_test( ---------- .. footbibliography:: """ - return _permutation_cluster_test_normalized( - X, - threshold, - n_permutations, - tail, - stat_fun, - adjacency, - n_jobs, - rng, - max_step, - exclude, - step_down_p, - t_power, - out_type, - check_disjoint, - buffer_size, - kind="between", + stat_fun, threshold = _check_fun(X, stat_fun, threshold, tail, "between") + return _permutation_cluster_test( + X=X, + threshold=threshold, + n_permutations=n_permutations, + tail=tail, + stat_fun=stat_fun, + adjacency=adjacency, + n_jobs=n_jobs, + rng=rng, + max_step=max_step, + exclude=exclude, + step_down_p=step_down_p, + t_power=t_power, + out_type=out_type, + check_disjoint=check_disjoint, + buffer_size=buffer_size, ) @@ -1338,23 +1296,23 @@ def permutation_cluster_1samp_test( ---------- .. footbibliography:: """ - return _permutation_cluster_test_normalized( - X, - threshold, - n_permutations, - tail, - stat_fun, - adjacency, - n_jobs, - rng, - max_step, - exclude, - step_down_p, - t_power, - out_type, - check_disjoint, - buffer_size, - kind="within", + stat_fun, threshold = _check_fun(X, stat_fun, threshold, tail) + return _permutation_cluster_test( + X=[X], + threshold=threshold, + n_permutations=n_permutations, + tail=tail, + stat_fun=stat_fun, + adjacency=adjacency, + n_jobs=n_jobs, + rng=rng, + max_step=max_step, + exclude=exclude, + step_down_p=step_down_p, + t_power=t_power, + out_type=out_type, + check_disjoint=check_disjoint, + buffer_size=buffer_size, ) @@ -1440,23 +1398,22 @@ def spatio_temporal_cluster_1samp_test( ) else: exclude = None - return _permutation_cluster_test_normalized( + return permutation_cluster_1samp_test( X, - threshold, - n_permutations, - tail, - stat_fun, - adjacency, - n_jobs, - rng, - max_step, - exclude, - step_down_p, - t_power, - out_type, - check_disjoint, - buffer_size, - kind="within", + threshold=threshold, + stat_fun=stat_fun, + tail=tail, + n_permutations=n_permutations, + adjacency=adjacency, + n_jobs=n_jobs, + rng=rng, + max_step=max_step, + exclude=exclude, + step_down_p=step_down_p, + t_power=t_power, + out_type=out_type, + check_disjoint=check_disjoint, + buffer_size=buffer_size, ) @@ -1544,23 +1501,22 @@ def spatio_temporal_cluster_test( ) else: exclude = None - return _permutation_cluster_test_normalized( + return permutation_cluster_test( X, - threshold, - n_permutations, - tail, - stat_fun, - adjacency, - n_jobs, - rng, - max_step, - exclude, - step_down_p, - t_power, - out_type, - check_disjoint, - buffer_size, - kind="between", + threshold=threshold, + stat_fun=stat_fun, + tail=tail, + n_permutations=n_permutations, + adjacency=adjacency, + n_jobs=n_jobs, + rng=rng, + max_step=max_step, + exclude=exclude, + step_down_p=step_down_p, + t_power=t_power, + out_type=out_type, + check_disjoint=check_disjoint, + buffer_size=buffer_size, ) diff --git a/mne/utils/docs.py b/mne/utils/docs.py index d4df5f33ef5..5ba8592c017 100644 --- a/mne/utils/docs.py +++ b/mne/utils/docs.py @@ -4157,17 +4157,9 @@ def _reflow_param_docstring(docstring, has_first_line=True, width=75): ``RandomState`` to control the random-number stream. """ -docdict["seed_deprecated"] = """ -seed : None | int | instance of ~numpy.random.RandomState - The legacy random-number control. If explicitly passed as ``None``, NumPy's - global :class:`~numpy.random.RandomState` singleton is used. An int creates - a legacy ``RandomState`` seeded with that value. Passing the same int to - ``rng`` uses :func:`numpy.random.default_rng` and produces a different - stream. If both parameters are omitted, a fresh ``Generator`` is used. - - .. deprecated:: 1.13 - Use ``rng`` instead. -""" +docdict["seed_deprecated"] = docdict["random_state_deprecated"].replace( + "random_state", "seed" +) docdict["seeg"] = """ seeg : bool From f80d36ed14b35383fc7fa837d2bf169232c6c919 Mon Sep 17 00:00:00 2001 From: Bru Date: Tue, 25 Aug 2026 10:26:20 +0200 Subject: [PATCH 22/34] MAINT: Compact RNG transition tests Drop per-domain assertions of decorator semantics that are covered once centrally in test_check (both-supplied TypeError, FutureWarning, plain int-vs-RandomState parity), and parametrize the spatio-temporal sibling functions. Domain-specific behavior keeps explicit pins: the per-event stream restart quirk, the ICA sklearn boundary, and nested wrapper delegation. --- mne/inverse_sparse/tests/test_mxne_inverse.py | 8 --- mne/simulation/tests/test_evoked.py | 10 --- mne/simulation/tests/test_source.py | 31 ++------- mne/stats/tests/test_cluster_level.py | 64 +++++++++--------- mne/tests/test_epochs.py | 67 +++++++++---------- mne/utils/tests/test_numerics.py | 37 ++++------ 6 files changed, 82 insertions(+), 135 deletions(-) diff --git a/mne/inverse_sparse/tests/test_mxne_inverse.py b/mne/inverse_sparse/tests/test_mxne_inverse.py index c32ebc5debe..193fc3547d7 100644 --- a/mne/inverse_sparse/tests/test_mxne_inverse.py +++ b/mne/inverse_sparse/tests/test_mxne_inverse.py @@ -46,14 +46,6 @@ def forward(): return read_forward_solution(fname_fwd) -def test_mixed_norm_rng_conflict_without_sure(): - """Test RNG spelling conflicts when SURE randomness is inactive.""" - with pytest.raises(TypeError, match="only one"): - mixed_norm(None, None, None, alpha=1, random_state=0, rng=1) - with pytest.raises(TypeError, match="only one"): - mixed_norm(None, None, None, alpha=1, random_state=None, rng=None) - - @testing.requires_testing_data @pytest.mark.timeout(150) # ~30 s on Travis Linux @pytest.mark.ultraslowtest diff --git a/mne/simulation/tests/test_evoked.py b/mne/simulation/tests/test_evoked.py index 73c09faab58..c60287e6234 100644 --- a/mne/simulation/tests/test_evoked.py +++ b/mne/simulation/tests/test_evoked.py @@ -112,10 +112,6 @@ def test_add_noise(): with pytest.raises(RuntimeError, match="to be loaded"): add_noise(raw, cov) raw.crop(0, 1).load_data() - with pytest.warns(FutureWarning, match="random_state"): - add_noise(raw.copy(), cov, random_state=0) - with pytest.raises(TypeError, match="only one"): - add_noise(raw, cov, random_state=0, rng=0) with pytest.raises(TypeError, match="Raw, Epochs, or Evoked"): add_noise(0.0, cov) with pytest.raises(TypeError, match="Covariance"): @@ -173,12 +169,6 @@ def test_add_noise(): ) -def test_simulate_evoked_rng_conflict_without_noise(): - """Test RNG spelling conflicts are checked when noise is inactive.""" - with pytest.raises(TypeError, match="only one"): - simulate_evoked(None, None, None, cov=None, random_state=None, rng=None) - - def test_rank_deficiency(): """Test adding noise from M/EEG float32 (I/O) cov with projectors.""" # See gh-5940 diff --git a/mne/simulation/tests/test_source.py b/mne/simulation/tests/test_source.py index 0f9341996ae..fc5a350bce9 100644 --- a/mne/simulation/tests/test_source.py +++ b/mne/simulation/tests/test_source.py @@ -55,21 +55,16 @@ def test_simulate_sparse_stc_legacy_rng_nested(): [ dict( type="surf", - vertno=np.array([1, 2, 3]), + vertno=np.arange(1, 4) + 3 * hemi, nuse=3, subject_his_id="sample", - ), - dict( - type="surf", - vertno=np.array([4, 5, 6]), - nuse=3, - subject_his_id="sample", - ), + ) + for hemi in range(2) ] ) labels = [ - Label([1, 2, 3], hemi="lh", subject="sample"), - Label([4, 5, 6], hemi="rh", subject="sample"), + Label(np.arange(1, 4) + 3 * idx, hemi=hemi, subject="sample") + for idx, hemi in enumerate(("lh", "rh")) ] results = [] for random_state in (0, check_random_state(0)): @@ -188,22 +183,6 @@ def test_simulate_sparse_stc(_get_fwd_labels): this_label.values.fill(1.0) mylabels.append(this_label) - legacy_stcs = [] - for random_state in (0, check_random_state(0)): - with pytest.warns(FutureWarning, match="random_state"): - legacy_stcs.append( - simulate_sparse_stc( - fwd["src"], - len(mylabels), - times, - labels=mylabels, - random_state=random_state, - subjects_dir=subjects_dir, - ) - ) - for hemi in (0, 1): - assert_array_equal(legacy_stcs[0].vertices[hemi], legacy_stcs[1].vertices[hemi]) - for location in ("random", "center"): random_state = 0 if location == "random" else None stc_1 = simulate_sparse_stc( diff --git a/mne/stats/tests/test_cluster_level.py b/mne/stats/tests/test_cluster_level.py index ce1cd73c8e1..3f4d28a27b8 100644 --- a/mne/stats/tests/test_cluster_level.py +++ b/mne/stats/tests/test_cluster_level.py @@ -56,40 +56,40 @@ def _get_conditions(): return condition1_1d, condition2_1d, condition1_2d, condition2_2d -def test_cluster_rng_transition(): - """Test the transition from seed to rng.""" - X = np.arange(24.0).reshape(8, 3) - with pytest.warns(FutureWarning, match="seed"): - permutation_cluster_1samp_test(X, threshold=0, n_permutations=2, seed=0) - with pytest.raises(TypeError, match="Specify only one"): - permutation_cluster_1samp_test(X, threshold=0, n_permutations=2, seed=0, rng=0) - - -def test_spatio_temporal_cluster_legacy_rng_nested(): +@pytest.mark.parametrize( + "function, make_X", + ( + ( + spatio_temporal_cluster_1samp_test, + lambda rng: rng.standard_normal((8, 3, 1)), + ), + ( + spatio_temporal_cluster_test, + lambda rng: [ + rng.standard_normal((8, 3, 1)), + rng.standard_normal((8, 3, 1)), + ], + ), + ), +) +def test_spatio_temporal_cluster_legacy_rng_nested(function, make_X): """Test legacy RNGs survive nested spatio-temporal wrappers.""" - rng = np.random.default_rng(0) - X = rng.standard_normal((8, 3, 1)) - cases = ( - (spatio_temporal_cluster_1samp_test, X), - (spatio_temporal_cluster_test, [X, rng.standard_normal(X.shape)]), - ) - for function, data in cases: - results = [] - for kind in ("int", "state"): - seed = 0 if kind == "int" else check_random_state(0) - with pytest.warns(FutureWarning, match="seed"): - results.append( - function( - data, - threshold=0, - n_permutations=2, - seed=seed, - out_type="mask", - ) + data = make_X(np.random.default_rng(0)) + results = [] + for seed in (0, check_random_state(0)): + with pytest.warns(FutureWarning, match="seed"): + results.append( + function( + data, + threshold=0, + n_permutations=2, + seed=seed, + out_type="mask", ) - assert_array_equal(results[0][0], results[1][0]) - assert_array_equal(results[0][2], results[1][2]) - assert_array_equal(results[0][3], results[1][3]) + ) + assert_array_equal(results[0][0], results[1][0]) + assert_array_equal(results[0][2], results[1][2]) + assert_array_equal(results[0][3], results[1][3]) def test_thresholds(numba_conditional): diff --git a/mne/tests/test_epochs.py b/mne/tests/test_epochs.py index e6ec5e8b876..27b15ed324c 100644 --- a/mne/tests/test_epochs.py +++ b/mne/tests/test_epochs.py @@ -2731,8 +2731,6 @@ def test_bootstrap(): with pytest.warns(FutureWarning, match="random_state"): bootstrap(epochs, random_state=0) - with pytest.raises(TypeError, match="only one"): - bootstrap(epochs, random_state=0, rng=0) def test_epochs_copy(): @@ -3044,26 +3042,30 @@ def _make_equalization_epochs(lengths): return epochs +def _equalized_drop_inds(epochs): + """Get the indices of the dropped epochs for each condition.""" + return [ + np.flatnonzero([entry == ("EQUALIZED_COUNT",) for entry in epoch.drop_log]) + for epoch in epochs + ] + + def test_equalize_epoch_counts_rng_streams(): """Test legacy integers re-seed while new RNG streams advance.""" epochs = _make_equalization_epochs((3, 5, 6)) with pytest.warns(FutureWarning, match="random_state"): equalize_epoch_counts(epochs, method="random", random_state=0) - got = [ - np.flatnonzero([entry == ("EQUALIZED_COUNT",) for entry in epoch.drop_log]) - for epoch in epochs - ] - for this_got, want in zip(got, ([], [3, 4], [0, 3, 4])): - assert_array_equal(this_got, want) + for got, want in zip( + _equalized_drop_inds(epochs), ([], [3, 4], [0, 3, 4]), strict=True + ): + assert_array_equal(got, want) epochs = _make_equalization_epochs((3, 5, 6)) equalize_epoch_counts(epochs, method="random", rng=0) - got = [ - np.flatnonzero([entry == ("EQUALIZED_COUNT",) for entry in epoch.drop_log]) - for epoch in epochs - ] - for this_got, want in zip(got, ([], [1, 2], [0, 1, 2])): - assert_array_equal(this_got, want) + for got, want in zip( + _equalized_drop_inds(epochs), ([], [1, 2], [0, 1, 2]), strict=True + ): + assert_array_equal(got, want) events = np.column_stack( ( @@ -3072,26 +3074,23 @@ def test_equalize_epoch_counts_rng_streams(): np.repeat((1, 2, 3), (3, 5, 6)), ) ) - epochs = EpochsArray( - np.zeros((14, 1, 1)), - create_info(["EEG 001"], 100.0, "eeg"), - events=events, - event_id={"a": 1, "b": 2, "c": 3}, - verbose=False, - ) - with pytest.warns(FutureWarning, match="random_state"): - _, dropped = epochs.equalize_event_counts(method="random", random_state=0) - assert_array_equal(dropped, [6, 7, 8, 11, 12]) - - epochs = EpochsArray( - np.zeros((14, 1, 1)), - create_info(["EEG 001"], 100.0, "eeg"), - events=events, - event_id={"a": 1, "b": 2, "c": 3}, - verbose=False, - ) - _, dropped = epochs.equalize_event_counts(method="random", rng=0) - assert_array_equal(dropped, [4, 5, 8, 9, 10]) + for kwargs, want in ( + (dict(random_state=0), [6, 7, 8, 11, 12]), + (dict(rng=0), [4, 5, 8, 9, 10]), + ): + epochs = EpochsArray( + np.zeros((14, 1, 1)), + create_info(["EEG 001"], 100.0, "eeg"), + events=events, + event_id={"a": 1, "b": 2, "c": 3}, + verbose=False, + ) + if "random_state" in kwargs: + with pytest.warns(FutureWarning, match="random_state"): + _, dropped = epochs.equalize_event_counts(method="random", **kwargs) + else: + _, dropped = epochs.equalize_event_counts(method="random", **kwargs) + assert_array_equal(dropped, want) def test_access_by_name(tmp_path): diff --git a/mne/utils/tests/test_numerics.py b/mne/utils/tests/test_numerics.py index c117d97852c..4302466dcdd 100644 --- a/mne/utils/tests/test_numerics.py +++ b/mne/utils/tests/test_numerics.py @@ -215,28 +215,22 @@ def test_freq_mask(): def test_random_permutation(): - """Test random permutation function.""" + """Test random permutation function and its RNG transition.""" n_samples = 10 - random_state = 42 with pytest.warns(FutureWarning, match="random_state"): - python_randperm = random_permutation(n_samples, random_state) - - # matlab output when we execute rng(42), randperm(10) - matlab_randperm = np.array([7, 6, 5, 1, 4, 9, 10, 3, 8, 2]) - - assert_array_equal(python_randperm, matlab_randperm - 1) - + # matlab output when we execute rng(42), randperm(10) + assert_array_equal( + random_permutation(n_samples, 42), + np.array([7, 6, 5, 1, 4, 9, 10, 3, 8, 2]) - 1, + ) + # an integer ``rng`` seed is reproducible while a Generator instance advances assert_array_equal( - random_permutation(n_samples, rng=42), - random_permutation(n_samples, rng=42), + random_permutation(n_samples, rng=42), random_permutation(n_samples, rng=42) ) rng = np.random.default_rng(42) assert not np.array_equal( - random_permutation(n_samples, rng=rng), - random_permutation(n_samples, rng=rng), + random_permutation(n_samples, rng=rng), random_permutation(n_samples, rng=rng) ) - with pytest.raises(TypeError, match="only one"): - random_permutation(n_samples, random_state=42, rng=42) @pytest.mark.parametrize("use_keyword", (False, True)) @@ -244,10 +238,9 @@ def test_random_permutation_legacy_none(use_keyword): """Test explicit legacy None uses NumPy's global RandomState.""" global_rng = check_random_state(None) original_state = global_rng.get_state() - seeded_state = check_random_state(42).get_state() want = np.array([6, 5, 4, 0, 3, 8, 9, 2, 7, 1]) try: - global_rng.set_state(seeded_state) + global_rng.set_state(check_random_state(42).get_state()) with pytest.warns(FutureWarning, match="random_state"): if use_keyword: got = random_permutation(10, random_state=None) @@ -256,14 +249,8 @@ def test_random_permutation_legacy_none(use_keyword): assert_array_equal(got, want) finally: global_rng.set_state(original_state) - - with pytest.raises(TypeError, match="only one"): - random_permutation(10, None, rng=None) - with pytest.raises(TypeError, match="only one"): - random_permutation(10, random_state=None, rng=0) - assert ( - str(signature(random_permutation)) - == "(n_samples, random_state=None, *, rng=None)" + assert str(signature(random_permutation)) == ( + "(n_samples, random_state=None, *, rng=None)" ) From 8fbf77cec7c4a596b146a620a0fe4b0e3a071b1f Mon Sep 17 00:00:00 2001 From: Bru Date: Tue, 25 Aug 2026 11:53:48 +0200 Subject: [PATCH 23/34] MAINT: Retain legacy RNG parameters --- doc/changes/dev/14199.apichange.rst | 2 +- mne/beamformer/tests/test_rap_music.py | 7 +- mne/epochs.py | 6 +- mne/inverse_sparse/mxne_inverse.py | 2 +- mne/inverse_sparse/tests/test_mxne_inverse.py | 57 +++++----- mne/label.py | 4 +- mne/preprocessing/ica.py | 8 +- mne/preprocessing/infomax_.py | 2 +- .../tests/test_eeglab_infomax.py | 30 +++--- mne/preprocessing/tests/test_ica.py | 16 ++- mne/preprocessing/tests/test_infomax.py | 17 ++- mne/simulation/evoked.py | 4 +- mne/simulation/raw.py | 4 +- mne/simulation/source.py | 4 +- mne/simulation/tests/test_evoked.py | 3 +- mne/simulation/tests/test_source.py | 17 ++- mne/stats/cluster_level.py | 8 +- mne/stats/permutations.py | 4 +- mne/stats/tests/test_cluster_level.py | 17 ++- mne/tests/test_dipole.py | 7 +- mne/tests/test_docstring_parameters.py | 91 ---------------- mne/tests/test_epochs.py | 102 ++++++------------ mne/utils/check.py | 6 +- mne/utils/docs.py | 20 ++-- mne/utils/numerics.py | 2 +- mne/utils/tests/test_check.py | 26 +++-- mne/utils/tests/test_numerics.py | 20 ++-- mne/viz/tests/test_circle.py | 27 ++--- mne/viz/tests/test_utils.py | 12 +-- 29 files changed, 177 insertions(+), 348 deletions(-) diff --git a/doc/changes/dev/14199.apichange.rst b/doc/changes/dev/14199.apichange.rst index 4990b353eb8..b2c014ec1a9 100644 --- a/doc/changes/dev/14199.apichange.rst +++ b/doc/changes/dev/14199.apichange.rst @@ -1 +1 @@ -Add keyword-only ``rng`` parameters backed by :class:`numpy.random.Generator` to statistical, epoch-sampling, simulation, label, ICA, and sparse-inverse APIs, with deprecated ``seed`` and ``random_state`` compatibility paths, by `Bruno Aristimunha`_ (:gh:`9233`). Omitting both parameters now creates a fresh generator, whereas explicitly passing ``None`` to a legacy parameter retains NumPy's global :class:`~numpy.random.RandomState` stream. An integer passed to ``rng`` uses :func:`numpy.random.default_rng`, so it intentionally produces different results from the same integer passed to a legacy parameter. Legacy :class:`~numpy.random.RandomState` instances are also accepted by ``rng`` (passed through unchanged) for interoperability with third-party code such as scikit-learn that does not support generators. +Add keyword-only ``rng`` parameters backed by :class:`numpy.random.Generator` to statistical, epoch-sampling, simulation, label, ICA, and sparse-inverse APIs. The legacy ``seed`` and ``random_state`` parameters remain supported, while new code should prefer ``rng``. Omitting both parameters creates a fresh generator, whereas explicitly passing ``None`` to a legacy parameter retains NumPy's global :class:`~numpy.random.RandomState` stream. An integer passed to ``rng`` uses :func:`numpy.random.default_rng`, so it intentionally produces different results from the same integer passed to a legacy parameter. Legacy :class:`~numpy.random.RandomState` instances are also accepted by ``rng`` for interoperability with third-party code such as scikit-learn that does not support generators (:gh:`9233` by `Bruno Aristimunha`_). diff --git a/mne/beamformer/tests/test_rap_music.py b/mne/beamformer/tests/test_rap_music.py index 761d1b63125..20cbb027643 100644 --- a/mne/beamformer/tests/test_rap_music.py +++ b/mne/beamformer/tests/test_rap_music.py @@ -72,10 +72,9 @@ def simu_data(evoked, forward, noise_cov, n_dipoles, times, nave=1): stc = mne.SourceEstimate(data, vertices=vertices, tmin=tmin, tstep=tstep) # The bounds below were calibrated against this legacy noise stream. - with pytest.warns(FutureWarning, match="random_state"): - sim_evoked = mne.simulation.simulate_evoked( - forward, stc, evoked.info, noise_cov, nave=nave, random_state=106 - ) + sim_evoked = mne.simulation.simulate_evoked( + forward, stc, evoked.info, noise_cov, nave=nave, random_state=106 + ) return sim_evoked, stc diff --git a/mne/epochs.py b/mne/epochs.py index 3d3eed221a7..700b437bef5 100644 --- a/mne/epochs.py +++ b/mne/epochs.py @@ -2539,7 +2539,7 @@ def equalize_event_counts( The ``event_ids`` must identify non-overlapping subsets of the epochs. %(equalize_events_method)s - %(random_state_deprecated)s + %(random_state_rng)s Used only if ``method='random'``. %(rng)s Used only if ``method='random'``. @@ -4036,7 +4036,7 @@ def equalize_epoch_counts( epochs_list : list of Epochs The Epochs instances to equalize trial counts for. %(equalize_events_method)s - %(random_state_deprecated)s + %(random_state_rng)s Used only if ``method='random'``. %(rng)s Used only if ``method='random'``. @@ -4682,7 +4682,7 @@ def bootstrap(epochs, random_state=None, *, rng=None): ---------- epochs : Epochs instance epochs data to be bootstrapped - %(random_state_deprecated)s + %(random_state_rng)s %(rng)s Returns diff --git a/mne/inverse_sparse/mxne_inverse.py b/mne/inverse_sparse/mxne_inverse.py index e5e6ea004e5..b2045619621 100644 --- a/mne/inverse_sparse/mxne_inverse.py +++ b/mne/inverse_sparse/mxne_inverse.py @@ -435,7 +435,7 @@ def mixed_norm( grid is directly specified. Ignored if alpha is not "sure". .. versionadded:: 0.24 - %(random_state_deprecated)s + %(random_state_rng)s Used for the random delta and epsilon in the SURE computation. .. versionadded:: 0.24 diff --git a/mne/inverse_sparse/tests/test_mxne_inverse.py b/mne/inverse_sparse/tests/test_mxne_inverse.py index 193fc3547d7..7cce3daef02 100644 --- a/mne/inverse_sparse/tests/test_mxne_inverse.py +++ b/mne/inverse_sparse/tests/test_mxne_inverse.py @@ -580,41 +580,38 @@ def data_fun(times): forward = mne.read_forward_solution(fname_fwd) forward = mne.pick_channels_forward(forward, info["ch_names"]) times = np.arange(100, dtype=np.float64) / info["sfreq"] - 0.1 - with pytest.warns(FutureWarning, match="random_state"): - stc = simulate_sparse_stc( - forward["src"], - n_dipoles=n_dipoles, - times=times, - random_state=1, - labels=labels, - data_fun=data_fun, - ) + stc = simulate_sparse_stc( + forward["src"], + n_dipoles=n_dipoles, + times=times, + random_state=1, + labels=labels, + data_fun=data_fun, + ) assert len(stc.vertices) == 2 assert_array_equal(stc.vertices[0], [89259]) assert_array_equal(stc.vertices[1], [70279]) nave = 30 - with pytest.warns(FutureWarning, match="random_state"): - evoked = simulate_evoked( - forward, - stc, - info, - noise_cov, - nave=nave, - use_cps=False, - iir_filter=None, - random_state=0, - ) + evoked = simulate_evoked( + forward, + stc, + info, + noise_cov, + nave=nave, + use_cps=False, + iir_filter=None, + random_state=0, + ) evoked = evoked.crop(tmin=0, tmax=10e-3) - with pytest.warns(FutureWarning, match="random_state"): - stc_ = mixed_norm( - evoked, - forward, - noise_cov, - loose=0.9, - n_mxne_iter=5, - depth=0.9, - random_state=1, - ) + stc_ = mixed_norm( + evoked, + forward, + noise_cov, + loose=0.9, + n_mxne_iter=5, + depth=0.9, + random_state=1, + ) assert len(stc_.vertices) == len(stc.vertices) == 2 for si in range(len(stc_.vertices)): assert_array_equal(stc_.vertices[si], stc.vertices[si], err_msg=f"{si=}") diff --git a/mne/label.py b/mne/label.py index 35745ec9286..7d13dc5216c 100644 --- a/mne/label.py +++ b/mne/label.py @@ -2024,7 +2024,7 @@ def random_parcellation( parcels per hemisphere. %(subjects_dir)s %(surface)s - %(random_state_deprecated)s + %(random_state_rng)s %(rng)s Returns @@ -2979,7 +2979,7 @@ def select_sources( %(subjects_dir)s name : None | str Assign name to the new label. - %(random_state_deprecated)s + %(random_state_rng)s surf : str The surface used to simulated the label, defaults to the white surface. %(rng)s diff --git a/mne/preprocessing/ica.py b/mne/preprocessing/ica.py index 27fe1974205..9b6238ef4ce 100644 --- a/mne/preprocessing/ica.py +++ b/mne/preprocessing/ica.py @@ -247,7 +247,7 @@ class ICA(ContainsMixin): Noise covariance used for pre-whitening. If None (default), channels are scaled to unit variance ("z-standardized") as a group by channel type prior to the whitening by PCA. - %(random_state_deprecated)s + %(random_state_rng)s %(rng)s method : 'fastica' | 'infomax' | 'picard' The ICA method to use in the fit method. Use the ``fit_params`` argument @@ -479,11 +479,7 @@ def __init__( if rng is not None and random_state is not None: raise TypeError("Specify only one of rng or random_state") if random_state is not None: - warn( - "random_state is deprecated and will be removed in a future " - "release; use rng instead.", - FutureWarning, - ) + logger.info("random_state= is legacy; prefer rng= in new code") self.random_state = random_state # stored un-normalized so that integer seeds stay intact for the # third-party ``random_state`` parameters used during fitting diff --git a/mne/preprocessing/infomax_.py b/mne/preprocessing/infomax_.py index c518c7013ed..ac343187673 100644 --- a/mne/preprocessing/infomax_.py +++ b/mne/preprocessing/infomax_.py @@ -82,7 +82,7 @@ def infomax( Defaults to 1. max_iter : int The maximum number of iterations. Defaults to 200. - %(random_state_deprecated)s + %(random_state_rng)s blowup : float The maximum difference allowed between two successive estimations of the unmixing matrix. Defaults to 10000. diff --git a/mne/preprocessing/tests/test_eeglab_infomax.py b/mne/preprocessing/tests/test_eeglab_infomax.py index 3d616e78d8c..3e4d2537230 100644 --- a/mne/preprocessing/tests/test_eeglab_infomax.py +++ b/mne/preprocessing/tests/test_eeglab_infomax.py @@ -33,8 +33,7 @@ def generate_data_for_comparing_against_eeglab_infomax(ch_type, random_state): # select a small number of channels for the test number_of_channels_to_use = 5 - with pytest.warns(FutureWarning, match="random_state"): - idx_perm = random_permutation(picks.shape[0], random_state=random_state) + idx_perm = random_permutation(picks.shape[0], random_state=random_state) picks = picks[idx_perm[:number_of_channels_to_use]] raw.filter( @@ -156,20 +155,19 @@ def test_mne_python_vs_eeglab(): # Call mne_python infomax version using the following syntax # to obtain the same result than eeglab version - with pytest.warns(FutureWarning, match="random_state"): - unmixing = infomax( - Y.T, - extended=use_extended, - random_state=random_state, - max_iter=max_iter_eeglab, - l_rate=l_rate_eeglab, - block=block_eeglab, - w_change=w_change_eeglab, - blowup=blowup_eeglab, - blowup_fac=blowup_fac_eeglab, - n_small_angle=None, - anneal_step=anneal_step_eeglab, - ) + unmixing = infomax( + Y.T, + extended=use_extended, + random_state=random_state, + max_iter=max_iter_eeglab, + l_rate=l_rate_eeglab, + block=block_eeglab, + w_change=w_change_eeglab, + blowup=blowup_eeglab, + blowup_fac=blowup_fac_eeglab, + n_small_angle=None, + anneal_step=anneal_step_eeglab, + ) # Order the components in the same way that eeglab does sources = np.dot(unmixing, Y) diff --git a/mne/preprocessing/tests/test_ica.py b/mne/preprocessing/tests/test_ica.py index 2a0df1d490b..567c3bc50ac 100644 --- a/mne/preprocessing/tests/test_ica.py +++ b/mne/preprocessing/tests/test_ica.py @@ -292,8 +292,7 @@ def test_ica_max_iter_(method, max_iter_default): def test_ica_rng_transition(): """Test the transition from random_state to rng.""" - with pytest.warns(FutureWarning, match="random_state"): - _ICA(random_state=0) + _ICA(random_state=0) with pytest.raises(TypeError, match="only one"): _ICA(random_state=0, rng=0) @@ -303,13 +302,12 @@ def test_ica_rng_transition(): raw = RawArray(np.random.default_rng(0).standard_normal((3, 200)), info) unmixings = [] for random_state in (0, check_random_state(0)): - with pytest.warns(FutureWarning, match="random_state"): - ica = _ICA( - n_components=2, - method="fastica", - max_iter=1000, - random_state=random_state, - ) + ica = _ICA( + n_components=2, + method="fastica", + max_iter=1000, + random_state=random_state, + ) with _record_warnings(): # ICA does not necessarily converge ica.fit(raw) unmixings.append(ica.unmixing_matrix_) diff --git a/mne/preprocessing/tests/test_infomax.py b/mne/preprocessing/tests/test_infomax.py index 1df7e1d56e3..075a99f2d47 100644 --- a/mne/preprocessing/tests/test_infomax.py +++ b/mne/preprocessing/tests/test_infomax.py @@ -195,16 +195,15 @@ def test_infomax_legacy_rng_nested(): X = np.random.default_rng(0).standard_normal((20, 2)) results = [] for random_state in (0, check_random_state(0)): - with pytest.warns(FutureWarning, match="random_state"): - results.append( - infomax( - X, - block=5, - extended=False, - max_iter=1, - random_state=random_state, - ) + results.append( + infomax( + X, + block=5, + extended=False, + max_iter=1, + random_state=random_state, ) + ) assert_array_equal(results[0], results[1]) diff --git a/mne/simulation/evoked.py b/mne/simulation/evoked.py index bdf20dae083..a74e7b18b4a 100644 --- a/mne/simulation/evoked.py +++ b/mne/simulation/evoked.py @@ -61,7 +61,7 @@ def simulate_evoked( .. versionadded:: 0.15.0 iir_filter : None | array IIR filter coefficients (denominator) e.g. [1, -1, 0.2]. - %(random_state_deprecated)s + %(random_state_rng)s %(use_cps)s .. versionadded:: 0.15 @@ -124,7 +124,7 @@ def add_noise(inst, cov, iir_filter=None, random_state=None, verbose=None, *, rn The noise covariance. iir_filter : None | array-like IIR filter coefficients (denominator). - %(random_state_deprecated)s + %(random_state_rng)s %(verbose)s %(rng)s diff --git a/mne/simulation/raw.py b/mne/simulation/raw.py index 757d9f141a5..72eeb50ebd1 100644 --- a/mne/simulation/raw.py +++ b/mne/simulation/raw.py @@ -408,7 +408,7 @@ def add_eog( %(head_pos)s %(interp)s %(n_jobs)s - %(random_state_deprecated)s + %(random_state_rng)s The random generator state used for blink, ECG, and sensor noise randomization. %(verbose)s @@ -472,7 +472,7 @@ def add_ecg( %(head_pos)s %(interp)s %(n_jobs)s - %(random_state_deprecated)s + %(random_state_rng)s The random generator state used for blink, ECG, and sensor noise randomization. %(verbose)s diff --git a/mne/simulation/source.py b/mne/simulation/source.py index 2263bbe19ba..9d0e478d07f 100644 --- a/mne/simulation/source.py +++ b/mne/simulation/source.py @@ -41,7 +41,7 @@ def select_source_in_label( The source space. label : Label The label. - %(random_state_deprecated)s + %(random_state_rng)s location : str The label location to choose. Can be 'random' (default) or 'center' to use :func:`mne.Label.center_of_mass` (restricting to vertices @@ -144,7 +144,7 @@ def simulate_sparse_stc( the same length containing the time courses. labels : None | list of Label The labels. The default is None, otherwise its size must be n_dipoles. - %(random_state_deprecated)s + %(random_state_rng)s location : str The label location to choose. Can be ``'random'`` (default) or ``'center'`` to use :func:`mne.Label.center_of_mass`. Note that for diff --git a/mne/simulation/tests/test_evoked.py b/mne/simulation/tests/test_evoked.py index c60287e6234..f805658fea3 100644 --- a/mne/simulation/tests/test_evoked.py +++ b/mne/simulation/tests/test_evoked.py @@ -148,8 +148,7 @@ def test_add_noise(): info = create_info(["EEG 001"], 100.0, "eeg") small_cov = Covariance(np.ones(1), info["ch_names"], [], [], 1) legacy = EpochsArray(np.zeros((2, 1, 5)), info, verbose=False) - with pytest.warns(FutureWarning, match="random_state"): - add_noise(legacy, small_cov, random_state=0) + add_noise(legacy, small_cov, random_state=0) want = np.array( [ 1.764052345967664, diff --git a/mne/simulation/tests/test_source.py b/mne/simulation/tests/test_source.py index fc5a350bce9..e86e00a3134 100644 --- a/mne/simulation/tests/test_source.py +++ b/mne/simulation/tests/test_source.py @@ -68,16 +68,15 @@ def test_simulate_sparse_stc_legacy_rng_nested(): ] results = [] for random_state in (0, check_random_state(0)): - with pytest.warns(FutureWarning, match="random_state"): - results.append( - simulate_sparse_stc( - src, - 2, - np.arange(2.0), - labels=labels, - random_state=random_state, - ) + results.append( + simulate_sparse_stc( + src, + 2, + np.arange(2.0), + labels=labels, + random_state=random_state, ) + ) for hemi in (0, 1): assert_array_equal(results[0].vertices[hemi], results[1].vertices[hemi]) diff --git a/mne/stats/cluster_level.py b/mne/stats/cluster_level.py index a735573aa3d..cc6b279af63 100644 --- a/mne/stats/cluster_level.py +++ b/mne/stats/cluster_level.py @@ -1152,7 +1152,7 @@ def permutation_cluster_test( %(stat_fun_clust_f)s %(adjacency_clust_n)s %(n_jobs)s - %(seed_deprecated)s + %(seed_rng)s %(max_step_clust)s %(exclude_clust)s %(step_down_p_clust)s @@ -1243,7 +1243,7 @@ def permutation_cluster_1samp_test( %(stat_fun_clust_t)s %(adjacency_clust_1)s %(n_jobs)s - %(seed_deprecated)s + %(seed_rng)s %(max_step_clust)s %(exclude_clust)s %(step_down_p_clust)s @@ -1360,7 +1360,7 @@ def spatio_temporal_cluster_1samp_test( %(stat_fun_clust_t)s %(adjacency_clust_st1)s %(n_jobs)s - %(seed_deprecated)s + %(seed_rng)s %(max_step_clust)s spatial_exclude : list of int or None List of spatial indices to exclude from clustering. @@ -1463,7 +1463,7 @@ def spatio_temporal_cluster_test( %(stat_fun_clust_f)s %(adjacency_clust_stn)s %(n_jobs)s - %(seed_deprecated)s + %(seed_rng)s %(max_step_clust)s spatial_exclude : list of int or None List of spatial indices to exclude from clustering. diff --git a/mne/stats/permutations.py b/mne/stats/permutations.py index 7662845c1ed..3348569005f 100644 --- a/mne/stats/permutations.py +++ b/mne/stats/permutations.py @@ -66,7 +66,7 @@ def permutation_t_test( than 0 (two tailed test). If tail is -1, the alternative hypothesis is that the mean of the data is less than 0 (lower tailed test). %(n_jobs)s - %(seed_deprecated)s + %(seed_rng)s %(verbose)s %(rng)s @@ -142,7 +142,7 @@ def bootstrap_confidence_interval( Number of bootstraps. stat_fun : str | callable Can be "mean", "median", or a callable operating along ``axis=0``. - %(random_state_deprecated)s + %(random_state_rng)s %(rng)s Returns diff --git a/mne/stats/tests/test_cluster_level.py b/mne/stats/tests/test_cluster_level.py index 3f4d28a27b8..3c24e53c111 100644 --- a/mne/stats/tests/test_cluster_level.py +++ b/mne/stats/tests/test_cluster_level.py @@ -77,16 +77,15 @@ def test_spatio_temporal_cluster_legacy_rng_nested(function, make_X): data = make_X(np.random.default_rng(0)) results = [] for seed in (0, check_random_state(0)): - with pytest.warns(FutureWarning, match="seed"): - results.append( - function( - data, - threshold=0, - n_permutations=2, - seed=seed, - out_type="mask", - ) + results.append( + function( + data, + threshold=0, + n_permutations=2, + seed=seed, + out_type="mask", ) + ) assert_array_equal(results[0][0], results[1][0]) assert_array_equal(results[0][2], results[1][2]) assert_array_equal(results[0][3], results[1][3]) diff --git a/mne/tests/test_dipole.py b/mne/tests/test_dipole.py index 8a558d435d1..d1c6e8e0bfb 100644 --- a/mne/tests/test_dipole.py +++ b/mne/tests/test_dipole.py @@ -141,10 +141,9 @@ def test_dipole_fitting(tmp_path): vertices = [np.sort(rng.permutation(s["vertno"])[:n_per_hemi]) for s in fwd["src"]] nv = sum(len(v) for v in vertices) stc = SourceEstimate(amp * np.eye(nv), vertices, 0, 0.001) - with pytest.warns(FutureWarning, match="random_state"): - evoked = simulate_evoked( - fwd, stc, evoked.info, cov, nave=evoked.nave, random_state=rng - ) + evoked = simulate_evoked( + fwd, stc, evoked.info, cov, nave=evoked.nave, random_state=rng + ) # For speed, let's use a subset of channels (strange but works) picks = np.sort( np.concatenate( diff --git a/mne/tests/test_docstring_parameters.py b/mne/tests/test_docstring_parameters.py index 8526ce641f8..4f901430325 100644 --- a/mne/tests/test_docstring_parameters.py +++ b/mne/tests/test_docstring_parameters.py @@ -323,51 +323,6 @@ def test_tabs(): "MultiTaskLasso", "PCA", } -mne_rng_functions = { - "ICA", - "add_ecg", - "add_eog", - "add_noise", - "bootstrap", - "bootstrap_confidence_interval", - "equalize_epoch_counts", - "equalize_event_counts", - "infomax", - "mixed_norm", - "permutation_cluster_1samp_test", - "permutation_cluster_test", - "permutation_t_test", - "random_parcellation", - "random_permutation", - "select_source_in_label", - "select_sources", - "simulate_evoked", - "simulate_sparse_stc", - "spatio_temporal_cluster_1samp_test", - "spatio_temporal_cluster_test", -} -# Zero-based position of the legacy RNG argument when it can be positional. -mne_rng_legacy_positions = { - "add_ecg": 4, - "add_eog": 4, - "add_noise": 3, - "bootstrap": 1, - "bootstrap_confidence_interval": 4, - "infomax": 12, - "mixed_norm": 21, - "permutation_cluster_1samp_test": 7, - "permutation_cluster_test": 7, - "permutation_t_test": 4, - "random_parcellation": 5, - "random_permutation": 1, - "select_source_in_label": 2, - "select_sources": 7, - "simulate_evoked": 6, - "simulate_sparse_stc": 5, - "spatio_temporal_cluster_1samp_test": 7, - "spatio_temporal_cluster_test": 7, -} - # These are compatibility implementations or reference fixtures whose expected # values were generated from the legacy bit stream. Keep this allowlist at the # function level so a new legacy RNG use elsewhere in the same file still fails. @@ -398,31 +353,6 @@ def _enclosing_function(node, parents): return None -def _expected_legacy_mne_call(node, parents): - """Return whether a deprecated call explicitly checks its transition.""" - while node in parents: - node = parents[node] - if not isinstance(node, ast.With): - continue - for item in node.items: - context = item.context_expr - if not isinstance(context, ast.Call): - continue - name = getattr(context.func, "id", None) or getattr( - context.func, "attr", None - ) - if name not in ("raises", "warns") or not context.args: - continue - category = context.args[0] - category = getattr(category, "id", None) or getattr(category, "attr", None) - if (name, category) in ( - ("raises", "TypeError"), - ("warns", "FutureWarning"), - ): - return True - return False - - def _is_np_random(node, numpy_aliases, numpy_random_aliases): """Return whether ``node`` is the ``np.random`` module attribute.""" return ( @@ -515,27 +445,6 @@ def _rng_violations(source, rel): bad.append( f"{rel}:{node.lineno}: {called_name}() (set random_state explicitly)" ) - # 5. authored calls to deprecated MNE RNG parameters - elif isinstance(node, ast.Call): - legacy = {"random_state", "seed"}.intersection( - kw.arg for kw in node.keywords - ) - legacy_position = mne_rng_legacy_positions.get(called_name) - legacy_positional = ( - legacy_position is not None and len(node.args) > legacy_position - ) - if ( - called_name in mne_rng_functions - and (legacy or legacy_positional) - and not _expected_legacy_mne_call(node, parents) - ): - spelling = ", ".join(sorted(legacy)) - if legacy_positional: - spelling = f"{spelling}, " if spelling else "" - spelling += "a positional legacy RNG" - bad.append( - f"{rel}:{node.lineno}: {called_name}() uses {spelling} (use rng)" - ) return bad diff --git a/mne/tests/test_epochs.py b/mne/tests/test_epochs.py index 27b15ed324c..64b4a5b3fda 100644 --- a/mne/tests/test_epochs.py +++ b/mne/tests/test_epochs.py @@ -2729,8 +2729,7 @@ def test_bootstrap(): assert len(epochs2.events) == len(epochs.events) assert epochs._data.shape == epochs2._data.shape - with pytest.warns(FutureWarning, match="random_state"): - bootstrap(epochs, random_state=0) + bootstrap(epochs, random_state=0) def test_epochs_copy(): @@ -3022,75 +3021,42 @@ def test_equalize_epoch_counts_random(): assert len(epochs_1) == len(epochs_2) -def _make_equalization_epochs(lengths): - """Create small EpochsArray instances for RNG stream tests.""" - info = create_info(["EEG 001"], 100.0, "eeg") - epochs = [] - for index, length in enumerate(lengths): - events = np.column_stack( - (np.arange(length), np.zeros(length, int), np.full(length, index + 1)) - ) - epochs.append( - EpochsArray( - np.zeros((length, 1, 1)), - info, - events=events, - event_id={str(index): index + 1}, - verbose=False, - ) - ) - return epochs - - -def _equalized_drop_inds(epochs): - """Get the indices of the dropped epochs for each condition.""" - return [ - np.flatnonzero([entry == ("EQUALIZED_COUNT",) for entry in epoch.drop_log]) - for epoch in epochs - ] - - -def test_equalize_epoch_counts_rng_streams(): +@pytest.mark.parametrize( + "api, legacy, want", + ( + ("counts", True, ([], [3, 4], [0, 3, 4])), + ("counts", False, ([], [1, 2], [0, 1, 2])), + ("events", True, [6, 7, 8, 11, 12]), + ("events", False, [4, 5, 8, 9, 10]), + ), +) +def test_equalize_epoch_counts_rng_streams(api, legacy, want): """Test legacy integers re-seed while new RNG streams advance.""" - epochs = _make_equalization_epochs((3, 5, 6)) - with pytest.warns(FutureWarning, match="random_state"): - equalize_epoch_counts(epochs, method="random", random_state=0) - for got, want in zip( - _equalized_drop_inds(epochs), ([], [3, 4], [0, 3, 4]), strict=True - ): - assert_array_equal(got, want) - - epochs = _make_equalization_epochs((3, 5, 6)) - equalize_epoch_counts(epochs, method="random", rng=0) - for got, want in zip( - _equalized_drop_inds(epochs), ([], [1, 2], [0, 1, 2]), strict=True - ): - assert_array_equal(got, want) - - events = np.column_stack( - ( - np.arange(14), - np.zeros(14, int), - np.repeat((1, 2, 3), (3, 5, 6)), - ) - ) - for kwargs, want in ( - (dict(random_state=0), [6, 7, 8, 11, 12]), - (dict(rng=0), [4, 5, 8, 9, 10]), - ): - epochs = EpochsArray( - np.zeros((14, 1, 1)), - create_info(["EEG 001"], 100.0, "eeg"), - events=events, - event_id={"a": 1, "b": 2, "c": 3}, + info = create_info(["EEG 001"], 100.0, "eeg") + epochs = [ + EpochsArray( + np.zeros((length, 1, 1)), + info, + events=np.column_stack( + (np.arange(length), np.zeros(length, int), np.full(length, code)) + ), + event_id={str(code): code}, verbose=False, ) - if "random_state" in kwargs: - with pytest.warns(FutureWarning, match="random_state"): - _, dropped = epochs.equalize_event_counts(method="random", **kwargs) - else: - _, dropped = epochs.equalize_event_counts(method="random", **kwargs) - assert_array_equal(dropped, want) + for code, length in enumerate((3, 5, 6), 1) + ] + kwargs = {"random_state" if legacy else "rng": 0} + if api == "counts": + equalize_epoch_counts(epochs, method="random", **kwargs) + got = [ + np.flatnonzero([entry == ("EQUALIZED_COUNT",) for entry in epoch.drop_log]) + for epoch in epochs + ] + else: + epochs = concatenate_epochs(epochs) + _, got = epochs.equalize_event_counts(method="random", **kwargs) + for this_got, expected in zip(got, want, strict=True): + assert_array_equal(this_got, expected) def test_access_by_name(tmp_path): diff --git a/mne/utils/check.py b/mne/utils/check.py index b2d10d1e01d..e1171a7c73c 100644 --- a/mne/utils/check.py +++ b/mne/utils/check.py @@ -276,11 +276,7 @@ def _legacy_rng_wrapper(*args, **kwargs): return function(*args, **kwargs) if "rng" in kwargs: raise TypeError(f"Specify only one of rng or {legacy_name}") - warn( - f"{legacy_name} is deprecated and will be removed in a future " - "release; use rng instead.", - FutureWarning, - ) + logger.info(f"{legacy_name}= is legacy; prefer rng= in new code") kwargs["rng"] = check_random_state(value) return function(*args, **kwargs) diff --git a/mne/utils/docs.py b/mne/utils/docs.py index 5ba8592c017..6af1ce5faee 100644 --- a/mne/utils/docs.py +++ b/mne/utils/docs.py @@ -3765,16 +3765,14 @@ def _reflow_param_docstring(docstring, has_first_line=True, width=75): ``RandomState`` to control the random-number stream. """ -docdict["random_state_deprecated"] = """ +docdict["random_state_rng"] = """ random_state : None | int | instance of ~numpy.random.RandomState - The legacy random-number control. If explicitly passed as ``None``, NumPy's - global :class:`~numpy.random.RandomState` singleton is used. An int creates - a legacy ``RandomState`` seeded with that value. Passing the same int to - ``rng`` uses :func:`numpy.random.default_rng` and produces a different - stream. If both parameters are omitted, a fresh ``Generator`` is used. - - .. deprecated:: 1.13 - Use ``rng`` instead. + The legacy random-number control, supported for compatibility. New code + should prefer ``rng``. If explicitly passed as ``None``, NumPy's global + :class:`~numpy.random.RandomState` singleton is used. An int creates a + ``RandomState`` seeded with that value. Passing the same int to ``rng`` + uses :func:`numpy.random.default_rng` and produces a different stream. If + both parameters are omitted, a fresh ``Generator`` is used. """ _rank_base = """ @@ -4157,9 +4155,7 @@ def _reflow_param_docstring(docstring, has_first_line=True, width=75): ``RandomState`` to control the random-number stream. """ -docdict["seed_deprecated"] = docdict["random_state_deprecated"].replace( - "random_state", "seed" -) +docdict["seed_rng"] = docdict["random_state_rng"].replace("random_state", "seed") docdict["seeg"] = """ seeg : bool diff --git a/mne/utils/numerics.py b/mne/utils/numerics.py index ff00273ce7a..283c28d1619 100644 --- a/mne/utils/numerics.py +++ b/mne/utils/numerics.py @@ -289,7 +289,7 @@ def random_permutation(n_samples, random_state=None, *, rng=None): n_samples : int End point of the sequence to be permuted (excluded, i.e., the end point is equal to n_samples-1) - %(random_state_deprecated)s + %(random_state_rng)s %(rng)s Returns diff --git a/mne/utils/tests/test_check.py b/mne/utils/tests/test_check.py index ad10b39f1e5..f4a0dac1b31 100644 --- a/mne/utils/tests/test_check.py +++ b/mne/utils/tests/test_check.py @@ -68,11 +68,14 @@ def test_check_rng(): _check_rng("foo") -def test_legacy_rng_decorator(): - """Test that the transition decorator normalizes and warns.""" +@pytest.mark.parametrize( + "legacy_name, legacy_args", (("random_state", (0,)), ("seed", (None, 0))) +) +def test_legacy_rng_decorator(legacy_name, legacy_args): + """Test that the transition decorator normalizes and logs.""" - @_legacy_rng("random_state") - def _func(random_state=None, *, rng=None): + @_legacy_rng(legacy_name) + def _func(random_state=None, seed=None, *, rng=None): return rng # no argument: a fresh generator is created @@ -84,16 +87,17 @@ def _func(random_state=None, *, rng=None): # legacy RandomState passthrough random_state = np.random.RandomState(0) assert _func(rng=random_state) is random_state - # legacy int/None keep their RandomState semantics, with a deprecation warning - with pytest.warns(FutureWarning, match="random_state is deprecated"): - assert isinstance(_func(random_state=0), np.random.RandomState) - with pytest.warns(FutureWarning, match="random_state is deprecated"): - assert isinstance(_func(random_state=None), np.random.mtrand.RandomState) + # legacy int/None keep their RandomState semantics and log migration guidance + with catch_logging() as log: + assert isinstance(_func(**{legacy_name: 0}), np.random.RandomState) + assert f"{legacy_name}= is legacy; prefer rng=" in log.getvalue() + assert isinstance(_func(**{legacy_name: None}), np.random.mtrand.RandomState) # supplying both is an error; positional legacy arguments are supported + assert isinstance(_func(*legacy_args), np.random.RandomState) with pytest.raises(TypeError, match="Specify only one"): - _func(0, rng=0) + _func(*legacy_args, rng=0) with pytest.raises(TypeError, match="Specify only one"): - _func(random_state=0, rng=0) + _func(**{legacy_name: 0}, rng=0) @testing.requires_testing_data diff --git a/mne/utils/tests/test_numerics.py b/mne/utils/tests/test_numerics.py index 4302466dcdd..1f308222438 100644 --- a/mne/utils/tests/test_numerics.py +++ b/mne/utils/tests/test_numerics.py @@ -217,12 +217,11 @@ def test_freq_mask(): def test_random_permutation(): """Test random permutation function and its RNG transition.""" n_samples = 10 - with pytest.warns(FutureWarning, match="random_state"): - # matlab output when we execute rng(42), randperm(10) - assert_array_equal( - random_permutation(n_samples, 42), - np.array([7, 6, 5, 1, 4, 9, 10, 3, 8, 2]) - 1, - ) + # matlab output when we execute rng(42), randperm(10) + assert_array_equal( + random_permutation(n_samples, 42), + np.array([7, 6, 5, 1, 4, 9, 10, 3, 8, 2]) - 1, + ) # an integer ``rng`` seed is reproducible while a Generator instance advances assert_array_equal( random_permutation(n_samples, rng=42), random_permutation(n_samples, rng=42) @@ -241,11 +240,10 @@ def test_random_permutation_legacy_none(use_keyword): want = np.array([6, 5, 4, 0, 3, 8, 9, 2, 7, 1]) try: global_rng.set_state(check_random_state(42).get_state()) - with pytest.warns(FutureWarning, match="random_state"): - if use_keyword: - got = random_permutation(10, random_state=None) - else: - got = random_permutation(10, None) + if use_keyword: + got = random_permutation(10, random_state=None) + else: + got = random_permutation(10, None) assert_array_equal(got, want) finally: global_rng.set_state(original_state) diff --git a/mne/viz/tests/test_circle.py b/mne/viz/tests/test_circle.py index c968b188d81..1a1e541d7cd 100644 --- a/mne/viz/tests/test_circle.py +++ b/mne/viz/tests/test_circle.py @@ -89,25 +89,10 @@ def test_plot_connectivity_circle_jitter_reproducible(): """Test connectivity-circle edge jitter uses a fixed local Generator.""" con = np.array([[0.0, 1.0, 2.0], [1.0, 0.0, 3.0], [2.0, 3.0, 0.0]]) vertices = [] - global_rng = np.random.mtrand._rand - original_rng_state = global_rng.get_state() - try: - for seed in (0, 1): - global_rng.set_state(np.random.RandomState(seed).get_state()) - fig, ax = _plot_connectivity_circle( - con, ["a", "b", "c"], colorbar=False, interactive=False, show=False - ) - vertices.append(ax.patches[0].get_path().vertices) - fig.clear() - assert_allclose(vertices[0], vertices[1]) - assert_allclose( - vertices[0], - [ - [2.16610807, 10.0], - [2.16610807, 5.0], - [-0.25314554, 5.0], - [-0.25314554, 10.0], - ], + for _ in range(2): + fig, ax = _plot_connectivity_circle( + con, ["a", "b", "c"], colorbar=False, interactive=False, show=False ) - finally: - global_rng.set_state(original_rng_state) + vertices.append(ax.patches[0].get_path().vertices) + fig.clear() + assert_allclose(*vertices) diff --git a/mne/viz/tests/test_utils.py b/mne/viz/tests/test_utils.py index c34f2d8e9c1..c02fbe4a4c0 100644 --- a/mne/viz/tests/test_utils.py +++ b/mne/viz/tests/test_utils.py @@ -225,16 +225,8 @@ def test_auto_scale_epoch_sampling_reproducible(monkeypatch): "mne.viz.utils.np.clip", partial(_limit_epoch_sample, n_epochs=len(epochs), clip=clip), ) - scalings = [] - global_rng = np.random.mtrand._rand - original_rng_state = global_rng.get_state() - try: - for seed in (0, 1): - global_rng.set_state(np.random.RandomState(seed).get_state()) - scalings.append(_compute_scalings({"eeg": "auto"}, epochs)["eeg"]) - assert_allclose(scalings, [12.5, 12.5]) - finally: - global_rng.set_state(original_rng_state) + scalings = [_compute_scalings({"eeg": "auto"}, epochs)["eeg"] for _ in range(2)] + assert_allclose(scalings, [12.5, 12.5]) def test_validate_if_list_of_axes(): From 5dacac401c6751b2e338b095dd014b646e7d5356 Mon Sep 17 00:00:00 2001 From: Bru Date: Tue, 25 Aug 2026 11:58:57 +0200 Subject: [PATCH 24/34] DOC: Simplify RNG migration wording --- doc/changes/dev/14199.apichange.rst | 2 +- mne/preprocessing/ica.py | 2 +- mne/utils/check.py | 2 +- mne/utils/docs.py | 8 ++------ mne/utils/tests/test_check.py | 2 +- 5 files changed, 6 insertions(+), 10 deletions(-) diff --git a/doc/changes/dev/14199.apichange.rst b/doc/changes/dev/14199.apichange.rst index b2c014ec1a9..4befaa32244 100644 --- a/doc/changes/dev/14199.apichange.rst +++ b/doc/changes/dev/14199.apichange.rst @@ -1 +1 @@ -Add keyword-only ``rng`` parameters backed by :class:`numpy.random.Generator` to statistical, epoch-sampling, simulation, label, ICA, and sparse-inverse APIs. The legacy ``seed`` and ``random_state`` parameters remain supported, while new code should prefer ``rng``. Omitting both parameters creates a fresh generator, whereas explicitly passing ``None`` to a legacy parameter retains NumPy's global :class:`~numpy.random.RandomState` stream. An integer passed to ``rng`` uses :func:`numpy.random.default_rng`, so it intentionally produces different results from the same integer passed to a legacy parameter. Legacy :class:`~numpy.random.RandomState` instances are also accepted by ``rng`` for interoperability with third-party code such as scikit-learn that does not support generators (:gh:`9233` by `Bruno Aristimunha`_). +Add ``rng`` parameters backed by :class:`numpy.random.Generator` to APIs that use randomness. The existing ``seed`` and ``random_state`` parameters remain supported, but new code should prefer ``rng`` (:gh:`9233` by `Bruno Aristimunha`_). diff --git a/mne/preprocessing/ica.py b/mne/preprocessing/ica.py index 9b6238ef4ce..8840c3b0ea7 100644 --- a/mne/preprocessing/ica.py +++ b/mne/preprocessing/ica.py @@ -479,7 +479,7 @@ def __init__( if rng is not None and random_state is not None: raise TypeError("Specify only one of rng or random_state") if random_state is not None: - logger.info("random_state= is legacy; prefer rng= in new code") + logger.info("Use rng= instead of random_state= in new code") self.random_state = random_state # stored un-normalized so that integer seeds stay intact for the # third-party ``random_state`` parameters used during fitting diff --git a/mne/utils/check.py b/mne/utils/check.py index e1171a7c73c..24e78ff97db 100644 --- a/mne/utils/check.py +++ b/mne/utils/check.py @@ -276,7 +276,7 @@ def _legacy_rng_wrapper(*args, **kwargs): return function(*args, **kwargs) if "rng" in kwargs: raise TypeError(f"Specify only one of rng or {legacy_name}") - logger.info(f"{legacy_name}= is legacy; prefer rng= in new code") + logger.info(f"Use rng= instead of {legacy_name}= in new code") kwargs["rng"] = check_random_state(value) return function(*args, **kwargs) diff --git a/mne/utils/docs.py b/mne/utils/docs.py index 6af1ce5faee..3f978cdcb83 100644 --- a/mne/utils/docs.py +++ b/mne/utils/docs.py @@ -3767,12 +3767,8 @@ def _reflow_param_docstring(docstring, has_first_line=True, width=75): docdict["random_state_rng"] = """ random_state : None | int | instance of ~numpy.random.RandomState - The legacy random-number control, supported for compatibility. New code - should prefer ``rng``. If explicitly passed as ``None``, NumPy's global - :class:`~numpy.random.RandomState` singleton is used. An int creates a - ``RandomState`` seeded with that value. Passing the same int to ``rng`` - uses :func:`numpy.random.default_rng` and produces a different stream. If - both parameters are omitted, a fresh ``Generator`` is used. + Supported for compatibility. New code should use ``rng``. If ``None``, + NumPy's global :class:`~numpy.random.RandomState` is used. """ _rank_base = """ diff --git a/mne/utils/tests/test_check.py b/mne/utils/tests/test_check.py index f4a0dac1b31..d3c4981c4de 100644 --- a/mne/utils/tests/test_check.py +++ b/mne/utils/tests/test_check.py @@ -90,7 +90,7 @@ def _func(random_state=None, seed=None, *, rng=None): # legacy int/None keep their RandomState semantics and log migration guidance with catch_logging() as log: assert isinstance(_func(**{legacy_name: 0}), np.random.RandomState) - assert f"{legacy_name}= is legacy; prefer rng=" in log.getvalue() + assert f"Use rng= instead of {legacy_name}=" in log.getvalue() assert isinstance(_func(**{legacy_name: None}), np.random.mtrand.RandomState) # supplying both is an error; positional legacy arguments are supported assert isinstance(_func(*legacy_args), np.random.RandomState) From d37e0e8af1d68ff65103144b1097f766b000592c Mon Sep 17 00:00:00 2001 From: Bru Date: Tue, 25 Aug 2026 12:01:09 +0200 Subject: [PATCH 25/34] MAINT: Simplify RNG checks --- doc/changes/dev/14199.apichange.rst | 2 +- mne/tests/test_docstring_parameters.py | 159 ++++--------------------- 2 files changed, 27 insertions(+), 134 deletions(-) diff --git a/doc/changes/dev/14199.apichange.rst b/doc/changes/dev/14199.apichange.rst index 4befaa32244..eab825dfb20 100644 --- a/doc/changes/dev/14199.apichange.rst +++ b/doc/changes/dev/14199.apichange.rst @@ -1 +1 @@ -Add ``rng`` parameters backed by :class:`numpy.random.Generator` to APIs that use randomness. The existing ``seed`` and ``random_state`` parameters remain supported, but new code should prefer ``rng`` (:gh:`9233` by `Bruno Aristimunha`_). +Add ``rng`` parameters to APIs that use randomness, while retaining ``seed`` and ``random_state`` for compatibility (:gh:`9233` by `Bruno Aristimunha`_). diff --git a/mne/tests/test_docstring_parameters.py b/mne/tests/test_docstring_parameters.py index 4f901430325..c6f1f6358ea 100644 --- a/mne/tests/test_docstring_parameters.py +++ b/mne/tests/test_docstring_parameters.py @@ -305,7 +305,7 @@ def test_tabs(): # (``np.random.seed``/``np.random.randn``/...) makes tests order-dependent and # flaky, and the legacy ``RandomState`` methods below don't exist on a # ``Generator``, so calling them silently locks code to the old bit stream. -global_rng_ok = ("default_rng", "Generator") +global_rng_ok = ("default_rng", "RandomState", "Generator", "mtrand") legacy_rng_methods = { "randn": "standard_normal", "rand": "random", @@ -323,144 +323,16 @@ def test_tabs(): "MultiTaskLasso", "PCA", } -# These are compatibility implementations or reference fixtures whose expected -# values were generated from the legacy bit stream. Keep this allowlist at the -# function level so a new legacy RNG use elsewhere in the same file still fails. -legacy_rng_allowlist = { - ("mne/decoding/tests/test_csp.py", "test_ajd"), - ("mne/preprocessing/ica.py", "_serialize"), - ("mne/stats/tests/test_parametric.py", "generate_data"), - ("mne/tests/test_cov.py", "test_auto_low_rank_ignores_global_rng"), - ("mne/tests/test_dipole.py", "test_dipole_fitting"), - ("mne/utils/check.py", "_check_rng"), - ("mne/utils/check.py", "check_random_state"), - ("mne/utils/tests/test_check.py", "test_check_rng"), - ("mne/utils/tests/test_check.py", "test_legacy_rng_decorator"), - ( - "mne/viz/tests/test_circle.py", - "test_plot_connectivity_circle_jitter_reproducible", - ), - ("mne/viz/tests/test_utils.py", "test_auto_scale_epoch_sampling_reproducible"), -} - - -def _enclosing_function(node, parents): - """Get the name of the function containing an AST node.""" - while node in parents: - node = parents[node] - if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef): - return node.name - return None -def _is_np_random(node, numpy_aliases, numpy_random_aliases): +def _is_np_random(node): """Return whether ``node`` is the ``np.random`` module attribute.""" return ( isinstance(node, ast.Attribute) and node.attr == "random" and isinstance(node.value, ast.Name) - and node.value.id in numpy_aliases - ) or (isinstance(node, ast.Name) and node.id in numpy_random_aliases) - - -def _rng_violations(source, rel): - """Find outdated RNG use in Python source.""" - bad = [] - tree = ast.parse(source) - parents = { - child: parent - for parent in ast.walk(tree) - for child in ast.iter_child_nodes(parent) - } - import_aliases = { - alias.asname or alias.name: alias.name - for node in ast.walk(tree) - if isinstance(node, ast.ImportFrom) - for alias in node.names - } - numpy_aliases = {"np", "numpy"} - numpy_random_aliases = set() - random_state_aliases = set() - for node in ast.walk(tree): - if isinstance(node, ast.Import): - for alias in node.names: - if alias.name == "numpy": - numpy_aliases.add(alias.asname or alias.name) - elif alias.name == "numpy.random" and alias.asname: - numpy_random_aliases.add(alias.asname) - elif isinstance(node, ast.ImportFrom): - for alias in node.names: - if node.module == "numpy" and alias.name == "random": - numpy_random_aliases.add(alias.asname or alias.name) - elif node.module == "numpy.random" and alias.name == "RandomState": - random_state_aliases.add(alias.asname or alias.name) - for node in ast.walk(tree): - function = _enclosing_function(node, parents) - legacy_allowed = (rel, function) in legacy_rng_allowlist - called_name = None - if isinstance(node, ast.Call): - called_name = getattr(node.func, "id", None) or getattr( - node.func, "attr", None - ) - called_name = import_aliases.get(called_name, called_name) - # 1. the global RNG: ``np.random.`` / ``numpy.random.`` - if ( - isinstance(node, ast.Attribute) - and node.attr not in global_rng_ok - and _is_np_random(node.value, numpy_aliases, numpy_random_aliases) - and not legacy_allowed - ): - bad.append( - f"{rel}:{node.lineno}: np.random.{node.attr} " - "(use a local np.random.default_rng)" - ) - # 2. imported legacy RandomState constructors - elif ( - isinstance(node, ast.Call) - and isinstance(node.func, ast.Name) - and node.func.id in random_state_aliases - and not legacy_allowed - ): - bad.append( - f"{rel}:{node.lineno}: numpy.random.RandomState " - "(use a local np.random.default_rng)" - ) - # 3. legacy RandomState-only methods, e.g. ``rng.randn(...)`` - elif ( - isinstance(node, ast.Call) - and isinstance(node.func, ast.Attribute) - and node.func.attr in legacy_rng_methods - and not _is_np_random(node.func.value, numpy_aliases, numpy_random_aliases) - and not legacy_allowed - ): - want = legacy_rng_methods[node.func.attr] - bad.append(f"{rel}:{node.lineno}: .{node.func.attr}() (use {want})") - # 4. MNE-owned sklearn estimators with implicit randomness - elif ( - "/tests/" not in rel - and isinstance(node, ast.Call) - and called_name in sklearn_rng_estimators - and not any(kw.arg == "random_state" for kw in node.keywords) - ): - bad.append( - f"{rel}:{node.lineno}: {called_name}() (set random_state explicitly)" - ) - return bad - - -@pytest.mark.parametrize( - "source", - ( - "from numpy.random import RandomState as RS\nRS(0)\n", - "import numpy.random as npr\nnpr.RandomState(0)\n", - ), - ids=("from-import", "module-alias"), -) -def test_no_aliased_random_state(source): - """Test that aliased legacy RNG constructors are rejected.""" - bad = _rng_violations(source, "mne/tests/rng_alias_probe.py") - assert len(bad) == 1 - assert "RandomState" in bad[0] + and node.value.id in ("np", "numpy") + ) def test_no_global_rng(): @@ -473,7 +345,28 @@ def test_no_global_rng(): continue for path in sorted(base.rglob("*.py")): rel = path.relative_to(root).as_posix() - bad.extend(_rng_violations(path.read_text("utf-8"), rel)) + for node in ast.walk(ast.parse(path.read_text("utf-8"))): + if ( + isinstance(node, ast.Attribute) + and node.attr not in global_rng_ok + and _is_np_random(node.value) + ): + bad.append(f"{rel}:{node.lineno}: global np.random.{node.attr}") + elif ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and node.func.attr in legacy_rng_methods + and not _is_np_random(node.func.value) + ): + bad.append(f"{rel}:{node.lineno}: legacy .{node.func.attr}()") + elif "/tests/" not in rel and isinstance(node, ast.Call): + name = getattr(node.func, "id", None) or getattr( + node.func, "attr", None + ) + if name in sklearn_rng_estimators and not any( + kw.arg == "random_state" for kw in node.keywords + ): + bad.append(f"{rel}:{node.lineno}: {name}() needs random_state") if bad: raise AssertionError( f"{len(bad)} outdated numpy RNG use{_pl(bad)} found:\n" + "\n".join(bad) From dbbcce0646802ce880915f27e3c42e2c31abe65c Mon Sep 17 00:00:00 2001 From: Bru Date: Tue, 25 Aug 2026 12:07:02 +0200 Subject: [PATCH 26/34] MAINT: Trim redundant RNG tests --- mne/preprocessing/tests/test_ica.py | 35 +++++------------------- mne/simulation/tests/test_source.py | 34 ------------------------ mne/stats/tests/test_cluster_level.py | 37 +------------------------- mne/tests/test_cov.py | 34 ------------------------ mne/tests/test_epochs.py | 38 --------------------------- mne/utils/tests/test_check.py | 6 ----- mne/utils/tests/test_numerics.py | 22 ---------------- mne/viz/tests/test_circle.py | 14 ---------- mne/viz/tests/test_utils.py | 38 ++------------------------- 9 files changed, 10 insertions(+), 248 deletions(-) diff --git a/mne/preprocessing/tests/test_ica.py b/mne/preprocessing/tests/test_ica.py index 567c3bc50ac..dacca6dccac 100644 --- a/mne/preprocessing/tests/test_ica.py +++ b/mne/preprocessing/tests/test_ica.py @@ -292,7 +292,6 @@ def test_ica_max_iter_(method, max_iter_default): def test_ica_rng_transition(): """Test the transition from random_state to rng.""" - _ICA(random_state=0) with pytest.raises(TypeError, match="only one"): _ICA(random_state=0, rng=0) @@ -301,46 +300,26 @@ def test_ica_rng_transition(): info["highpass"] = 1.0 raw = RawArray(np.random.default_rng(0).standard_normal((3, 200)), info) unmixings = [] - for random_state in (0, check_random_state(0)): + for kwargs in ( + dict(random_state=0), + dict(random_state=check_random_state(0)), + dict(rng=0), + ): ica = _ICA( n_components=2, method="fastica", max_iter=1000, - random_state=random_state, + **kwargs, ) with _record_warnings(): # ICA does not necessarily converge ica.fit(raw) unmixings.append(ica.unmixing_matrix_) - ica = _ICA(n_components=2, method="fastica", max_iter=1000, rng=0) - with _record_warnings(): # ICA does not necessarily converge - ica.fit(raw) - unmixings.append(ica.unmixing_matrix_) assert_array_equal(unmixings[0], unmixings[1]) # at the ICA/sklearn boundary an integer ``rng`` seed is forwarded verbatim, - # so it matches the same integer passed to the deprecated parameter + # so it matches the same integer passed to the legacy parameter assert_array_equal(unmixings[0], unmixings[2]) -def test_ica_infomax_fit_params_verbose(): - """Test Infomax fit_params can suppress its private logging scope.""" - info = create_info(["Fz", "Cz", "Pz"], 100.0, "eeg") - with info._unlock(): - info["highpass"] = 1.0 - raw = RawArray(np.random.default_rng(0).standard_normal((3, 200)), info) - ica = _ICA( - n_components=2, - method="infomax", - fit_params={"verbose": False}, - max_iter=1, - rng=0, - ) - with catch_logging(True) as log: - ica.fit(raw, verbose=True) - log = log.getvalue() - assert "Fitting ICA to data" in log - assert "Computing Infomax ICA" not in log - - @pytest.mark.parametrize("method", ["infomax", "fastica", "picard"]) def test_ica_n_iter_(method, tmp_path): """Test that ICA.n_iter_ is set after fitting.""" diff --git a/mne/simulation/tests/test_source.py b/mne/simulation/tests/test_source.py index e86e00a3134..a2f35b2221e 100644 --- a/mne/simulation/tests/test_source.py +++ b/mne/simulation/tests/test_source.py @@ -7,7 +7,6 @@ from numpy.testing import assert_array_almost_equal, assert_array_equal, assert_equal from mne import ( - SourceSpaces, convert_forward_solution, pick_types_forward, read_forward_solution, @@ -16,7 +15,6 @@ from mne.datasets import testing from mne.label import Label from mne.simulation import SourceSimulator, simulate_sparse_stc, simulate_stc -from mne.utils import check_random_state data_path = testing.data_path(download=False) fname_fwd = data_path / "MEG" / "sample" / "sample_audvis_trunc-meg-eeg-oct-6-fwd.fif" @@ -49,38 +47,6 @@ def _get_idx_label_stc(label, stc): return idx -def test_simulate_sparse_stc_legacy_rng_nested(): - """Test legacy RNGs survive nested label-source selection.""" - src = SourceSpaces( - [ - dict( - type="surf", - vertno=np.arange(1, 4) + 3 * hemi, - nuse=3, - subject_his_id="sample", - ) - for hemi in range(2) - ] - ) - labels = [ - Label(np.arange(1, 4) + 3 * idx, hemi=hemi, subject="sample") - for idx, hemi in enumerate(("lh", "rh")) - ] - results = [] - for random_state in (0, check_random_state(0)): - results.append( - simulate_sparse_stc( - src, - 2, - np.arange(2.0), - labels=labels, - random_state=random_state, - ) - ) - for hemi in (0, 1): - assert_array_equal(results[0].vertices[hemi], results[1].vertices[hemi]) - - def test_simulate_stc(_get_fwd_labels): """Test generation of source estimate.""" fwd, labels = _get_fwd_labels diff --git a/mne/stats/tests/test_cluster_level.py b/mne/stats/tests/test_cluster_level.py index 3c24e53c111..e306de32c81 100644 --- a/mne/stats/tests/test_cluster_level.py +++ b/mne/stats/tests/test_cluster_level.py @@ -28,7 +28,7 @@ summarize_clusters_stc, ttest_1samp_no_p, ) -from mne.utils import _record_warnings, catch_logging, check_random_state +from mne.utils import _record_warnings, catch_logging n_space = 50 @@ -56,41 +56,6 @@ def _get_conditions(): return condition1_1d, condition2_1d, condition1_2d, condition2_2d -@pytest.mark.parametrize( - "function, make_X", - ( - ( - spatio_temporal_cluster_1samp_test, - lambda rng: rng.standard_normal((8, 3, 1)), - ), - ( - spatio_temporal_cluster_test, - lambda rng: [ - rng.standard_normal((8, 3, 1)), - rng.standard_normal((8, 3, 1)), - ], - ), - ), -) -def test_spatio_temporal_cluster_legacy_rng_nested(function, make_X): - """Test legacy RNGs survive nested spatio-temporal wrappers.""" - data = make_X(np.random.default_rng(0)) - results = [] - for seed in (0, check_random_state(0)): - results.append( - function( - data, - threshold=0, - n_permutations=2, - seed=seed, - out_type="mask", - ) - ) - assert_array_equal(results[0][0], results[1][0]) - assert_array_equal(results[0][2], results[1][2]) - assert_array_equal(results[0][3], results[1][3]) - - def test_thresholds(numba_conditional): """Test automatic threshold calculations.""" # within subjects diff --git a/mne/tests/test_cov.py b/mne/tests/test_cov.py index 406ae07426c..8d4e4b3b467 100644 --- a/mne/tests/test_cov.py +++ b/mne/tests/test_cov.py @@ -673,40 +673,6 @@ def get_data(n_samples, n_features, rank, sigma): ) -@pytest.mark.parametrize( - ("mode", "method_params"), - ( - ("pca", dict(svd_solver="randomized")), - ("factor_analysis", dict(svd_method="randomized")), - ), -) -def test_auto_low_rank_ignores_global_rng(mode, method_params): - """Test low-rank covariance models use an explicit sklearn RNG state.""" - pytest.importorskip("sklearn") - rng = np.random.default_rng(42) - mixing = rng.standard_normal((10, 10)) - data = rng.standard_normal((400, 5)) - data = data @ _safe_svd(mixing.copy())[0][:, :5].T - data += rng.normal(scale=0.1 * rng.random(10) + 0.05, size=data.shape) - data *= 1e8 - global_rng = np.random.mtrand._rand - original_rng_state = global_rng.get_state() - try: - global_rng.set_state(np.random.RandomState(42).get_state()) - global_rng_state = global_rng.get_state() - est, _ = _auto_low_rank_model( - data, - mode=mode, - n_jobs=1, - method_params=dict(iter_n_components=[4], **method_params), - cv=2, - ) - assert_array_equal(global_rng.get_state()[1], global_rng_state[1]) - assert est.random_state == 0 - finally: - global_rng.set_state(original_rng_state) - - @pytest.mark.slowtest @pytest.mark.parametrize("rank", ("full", None, "info")) def test_compute_covariance_auto_reg(rank): diff --git a/mne/tests/test_epochs.py b/mne/tests/test_epochs.py index 64b4a5b3fda..d56b863d611 100644 --- a/mne/tests/test_epochs.py +++ b/mne/tests/test_epochs.py @@ -3021,44 +3021,6 @@ def test_equalize_epoch_counts_random(): assert len(epochs_1) == len(epochs_2) -@pytest.mark.parametrize( - "api, legacy, want", - ( - ("counts", True, ([], [3, 4], [0, 3, 4])), - ("counts", False, ([], [1, 2], [0, 1, 2])), - ("events", True, [6, 7, 8, 11, 12]), - ("events", False, [4, 5, 8, 9, 10]), - ), -) -def test_equalize_epoch_counts_rng_streams(api, legacy, want): - """Test legacy integers re-seed while new RNG streams advance.""" - info = create_info(["EEG 001"], 100.0, "eeg") - epochs = [ - EpochsArray( - np.zeros((length, 1, 1)), - info, - events=np.column_stack( - (np.arange(length), np.zeros(length, int), np.full(length, code)) - ), - event_id={str(code): code}, - verbose=False, - ) - for code, length in enumerate((3, 5, 6), 1) - ] - kwargs = {"random_state" if legacy else "rng": 0} - if api == "counts": - equalize_epoch_counts(epochs, method="random", **kwargs) - got = [ - np.flatnonzero([entry == ("EQUALIZED_COUNT",) for entry in epoch.drop_log]) - for epoch in epochs - ] - else: - epochs = concatenate_epochs(epochs) - _, got = epochs.equalize_event_counts(method="random", **kwargs) - for this_got, expected in zip(got, want, strict=True): - assert_array_equal(this_got, expected) - - def test_access_by_name(tmp_path): """Test accessing epochs by event name and on_missing for rare events.""" raw, events, picks = _get_data() diff --git a/mne/utils/tests/test_check.py b/mne/utils/tests/test_check.py index d3c4981c4de..ed2f204e05c 100644 --- a/mne/utils/tests/test_check.py +++ b/mne/utils/tests/test_check.py @@ -58,9 +58,6 @@ def test_check_rng(): assert_array_equal(rng.integers(10, size=3), _check_rng(0).integers(10, size=3)) assert _check_rng(rng) is rng - bit_generator = np.random.default_rng(0).bit_generator - assert isinstance(_check_rng(bit_generator.seed_seq), np.random.Generator) - assert isinstance(_check_rng(bit_generator), np.random.Generator) # legacy RandomState instances are passed through for scikit-learn interop random_state = np.random.RandomState(0) assert _check_rng(random_state) is random_state @@ -81,9 +78,6 @@ def _func(random_state=None, seed=None, *, rng=None): # no argument: a fresh generator is created assert isinstance(_func(), np.random.Generator) assert isinstance(_func(rng=0), np.random.Generator) - assert_array_equal( - _func(rng=0).integers(10, size=3), _func(rng=0).integers(10, size=3) - ) # legacy RandomState passthrough random_state = np.random.RandomState(0) assert _func(rng=random_state) is random_state diff --git a/mne/utils/tests/test_numerics.py b/mne/utils/tests/test_numerics.py index 1f308222438..ee4682d52a8 100644 --- a/mne/utils/tests/test_numerics.py +++ b/mne/utils/tests/test_numerics.py @@ -4,7 +4,6 @@ from copy import deepcopy from datetime import date -from inspect import signature from io import StringIO from pathlib import Path @@ -34,7 +33,6 @@ _time_mask, _undo_scaling_array, _undo_scaling_cov, - check_random_state, compute_corr, create_slices, grand_average, @@ -232,26 +230,6 @@ def test_random_permutation(): ) -@pytest.mark.parametrize("use_keyword", (False, True)) -def test_random_permutation_legacy_none(use_keyword): - """Test explicit legacy None uses NumPy's global RandomState.""" - global_rng = check_random_state(None) - original_state = global_rng.get_state() - want = np.array([6, 5, 4, 0, 3, 8, 9, 2, 7, 1]) - try: - global_rng.set_state(check_random_state(42).get_state()) - if use_keyword: - got = random_permutation(10, random_state=None) - else: - got = random_permutation(10, None) - assert_array_equal(got, want) - finally: - global_rng.set_state(original_state) - assert str(signature(random_permutation)) == ( - "(n_samples, random_state=None, *, rng=None)" - ) - - def test_cov_scaling(): """Test rescaling covs.""" evoked = read_evokeds(ave_fname, condition=0, baseline=(None, 0), proj=True) diff --git a/mne/viz/tests/test_circle.py b/mne/viz/tests/test_circle.py index 1a1e541d7cd..092a9cdd481 100644 --- a/mne/viz/tests/test_circle.py +++ b/mne/viz/tests/test_circle.py @@ -6,7 +6,6 @@ import matplotlib import numpy as np import pytest -from numpy.testing import assert_allclose from mne.viz import plot_channel_labels_circle from mne.viz.circle import _plot_connectivity_circle @@ -83,16 +82,3 @@ def test_plot_connectivity_circle_label_orientation(): f"Node '{name}' at {angle:.1f}° (left half) should have " f"ha='right', got '{ha}'" ) - - -def test_plot_connectivity_circle_jitter_reproducible(): - """Test connectivity-circle edge jitter uses a fixed local Generator.""" - con = np.array([[0.0, 1.0, 2.0], [1.0, 0.0, 3.0], [2.0, 3.0, 0.0]]) - vertices = [] - for _ in range(2): - fig, ax = _plot_connectivity_circle( - con, ["a", "b", "c"], colorbar=False, interactive=False, show=False - ) - vertices.append(ax.patches[0].get_path().vertices) - fig.clear() - assert_allclose(*vertices) diff --git a/mne/viz/tests/test_utils.py b/mne/viz/tests/test_utils.py index c02fbe4a4c0..339fc5b5edd 100644 --- a/mne/viz/tests/test_utils.py +++ b/mne/viz/tests/test_utils.py @@ -2,7 +2,6 @@ # License: BSD-3-Clause # Copyright the MNE-Python contributors. -from functools import partial from pathlib import Path import matplotlib.pyplot as plt @@ -12,10 +11,10 @@ from matplotlib import rc_context from numpy.testing import assert_allclose -from mne import create_info, read_evokeds +from mne import read_evokeds from mne.epochs import Epochs from mne.event import read_events -from mne.io import RawArray, read_raw_fif +from mne.io import read_raw_fif from mne.viz import ClickableImage, add_background_image, mne_analyze_colormap from mne.viz.ui_events import ColormapRange, link, subscribe from mne.viz.utils import ( @@ -42,13 +41,6 @@ ave_fname = base_dir / "test-ave.fif" -def _limit_epoch_sample(a, a_min, a_max, *args, n_epochs, clip, **kwargs): - """Limit automatic epoch sampling without allocating over 100 MB of data.""" - if a_min == 1 and a_max == n_epochs: - return 2 - return clip(a, a_min, a_max, *args, **kwargs) - - def test_setup_vmin_vmax_warns(): """Test that _setup_vmin_vmax warns properly.""" expected_msg = r"\(min=0.0, max=1\) range.*minimum of data is -1" @@ -203,32 +195,6 @@ def test_auto_scale(): epochs.pick(picks="eeg") -def test_auto_scale_epoch_sampling_reproducible(monkeypatch): - """Test automatic scaling selects unloaded epochs reproducibly.""" - info = create_info(["eeg"], 10.0, "eeg") - data = np.zeros((1, 100)) - events = [] - for idx, start in enumerate(range(0, 100, 10)): - data[0, start : start + 4] = (idx + 1) * np.array([1.0, 2.0, 3.0, 4.0]) - events.append([start, 0, 1]) - raw = RawArray(data, info) - epochs = Epochs( - raw, np.array(events), tmin=0, tmax=0.3, baseline=None, preload=False - ) - epochs.drop_bad() - - clip = np.clip - - # The production threshold requires over 100 MB of epoch data. Limit the - # sample size here so this test exercises the same unloaded-epoch path. - monkeypatch.setattr( - "mne.viz.utils.np.clip", - partial(_limit_epoch_sample, n_epochs=len(epochs), clip=clip), - ) - scalings = [_compute_scalings({"eeg": "auto"}, epochs)["eeg"] for _ in range(2)] - assert_allclose(scalings, [12.5, 12.5]) - - def test_validate_if_list_of_axes(): """Test validation of axes.""" fig, ax = plt.subplots(2, 2) From 63c71fdab05fe307ec3a9051818f8e55dc0a9b8d Mon Sep 17 00:00:00 2001 From: Bru Date: Tue, 25 Aug 2026 12:15:44 +0200 Subject: [PATCH 27/34] MAINT: Inspect sklearn RNG signatures --- examples/decoding/decoding_spoc_CMC.py | 2 +- mne/decoding/tests/test_base.py | 38 ++++++++++++++-------- mne/decoding/tests/test_csp.py | 4 +-- mne/decoding/tests/test_receptive_field.py | 27 ++++++++++----- mne/decoding/tests/test_search_light.py | 24 +++++++++----- mne/decoding/tests/test_transformer.py | 10 +++--- mne/preprocessing/tests/test_xdawn.py | 4 +-- mne/tests/test_docstring_parameters.py | 24 +++++++------- mne/utils/tests/test_numerics.py | 2 +- 9 files changed, 81 insertions(+), 54 deletions(-) diff --git a/examples/decoding/decoding_spoc_CMC.py b/examples/decoding/decoding_spoc_CMC.py index 3accd5b2cd6..bc2e88e66c4 100644 --- a/examples/decoding/decoding_spoc_CMC.py +++ b/examples/decoding/decoding_spoc_CMC.py @@ -60,7 +60,7 @@ # Classification pipeline with SPoC spatial filtering and Ridge Regression spoc = SPoC(n_components=2, log=True, reg="oas", rank="full") -clf = make_pipeline(spoc, Ridge()) +clf = make_pipeline(spoc, Ridge(random_state=0)) # Define a two fold cross-validation cv = KFold(n_splits=2, shuffle=False) diff --git a/mne/decoding/tests/test_base.py b/mne/decoding/tests/test_base.py index ca367a73ff0..adc439bd026 100644 --- a/mne/decoding/tests/test_base.py +++ b/mne/decoding/tests/test_base.py @@ -98,7 +98,9 @@ def _make_data(n_samples=1000, n_features=5, n_targets=3): def test_get_coef(): """Test getting linear coefficients (filters/patterns) from estimators.""" - lm_classification = LinearModel(LogisticRegression(solver="liblinear")) + lm_classification = LinearModel( + LogisticRegression(solver="liblinear", random_state=0) + ) assert hasattr(lm_classification, "__sklearn_tags__") if check_version("sklearn", "1.6"): print(lm_classification.__sklearn_tags__()) @@ -107,7 +109,7 @@ def test_get_coef(): assert not is_regressor(lm_classification.model) assert not is_regressor(lm_classification) - lm_regression = LinearModel(Ridge()) + lm_regression = LinearModel(Ridge(random_state=0)) assert is_regressor(lm_regression.model) assert is_regressor(lm_regression) assert not is_classifier(lm_regression.model) @@ -115,7 +117,7 @@ def test_get_coef(): parameters = {"kernel": ["linear"], "C": [1, 10]} lm_gs_classification = LinearModel( - GridSearchCV(svm.SVC(), parameters, cv=2, refit=True, n_jobs=None) + GridSearchCV(svm.SVC(random_state=0), parameters, cv=2, refit=True, n_jobs=None) ) assert is_classifier(lm_gs_classification) @@ -247,19 +249,21 @@ def transform(self, X): pytest.param( make_pipeline( Scaler(info=None, scalings="mean"), - SlidingEstimator(make_pipeline(LinearModel(Ridge()))), + SlidingEstimator(make_pipeline(LinearModel(Ridge(random_state=0)))), ), id="Scaler+SlidingEstimator", ), pytest.param( make_pipeline( _Noop(), - SlidingEstimator(make_pipeline(LinearModel(Ridge()))), + SlidingEstimator(make_pipeline(LinearModel(Ridge(random_state=0)))), ), id="Noop+SlidingEstimator", ), pytest.param( - SlidingEstimator(make_pipeline(StandardScaler(), LinearModel(Ridge()))), + SlidingEstimator( + make_pipeline(StandardScaler(), LinearModel(Ridge(random_state=0))) + ), id="SlidingEstimator+nested StandardScaler", ), ], @@ -293,7 +297,11 @@ def test_get_coef_inverse_step_name(): X, y, _ = _make_data(n_samples=100, n_features=5, n_targets=1) # Test with a simple pipeline - pipe = make_pipeline(StandardScaler(), PCA(n_components=3), LinearModel(Ridge())) + pipe = make_pipeline( + StandardScaler(), + PCA(n_components=3, random_state=0), + LinearModel(Ridge(random_state=0)), + ) pipe.fit(X, y) coef_inv_actual = get_coef( @@ -325,7 +333,9 @@ def test_get_coef_inverse_step_name(): ) # Test with a nested pipeline to check __ parsing - inner_pipe = make_pipeline(PCA(n_components=3), LinearModel(Ridge())) + inner_pipe = make_pipeline( + PCA(n_components=3, random_state=0), LinearModel(Ridge(random_state=0)) + ) nested_pipe = make_pipeline(StandardScaler(), inner_pipe) nested_pipe.fit(X, y) coef_nested_inv_actual = get_coef( @@ -360,7 +370,7 @@ def transform(self, X): # In a real scenario, this would modify X return X - pipe = make_pipeline(NonInvertibleTransformer(), LinearModel(Ridge())) + pipe = make_pipeline(NonInvertibleTransformer(), LinearModel(Ridge(random_state=0))) pipe.fit(X, y) with pytest.warns(RuntimeWarning, match="not invertible"): _ = get_coef( @@ -389,7 +399,7 @@ def test_get_coef_multiclass(n_features, n_targets): assert_array_equal(lm.filters_.shape, want_shape) if n_features > 1 and n_targets > 1: assert_array_almost_equal(A, lm.patterns_.T, decimal=2) - lm = LinearModel(Ridge(alpha=0)) + lm = LinearModel(Ridge(alpha=0, random_state=0)) clf = make_pipeline(lm) clf.fit(X, Y) if n_features > 1 and n_targets > 1: @@ -400,7 +410,7 @@ def test_get_coef_multiclass(n_features, n_targets): # With epochs, scaler, and vectorizer (typical use case) X_epo = X.reshape(X.shape + (1,)) info = create_info(n_features, 1000.0, "eeg") - lm = LinearModel(Ridge(alpha=1)) + lm = LinearModel(Ridge(alpha=1, random_state=0)) clf = make_pipeline( Scaler(info, scalings=dict(eeg=1.0)), # XXX adding this step breaks Vectorizer(), @@ -490,7 +500,7 @@ def test_linearmodel(): _ = clf.fit_transform(X, y) # check that model has to have coef_, RBF-SVM doesn't - clf = LinearModel(svm.SVC(kernel="rbf")) + clf = LinearModel(svm.SVC(kernel="rbf", random_state=0)) with pytest.raises(ValueError, match="does not have a `coef_`"): clf.fit(X, y) @@ -502,7 +512,7 @@ def test_linearmodel(): # check categorical target fit in standard linear model with GridSearchCV parameters = {"kernel": ["linear"], "C": [1, 10]} clf = LinearModel( - GridSearchCV(svm.SVC(), parameters, cv=2, refit=True, n_jobs=None) + GridSearchCV(svm.SVC(random_state=0), parameters, cv=2, refit=True, n_jobs=None) ) clf.fit(X, y) assert_equal(clf.filters_.shape, (n_features,)) @@ -596,7 +606,7 @@ def test_cross_val_multiscore(): assert_array_equal(manual, auto) -@parametrize_with_checks([LinearModel(LogisticRegression())]) +@parametrize_with_checks([LinearModel(LogisticRegression(random_state=0))]) def test_sklearn_compliance(estimator, check): """Test LinearModel compliance with sklearn.""" check(estimator) diff --git a/mne/decoding/tests/test_csp.py b/mne/decoding/tests/test_csp.py index 1110f0ddabb..04fdd6956fc 100644 --- a/mne/decoding/tests/test_csp.py +++ b/mne/decoding/tests/test_csp.py @@ -365,7 +365,7 @@ def test_regularized_csp(ch_type, rank, reg): clf = make_pipeline( sc, csp, - LinearModel(LogisticRegression(solver="liblinear")), + LinearModel(LogisticRegression(solver="liblinear", random_state=0)), ) score = cross_val_score(clf, epochs_data_orig, y, cv=cv, scoring="roc_auc").mean() assert 0.75 <= score <= 1.0 @@ -387,7 +387,7 @@ def test_regularized_csp(ch_type, rank, reg): def test_csp_pipeline(): """Test if CSP works in a pipeline.""" csp = CSP(reg=1, norm_trace=False) - svc = SVC() + svc = SVC(random_state=0) pipe = Pipeline([("CSP", csp), ("SVC", svc)]) pipe.set_params(CSP__reg=0.2) assert pipe.get_params()["CSP__reg"] == 0.2 diff --git a/mne/decoding/tests/test_receptive_field.py b/mne/decoding/tests/test_receptive_field.py index 01df9409e7e..bef99f12887 100644 --- a/mne/decoding/tests/test_receptive_field.py +++ b/mne/decoding/tests/test_receptive_field.py @@ -99,7 +99,7 @@ def test_rank_deficiency(): y = np.apply_along_axis(np.convolve, 0, eeg, win, mode="same") y += rng.normal(scale=100, size=y.shape) - for est in (Ridge(reg), reg): + for est in (Ridge(reg, random_state=0), reg): rf = ReceptiveField(tmin, tmax, fs, estimator=est, patterns=True) rf.fit(eeg, y) pred = rf.predict(eeg) @@ -179,7 +179,7 @@ def test_time_delay(): def test_receptive_field_basic(n_jobs): """Test model prep and fitting.""" # Make sure estimator pulling works - mod = Ridge() + mod = Ridge(random_state=0) rng = np.random.default_rng(1337) # Test the receptive field model @@ -401,7 +401,13 @@ def test_receptive_field_1d(n_jobs): fit_intercept=False, n_jobs=n_jobs, ) - for estimator in (Ridge(alpha=0.0), Ridge(alpha=0.1), 0.0, 0.1, lap): + for estimator in ( + Ridge(alpha=0.0, random_state=0), + Ridge(alpha=0.1, random_state=0), + 0.0, + 0.1, + lap, + ): for offset in (-100, 0, 100): model = ReceptiveField( smin, smax, 1.0, estimator=estimator, n_jobs=n_jobs @@ -456,7 +462,8 @@ def test_receptive_field_nd(n_jobs): smin, smax, 1.0, 0.1, n_jobs=n_jobs, edge_correction=False ) for estimator, atol in zip( - (Ridge(alpha=0.0), 0.0, 0.01, tdr_l, tdr_nc), (1e-3, 1e-3, 1e-3, 5e-3, 5e-2) + (Ridge(alpha=0.0, random_state=0), 0.0, 0.01, tdr_l, tdr_nc), + (1e-3, 1e-3, 1e-3, 5e-3, 5e-2), ): model = ReceptiveField(smin, smax, 1.0, estimator=estimator) model.fit(x, y) @@ -480,9 +487,9 @@ def test_receptive_field_nd(n_jobs): tdr = TimeDelayingRidge(smin, smax, 1.0, 0.0, n_jobs=n_jobs) tdr_no = TimeDelayingRidge(smin, smax, 1.0, 0.0, fit_intercept=False, n_jobs=n_jobs) for estimator in ( - Ridge(alpha=0.0), + Ridge(alpha=0.0, random_state=0), tdr, - Ridge(alpha=0.0, fit_intercept=False), + Ridge(alpha=0.0, fit_intercept=False, random_state=0), tdr_no, ): # first with no intercept in the data @@ -558,7 +565,7 @@ def test_inverse_coef(): # Check coefficient dims, for all estimator types X, y = _make_data(n_feats, n_targets, n_samples, tmin, tmax) tdr = TimeDelayingRidge(tmin, tmax, 1.0, 0.1, "laplacian") - for estimator in (0.0, 0.01, Ridge(alpha=0.0), tdr): + for estimator in (0.0, 0.01, Ridge(alpha=0.0, random_state=0), tdr): rf = ReceptiveField(tmin, tmax, 1.0, estimator=estimator, patterns=True) rf.fit(X, y) inv_rf = ReceptiveField(tmin, tmax, 1.0, estimator=estimator, patterns=True) @@ -581,7 +588,7 @@ def test_linalg_warning(): """Test that warnings are issued when no regularization is applied.""" n_feats, n_targets, n_samples = 5, 60, 50 X, y = _make_data(n_feats, n_targets, n_samples, tmin, tmax) - for estimator in (0.0, Ridge(alpha=0.0)): + for estimator in (0.0, Ridge(alpha=0.0, random_state=0)): rf = ReceptiveField(tmin, tmax, 1.0, estimator=estimator) with pytest.warns( (RuntimeWarning, UserWarning), match="[Singular|scipy.linalg.solve]" @@ -605,7 +612,9 @@ def test_tdr_sklearn_compliance(estimator, check): @pytest.mark.filterwarnings("ignore:.*invalid value encountered in subtract.*:") -@parametrize_with_checks([ReceptiveField(-1, 2, 1.0, estimator=Ridge(), patterns=True)]) +@parametrize_with_checks( + [ReceptiveField(-1, 2, 1.0, estimator=Ridge(random_state=0), patterns=True)] +) def test_rf_sklearn_compliance(estimator, check): """Test sklearn RF compliance.""" pytest.importorskip("sklearn", minversion="1.6") # TODO VERSION remove on 1.6+ diff --git a/mne/decoding/tests/test_search_light.py b/mne/decoding/tests/test_search_light.py index 162983a71da..934386b07a1 100644 --- a/mne/decoding/tests/test_search_light.py +++ b/mne/decoding/tests/test_search_light.py @@ -52,9 +52,9 @@ def test_search_light_basic(): sl = SlidingEstimator("foo") with pytest.raises(ValueError, match="must be"): sl.fit(X, y) - sl = SlidingEstimator(Ridge()) + sl = SlidingEstimator(Ridge(random_state=0)) assert not is_classifier(sl) - sl = SlidingEstimator(LogisticRegression(solver="liblinear")) + sl = SlidingEstimator(LogisticRegression(solver="liblinear", random_state=0)) assert is_classifier(sl.base_estimator) assert is_classifier(sl) # fit @@ -183,7 +183,9 @@ def transform(self, X): # Bagging classifiers X = rng.random((10, 3, 4)) for n_jobs in (1, 2): - pipe = SlidingEstimator(BaggingClassifier(None, 2), n_jobs=n_jobs) + pipe = SlidingEstimator( + BaggingClassifier(None, 2, random_state=0), n_jobs=n_jobs + ) pipe.fit(X, y) pipe.score(X, y) assert isinstance(pipe.estimators_[0], BaggingClassifier) @@ -324,9 +326,15 @@ def test_gl_score_branches(scoring, est_name, method): ) elif scoring == "accuracy_kwargs": # start from the default scorer but add a kwarg to prevent batching - acc_func = check_scoring(LogisticRegression(), "accuracy")._score_func + acc_func = check_scoring( + LogisticRegression(random_state=0), "accuracy" + )._score_func scoring = make_scorer(acc_func, normalize=False) - est = Ridge() if est_name == "ridge" else LogisticRegression(solver=solver) + est = ( + Ridge(random_state=0) + if est_name == "ridge" + else LogisticRegression(solver=solver, random_state=0) + ) gl = GeneralizingEstimator(est, scoring=scoring).fit(X, y) # Measure batching: count pred/call scores. Wraps `fn` calls so they append @@ -374,7 +382,7 @@ def force_fallback(e, X, y): def test_verbose_arg(capsys, n_jobs, verbose): """Test controlling output with the ``verbose`` argument.""" X, y = make_data() - clf = SVC() + clf = SVC(random_state=0) # shows progress bar and prints other messages to the console with use_log_level(True): @@ -424,8 +432,8 @@ def predict_proba(self, X): @pytest.mark.slowtest @parametrize_with_checks( [ - SlidingEstimator(LogisticRegression(), allow_2d=True), - GeneralizingEstimator(LogisticRegression(), allow_2d=True), + SlidingEstimator(LogisticRegression(random_state=0), allow_2d=True), + GeneralizingEstimator(LogisticRegression(random_state=0), allow_2d=True), ] ) def test_sklearn_compliance(estimator, check): diff --git a/mne/decoding/tests/test_transformer.py b/mne/decoding/tests/test_transformer.py index a91a4316521..f6d4bcc6031 100644 --- a/mne/decoding/tests/test_transformer.py +++ b/mne/decoding/tests/test_transformer.py @@ -262,9 +262,9 @@ def test_unsupervised_spatial_filter(): # Test fit n_components = 4 - usf = UnsupervisedSpatialFilter(PCA(n_components)) + usf = UnsupervisedSpatialFilter(PCA(n_components, random_state=0)) usf.fit(X) - usf1 = UnsupervisedSpatialFilter(PCA(n_components)) + usf1 = UnsupervisedSpatialFilter(PCA(n_components, random_state=0)) # test transform assert_equal(usf.transform(X).ndim, 3) @@ -274,9 +274,9 @@ def test_unsupervised_spatial_filter(): assert_array_almost_equal(usf.inverse_transform(usf.transform(X)), X) # Test with average param - usf = UnsupervisedSpatialFilter(PCA(4), average=True) + usf = UnsupervisedSpatialFilter(PCA(4, random_state=0), average=True) usf.fit_transform(X) - usf = UnsupervisedSpatialFilter(PCA(4), 2) + usf = UnsupervisedSpatialFilter(PCA(4, random_state=0), 2) with pytest.raises(TypeError, match="average must be"): usf.fit(X) @@ -335,7 +335,7 @@ def test_bad_triage(): Scaler(scalings="mean"), # Not easy to test Scaler(info) b/c number of channels must match TemporalFilter(), - UnsupervisedSpatialFilter(PCA()), + UnsupervisedSpatialFilter(PCA(random_state=0)), Vectorizer(), ] ) diff --git a/mne/preprocessing/tests/test_xdawn.py b/mne/preprocessing/tests/test_xdawn.py index 86bae4f9997..0fd83247706 100644 --- a/mne/preprocessing/tests/test_xdawn.py +++ b/mne/preprocessing/tests/test_xdawn.py @@ -372,13 +372,13 @@ def test_xdawn_decoding_performance(): Xdawn(n_components=n_xdawn_comps), Vectorizer(), MinMaxScaler(), - LogisticRegression(solver="liblinear"), + LogisticRegression(solver="liblinear", random_state=0), ) xdawn_trans_pipe = make_pipeline( XdawnTransformer(n_components=n_xdawn_comps), Vectorizer(), MinMaxScaler(), - LogisticRegression(solver="liblinear"), + LogisticRegression(solver="liblinear", random_state=0), ) cv = KFold(n_splits=3, shuffle=False) diff --git a/mne/tests/test_docstring_parameters.py b/mne/tests/test_docstring_parameters.py index c6f1f6358ea..5e6f40f58f5 100644 --- a/mne/tests/test_docstring_parameters.py +++ b/mne/tests/test_docstring_parameters.py @@ -316,13 +316,6 @@ def test_tabs(): "seed": "a local default_rng", "tomaxint": "integers", } -sklearn_rng_estimators = { - "FastICA", - "FactorAnalysis", - "LogisticRegression", - "MultiTaskLasso", - "PCA", -} def _is_np_random(node): @@ -337,6 +330,14 @@ def _is_np_random(node): def test_no_global_rng(): """Test that we use local generators and the modern numpy RNG API.""" + from sklearn.utils.discovery import all_estimators + + rng_names = {"random_state", "rng", "seed"} + rng_callables = { + name + for name, estimator in all_estimators() + if rng_names & inspect.signature(estimator).parameters.keys() + } root = pyproject_path.parent # only available in a dev/editable checkout bad = [] for sub in ("mne", "examples", "tutorials"): @@ -359,14 +360,13 @@ def test_no_global_rng(): and not _is_np_random(node.func.value) ): bad.append(f"{rel}:{node.lineno}: legacy .{node.func.attr}()") - elif "/tests/" not in rel and isinstance(node, ast.Call): + elif isinstance(node, ast.Call): name = getattr(node.func, "id", None) or getattr( node.func, "attr", None ) - if name in sklearn_rng_estimators and not any( - kw.arg == "random_state" for kw in node.keywords - ): - bad.append(f"{rel}:{node.lineno}: {name}() needs random_state") + supplied = rng_names & {kw.arg for kw in node.keywords} + if name in rng_callables and len(supplied) != 1: + bad.append(f"{rel}:{node.lineno}: {name}() needs one RNG") if bad: raise AssertionError( f"{len(bad)} outdated numpy RNG use{_pl(bad)} found:\n" + "\n".join(bad) diff --git a/mne/utils/tests/test_numerics.py b/mne/utils/tests/test_numerics.py index ee4682d52a8..12c5d48f23d 100644 --- a/mne/utils/tests/test_numerics.py +++ b/mne/utils/tests/test_numerics.py @@ -462,7 +462,7 @@ def test_pca(n_components, whiten): X = np.random.default_rng(0).standard_normal((n_samples, n_dim)) X[:, -1] = np.mean(X[:, :-1], axis=-1) # true X dim is ndim - 1 X_orig = X.copy() - pca_skl = PCA(n_components, whiten=whiten, svd_solver="full") + pca_skl = PCA(n_components, whiten=whiten, svd_solver="full", random_state=0) pca_mne = _PCA(n_components, whiten=whiten) X_skl = pca_skl.fit_transform(X) assert_array_equal(X, X_orig) From b70abb25f59d7c947f774d8390197313ad23b550 Mon Sep 17 00:00:00 2001 From: Bru Date: Tue, 25 Aug 2026 13:19:24 +0200 Subject: [PATCH 28/34] MAINT: Inspect RNG callables directly --- mne/stats/tests/test_regression.py | 6 ++- mne/tests/test_docstring_parameters.py | 59 +++++++++++++++++++++----- 2 files changed, 52 insertions(+), 13 deletions(-) diff --git a/mne/stats/tests/test_regression.py b/mne/stats/tests/test_regression.py index 84c23a33487..dd4444fde1f 100644 --- a/mne/stats/tests/test_regression.py +++ b/mne/stats/tests/test_regression.py @@ -141,7 +141,9 @@ def test_continuous_regression_with_overlap(): def solver(X, y): # Newer scikit-learn returns 1D array for ridge_regression, so ensure # 2D output - return np.atleast_2d(ridge_regression(X, y, alpha=0.0, solver="cholesky")) + return np.atleast_2d( + ridge_regression(X, y, alpha=0.0, solver="cholesky", random_state=0) + ) assert_allclose( effect, @@ -150,7 +152,7 @@ def solver(X, y): # test bad solvers def solT(X, y): - return ridge_regression(X, y, alpha=0.0, solver="cholesky").T + return ridge_regression(X, y, alpha=0.0, solver="cholesky", random_state=0).T pytest.raises(ValueError, linear_regression_raw, raw, events, solver=solT) pytest.raises(ValueError, linear_regression_raw, raw, events, solver="err") diff --git a/mne/tests/test_docstring_parameters.py b/mne/tests/test_docstring_parameters.py index 5e6f40f58f5..2954a99c34f 100644 --- a/mne/tests/test_docstring_parameters.py +++ b/mne/tests/test_docstring_parameters.py @@ -328,16 +328,45 @@ def _is_np_random(node): ) +def _sklearn_callables(tree): + """Resolve sklearn imports for signature inspection.""" + callables = {} + for node in ast.walk(tree): + if ( + isinstance(node, ast.ImportFrom) + and node.module + and node.module.startswith("sklearn") + ): + module = importlib.import_module(node.module) + callables.update( + (alias.asname or alias.name, getattr(module, alias.name)) + for alias in node.names + ) + elif isinstance(node, ast.Import): + for alias in node.names: + if alias.name.startswith("sklearn"): + callables[alias.asname or alias.name] = importlib.import_module( + alias.name + ) + return callables + + +def _rng_parameters(callable_, node): + """Get RNG parameters from a callable, if inspectable.""" + try: + parameters = inspect.signature(callable_).parameters + except (TypeError, ValueError): + return set() + shuffle = next((kw.value for kw in node.keywords if kw.arg == "shuffle"), None) + if "shuffle" in parameters and ( + shuffle is None or isinstance(shuffle, ast.Constant) and not shuffle.value + ): + return set() + return {"random_state", "rng", "seed"} & parameters.keys() + + def test_no_global_rng(): """Test that we use local generators and the modern numpy RNG API.""" - from sklearn.utils.discovery import all_estimators - - rng_names = {"random_state", "rng", "seed"} - rng_callables = { - name - for name, estimator in all_estimators() - if rng_names & inspect.signature(estimator).parameters.keys() - } root = pyproject_path.parent # only available in a dev/editable checkout bad = [] for sub in ("mne", "examples", "tutorials"): @@ -346,7 +375,9 @@ def test_no_global_rng(): continue for path in sorted(base.rglob("*.py")): rel = path.relative_to(root).as_posix() - for node in ast.walk(ast.parse(path.read_text("utf-8"))): + tree = ast.parse(path.read_text("utf-8")) + callables = _sklearn_callables(tree) + for node in ast.walk(tree): if ( isinstance(node, ast.Attribute) and node.attr not in global_rng_ok @@ -364,8 +395,14 @@ def test_no_global_rng(): name = getattr(node.func, "id", None) or getattr( node.func, "attr", None ) - supplied = rng_names & {kw.arg for kw in node.keywords} - if name in rng_callables and len(supplied) != 1: + callable_ = callables.get(name) + if isinstance(node.func, ast.Attribute): + module = callables.get(getattr(node.func.value, "id", None)) + if module is not None: + callable_ = getattr(module, node.func.attr, None) + parameters = _rng_parameters(callable_, node) + supplied = parameters & {kw.arg for kw in node.keywords} + if parameters and len(supplied) != 1: bad.append(f"{rel}:{node.lineno}: {name}() needs one RNG") if bad: raise AssertionError( From 23389c51dd79380d33673c3bd9666b545d10d371 Mon Sep 17 00:00:00 2001 From: Bru Date: Tue, 25 Aug 2026 13:42:53 +0200 Subject: [PATCH 29/34] API: Make legacy RNG arguments keyword-only --- doc/changes/dev/14199.apichange.rst | 2 +- mne/epochs.py | 16 ++++++++-------- mne/inverse_sparse/mxne_inverse.py | 6 +++--- mne/label.py | 8 ++++---- mne/preprocessing/ica.py | 4 ++-- mne/preprocessing/infomax_.py | 4 ++-- mne/simulation/evoked.py | 8 ++++---- mne/simulation/raw.py | 12 ++++++------ mne/simulation/source.py | 8 ++++---- mne/stats/cluster_level.py | 16 ++++++++-------- mne/stats/permutations.py | 8 ++++---- mne/utils/check.py | 21 ++------------------- mne/utils/numerics.py | 4 ++-- mne/utils/tests/test_check.py | 13 ++++--------- mne/utils/tests/test_numerics.py | 2 +- 15 files changed, 55 insertions(+), 77 deletions(-) diff --git a/doc/changes/dev/14199.apichange.rst b/doc/changes/dev/14199.apichange.rst index eab825dfb20..adc33299e3e 100644 --- a/doc/changes/dev/14199.apichange.rst +++ b/doc/changes/dev/14199.apichange.rst @@ -1 +1 @@ -Add ``rng`` parameters to APIs that use randomness, while retaining ``seed`` and ``random_state`` for compatibility (:gh:`9233` by `Bruno Aristimunha`_). +Add ``rng`` parameters to APIs that use randomness, while retaining keyword-only ``seed`` and ``random_state`` for compatibility (:gh:`9233` by `Bruno Aristimunha`_). diff --git a/mne/epochs.py b/mne/epochs.py index 700b437bef5..ebc3219a2cf 100644 --- a/mne/epochs.py +++ b/mne/epochs.py @@ -2496,8 +2496,8 @@ def equalize_event_counts( event_ids: list | dict | None = None, method: Literal["truncate", "mintime", "random"] = "mintime", *, - random_state: int | RandomState | None = None, rng=None, + random_state: int | RandomState | None = None, ) -> tuple: """Equalize the number of trials in each condition. @@ -2539,10 +2539,10 @@ def equalize_event_counts( The ``event_ids`` must identify non-overlapping subsets of the epochs. %(equalize_events_method)s - %(random_state_rng)s - Used only if ``method='random'``. %(rng)s Used only if ``method='random'``. + %(random_state_rng)s + Used only if ``method='random'``. Returns ------- @@ -4026,8 +4026,8 @@ def equalize_epoch_counts( epochs_list: list, method: Literal["truncate", "mintime", "random"] = "mintime", *, - random_state: int | RandomState | None = None, rng=None, + random_state: int | RandomState | None = None, ) -> None: """Equalize the number of trials in multiple Epochs or EpochsTFR instances. @@ -4036,10 +4036,10 @@ def equalize_epoch_counts( epochs_list : list of Epochs The Epochs instances to equalize trial counts for. %(equalize_events_method)s - %(random_state_rng)s - Used only if ``method='random'``. %(rng)s Used only if ``method='random'``. + %(random_state_rng)s + Used only if ``method='random'``. Notes ----- @@ -4675,15 +4675,15 @@ def _get_epoch_from_raw(self, idx, verbose=None): @_legacy_rng("random_state") @fill_doc -def bootstrap(epochs, random_state=None, *, rng=None): +def bootstrap(epochs, *, rng=None, random_state=None): """Compute epochs selected by bootstrapping. Parameters ---------- epochs : Epochs instance epochs data to be bootstrapped - %(random_state_rng)s %(rng)s + %(random_state_rng)s Returns ------- diff --git a/mne/inverse_sparse/mxne_inverse.py b/mne/inverse_sparse/mxne_inverse.py index b2045619621..b6207444394 100644 --- a/mne/inverse_sparse/mxne_inverse.py +++ b/mne/inverse_sparse/mxne_inverse.py @@ -365,10 +365,10 @@ def mixed_norm( rank=None, pick_ori=None, sure_alpha_grid="auto", - random_state=None, verbose=None, *, rng=None, + random_state=None, ): """Mixed-norm estimate (MxNE) and iterative reweighted MxNE (irMxNE). @@ -435,12 +435,12 @@ def mixed_norm( grid is directly specified. Ignored if alpha is not "sure". .. versionadded:: 0.24 + %(verbose)s + %(rng)s %(random_state_rng)s Used for the random delta and epsilon in the SURE computation. .. versionadded:: 0.24 - %(verbose)s - %(rng)s Returns ------- diff --git a/mne/label.py b/mne/label.py index 7d13dc5216c..6907fac05c2 100644 --- a/mne/label.py +++ b/mne/label.py @@ -2003,9 +2003,9 @@ def random_parcellation( hemi, subjects_dir=None, surface="white", - random_state=None, *, rng=None, + random_state=None, ): """Generate random cortex parcellation by growing labels. @@ -2024,8 +2024,8 @@ def random_parcellation( parcels per hemisphere. %(subjects_dir)s %(surface)s - %(random_state_rng)s %(rng)s + %(random_state_rng)s Returns ------- @@ -2952,10 +2952,10 @@ def select_sources( grow_outside=True, subjects_dir=None, name=None, - random_state=None, surf="white", *, rng=None, + random_state=None, ): """Select sources from a label. @@ -2979,10 +2979,10 @@ def select_sources( %(subjects_dir)s name : None | str Assign name to the new label. - %(random_state_rng)s surf : str The surface used to simulated the label, defaults to the white surface. %(rng)s + %(random_state_rng)s Returns ------- diff --git a/mne/preprocessing/ica.py b/mne/preprocessing/ica.py index 8840c3b0ea7..1c706979460 100644 --- a/mne/preprocessing/ica.py +++ b/mne/preprocessing/ica.py @@ -247,8 +247,8 @@ class ICA(ContainsMixin): Noise covariance used for pre-whitening. If None (default), channels are scaled to unit variance ("z-standardized") as a group by channel type prior to the whitening by PCA. - %(random_state_rng)s %(rng)s + %(random_state_rng)s method : 'fastica' | 'infomax' | 'picard' The ICA method to use in the fit method. Use the ``fit_params`` argument to set additional parameters. Specifically, if you want Extended @@ -441,8 +441,8 @@ def __init__( n_components=None, *, noise_cov=None, - random_state=None, rng=None, + random_state=None, method="fastica", fit_params=None, max_iter="auto", diff --git a/mne/preprocessing/infomax_.py b/mne/preprocessing/infomax_.py index ac343187673..64e4047eddb 100644 --- a/mne/preprocessing/infomax_.py +++ b/mne/preprocessing/infomax_.py @@ -26,7 +26,6 @@ def infomax( kurt_size=6000, ext_blocks=1, max_iter=200, - random_state=None, blowup=1e4, blowup_fac=0.5, n_small_angle=20, @@ -35,6 +34,7 @@ def infomax( return_n_iter=False, *, rng=None, + random_state=None, ): """Run (extended) Infomax ICA decomposition on raw data. @@ -82,7 +82,6 @@ def infomax( Defaults to 1. max_iter : int The maximum number of iterations. Defaults to 200. - %(random_state_rng)s blowup : float The maximum difference allowed between two successive estimations of the unmixing matrix. Defaults to 10000. @@ -103,6 +102,7 @@ def infomax( Whether to return the number of iterations performed. Defaults to False. %(rng)s + %(random_state_rng)s Returns ------- diff --git a/mne/simulation/evoked.py b/mne/simulation/evoked.py index a74e7b18b4a..d1229c23a7b 100644 --- a/mne/simulation/evoked.py +++ b/mne/simulation/evoked.py @@ -32,11 +32,11 @@ def simulate_evoked( cov=None, nave=30, iir_filter=None, - random_state=None, use_cps=True, verbose=None, *, rng=None, + random_state=None, ): """Generate noisy evoked data. @@ -61,12 +61,12 @@ def simulate_evoked( .. versionadded:: 0.15.0 iir_filter : None | array IIR filter coefficients (denominator) e.g. [1, -1, 0.2]. - %(random_state_rng)s %(use_cps)s .. versionadded:: 0.15 %(verbose)s %(rng)s + %(random_state_rng)s Returns ------- @@ -111,7 +111,7 @@ def _simulate_noise_evoked(evoked, cov, iir_filter, rng): @_legacy_rng("random_state") @verbose -def add_noise(inst, cov, iir_filter=None, random_state=None, verbose=None, *, rng=None): +def add_noise(inst, cov, iir_filter=None, verbose=None, *, rng=None, random_state=None): """Create noise as a multivariate Gaussian. The spatial covariance of the noise is given from the cov matrix. @@ -124,9 +124,9 @@ def add_noise(inst, cov, iir_filter=None, random_state=None, verbose=None, *, rn The noise covariance. iir_filter : None | array-like IIR filter coefficients (denominator). - %(random_state_rng)s %(verbose)s %(rng)s + %(random_state_rng)s Returns ------- diff --git a/mne/simulation/raw.py b/mne/simulation/raw.py index 72eeb50ebd1..5d5ddbac0f3 100644 --- a/mne/simulation/raw.py +++ b/mne/simulation/raw.py @@ -394,10 +394,10 @@ def add_eog( head_pos=None, interp="cos2", n_jobs=None, - random_state=None, verbose=None, *, rng=None, + random_state=None, ): """Add blink noise to raw data. @@ -408,11 +408,11 @@ def add_eog( %(head_pos)s %(interp)s %(n_jobs)s + %(verbose)s + %(rng)s %(random_state_rng)s The random generator state used for blink, ECG, and sensor noise randomization. - %(verbose)s - %(rng)s Returns ------- @@ -458,10 +458,10 @@ def add_ecg( head_pos=None, interp="cos2", n_jobs=None, - random_state=None, verbose=None, *, rng=None, + random_state=None, ): """Add ECG noise to raw data. @@ -472,11 +472,11 @@ def add_ecg( %(head_pos)s %(interp)s %(n_jobs)s + %(verbose)s + %(rng)s %(random_state_rng)s The random generator state used for blink, ECG, and sensor noise randomization. - %(verbose)s - %(rng)s Returns ------- diff --git a/mne/simulation/source.py b/mne/simulation/source.py index 9d0e478d07f..25d1e90f749 100644 --- a/mne/simulation/source.py +++ b/mne/simulation/source.py @@ -25,13 +25,13 @@ def select_source_in_label( src, label, - random_state=None, location="random", subject=None, subjects_dir=None, surf="sphere", *, rng=None, + random_state=None, ): """Select source positions using a label. @@ -41,7 +41,6 @@ def select_source_in_label( The source space. label : Label The label. - %(random_state_rng)s location : str The label location to choose. Can be 'random' (default) or 'center' to use :func:`mne.Label.center_of_mass` (restricting to vertices @@ -65,6 +64,7 @@ def select_source_in_label( .. versionadded:: 0.13 %(rng)s + %(random_state_rng)s Returns ------- @@ -114,13 +114,13 @@ def simulate_sparse_stc( times, data_fun=lambda t: 1e-7 * np.sin(20 * np.pi * t), labels=None, - random_state=None, location="random", subject=None, subjects_dir=None, surf="sphere", *, rng=None, + random_state=None, ): """Generate sparse (n_dipoles) sources time courses from data_fun. @@ -144,7 +144,6 @@ def simulate_sparse_stc( the same length containing the time courses. labels : None | list of Label The labels. The default is None, otherwise its size must be n_dipoles. - %(random_state_rng)s location : str The label location to choose. Can be ``'random'`` (default) or ``'center'`` to use :func:`mne.Label.center_of_mass`. Note that for @@ -167,6 +166,7 @@ def simulate_sparse_stc( .. versionadded:: 0.13 %(rng)s + %(random_state_rng)s Returns ------- diff --git a/mne/stats/cluster_level.py b/mne/stats/cluster_level.py index cc6b279af63..c98989cbdb8 100644 --- a/mne/stats/cluster_level.py +++ b/mne/stats/cluster_level.py @@ -1110,7 +1110,6 @@ def permutation_cluster_test( stat_fun=None, adjacency=None, n_jobs=None, - seed=None, max_step=1, exclude=None, step_down_p=0, @@ -1121,6 +1120,7 @@ def permutation_cluster_test( verbose=None, *, rng=None, + seed=None, ): """Cluster-level statistical permutation test. @@ -1152,7 +1152,6 @@ def permutation_cluster_test( %(stat_fun_clust_f)s %(adjacency_clust_n)s %(n_jobs)s - %(seed_rng)s %(max_step_clust)s %(exclude_clust)s %(step_down_p_clust)s @@ -1162,6 +1161,7 @@ def permutation_cluster_test( %(buffer_size_clust)s %(verbose)s %(rng)s + %(seed_rng)s Returns ------- @@ -1212,7 +1212,6 @@ def permutation_cluster_1samp_test( stat_fun=None, adjacency=None, n_jobs=None, - seed=None, max_step=1, exclude=None, step_down_p=0, @@ -1223,6 +1222,7 @@ def permutation_cluster_1samp_test( verbose=None, *, rng=None, + seed=None, ): """Non-parametric cluster-level paired t-test. @@ -1243,7 +1243,6 @@ def permutation_cluster_1samp_test( %(stat_fun_clust_t)s %(adjacency_clust_1)s %(n_jobs)s - %(seed_rng)s %(max_step_clust)s %(exclude_clust)s %(step_down_p_clust)s @@ -1253,6 +1252,7 @@ def permutation_cluster_1samp_test( %(buffer_size_clust)s %(verbose)s %(rng)s + %(seed_rng)s Returns ------- @@ -1326,7 +1326,6 @@ def spatio_temporal_cluster_1samp_test( stat_fun=None, adjacency=None, n_jobs=None, - seed=None, max_step=1, spatial_exclude=None, step_down_p=0, @@ -1337,6 +1336,7 @@ def spatio_temporal_cluster_1samp_test( verbose=None, *, rng=None, + seed=None, ): """Non-parametric cluster-level paired t-test for spatio-temporal data. @@ -1360,7 +1360,6 @@ def spatio_temporal_cluster_1samp_test( %(stat_fun_clust_t)s %(adjacency_clust_st1)s %(n_jobs)s - %(seed_rng)s %(max_step_clust)s spatial_exclude : list of int or None List of spatial indices to exclude from clustering. @@ -1371,6 +1370,7 @@ def spatio_temporal_cluster_1samp_test( %(buffer_size_clust)s %(verbose)s %(rng)s + %(seed_rng)s Returns ------- @@ -1427,7 +1427,6 @@ def spatio_temporal_cluster_test( stat_fun=None, adjacency=None, n_jobs=None, - seed=None, max_step=1, spatial_exclude=None, step_down_p=0, @@ -1438,6 +1437,7 @@ def spatio_temporal_cluster_test( verbose=None, *, rng=None, + seed=None, ): """Non-parametric cluster-level test for spatio-temporal data. @@ -1463,7 +1463,6 @@ def spatio_temporal_cluster_test( %(stat_fun_clust_f)s %(adjacency_clust_stn)s %(n_jobs)s - %(seed_rng)s %(max_step_clust)s spatial_exclude : list of int or None List of spatial indices to exclude from clustering. @@ -1474,6 +1473,7 @@ def spatio_temporal_cluster_test( %(buffer_size_clust)s %(verbose)s %(rng)s + %(seed_rng)s Returns ------- diff --git a/mne/stats/permutations.py b/mne/stats/permutations.py index 3348569005f..33ba199cac7 100644 --- a/mne/stats/permutations.py +++ b/mne/stats/permutations.py @@ -34,10 +34,10 @@ def permutation_t_test( n_permutations=10000, tail=0, n_jobs=None, - seed=None, verbose=None, *, rng=None, + seed=None, ): """One sample/paired sample permutation test based on a t-statistic. @@ -66,9 +66,9 @@ def permutation_t_test( than 0 (two tailed test). If tail is -1, the alternative hypothesis is that the mean of the data is less than 0 (lower tailed test). %(n_jobs)s - %(seed_rng)s %(verbose)s %(rng)s + %(seed_rng)s Returns ------- @@ -126,9 +126,9 @@ def bootstrap_confidence_interval( ci=0.95, n_bootstraps=2000, stat_fun="mean", - random_state=None, *, rng=None, + random_state=None, ): """Get confidence intervals from non-parametric bootstrap. @@ -142,8 +142,8 @@ def bootstrap_confidence_interval( Number of bootstraps. stat_fun : str | callable Can be "mean", "median", or a callable operating along ``axis=0``. - %(random_state_rng)s %(rng)s + %(random_state_rng)s Returns ------- diff --git a/mne/utils/check.py b/mne/utils/check.py index 24e78ff97db..1c95b198f02 100644 --- a/mne/utils/check.py +++ b/mne/utils/check.py @@ -252,32 +252,15 @@ def _legacy_rng(legacy_name): """ def decorator(function): - parameters = signature(function).parameters - positional = [ - name - for name, parameter in parameters.items() - if parameter.kind - in (parameter.POSITIONAL_ONLY, parameter.POSITIONAL_OR_KEYWORD) - ] - legacy_position = ( - positional.index(legacy_name) if legacy_name in positional else None - ) - @wraps(function) def _legacy_rng_wrapper(*args, **kwargs): - if legacy_position is not None and len(args) > legacy_position: - if legacy_name in kwargs: - return function(*args, **kwargs) - value = args[legacy_position] - elif legacy_name in kwargs: - value = kwargs[legacy_name] - else: + if legacy_name not in kwargs: kwargs["rng"] = _check_rng(kwargs.get("rng")) return function(*args, **kwargs) if "rng" in kwargs: raise TypeError(f"Specify only one of rng or {legacy_name}") logger.info(f"Use rng= instead of {legacy_name}= in new code") - kwargs["rng"] = check_random_state(value) + kwargs["rng"] = check_random_state(kwargs[legacy_name]) return function(*args, **kwargs) return _legacy_rng_wrapper diff --git a/mne/utils/numerics.py b/mne/utils/numerics.py index 283c28d1619..3c25ae2d745 100644 --- a/mne/utils/numerics.py +++ b/mne/utils/numerics.py @@ -267,7 +267,7 @@ def compute_corr(x, y): @_legacy_rng("random_state") @fill_doc -def random_permutation(n_samples, random_state=None, *, rng=None): +def random_permutation(n_samples, *, rng=None, random_state=None): """Emulate the randperm matlab function. It returns a vector containing a random permutation of the @@ -289,8 +289,8 @@ def random_permutation(n_samples, random_state=None, *, rng=None): n_samples : int End point of the sequence to be permuted (excluded, i.e., the end point is equal to n_samples-1) - %(random_state_rng)s %(rng)s + %(random_state_rng)s Returns ------- diff --git a/mne/utils/tests/test_check.py b/mne/utils/tests/test_check.py index ed2f204e05c..626653ade48 100644 --- a/mne/utils/tests/test_check.py +++ b/mne/utils/tests/test_check.py @@ -65,14 +65,12 @@ def test_check_rng(): _check_rng("foo") -@pytest.mark.parametrize( - "legacy_name, legacy_args", (("random_state", (0,)), ("seed", (None, 0))) -) -def test_legacy_rng_decorator(legacy_name, legacy_args): +@pytest.mark.parametrize("legacy_name", ("random_state", "seed")) +def test_legacy_rng_decorator(legacy_name): """Test that the transition decorator normalizes and logs.""" @_legacy_rng(legacy_name) - def _func(random_state=None, seed=None, *, rng=None): + def _func(*, rng=None, random_state=None, seed=None): return rng # no argument: a fresh generator is created @@ -86,10 +84,7 @@ def _func(random_state=None, seed=None, *, rng=None): assert isinstance(_func(**{legacy_name: 0}), np.random.RandomState) assert f"Use rng= instead of {legacy_name}=" in log.getvalue() assert isinstance(_func(**{legacy_name: None}), np.random.mtrand.RandomState) - # supplying both is an error; positional legacy arguments are supported - assert isinstance(_func(*legacy_args), np.random.RandomState) - with pytest.raises(TypeError, match="Specify only one"): - _func(*legacy_args, rng=0) + # supplying both is an error with pytest.raises(TypeError, match="Specify only one"): _func(**{legacy_name: 0}, rng=0) diff --git a/mne/utils/tests/test_numerics.py b/mne/utils/tests/test_numerics.py index 12c5d48f23d..a679e3d5dfd 100644 --- a/mne/utils/tests/test_numerics.py +++ b/mne/utils/tests/test_numerics.py @@ -217,7 +217,7 @@ def test_random_permutation(): n_samples = 10 # matlab output when we execute rng(42), randperm(10) assert_array_equal( - random_permutation(n_samples, 42), + random_permutation(n_samples, random_state=42), np.array([7, 6, 5, 1, 4, 9, 10, 3, 8, 2]) - 1, ) # an integer ``rng`` seed is reproducible while a Generator instance advances From 25d3170e6d3c8471f168d47601bfbd6b85e19ca8 Mon Sep 17 00:00:00 2001 From: Bru Date: Tue, 25 Aug 2026 13:42:59 +0200 Subject: [PATCH 30/34] DOC: Vary fixed random seeds --- examples/datasets/spm_faces_dataset.py | 2 +- examples/decoding/decoding_csp_eeg.py | 2 +- examples/decoding/decoding_csp_timefreq.py | 2 +- examples/decoding/decoding_rsa.py | 6 +++--- examples/decoding/decoding_spatio_temporal_source.py | 2 +- examples/decoding/decoding_spoc_CMC.py | 2 +- .../decoding_time_generalization_conditions.py | 2 +- .../decoding/decoding_unsupervised_spatial_filter.py | 4 ++-- examples/decoding/decoding_xdawn_eeg.py | 4 ++-- examples/decoding/linear_model_patterns.py | 4 ++-- examples/inverse/mixed_norm_inverse.py | 2 +- examples/preprocessing/ica_comparison.py | 2 +- examples/simulation/plot_stc_metrics.py | 2 +- examples/simulation/simulate_evoked_data.py | 2 +- .../simulated_raw_data_using_subject_anatomy.py | 6 +++--- examples/stats/cluster_stats_evoked.py | 2 +- examples/stats/sensor_permutation_test.py | 2 +- .../time_frequency_global_field_power.py | 2 +- examples/time_frequency/time_frequency_simulated.py | 2 +- examples/visualization/channel_epochs_image.py | 2 +- tutorials/clinical/60_sleep.py | 2 +- tutorials/machine-learning/50_decoding.py | 8 ++++---- tutorials/simulation/70_point_spread.py | 2 +- tutorials/stats-sensor-space/10_background_stats.py | 12 ++++++------ tutorials/stats-sensor-space/20_erp_stats.py | 2 +- .../stats-sensor-space/40_cluster_1samp_time_freq.py | 2 +- .../70_cluster_rmANOVA_time_freq.py | 2 +- .../75_cluster_ftest_spatiotemporal.py | 4 ++-- .../20_cluster_1samp_spatiotemporal.py | 2 +- .../30_cluster_ftest_spatiotemporal.py | 2 +- .../60_cluster_rmANOVA_spatiotemporal.py | 2 +- 31 files changed, 47 insertions(+), 47 deletions(-) diff --git a/examples/datasets/spm_faces_dataset.py b/examples/datasets/spm_faces_dataset.py index 76f4cff88d7..7ad8f130e6b 100644 --- a/examples/datasets/spm_faces_dataset.py +++ b/examples/datasets/spm_faces_dataset.py @@ -36,7 +36,7 @@ raw.resample(100) raw.filter(1.0, None) # high-pass reject = dict(mag=5e-12) -ica = ICA(n_components=0.95, max_iter="auto", rng=0) +ica = ICA(n_components=0.95, max_iter="auto", rng=97) ica.fit(raw, reject=reject) # compute correlation scores, get bad indices sorted by score eog_epochs = create_eog_epochs(raw, ch_name="MRT31-2908", reject=reject) diff --git a/examples/decoding/decoding_csp_eeg.py b/examples/decoding/decoding_csp_eeg.py index 48c0e7aaf19..6ff746c98c0 100644 --- a/examples/decoding/decoding_csp_eeg.py +++ b/examples/decoding/decoding_csp_eeg.py @@ -80,7 +80,7 @@ scores = [] epochs_data = epochs.get_data(copy=False) epochs_data_train = epochs_train.get_data(copy=False) -cv = ShuffleSplit(10, test_size=0.2, random_state=42) +cv = ShuffleSplit(10, test_size=0.2, random_state=103) cv_split = cv.split(epochs_data_train) # Assemble a classifier diff --git a/examples/decoding/decoding_csp_timefreq.py b/examples/decoding/decoding_csp_timefreq.py index 9c26bf05444..1601bc5e803 100644 --- a/examples/decoding/decoding_csp_timefreq.py +++ b/examples/decoding/decoding_csp_timefreq.py @@ -53,7 +53,7 @@ LinearDiscriminantAnalysis(), ) n_splits = 3 # for cross-validation, 5 is better, here we use 3 for speed -cv = StratifiedKFold(n_splits=n_splits, shuffle=True, random_state=42) +cv = StratifiedKFold(n_splits=n_splits, shuffle=True, random_state=191) # Classification & time-frequency parameters tmin, tmax = -0.200, 2.000 diff --git a/examples/decoding/decoding_rsa.py b/examples/decoding/decoding_rsa.py index 999ab74bd45..73c0124178e 100644 --- a/examples/decoding/decoding_rsa.py +++ b/examples/decoding/decoding_rsa.py @@ -125,13 +125,13 @@ # to focus the classifier on the time interval with best SNR. clf = make_pipeline( StandardScaler(), - OneVsRestClassifier(LogisticRegression(C=1, random_state=0)), + OneVsRestClassifier(LogisticRegression(C=1, random_state=79)), ) X = epochs.get_data(tmin=0.05, tmax=0.3).mean(axis=2) y = epochs.events[:, 2] classes = set(y) -cv = StratifiedKFold(n_splits=5, random_state=0, shuffle=True) +cv = StratifiedKFold(n_splits=5, random_state=83, shuffle=True) # Compute confusion matrix for each cross-validation fold y_pred = np.zeros((len(y), len(classes))) @@ -173,7 +173,7 @@ chance = 0.5 # TODO VERSION: this is MDS(2, n_init=4, init='random', metric='precomputed'), # but that spelling requires scikit-learn >= 1.8 -summary, _ = smacof(chance - confusion, n_components=2, n_init=4, random_state=0) +summary, _ = smacof(chance - confusion, n_components=2, n_init=4, random_state=89) cmap = plt.colormaps["rainbow"] colors = ["r", "b"] names = list(conds["condition"].values) diff --git a/examples/decoding/decoding_spatio_temporal_source.py b/examples/decoding/decoding_spatio_temporal_source.py index 0462679ef9e..e7fcf6f9ffd 100644 --- a/examples/decoding/decoding_spatio_temporal_source.py +++ b/examples/decoding/decoding_spatio_temporal_source.py @@ -103,7 +103,7 @@ clf = make_pipeline( StandardScaler(), # z-score normalization SelectKBest(f_classif, k=500), # select features for speed - LinearModel(LogisticRegression(C=1, solver="liblinear", random_state=0)), + LinearModel(LogisticRegression(C=1, solver="liblinear", random_state=107)), ) time_decod = SlidingEstimator(clf, scoring="roc_auc") diff --git a/examples/decoding/decoding_spoc_CMC.py b/examples/decoding/decoding_spoc_CMC.py index bc2e88e66c4..f24519b76fa 100644 --- a/examples/decoding/decoding_spoc_CMC.py +++ b/examples/decoding/decoding_spoc_CMC.py @@ -60,7 +60,7 @@ # Classification pipeline with SPoC spatial filtering and Ridge Regression spoc = SPoC(n_components=2, log=True, reg="oas", rank="full") -clf = make_pipeline(spoc, Ridge(random_state=0)) +clf = make_pipeline(spoc, Ridge(random_state=127)) # Define a two fold cross-validation cv = KFold(n_splits=2, shuffle=False) diff --git a/examples/decoding/decoding_time_generalization_conditions.py b/examples/decoding/decoding_time_generalization_conditions.py index b7c7881cfa5..21c6e8e50eb 100644 --- a/examples/decoding/decoding_time_generalization_conditions.py +++ b/examples/decoding/decoding_time_generalization_conditions.py @@ -70,7 +70,7 @@ clf = make_pipeline( StandardScaler(), # liblinear is faster than lbfgs - LogisticRegression(solver="liblinear", random_state=0), + LogisticRegression(solver="liblinear", random_state=131), ) time_gen = GeneralizingEstimator(clf, scoring="roc_auc", n_jobs=None, verbose=True) diff --git a/examples/decoding/decoding_unsupervised_spatial_filter.py b/examples/decoding/decoding_unsupervised_spatial_filter.py index d37329036bd..764214adce3 100644 --- a/examples/decoding/decoding_unsupervised_spatial_filter.py +++ b/examples/decoding/decoding_unsupervised_spatial_filter.py @@ -63,7 +63,7 @@ ############################################################################## # Transform data with PCA computed on the average ie evoked response -pca = UnsupervisedSpatialFilter(PCA(30, random_state=0), average=False) +pca = UnsupervisedSpatialFilter(PCA(30, random_state=193), average=False) pca_data = pca.fit_transform(X) ev = mne.EvokedArray( np.mean(pca_data, axis=0), @@ -75,7 +75,7 @@ ############################################################################## # Transform data with ICA computed on the raw epochs (no averaging) ica = UnsupervisedSpatialFilter( - FastICA(30, whiten="unit-variance", random_state=0), average=False + FastICA(30, whiten="unit-variance", random_state=197), average=False ) ica_data = ica.fit_transform(X) ev1 = mne.EvokedArray( diff --git a/examples/decoding/decoding_xdawn_eeg.py b/examples/decoding/decoding_xdawn_eeg.py index f545f43f465..cf08446ffc0 100644 --- a/examples/decoding/decoding_xdawn_eeg.py +++ b/examples/decoding/decoding_xdawn_eeg.py @@ -81,7 +81,7 @@ Vectorizer(), MinMaxScaler(), OneVsRestClassifier( - LogisticRegression(solver="liblinear", random_state=0, **kwargs) + LogisticRegression(solver="liblinear", random_state=157, **kwargs) ), ) @@ -91,7 +91,7 @@ y = epochs.events[:, -1] # Cross validator -cv = StratifiedKFold(n_splits=10, shuffle=True, random_state=42) +cv = StratifiedKFold(n_splits=10, shuffle=True, random_state=163) # Do cross-validation preds = np.empty(len(y)) diff --git a/examples/decoding/linear_model_patterns.py b/examples/decoding/linear_model_patterns.py index ebd0f17916f..b36f769b3ba 100644 --- a/examples/decoding/linear_model_patterns.py +++ b/examples/decoding/linear_model_patterns.py @@ -74,7 +74,7 @@ # -------------------------------------------------------------- # liblinear is faster than lbfgs -clf = LogisticRegression(solver="liblinear", random_state=0) +clf = LogisticRegression(solver="liblinear", random_state=137) scaler = StandardScaler() # create a linear model with LogisticRegression @@ -128,7 +128,7 @@ Vectorizer(), # 1) vectorize across time and channels StandardScaler(), # 2) normalize features across trials LinearModel( # 3) fits a logistic regression - LogisticRegression(solver="liblinear", random_state=0) + LogisticRegression(solver="liblinear", random_state=139) ), ) clf.fit(X, y) diff --git a/examples/inverse/mixed_norm_inverse.py b/examples/inverse/mixed_norm_inverse.py index c36e747d510..47f76f7f63d 100644 --- a/examples/inverse/mixed_norm_inverse.py +++ b/examples/inverse/mixed_norm_inverse.py @@ -81,7 +81,7 @@ return_residual=True, return_as_dipoles=True, verbose=True, - rng=0, + rng=251, # for this dataset we know we should use a high alpha, so avoid some # of the slower (lower) alpha values sure_alpha_grid=np.linspace(90, 30, 10), diff --git a/examples/preprocessing/ica_comparison.py b/examples/preprocessing/ica_comparison.py index 21b564fda96..b3e9bfb9468 100644 --- a/examples/preprocessing/ica_comparison.py +++ b/examples/preprocessing/ica_comparison.py @@ -50,7 +50,7 @@ def run_ica(method, fit_params=None): method=method, fit_params=fit_params, max_iter="auto", - rng=0, + rng=29, ) t0 = time() ica.fit(raw, reject=reject) diff --git a/examples/simulation/plot_stc_metrics.py b/examples/simulation/plot_stc_metrics.py index 457ac639963..a86694bf090 100644 --- a/examples/simulation/plot_stc_metrics.py +++ b/examples/simulation/plot_stc_metrics.py @@ -32,7 +32,7 @@ spatial_deviation_error, ) -random_state = 42 # set random state to make this example deterministic +random_state = 229 # set random state to make this example deterministic # Import sample data data_path = sample.data_path() diff --git a/examples/simulation/simulate_evoked_data.py b/examples/simulation/simulate_evoked_data.py index 39bb98ca288..fe7150d9d12 100644 --- a/examples/simulation/simulate_evoked_data.py +++ b/examples/simulation/simulate_evoked_data.py @@ -67,7 +67,7 @@ def data_fun(times): fwd["src"], n_dipoles=2, times=times, - rng=42, + rng=239, labels=labels, data_fun=data_fun, ) diff --git a/examples/simulation/simulated_raw_data_using_subject_anatomy.py b/examples/simulation/simulated_raw_data_using_subject_anatomy.py index 8bda147c412..b9ae6e92c38 100644 --- a/examples/simulation/simulated_raw_data_using_subject_anatomy.py +++ b/examples/simulation/simulated_raw_data_using_subject_anatomy.py @@ -204,9 +204,9 @@ def data_fun(times, latency, duration): raw_sim = mne.simulation.simulate_raw(info, source_simulator, forward=fwd) raw_sim.set_eeg_reference(projection=True) -mne.simulation.add_noise(raw_sim, cov=noise_cov, rng=0) -mne.simulation.add_eog(raw_sim, rng=0) -mne.simulation.add_ecg(raw_sim, rng=0) +mne.simulation.add_noise(raw_sim, cov=noise_cov, rng=211) +mne.simulation.add_eog(raw_sim, rng=223) +mne.simulation.add_ecg(raw_sim, rng=227) # Plot original and simulated raw data. raw_sim.plot(title="Simulated raw data") diff --git a/examples/stats/cluster_stats_evoked.py b/examples/stats/cluster_stats_evoked.py index e196feddaac..597e8d6286a 100644 --- a/examples/stats/cluster_stats_evoked.py +++ b/examples/stats/cluster_stats_evoked.py @@ -70,7 +70,7 @@ threshold=threshold, tail=1, n_jobs=None, - rng=0, + rng=23, out_type="mask", ) diff --git a/examples/stats/sensor_permutation_test.py b/examples/stats/sensor_permutation_test.py index f47dd21d0d5..8507b5fdc85 100644 --- a/examples/stats/sensor_permutation_test.py +++ b/examples/stats/sensor_permutation_test.py @@ -61,7 +61,7 @@ data = np.mean(data[:, :, temporal_mask], axis=2) n_permutations = 50000 -T0, p_values, H0 = permutation_t_test(data, n_permutations, n_jobs=None, rng=0) +T0, p_values, H0 = permutation_t_test(data, n_permutations, n_jobs=None, rng=17) significant_sensors = picks[p_values <= 0.05] significant_sensors_names = [raw.ch_names[k] for k in significant_sensors] diff --git a/examples/time_frequency/time_frequency_global_field_power.py b/examples/time_frequency/time_frequency_global_field_power.py index 12ef0f3fbc4..efadad4ba03 100644 --- a/examples/time_frequency/time_frequency_global_field_power.py +++ b/examples/time_frequency/time_frequency_global_field_power.py @@ -136,7 +136,7 @@ def stat_fun(x): ax.plot(times, gfp, label=freq_name, color=color, linewidth=2.5) ax.axhline(0, linestyle="--", color="grey", linewidth=2) ci_low, ci_up = bootstrap_confidence_interval( - average.data, rng=0, stat_fun=stat_fun + average.data, rng=109, stat_fun=stat_fun ) ci_low = rescale(ci_low, average.times, baseline=(None, 0)) ci_up = rescale(ci_up, average.times, baseline=(None, 0)) diff --git a/examples/time_frequency/time_frequency_simulated.py b/examples/time_frequency/time_frequency_simulated.py index e04dda3c7d7..f9eaaeb4f50 100644 --- a/examples/time_frequency/time_frequency_simulated.py +++ b/examples/time_frequency/time_frequency_simulated.py @@ -43,7 +43,7 @@ n_times = 1024 # Just over 1 second epochs n_epochs = 40 -seed = 42 +seed = 181 rng = np.random.default_rng(seed) data = rng.standard_normal((len(ch_names), n_times * n_epochs + 200)) # buffer diff --git a/examples/visualization/channel_epochs_image.py b/examples/visualization/channel_epochs_image.py index 9281270a8c1..cef054b84da 100644 --- a/examples/visualization/channel_epochs_image.py +++ b/examples/visualization/channel_epochs_image.py @@ -74,7 +74,7 @@ def order_func(times, data): this_data /= np.sqrt(np.sum(this_data**2, axis=1))[:, np.newaxis] return np.argsort( spectral_embedding( - rbf_kernel(this_data, gamma=1.0), n_components=1, random_state=0 + rbf_kernel(this_data, gamma=1.0), n_components=1, random_state=233 ).ravel() ) diff --git a/tutorials/clinical/60_sleep.py b/tutorials/clinical/60_sleep.py index e50b0740a7c..50755b5d411 100644 --- a/tutorials/clinical/60_sleep.py +++ b/tutorials/clinical/60_sleep.py @@ -301,7 +301,7 @@ def eeg_power_band(epochs): pipe = make_pipeline( FunctionTransformer(eeg_power_band, validate=False), - RandomForestClassifier(n_estimators=100, random_state=42), + RandomForestClassifier(n_estimators=100, random_state=241), ) # Train diff --git a/tutorials/machine-learning/50_decoding.py b/tutorials/machine-learning/50_decoding.py index 69d1844df6e..87bfffa15b7 100644 --- a/tutorials/machine-learning/50_decoding.py +++ b/tutorials/machine-learning/50_decoding.py @@ -142,7 +142,7 @@ Scaler(epochs.info), Vectorizer(), # liblinear is faster than lbfgs - LogisticRegression(solver="liblinear", random_state=0), + LogisticRegression(solver="liblinear", random_state=31), ) scores = cross_val_multiscore(clf, X, y, cv=5, n_jobs=None) @@ -227,7 +227,7 @@ csp = CSP(n_components=3, norm_trace=False) clf_csp = make_pipeline( - csp, LinearModel(LogisticRegression(solver="liblinear", random_state=0)) + csp, LinearModel(LogisticRegression(solver="liblinear", random_state=37)) ) scores = cross_val_multiscore(clf_csp, X, y, cv=5, n_jobs=None) print(f"CSP: {100 * scores.mean():0.1f}%") @@ -328,7 +328,7 @@ # We will train the classifier on all left visual vs auditory trials on MEG clf = make_pipeline( - StandardScaler(), LogisticRegression(solver="liblinear", random_state=0) + StandardScaler(), LogisticRegression(solver="liblinear", random_state=41) ) time_decod = SlidingEstimator(clf, n_jobs=None, scoring="roc_auc", verbose=True) @@ -353,7 +353,7 @@ # use a LinearModel clf = make_pipeline( StandardScaler(), - LinearModel(LogisticRegression(solver="liblinear", random_state=0)), + LinearModel(LogisticRegression(solver="liblinear", random_state=43)), ) time_decod = SlidingEstimator(clf, n_jobs=None, scoring="roc_auc", verbose=True) time_decod.fit(X, y) diff --git a/tutorials/simulation/70_point_spread.py b/tutorials/simulation/70_point_spread.py index 5c40886e84f..f803a398da6 100644 --- a/tutorials/simulation/70_point_spread.py +++ b/tutorials/simulation/70_point_spread.py @@ -26,7 +26,7 @@ # %% # First, we set some parameters. -seed = 42 +seed = 199 # parameters for inverse method method = "sLORETA" diff --git a/tutorials/stats-sensor-space/10_background_stats.py b/tutorials/stats-sensor-space/10_background_stats.py index d43e519b75e..1a25e741b7c 100644 --- a/tutorials/stats-sensor-space/10_background_stats.py +++ b/tutorials/stats-sensor-space/10_background_stats.py @@ -251,7 +251,7 @@ def plot_t_p(t, p, title, mcc, axes=None): ps.append(np.zeros(width * width)) mccs.append(False) for ii in range(n_src): - t, p = permutation_t_test(X[:, [ii]], verbose=False, rng=0)[:2] + t, p = permutation_t_test(X[:, [ii]], verbose=False, rng=47)[:2] ts[-1][ii], ps[-1][ii] = t[0], p[0] plot_t_p(ts[-1], ps[-1], titles[-1], mccs[-1]) @@ -370,7 +370,7 @@ def plot_t_p(t, p, title, mcc, axes=None): # of processed neuroimaging data). titles.append(r"$\mathbf{Perm_{max}}$") -out = permutation_t_test(X, verbose=False, rng=0)[:2] +out = permutation_t_test(X, verbose=False, rng=53)[:2] ts.append(out[0]) ps.append(out[1]) mccs.append(True) @@ -509,7 +509,7 @@ def plot_t_p(t, p, title, mcc, axes=None): # run the cluster test t_clust, clusters, p_values, H0 = permutation_cluster_1samp_test( X, - rng=0, + rng=59, n_jobs=None, threshold=t_thresh, adjacency=None, @@ -535,7 +535,7 @@ def plot_t_p(t, p, title, mcc, axes=None): stat_fun_hat = partial(ttest_1samp_no_p, sigma=sigma) t_hat, clusters, p_values, H0 = permutation_cluster_1samp_test( X, - rng=0, + rng=61, n_jobs=None, threshold=t_thresh, adjacency=None, @@ -579,7 +579,7 @@ def plot_t_p(t, p, title, mcc, axes=None): threshold_tfce = dict(start=0, step=0.2) t_tfce, _, p_tfce, H0 = permutation_cluster_1samp_test( X, - rng=0, + rng=67, n_jobs=None, threshold=threshold_tfce, adjacency=None, @@ -596,7 +596,7 @@ def plot_t_p(t, p, title, mcc, axes=None): titles.append(r"$\mathbf{C_{hat,TFCE}}$") t_tfce_hat, _, p_tfce_hat, H0 = permutation_cluster_1samp_test( X, - rng=0, + rng=71, n_jobs=None, threshold=threshold_tfce, adjacency=None, diff --git a/tutorials/stats-sensor-space/20_erp_stats.py b/tutorials/stats-sensor-space/20_erp_stats.py index e0e6813fa0a..17c3655845d 100644 --- a/tutorials/stats-sensor-space/20_erp_stats.py +++ b/tutorials/stats-sensor-space/20_erp_stats.py @@ -98,7 +98,7 @@ # Calculate statistical thresholds t_obs, clusters, cluster_pv, h0 = spatio_temporal_cluster_test( - X, tfce, adjacency=adjacency, n_permutations=100, rng=0 + X, tfce, adjacency=adjacency, n_permutations=100, rng=113 ) # a more standard number would be 1000+ significant_points = cluster_pv.reshape(t_obs.shape).T < 0.05 print(str(significant_points.sum()) + " points selected by TFCE ...") diff --git a/tutorials/stats-sensor-space/40_cluster_1samp_time_freq.py b/tutorials/stats-sensor-space/40_cluster_1samp_time_freq.py index 87ddd77f476..3ad9b11e2e0 100644 --- a/tutorials/stats-sensor-space/40_cluster_1samp_time_freq.py +++ b/tutorials/stats-sensor-space/40_cluster_1samp_time_freq.py @@ -208,7 +208,7 @@ tail=tail, adjacency=adjacency, out_type="mask", - rng=0, + rng=73, verbose=True, ) diff --git a/tutorials/stats-sensor-space/70_cluster_rmANOVA_time_freq.py b/tutorials/stats-sensor-space/70_cluster_rmANOVA_time_freq.py index ca0b8eb4756..febfdcb1a5e 100644 --- a/tutorials/stats-sensor-space/70_cluster_rmANOVA_time_freq.py +++ b/tutorials/stats-sensor-space/70_cluster_rmANOVA_time_freq.py @@ -239,7 +239,7 @@ def stat_fun(*args): n_permutations=n_permutations, buffer_size=None, out_type="mask", - rng=0, + rng=101, ) # %% diff --git a/tutorials/stats-sensor-space/75_cluster_ftest_spatiotemporal.py b/tutorials/stats-sensor-space/75_cluster_ftest_spatiotemporal.py index 7ae6dca38f0..9df6f787873 100644 --- a/tutorials/stats-sensor-space/75_cluster_ftest_spatiotemporal.py +++ b/tutorials/stats-sensor-space/75_cluster_ftest_spatiotemporal.py @@ -144,7 +144,7 @@ n_jobs=None, buffer_size=None, adjacency=adjacency, - rng=0, + rng=149, ) F_obs, clusters, p_values, _ = cluster_stats @@ -318,7 +318,7 @@ n_jobs=None, buffer_size=None, adjacency=tfr_adjacency, - rng=0, + rng=151, ) # %% diff --git a/tutorials/stats-source-space/20_cluster_1samp_spatiotemporal.py b/tutorials/stats-source-space/20_cluster_1samp_spatiotemporal.py index ee2e98d67ad..f767cdf42c6 100644 --- a/tutorials/stats-source-space/20_cluster_1samp_spatiotemporal.py +++ b/tutorials/stats-source-space/20_cluster_1samp_spatiotemporal.py @@ -209,7 +209,7 @@ n_jobs=None, threshold=t_threshold, buffer_size=None, - rng=0, + rng=179, verbose=True, ) diff --git a/tutorials/stats-source-space/30_cluster_ftest_spatiotemporal.py b/tutorials/stats-source-space/30_cluster_ftest_spatiotemporal.py index 2a48f3df2dd..890e2cbddf9 100644 --- a/tutorials/stats-source-space/30_cluster_ftest_spatiotemporal.py +++ b/tutorials/stats-source-space/30_cluster_ftest_spatiotemporal.py @@ -101,7 +101,7 @@ n_permutations=n_permutations, threshold=f_threshold, buffer_size=None, - rng=0, + rng=167, ) # Now select the clusters that are sig. at p < 0.05 (note that this value # is multiple-comparisons corrected). diff --git a/tutorials/stats-source-space/60_cluster_rmANOVA_spatiotemporal.py b/tutorials/stats-source-space/60_cluster_rmANOVA_spatiotemporal.py index 0ee89dcbb88..61ddb3cfbf5 100644 --- a/tutorials/stats-source-space/60_cluster_rmANOVA_spatiotemporal.py +++ b/tutorials/stats-source-space/60_cluster_rmANOVA_spatiotemporal.py @@ -245,7 +245,7 @@ def stat_fun(*args): stat_fun=stat_fun, n_permutations=n_permutations, buffer_size=None, - rng=0, + rng=173, ) # Now select the clusters that are sig. at p < 0.05 (note that this value # is multiple-comparisons corrected). From aa3b31e8a87715404d8fd8573818c53e80fb38f1 Mon Sep 17 00:00:00 2001 From: Bru Date: Tue, 25 Aug 2026 14:09:00 +0200 Subject: [PATCH 31/34] TEST: Soft-import sklearn in RNG check --- mne/tests/test_docstring_parameters.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/mne/tests/test_docstring_parameters.py b/mne/tests/test_docstring_parameters.py index 2954a99c34f..142d4825833 100644 --- a/mne/tests/test_docstring_parameters.py +++ b/mne/tests/test_docstring_parameters.py @@ -16,7 +16,7 @@ import pytest import mne -from mne.utils import _pl, _record_warnings +from mne.utils import _pl, _record_warnings, _soft_import from mne.utils._typing import Color, FileLike from mne.utils.docs import _doc_special_members @@ -368,6 +368,7 @@ def _rng_parameters(callable_, node): def test_no_global_rng(): """Test that we use local generators and the modern numpy RNG API.""" root = pyproject_path.parent # only available in a dev/editable checkout + sklearn = _soft_import("sklearn", "checking RNG parameters", strict=False) bad = [] for sub in ("mne", "examples", "tutorials"): base = root / sub @@ -376,7 +377,7 @@ def test_no_global_rng(): for path in sorted(base.rglob("*.py")): rel = path.relative_to(root).as_posix() tree = ast.parse(path.read_text("utf-8")) - callables = _sklearn_callables(tree) + callables = _sklearn_callables(tree) if sklearn else {} for node in ast.walk(tree): if ( isinstance(node, ast.Attribute) From d2c5dfeb602ddc2fcd76107434a6385d83e3d2c2 Mon Sep 17 00:00:00 2001 From: Bru Date: Tue, 25 Aug 2026 14:39:18 +0200 Subject: [PATCH 32/34] TEST: Soft-import sklearn RNG callables --- mne/tests/test_docstring_parameters.py | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/mne/tests/test_docstring_parameters.py b/mne/tests/test_docstring_parameters.py index 142d4825833..dfb0bb4af93 100644 --- a/mne/tests/test_docstring_parameters.py +++ b/mne/tests/test_docstring_parameters.py @@ -337,17 +337,20 @@ def _sklearn_callables(tree): and node.module and node.module.startswith("sklearn") ): - module = importlib.import_module(node.module) - callables.update( - (alias.asname or alias.name, getattr(module, alias.name)) - for alias in node.names - ) + module = _soft_import(node.module, "checking RNG parameters", strict=False) + if module: + for alias in node.names: + callable_ = getattr(module, alias.name, None) + if callable_ is not None: + callables[alias.asname or alias.name] = callable_ elif isinstance(node, ast.Import): for alias in node.names: if alias.name.startswith("sklearn"): - callables[alias.asname or alias.name] = importlib.import_module( - alias.name + module = _soft_import( + alias.name, "checking RNG parameters", strict=False ) + if module: + callables[alias.asname or alias.name] = module return callables @@ -368,7 +371,6 @@ def _rng_parameters(callable_, node): def test_no_global_rng(): """Test that we use local generators and the modern numpy RNG API.""" root = pyproject_path.parent # only available in a dev/editable checkout - sklearn = _soft_import("sklearn", "checking RNG parameters", strict=False) bad = [] for sub in ("mne", "examples", "tutorials"): base = root / sub @@ -377,7 +379,7 @@ def test_no_global_rng(): for path in sorted(base.rglob("*.py")): rel = path.relative_to(root).as_posix() tree = ast.parse(path.read_text("utf-8")) - callables = _sklearn_callables(tree) if sklearn else {} + callables = _sklearn_callables(tree) for node in ast.walk(tree): if ( isinstance(node, ast.Attribute) From 45c08364709f4e28a623f4ac825fe0c9c0748f8f Mon Sep 17 00:00:00 2001 From: Bru Date: Tue, 25 Aug 2026 14:54:22 +0200 Subject: [PATCH 33/34] TEST: Capture RNG migration logs at info level --- mne/utils/tests/test_check.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mne/utils/tests/test_check.py b/mne/utils/tests/test_check.py index 626653ade48..0c455d64d26 100644 --- a/mne/utils/tests/test_check.py +++ b/mne/utils/tests/test_check.py @@ -80,7 +80,7 @@ def _func(*, rng=None, random_state=None, seed=None): random_state = np.random.RandomState(0) assert _func(rng=random_state) is random_state # legacy int/None keep their RandomState semantics and log migration guidance - with catch_logging() as log: + with catch_logging(verbose="info") as log: assert isinstance(_func(**{legacy_name: 0}), np.random.RandomState) assert f"Use rng= instead of {legacy_name}=" in log.getvalue() assert isinstance(_func(**{legacy_name: None}), np.random.mtrand.RandomState) From 97c5f1c264fc8b3cbf13caca3bc23a490a07f6c7 Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Tue, 25 Aug 2026 17:05:20 +0200 Subject: [PATCH 34/34] FIX: Docstring --- mne/epochs.py | 12 ++++-------- mne/utils/docs.py | 12 ++++++++++++ 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/mne/epochs.py b/mne/epochs.py index ebc3219a2cf..f313a94e2ac 100644 --- a/mne/epochs.py +++ b/mne/epochs.py @@ -2539,10 +2539,8 @@ def equalize_event_counts( The ``event_ids`` must identify non-overlapping subsets of the epochs. %(equalize_events_method)s - %(rng)s - Used only if ``method='random'``. - %(random_state_rng)s - Used only if ``method='random'``. + %(rng_method_random)s + %(random_state_rng_method_random)s Returns ------- @@ -4036,10 +4034,8 @@ def equalize_epoch_counts( epochs_list : list of Epochs The Epochs instances to equalize trial counts for. %(equalize_events_method)s - %(rng)s - Used only if ``method='random'``. - %(random_state_rng)s - Used only if ``method='random'``. + %(rng_method_random)s + %(random_state_rng_method_random)s Notes ----- diff --git a/mne/utils/docs.py b/mne/utils/docs.py index 3f978cdcb83..10eb70e4046 100644 --- a/mne/utils/docs.py +++ b/mne/utils/docs.py @@ -3771,6 +3771,11 @@ def _reflow_param_docstring(docstring, has_first_line=True, width=75): NumPy's global :class:`~numpy.random.RandomState` is used. """ +docdict["random_state_rng_method_random"] = ( + docdict["random_state_rng"].rstrip("\n") + + "\n Used only if ``method='random'``.\n" +) + _rank_base = """ rank : None | 'info' | 'full' | dict This controls the rank computation that can be read from the @@ -4053,6 +4058,13 @@ def _reflow_param_docstring(docstring, has_first_line=True, width=75): .. versionadded:: 1.13 """ +# The ``rng`` entry ends with a directive, so anything appended at the call site +# would be swallowed by it; make the ``method='random'`` variant here instead. +docdict["rng_method_random"] = docdict["rng"].replace( + "\n\n .. versionadded", + "\n Used only if ``method='random'``.\n\n .. versionadded", +) + docdict["roll"] = """ roll : float | None The roll of the camera rendering the view in degrees.