From 9e72b47d8689695d9ea2cfe00da679f97d2ead8a Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Tue, 5 May 2026 10:33:25 -0400 Subject: [PATCH 01/14] Fix bug with ica.plot_properties --- mne/viz/ica.py | 29 ++++++------------ mne/viz/tests/test_ica.py | 30 ++++++++++++++++++- .../40_artifact_correction_ica.py | 1 - 3 files changed, 38 insertions(+), 22 deletions(-) diff --git a/mne/viz/ica.py b/mne/viz/ica.py index b1458896e69..3406b0c5f0b 100644 --- a/mne/viz/ica.py +++ b/mne/viz/ica.py @@ -238,13 +238,11 @@ def _plot_ica_properties( # image and erp # we create a new epoch with dropped rows - epoch_data = epochs_src.get_data(copy=False) - epoch_data = np.insert( - arr=epoch_data, - obj=(dropped_indices - np.arange(len(dropped_indices))).astype(int), - values=0.0, - axis=0, - ) + src_data = epochs_src.get_data(copy=False) + n = len(src_data) + len(dropped_indices) + epoch_data = np.zeros((n,) + (src_data.shape[1:]), dtype=src_data.dtype) + use_idx = np.setdiff1d(np.arange(n), dropped_indices) + epoch_data[use_idx] = src_data from ..epochs import EpochsArray epochs_src = EpochsArray( @@ -283,9 +281,11 @@ def _plot_ica_properties( range(len(epoch_var)), epoch_var, alpha=0.5, facecolor=[0, 0, 0], lw=0 ) # rejected epochs in red + # TODO: This can't be right as the variance is computed on the good/remaining + # epochs, so these are by necessity zero var_ax.scatter( dropped_indices, - epoch_var[dropped_indices], + 0, alpha=1.0, facecolor=[1, 0, 0], lw=0, @@ -610,7 +610,6 @@ def _fast_plot_ica_properties( ) del reject ica_data = np.swapaxes(data[:, picks, :], 0, 1) - dropped_src = ica_data # spectrum Nyquist = inst.info["sfreq"] / 2.0 @@ -656,16 +655,6 @@ def set_title_and_labels(ax, title, xlab, ylab): # we reconstruct an epoch_variance with 0 where indexes where dropped epoch_var = np.var(ica_data[idx], axis=1) - drop_var = np.var(dropped_src[idx], axis=1) - drop_indices_corrected = ( - dropped_indices - np.arange(len(dropped_indices)) - ).astype(int) - epoch_var = np.insert( - arr=epoch_var, - obj=drop_indices_corrected, - values=drop_var[dropped_indices], - axis=0, - ) # the actual plot fig = _plot_ica_properties( @@ -772,7 +761,7 @@ def _prepare_data_ica_properties(inst, ica, reject_by_annotation=True, reject="a ) # getting dropped epochs indexes if drop_inds is not None: - dropped_indices = [(d[0] // len(epochs_src.times)) + 1 for d in drop_inds] + dropped_indices = [(d[0] // len(epochs_src.times)) for d in drop_inds] kind = "Segment" else: drop_inds = None diff --git a/mne/viz/tests/test_ica.py b/mne/viz/tests/test_ica.py index 46d1145e851..fcd50911352 100644 --- a/mne/viz/tests/test_ica.py +++ b/mne/viz/tests/test_ica.py @@ -13,6 +13,7 @@ from mne import ( Annotations, Epochs, + create_info, make_fixed_length_events, pick_types, read_cov, @@ -143,7 +144,7 @@ def test_plot_ica_components(): @pytest.mark.slowtest -def test_plot_ica_properties(): +def test_plot_ica_properties_basic(): """Test plotting of ICA properties.""" raw = _get_raw(preload=True).crop(0, 5) raw.add_proj([], remove_existing=True) @@ -281,6 +282,33 @@ def test_plot_ica_properties(): plt.close("all") +@pytest.mark.parametrize("kind", ["first", "last"]) +def test_plot_ica_properties_reject(kind): + """Check for gh-13879.""" + sfreq, duration = 100.0, 10.0 + n_samples = int(sfreq * duration) + rng = np.random.default_rng(0) + n_channels = 3 + data = rng.uniform(-3e-6, 3e-6, size=(n_channels, n_samples)) + assert kind in ("first", "last") + idx = 0 if kind == "first" else -1 + data[0, idx] = 1000e-6 + info = create_info(["Fz", "Cz", "C2"], sfreq, "eeg") + raw = RawArray(data, info) + raw.set_montage("standard_1020") + ica = ICA( + n_components=2, + method="picard", + random_state=0, + fit_params=dict(ortho=False, extended=True), + ) + with pytest.warns(RuntimeWarning, match="filtered"), catch_logging(True) as log: + ica.fit(raw, reject=dict(eeg=500e-6)) + log = log.getvalue() + assert log.count("Artifact detected") == 1 # dropped one epoch + ica.plot_properties(raw, picks=[0], show=False) + + def test_plot_ica_sources(raw_orig, browser_backend, monkeypatch): """Test plotting of ICA panel.""" raw = raw_orig.copy().crop(0, 1) diff --git a/tutorials/preprocessing/40_artifact_correction_ica.py b/tutorials/preprocessing/40_artifact_correction_ica.py index 257b1f85051..7d5c123ff80 100644 --- a/tutorials/preprocessing/40_artifact_correction_ica.py +++ b/tutorials/preprocessing/40_artifact_correction_ica.py @@ -320,7 +320,6 @@ # %% # .. note:: -# # `~mne.preprocessing.ICA.plot_components` (which plots the scalp # field topographies for each component) has an optional ``inst`` parameter # that takes an instance of `~mne.io.Raw` or `~mne.Epochs`. From f996c0573c9f1912bd390abd50c10ccb5dd20461 Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Tue, 5 May 2026 10:37:55 -0400 Subject: [PATCH 02/14] Changelog --- doc/changes/dev/13885.bugfix.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 doc/changes/dev/13885.bugfix.rst diff --git a/doc/changes/dev/13885.bugfix.rst b/doc/changes/dev/13885.bugfix.rst new file mode 100644 index 00000000000..828da0d5673 --- /dev/null +++ b/doc/changes/dev/13885.bugfix.rst @@ -0,0 +1 @@ +Fix bug with :meth:`mne.preprocessing.ICA.plot_properties` when using ``reject`` in :meth:`mne.preprocessing.ICA.fit`, by `Eric Larson`_. From 4c8eaa1dfb87be6c056ad1249842c37ccc324071 Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Tue, 5 May 2026 11:06:16 -0400 Subject: [PATCH 03/14] FIX: Test --- mne/viz/ica.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mne/viz/ica.py b/mne/viz/ica.py index 3406b0c5f0b..341858423d7 100644 --- a/mne/viz/ica.py +++ b/mne/viz/ica.py @@ -285,7 +285,7 @@ def _plot_ica_properties( # epochs, so these are by necessity zero var_ax.scatter( dropped_indices, - 0, + np.zeros(len(dropped_indices)), alpha=1.0, facecolor=[1, 0, 0], lw=0, From 415d5a03433ece65a2722f24bd363cde7bef00d0 Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Tue, 5 May 2026 12:54:42 -0400 Subject: [PATCH 04/14] FX: More --- mne/viz/tests/test_ica.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/mne/viz/tests/test_ica.py b/mne/viz/tests/test_ica.py index fcd50911352..7188b062ef2 100644 --- a/mne/viz/tests/test_ica.py +++ b/mne/viz/tests/test_ica.py @@ -298,11 +298,14 @@ def test_plot_ica_properties_reject(kind): raw.set_montage("standard_1020") ica = ICA( n_components=2, - method="picard", random_state=0, - fit_params=dict(ortho=False, extended=True), + max_iter=1, ) - with pytest.warns(RuntimeWarning, match="filtered"), catch_logging(True) as log: + with ( + pytest.warns(RuntimeWarning, match="filtered"), + pytest.warns(Warning, match="converge"), + catch_logging(True) as log, + ): ica.fit(raw, reject=dict(eeg=500e-6)) log = log.getvalue() assert log.count("Artifact detected") == 1 # dropped one epoch From 5e0c34ccd882c7956aa365fb43e0cdc309524972 Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Tue, 5 May 2026 14:58:16 -0400 Subject: [PATCH 05/14] FIX: Docs --- examples/preprocessing/find_ref_artifacts.py | 1 - examples/preprocessing/muscle_ica.py | 4 +- mne/_fiff/pick.py | 3 +- mne/utils/numerics.py | 16 +- mne/viz/ica.py | 189 +++++++++--------- .../40_artifact_correction_ica.py | 2 +- 6 files changed, 107 insertions(+), 108 deletions(-) diff --git a/examples/preprocessing/find_ref_artifacts.py b/examples/preprocessing/find_ref_artifacts.py index 90e3d1fb0da..c3b9a126683 100644 --- a/examples/preprocessing/find_ref_artifacts.py +++ b/examples/preprocessing/find_ref_artifacts.py @@ -24,7 +24,6 @@ on the reference channels are removed. This technique is fully described and validated in :footcite:`HannaEtAl2020` - """ # Authors: Jeff Hanna # diff --git a/examples/preprocessing/muscle_ica.py b/examples/preprocessing/muscle_ica.py index f61d1e22bc4..0395503defd 100644 --- a/examples/preprocessing/muscle_ica.py +++ b/examples/preprocessing/muscle_ica.py @@ -48,9 +48,7 @@ # %% # By inspection, let's select out the muscle-artifact components based on -# :footcite:`DharmapraniEtAl2016` manually. -# -# The criteria are: +# :footcite:`DharmapraniEtAl2016` manually. The criteria are: # # - Positive slope of log-log power spectrum between 7 and 75 Hz # (here just flat because it's not in log-log) diff --git a/mne/_fiff/pick.py b/mne/_fiff/pick.py index e007100dae1..ff454624571 100644 --- a/mne/_fiff/pick.py +++ b/mne/_fiff/pick.py @@ -1238,7 +1238,7 @@ def _picks_to_idx( extra_repr = ", treated as range({n_chan})" else: picks = none # let _picks_str_to_idx handle it - extra_repr = f'None, treated as "{none}"' + extra_repr = f', treated as "{none}"' # # slice @@ -1309,7 +1309,6 @@ def _picks_str_to_idx( # # first: check our special cases # - picks_generic = list() if len(picks) == 1: if picks[0] in ("all", "data", "data_or_ica"): diff --git a/mne/utils/numerics.py b/mne/utils/numerics.py index a287ae8814c..e343fc2c5f0 100644 --- a/mne/utils/numerics.py +++ b/mne/utils/numerics.py @@ -199,7 +199,9 @@ def _gen_events(n_epochs): return events -def _reject_data_segments(data, reject, flat, decim, info, tstep): +def _reject_data_segments( + data, reject, flat, decim, info, tstep, *, return_drop_var=False +): """Reject data segments using peak-to-peak amplitude.""" from .._fiff.pick import channel_indices_by_type from ..epochs import _is_good @@ -212,6 +214,7 @@ def _reject_data_segments(data, reject, flat, decim, info, tstep): this_start = 0 this_stop = 0 drop_inds = [] + drop_var = [] for first in range(0, data.shape[1], step): last = first + step data_buffer = data[:, first:last] @@ -231,6 +234,8 @@ def _reject_data_segments(data, reject, flat, decim, info, tstep): else: logger.info(f"Artifact detected in [{first}, {last}]") drop_inds.append((first, last)) + if return_drop_var: + drop_var.append(np.var(data_buffer, axis=1)) data = data_clean[:, :this_stop] if not data.any(): raise RuntimeError( @@ -238,7 +243,14 @@ def _reject_data_segments(data, reject, flat, decim, info, tstep): "consider updating your rejection " "thresholds." ) - return data, drop_inds + out = (data, drop_inds) + if return_drop_var: + drop_var = np.reshape( + np.array(drop_var), (len(drop_inds), data.shape[0]), copy=False + ) + assert drop_var.shape == (len(drop_inds), data.shape[0]) + out += (drop_var,) + return out def _get_inst_data(inst): diff --git a/mne/viz/ica.py b/mne/viz/ica.py index 341858423d7..a1f58eaf308 100644 --- a/mne/viz/ica.py +++ b/mne/viz/ica.py @@ -15,7 +15,6 @@ from .._fiff.proj import _has_eeg_average_ref_proj from ..defaults import DEFAULTS, _handle_default from ..utils import ( - _reject_data_segments, _validate_type, fill_doc, verbose, @@ -202,13 +201,10 @@ def _create_properties_layout(figsize=None, fig=None): def _plot_ica_properties( pick, ica, - inst, psds_mean, freqs, - n_trials, - epoch_var, plot_lowpass_edge, - epochs_src, + this_epochs_src, set_title_and_labels, plot_std, psd_ylabel, @@ -219,7 +215,7 @@ def _plot_ica_properties( fig, axes, kind, - dropped_indices, + bad_indices, ): """Plot ICA properties (helper).""" from mpl_toolkits.axes_grid1.axes_divider import make_axes_locatable @@ -237,21 +233,15 @@ def _plot_ica_properties( ) # image and erp - # we create a new epoch with dropped rows - src_data = epochs_src.get_data(copy=False) - n = len(src_data) + len(dropped_indices) - epoch_data = np.zeros((n,) + (src_data.shape[1:]), dtype=src_data.dtype) - use_idx = np.setdiff1d(np.arange(n), dropped_indices) - epoch_data[use_idx] = src_data - from ..epochs import EpochsArray - - epochs_src = EpochsArray( - epoch_data, epochs_src.info, tmin=epochs_src.tmin, verbose=0 - ) - + n_trials = len(this_epochs_src) + epoch_var = np.var(this_epochs_src.get_data(), axis=-1) + assert epoch_var.shape[1] == 1 # single channel + epoch_var = epoch_var[:, 0] + assert epoch_var.shape == (len(this_epochs_src),) + this_epochs_src._data[bad_indices] = 0 plot_epochs_image( - epochs_src, - picks=pick, + this_epochs_src, + picks=[0], axes=[image_ax, erp_ax], combine=None, colorbar=False, @@ -270,47 +260,40 @@ def _plot_ica_properties( alpha=0.2, ) if plot_lowpass_edge: - spec_ax.axvline( - inst.info["lowpass"], lw=2, linestyle="--", color="k", alpha=0.2 - ) + spec_ax.axvline(ica.info["lowpass"], lw=2, linestyle="--", color="k", alpha=0.2) # epoch variance + good_indices = np.setdiff1d(np.arange(n_trials), bad_indices) var_ax_divider = make_axes_locatable(var_ax) - hist_ax = var_ax_divider.append_axes("right", size="33%", pad="2.5%") - var_ax.scatter( - range(len(epoch_var)), epoch_var, alpha=0.5, facecolor=[0, 0, 0], lw=0 - ) + hist_ax = var_ax_divider.append_axes("right", size="33%", pad="2.5%", sharey=var_ax) + facecolor = np.zeros((len(epoch_var), 3)) + alpha = np.full(len(epoch_var), 0.5) # rejected epochs in red - # TODO: This can't be right as the variance is computed on the good/remaining - # epochs, so these are by necessity zero + facecolor[bad_indices] = [1, 0, 0] + alpha[bad_indices] = 0.75 var_ax.scatter( - dropped_indices, - np.zeros(len(dropped_indices)), - alpha=1.0, - facecolor=[1, 0, 0], - lw=0, + np.arange(n_trials), epoch_var, alpha=alpha, facecolor=facecolor, lw=0 ) # compute percentage of dropped epochs - var_percent = float(len(dropped_indices)) / float(len(epoch_var)) * 100.0 + var_percent = 100 * len(bad_indices) / n_trials # histogram & histogram + epoch_var_good = epoch_var[good_indices] _, counts, _ = hist_ax.hist( - epoch_var, orientation="horizontal", color="k", alpha=0.5 + epoch_var_good, orientation="horizontal", color="k", alpha=0.5 ) # kde - ymin, ymax = hist_ax.get_ylim() try: - kde = gaussian_kde(epoch_var) + kde = gaussian_kde(epoch_var_good) except np.linalg.LinAlgError: pass # singular: happens when there is nothing plotted else: - x = np.linspace(ymin, ymax, 50) + x = np.linspace(epoch_var_good.min(), epoch_var_good.max(), 50) kde_ = kde(x) kde_ /= kde_.max() or 1.0 kde_ *= hist_ax.get_xlim()[-1] * 0.9 hist_ax.plot(kde_, x, color="k") - hist_ax.set_ylim(ymin, ymax) # aesthetics # ---------- @@ -319,7 +302,7 @@ def _plot_ica_properties( # erp set_title_and_labels(erp_ax, [], "Time (s)", "AU") erp_ax.spines["right"].set_color("k") - erp_ax.set_xlim(epochs_src.times[[0, -1]]) + erp_ax.set_xlim(this_epochs_src.times[[0, -1]]) # remove half of yticks if more than 5 yt = erp_ax.get_yticks() if len(yt) > 5: @@ -603,23 +586,26 @@ def _fast_plot_ica_properties( # calculations # ------------ if isinstance(precomputed_data, tuple): - kind, dropped_indices, epochs_src, data = precomputed_data + kind, bad_indices, epochs_src = precomputed_data else: - kind, dropped_indices, epochs_src, data = _prepare_data_ica_properties( + kind, bad_indices, epochs_src = _prepare_data_ica_properties( inst, ica, reject_by_annotation, reject ) - del reject - ica_data = np.swapaxes(data[:, picks, :], 0, 1) + del reject, inst + if len(epochs_src) == 0: + return [fig] + epochs_src_picked = epochs_src.pick(picks) + del epochs_src + good_indices = np.setdiff1d(np.arange(len(epochs_src_picked)), bad_indices) # spectrum - Nyquist = inst.info["sfreq"] / 2.0 - lp = inst.info["lowpass"] + Nyquist = ica.info["sfreq"] / 2.0 + lp = ica.info["lowpass"] if "fmax" not in psd_args: psd_args["fmax"] = min(lp * 1.25, Nyquist) plot_lowpass_edge = lp < Nyquist and (psd_args["fmax"] > lp) - spectrum = epochs_src.compute_psd(picks=picks, **psd_args) - # we've already restricted picks ↑↑↑↑↑↑↑↑↑↑↑ - # in the spectrum object, so here we do picks=all ↓↓↓↓↓↓↓↓↓↓↓ + # we've already restricted picks in epochs_src_picked, so here we do picks=all + spectrum = epochs_src_picked[good_indices].compute_psd(picks="all", **psd_args) psds, freqs = spectrum.get_data(return_freqs=True, picks="all", exclude=[]) # we also pass exclude=[] so that when this is called by right-clicking in # a plot_sources() window on an ICA component name that has been marked as @@ -653,20 +639,14 @@ def set_title_and_labels(ax, title, xlab, ylab): if idx > 0: fig, axes = _create_properties_layout(figsize=figsize) - # we reconstruct an epoch_variance with 0 where indexes where dropped - epoch_var = np.var(ica_data[idx], axis=1) - # the actual plot fig = _plot_ica_properties( pick, ica, - inst, psds_mean, freqs, - ica_data.shape[1], - epoch_var, plot_lowpass_edge, - epochs_src, + epochs_src_picked.copy().pick(picks=[idx]), set_title_and_labels, plot_std, psd_ylabel, @@ -677,7 +657,7 @@ def set_title_and_labels(ax, title, xlab, ylab): fig, axes, kind, - dropped_indices, + bad_indices, ) all_fig.append(fig) @@ -710,65 +690,76 @@ def _prepare_data_ica_properties(inst, ica, reject_by_annotation=True, reject="a data : array of shape (n_epochs, n_ica_sources, n_times) A view on epochs ICA sources data. """ - from ..epochs import BaseEpochs + from ..epochs import BaseEpochs, Epochs, make_fixed_length_events from ..io import BaseRaw, RawArray _validate_type(inst, (BaseRaw, BaseEpochs), "inst", "Raw or Epochs") + bad_indices = [] if isinstance(inst, BaseRaw): # when auto, delegate reject to the ica - from ..epochs import make_fixed_length_epochs if reject == "auto": reject = ica.reject_ - drop_inds = None - dropped_indices = [] - if reject is None: - inst_current = inst - else: - data = inst.get_data() - data, drop_inds = _reject_data_segments( - data, reject, flat=None, decim=None, info=inst.info, tstep=2.0 - ) - inst_current = RawArray(data, inst.info) - # break up continuous signal into segments; suppress "All epochs were - # dropped!" because we handle that case gracefully below - with warnings.catch_warnings(): - warnings.filterwarnings( - "ignore", "All epochs were dropped!", RuntimeWarning - ) - epochs_src = make_fixed_length_epochs( - ica.get_sources(inst_current), - duration=2, - preload=True, - reject_by_annotation=reject_by_annotation, - proj=False, - verbose=False, - ) + events = make_fixed_length_events(inst, duration=2) + kwargs = dict( + tmin=0, + tmax=2 - 1.0 / inst.info["sfreq"], + baseline=None, + verbose="error", + preload=True, + proj=False, + ) + epochs = Epochs( + inst, + events, + reject=reject, + reject_by_annotation=reject_by_annotation, + **kwargs, + ) # if all epochs were dropped by annotations, stitch the good segments # together so that the plot can still be generated - if reject_by_annotation and len(epochs_src) == 0: - good_data = inst_current.get_data(reject_by_annotation="omit") + epochs_src = None + if reject_by_annotation and len(epochs) == 0: + epochs_good = Epochs( + inst, + events, + reject=reject, + reject_by_annotation=False, + **kwargs, + ) + got_samps = len(epochs_good) * len(epochs.times) min_samples = int(2 * inst.info["sfreq"]) - if good_data.shape[1] >= min_samples: - inst_good = RawArray(good_data, inst_current.info.copy(), verbose=False) - epochs_src = make_fixed_length_epochs( + if got_samps >= min_samples: + good_data = np.reshape( + np.transpose(epochs_good.get_data(), [1, 0, 2]), + (len(epochs.ch_names), got_samps), + ) + inst_good = RawArray(good_data, inst.info.copy(), verbose="error") + epochs_src = Epochs( ica.get_sources(inst_good), - duration=2, - preload=True, + events, + reject=reject, reject_by_annotation=False, - proj=False, - verbose=False, + **kwargs, ) - # getting dropped epochs indexes - if drop_inds is not None: - dropped_indices = [(d[0] // len(epochs_src.times)) for d in drop_inds] + if epochs_src is None: + ica.get_sources(inst) + epochs_src = Epochs( + ica.get_sources(inst), + events, + # We have already rejected by annotation and reject above, but we don't + # here so we can keep data for bad epochs around + reject=None, + reject_by_annotation=False, + **kwargs, + ) + bad_indices = np.where([len(log) for log in epochs.drop_log])[0] + assert len(epochs_src) == len(epochs) + len(bad_indices) kind = "Segment" else: - drop_inds = None epochs_src = ica.get_sources(inst) - dropped_indices = [] kind = "Epochs" - return kind, dropped_indices, epochs_src, epochs_src.get_data(copy=False) + return kind, bad_indices, epochs_src def _plot_ica_sources_evoked(evoked, picks, exclude, title, show, ica, labels=None): diff --git a/tutorials/preprocessing/40_artifact_correction_ica.py b/tutorials/preprocessing/40_artifact_correction_ica.py index 7d5c123ff80..9375617f4a5 100644 --- a/tutorials/preprocessing/40_artifact_correction_ica.py +++ b/tutorials/preprocessing/40_artifact_correction_ica.py @@ -255,7 +255,7 @@ # baseline correction. ica = ICA(n_components=15, max_iter="auto", random_state=97) -ica.fit(filt_raw) +ica.fit(filt_raw, reject=dict(eeg=200e-6)) # avoid a couple of big artifacts ica # %% From 7bf0cee9692832a7293c59cf5f78a0c2e68253b0 Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Tue, 5 May 2026 14:59:17 -0400 Subject: [PATCH 06/14] FIX: Restore --- mne/_fiff/pick.py | 1 + mne/utils/numerics.py | 16 ++-------------- 2 files changed, 3 insertions(+), 14 deletions(-) diff --git a/mne/_fiff/pick.py b/mne/_fiff/pick.py index ff454624571..1b24fd27509 100644 --- a/mne/_fiff/pick.py +++ b/mne/_fiff/pick.py @@ -1309,6 +1309,7 @@ def _picks_str_to_idx( # # first: check our special cases # + picks_generic = list() if len(picks) == 1: if picks[0] in ("all", "data", "data_or_ica"): diff --git a/mne/utils/numerics.py b/mne/utils/numerics.py index e343fc2c5f0..a287ae8814c 100644 --- a/mne/utils/numerics.py +++ b/mne/utils/numerics.py @@ -199,9 +199,7 @@ def _gen_events(n_epochs): return events -def _reject_data_segments( - data, reject, flat, decim, info, tstep, *, return_drop_var=False -): +def _reject_data_segments(data, reject, flat, decim, info, tstep): """Reject data segments using peak-to-peak amplitude.""" from .._fiff.pick import channel_indices_by_type from ..epochs import _is_good @@ -214,7 +212,6 @@ def _reject_data_segments( this_start = 0 this_stop = 0 drop_inds = [] - drop_var = [] for first in range(0, data.shape[1], step): last = first + step data_buffer = data[:, first:last] @@ -234,8 +231,6 @@ def _reject_data_segments( else: logger.info(f"Artifact detected in [{first}, {last}]") drop_inds.append((first, last)) - if return_drop_var: - drop_var.append(np.var(data_buffer, axis=1)) data = data_clean[:, :this_stop] if not data.any(): raise RuntimeError( @@ -243,14 +238,7 @@ def _reject_data_segments( "consider updating your rejection " "thresholds." ) - out = (data, drop_inds) - if return_drop_var: - drop_var = np.reshape( - np.array(drop_var), (len(drop_inds), data.shape[0]), copy=False - ) - assert drop_var.shape == (len(drop_inds), data.shape[0]) - out += (drop_var,) - return out + return data, drop_inds def _get_inst_data(inst): From 9c954aca074803896c314de8f876d08a77499dc6 Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Tue, 5 May 2026 15:00:16 -0400 Subject: [PATCH 07/14] FIX: Comment --- mne/viz/tests/test_ica.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/mne/viz/tests/test_ica.py b/mne/viz/tests/test_ica.py index 7188b062ef2..a2058918943 100644 --- a/mne/viz/tests/test_ica.py +++ b/mne/viz/tests/test_ica.py @@ -309,7 +309,9 @@ def test_plot_ica_properties_reject(kind): ica.fit(raw, reject=dict(eeg=500e-6)) log = log.getvalue() assert log.count("Artifact detected") == 1 # dropped one epoch - ica.plot_properties(raw, picks=[0], show=False) + fig = ica.plot_properties(raw, picks=[0], show=False) + # TODO: Assert stuff about axis limits, etc. + assert fig def test_plot_ica_sources(raw_orig, browser_backend, monkeypatch): From 3ecd90c8295d174a301d92eab74382181bea9bfc Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Tue, 5 May 2026 15:01:02 -0400 Subject: [PATCH 08/14] FIX: Diff min --- tutorials/preprocessing/40_artifact_correction_ica.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tutorials/preprocessing/40_artifact_correction_ica.py b/tutorials/preprocessing/40_artifact_correction_ica.py index 9375617f4a5..aceee1f610d 100644 --- a/tutorials/preprocessing/40_artifact_correction_ica.py +++ b/tutorials/preprocessing/40_artifact_correction_ica.py @@ -320,6 +320,7 @@ # %% # .. note:: +# # `~mne.preprocessing.ICA.plot_components` (which plots the scalp # field topographies for each component) has an optional ``inst`` parameter # that takes an instance of `~mne.io.Raw` or `~mne.Epochs`. From f06f759cf0a22761cdb1f148aed07a59705987fc Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Tue, 5 May 2026 17:02:48 -0400 Subject: [PATCH 09/14] FIX: Cleaner --- mne/viz/ica.py | 75 +++++++++++++++++---------------------- mne/viz/tests/test_ica.py | 20 ++++++++--- 2 files changed, 48 insertions(+), 47 deletions(-) diff --git a/mne/viz/ica.py b/mne/viz/ica.py index a1f58eaf308..8bccf0f3f85 100644 --- a/mne/viz/ica.py +++ b/mne/viz/ica.py @@ -306,12 +306,12 @@ def _plot_ica_properties( # remove half of yticks if more than 5 yt = erp_ax.get_yticks() if len(yt) > 5: - erp_ax.yaxis.set_ticks(yt[::2]) + erp_ax.set_yticks(yt[::2]) # remove xticks - erp plot shows xticks for both image and erp plot - image_ax.xaxis.set_ticks([]) + image_ax.set_xticks([]) yt = image_ax.get_yticks() - image_ax.yaxis.set_ticks(yt[1:]) + image_ax.set_yticks(yt[1:]) image_ax.set_ylim([-0.5, n_trials + 0.5]) def _set_scale(ax, scale): @@ -325,10 +325,6 @@ def _set_scale(ax, scale): set_title_and_labels(spec_ax, "Spectrum", "Frequency (Hz)", psd_ylabel) spec_ax.yaxis.labelpad = 0 spec_ax.set_xlim(freqs[[0, -1]]) - ylim = spec_ax.get_ylim() - air = np.diff(ylim)[0] * 0.1 - spec_ax.set_ylim(ylim[0] - air, ylim[1] + air) - image_ax.axhline(0, color="k", linewidth=0.5) if log_scale: _set_scale(spec_ax, "log") @@ -700,13 +696,13 @@ def _prepare_data_ica_properties(inst, ica, reject_by_annotation=True, reject="a if reject == "auto": reject = ica.reject_ + # First we try making epochs in the normal way and see if we have enough events = make_fixed_length_events(inst, duration=2) kwargs = dict( tmin=0, tmax=2 - 1.0 / inst.info["sfreq"], baseline=None, verbose="error", - preload=True, proj=False, ) epochs = Epochs( @@ -714,48 +710,43 @@ def _prepare_data_ica_properties(inst, ica, reject_by_annotation=True, reject="a events, reject=reject, reject_by_annotation=reject_by_annotation, + preload=False, **kwargs, - ) - # if all epochs were dropped by annotations, stitch the good segments - # together so that the plot can still be generated - epochs_src = None + ).drop_bad(verbose="error") + # If all epochs were dropped, stitch the good segments according to + # reject_by_annotation back together and get sources for those, subject to + # the reject param if reject_by_annotation and len(epochs) == 0: - epochs_good = Epochs( - inst, - events, + good_data = inst.get_data(reject_by_annotation="omit") + inst_stitched = RawArray(good_data, inst.info.copy(), verbose="error") + events_stitched = make_fixed_length_events(inst_stitched, duration=2) + epochs_stitched = Epochs( + inst_stitched, + events_stitched, reject=reject, reject_by_annotation=False, + preload=False, **kwargs, - ) - got_samps = len(epochs_good) * len(epochs.times) + ).drop_bad(verbose="error") + got_samps = len(epochs_stitched) * len(epochs_stitched.times) min_samples = int(2 * inst.info["sfreq"]) if got_samps >= min_samples: - good_data = np.reshape( - np.transpose(epochs_good.get_data(), [1, 0, 2]), - (len(epochs.ch_names), got_samps), - ) - inst_good = RawArray(good_data, inst.info.copy(), verbose="error") - epochs_src = Epochs( - ica.get_sources(inst_good), - events, - reject=reject, - reject_by_annotation=False, - **kwargs, - ) - if epochs_src is None: - ica.get_sources(inst) - epochs_src = Epochs( - ica.get_sources(inst), - events, - # We have already rejected by annotation and reject above, but we don't - # here so we can keep data for bad epochs around - reject=None, - reject_by_annotation=False, - **kwargs, - ) - bad_indices = np.where([len(log) for log in epochs.drop_log])[0] - assert len(epochs_src) == len(epochs) + len(bad_indices) + inst = inst_stitched + events = events_stitched + epochs = epochs_stitched + epochs_src = Epochs( + ica.get_sources(inst), + events, + # We have already rejected by annotation and reject above, but we don't + # here so we can keep data for bad epochs around + reject=None, + reject_by_annotation=False, + preload=True, + **kwargs, + ) + bad_indices = np.where([len(log) for log in epochs.drop_log])[0] kind = "Segment" + assert len(epochs_src) == len(epochs) + len(bad_indices) else: epochs_src = ica.get_sources(inst) kind = "Epochs" diff --git a/mne/viz/tests/test_ica.py b/mne/viz/tests/test_ica.py index a2058918943..dc31bbd0ae8 100644 --- a/mne/viz/tests/test_ica.py +++ b/mne/viz/tests/test_ica.py @@ -285,8 +285,8 @@ def test_plot_ica_properties_basic(): @pytest.mark.parametrize("kind", ["first", "last"]) def test_plot_ica_properties_reject(kind): """Check for gh-13879.""" - sfreq, duration = 100.0, 10.0 - n_samples = int(sfreq * duration) + sfreq, n_epochs = 100.0, 5 + n_samples = int(sfreq * n_epochs * 2.0) # 2s per segment rng = np.random.default_rng(0) n_channels = 3 data = rng.uniform(-3e-6, 3e-6, size=(n_channels, n_samples)) @@ -309,9 +309,19 @@ def test_plot_ica_properties_reject(kind): ica.fit(raw, reject=dict(eeg=500e-6)) log = log.getvalue() assert log.count("Artifact detected") == 1 # dropped one epoch - fig = ica.plot_properties(raw, picks=[0], show=False) - # TODO: Assert stuff about axis limits, etc. - assert fig + figs = ica.plot_properties(raw, picks=[0], show=False) + assert len(figs) == 1 + fig = figs[0] + img_ax = fig.axes[1] + img_ylim = img_ax.get_ylim() + assert img_ylim[0] == -0.5 + assert img_ylim[1] == n_epochs + 0.5 + hist_ax = fig.axes[-1] + var_ax = fig.axes[-2] + min_hist = np.min(hist_ax.lines[0].get_ydata()) + assert min_hist > 0 + scatter_x, _ = var_ax.collections[0].get_offsets().data.T + assert_array_equal(scatter_x, np.arange(n_epochs)) def test_plot_ica_sources(raw_orig, browser_backend, monkeypatch): From 3537417d3b9702453a064150784b506da4281adf Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Tue, 5 May 2026 17:09:06 -0400 Subject: [PATCH 10/14] FIX: Test --- mne/viz/ica.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mne/viz/ica.py b/mne/viz/ica.py index 8bccf0f3f85..c31c222326f 100644 --- a/mne/viz/ica.py +++ b/mne/viz/ica.py @@ -595,8 +595,8 @@ def _fast_plot_ica_properties( good_indices = np.setdiff1d(np.arange(len(epochs_src_picked)), bad_indices) # spectrum - Nyquist = ica.info["sfreq"] / 2.0 - lp = ica.info["lowpass"] + Nyquist = epochs_src_picked.info["sfreq"] / 2.0 + lp = epochs_src_picked.info["lowpass"] if "fmax" not in psd_args: psd_args["fmax"] = min(lp * 1.25, Nyquist) plot_lowpass_edge = lp < Nyquist and (psd_args["fmax"] > lp) From 6d4913b37a269888c6e0a47a1508fbb58cd40405 Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Tue, 5 May 2026 17:10:49 -0400 Subject: [PATCH 11/14] FIX: plot --- mne/viz/ica.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/mne/viz/ica.py b/mne/viz/ica.py index c31c222326f..176b8564aa7 100644 --- a/mne/viz/ica.py +++ b/mne/viz/ica.py @@ -260,7 +260,9 @@ def _plot_ica_properties( alpha=0.2, ) if plot_lowpass_edge: - spec_ax.axvline(ica.info["lowpass"], lw=2, linestyle="--", color="k", alpha=0.2) + spec_ax.axvline( + this_epochs_src.info["lowpass"], lw=2, linestyle="--", color="k", alpha=0.2 + ) # epoch variance good_indices = np.setdiff1d(np.arange(n_trials), bad_indices) From ccb680d47cc678e70b1b383a804a78cb9b8ca1be Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Tue, 5 May 2026 17:14:31 -0400 Subject: [PATCH 12/14] FIX: Cleanup --- mne/viz/tests/test_ica.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/mne/viz/tests/test_ica.py b/mne/viz/tests/test_ica.py index dc31bbd0ae8..3c0c0617d79 100644 --- a/mne/viz/tests/test_ica.py +++ b/mne/viz/tests/test_ica.py @@ -279,7 +279,6 @@ def test_plot_ica_properties_basic(): raw_all_bad.del_proj() fig = ica.plot_properties(raw_all_bad, picks=[0], **topoargs) assert_equal(len(fig), 1) - plt.close("all") @pytest.mark.parametrize("kind", ["first", "last"]) @@ -494,7 +493,6 @@ def test_plot_ica_overlay(): pytest.raises(TypeError, ica.plot_overlay, raw[:2, :3][0]) pytest.raises(TypeError, ica.plot_overlay, raw, exclude=2) ica.plot_overlay(raw) - plt.close("all") # smoke test for CTF raw = read_raw_fif(raw_ctf_fname) From 6c10786097eb4ed3a8610f23cbb9a8908fbb1a1d Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Wed, 6 May 2026 09:15:58 -0400 Subject: [PATCH 13/14] FIX: Fix it --- examples/preprocessing/find_ref_artifacts.py | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/preprocessing/find_ref_artifacts.py b/examples/preprocessing/find_ref_artifacts.py index c3b9a126683..d03701e1e5a 100644 --- a/examples/preprocessing/find_ref_artifacts.py +++ b/examples/preprocessing/find_ref_artifacts.py @@ -77,6 +77,7 @@ ica_kwargs = dict( method="picard", fit_params=dict(tol=1e-4), # use a high tol here for speed + random_state=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) From d00bc9f9163f8ca9a53c56b9aa6b6733a0daf56a Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Wed, 6 May 2026 10:06:56 -0400 Subject: [PATCH 14/14] FIX: Test explicitly --- mne/viz/ica.py | 7 +++++-- mne/viz/tests/test_ica.py | 2 ++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/mne/viz/ica.py b/mne/viz/ica.py index 176b8564aa7..08d1b81bd7e 100644 --- a/mne/viz/ica.py +++ b/mne/viz/ica.py @@ -590,8 +590,6 @@ def _fast_plot_ica_properties( inst, ica, reject_by_annotation, reject ) del reject, inst - if len(epochs_src) == 0: - return [fig] epochs_src_picked = epochs_src.pick(picks) del epochs_src good_indices = np.setdiff1d(np.arange(len(epochs_src_picked)), bad_indices) @@ -749,6 +747,11 @@ def _prepare_data_ica_properties(inst, ica, reject_by_annotation=True, reject="a bad_indices = np.where([len(log) for log in epochs.drop_log])[0] kind = "Segment" assert len(epochs_src) == len(epochs) + len(bad_indices) + if len(epochs_src) == len(bad_indices): + raise RuntimeError( + f"No clean 2-second segments found out of {len(events)} using " + f"{reject=} and {reject_by_annotation=}." + ) else: epochs_src = ica.get_sources(inst) kind = "Epochs" diff --git a/mne/viz/tests/test_ica.py b/mne/viz/tests/test_ica.py index 3c0c0617d79..37491985b4a 100644 --- a/mne/viz/tests/test_ica.py +++ b/mne/viz/tests/test_ica.py @@ -321,6 +321,8 @@ def test_plot_ica_properties_reject(kind): assert min_hist > 0 scatter_x, _ = var_ax.collections[0].get_offsets().data.T assert_array_equal(scatter_x, np.arange(n_epochs)) + with pytest.raises(RuntimeError, match="No clean"): + ica.plot_properties(raw, reject=dict(eeg=1e-6)) def test_plot_ica_sources(raw_orig, browser_backend, monkeypatch):