Artefact Removal with Multi-Model AMICA

Demonstrates the full artefact-removal workflow on data with two distinct experimental conditions and blink artefacts occurring throughout both.

With M=2 AMICA each model gets its own ICA decomposition, so the blink component is identified separately per model. When applying, apply() uses the dominant model’s decomposition at each time point - artefact removal is always done with the most appropriate model. This is something single-model ICA cannot do.

import numpy as np
import mne
import matplotlib.pyplot as plt

from pyamica import AmicaICA

mne.set_log_level("WARNING")

Raw Data

Both conditions share the same amplitude distribution (Laplacian), but their inter-channel correlations differ because A1 A2. Blinks (red lines) appear throughout both halves.

fig, axes = plt.subplots(4, 1, figsize=(12, 5), sharex=True)
for ax, ch_idx in zip(axes, [0, 3, 6, n_eeg]):
    ax.plot(raw.times[:half], data[ch_idx, :half] * 1e6,
            lw=0.5, color="darkorange", label="Condition 1")
    ax.plot(raw.times[half:], data[ch_idx, half:] * 1e6,
            lw=0.5, color="steelblue", label="Condition 2")
    for bt in blink_times:
        ax.axvline(bt, color="red", lw=0.4, alpha=0.4)
    ax.set_ylabel(f"{ch_names[ch_idx]}\n(µV)", fontsize=7)
    ax.set_yticks([])
axes[0].legend(loc="upper right", fontsize=7)
axes[-1].axvline(15.0, color="k", linestyle="--", lw=1.0, label="Condition boundary")
axes[-1].legend(loc="upper right", fontsize=7)
axes[-1].set_xlabel("Time (s)")
fig.suptitle("Raw data - blinks (red) throughout both conditions")
fig.tight_layout()
plt.show()
Raw data - blinks (red) throughout both conditions

Fit AmicaICA M=2

AMICA discovers the two mixing matrices and fits a separate ICA decomposition for each condition. Because the source statistics are identical, model assignment is driven purely by spatial structure (which W best demixes the data at each time point). Blink samples contribute equal likelihood to both models and do not bias the assignments.

import torch; torch.manual_seed(0)

ica = AmicaICA(n_models=2, max_iter=200, verbose=False)
ica.fit(raw, picks="eeg")

gm = ica._model.gm_.numpy()
print(f"Global model weights: {gm.round(3)}")
Global model weights: [0.5 0.5]

Model Dominance

The two models split cleanly at 15 s. Unlike uniform-vs-Laplacian data, blink peaks do not bleed into the wrong model because both models carry the same Laplacian prior; blink samples look equally plausible under either.

ax = ica.plot_model_dominance(smooth_s=0.2, figsize=(12, 3))
ax.axvline(15.0, color="k", linestyle="--", lw=1.2, label="Condition boundary")
ax.legend(loc="upper right")
plt.show()
AMICA model dominance, smoothed 0.2s

The review() Workflow

In practice, review each model interactively:

for m in range(ica.n_models):
    ica.review(raw, model_idx=m, eog_ch="VEOG")
ica.apply(raw)

review() runs automated EOG/ECG detection, pre-selects flagged components, then opens plot_sources() for interactive confirmation. Each model is reviewed independently - the blink component may land on a different IC index in each condition’s decomposition.

Set Per-Model Exclusions and Apply

Exclude the blink component from each model independently, then apply. apply() uses per-sample posterior assignment to decide which model’s exclusion list to use at each time point.

for m in range(ica.n_models):
    eog_idx, scores = ica.find_bads_eog(
        raw, model_idx=m, ch_name="VEOG",
        measure="correlation", threshold=0.5,
    )
    if not eog_idx:
        eog_idx = [int(np.argmax(np.abs(scores)))]
    ica.get_mne_ica(m).exclude = eog_idx
    print(f"Model {m}: excluding IC{eog_idx[0]:03d}")

raw_clean = raw.copy()
ica.apply(raw_clean)
Model 0: excluding IC007
Model 1: excluding IC007
General
MNE object type RawArray
Measurement date Unknown
Participant Unknown
Experimenter Unknown
Acquisition
Duration 00:00:30 (HH:MM:SS)
Sampling frequency 250.00 Hz
Time points 7,500
Channels
EEG
EOG
Head & sensor digitization Not available
Filters
Highpass 0.00 Hz
Lowpass 125.00 Hz


Before and After

EEG001 (most blink-contaminated channel in this realisation) before and after removal. Blinks are suppressed in both conditions.

fig, axes = plt.subplots(3, 1, figsize=(12, 5), sharex=True)
orig_µV  = raw.get_data(picks="eeg")[3] * 1e6
clean_µV = raw_clean.get_data(picks="eeg")[3] * 1e6

axes[0].plot(raw.times, orig_µV,  lw=0.5, color="steelblue")
axes[0].set_title("EEG004 - original (blinks visible in both conditions)")
axes[0].set_ylabel("µV")

axes[1].plot(raw.times, clean_µV, lw=0.5, color="coral")
axes[1].set_title("EEG004 - cleaned (blinks removed)")
axes[1].set_ylabel("µV")

axes[2].plot(raw.times, orig_µV - clean_µV, lw=0.5, color="gray")
axes[2].set_title("Removed artefact signal (original − cleaned)")
axes[2].set_ylabel("µV")
axes[2].set_xlabel("Time (s)")

for ax in axes:
    ax.axvline(15.0, color="k", linestyle="--", lw=0.8)
    for bt in blink_times:
        ax.axvline(bt, color="red", lw=0.4, alpha=0.3)

fig.tight_layout()
plt.show()

removed_peak   = np.abs(orig_µV - clean_µV).max()
remaining_peak = np.abs(clean_µV).max()
print(f"Removed signal peak:   {removed_peak:.1f} µV")
print(f"Remaining signal peak: {remaining_peak:.1f} µV")
EEG004 - original (blinks visible in both conditions), EEG004 - cleaned (blinks removed), Removed artefact signal (original − cleaned)
Removed signal peak:   20.7 µV
Remaining signal peak: 244.9 µV

Total running time of the script: (0 minutes 7.964 seconds)

Gallery generated by Sphinx-Gallery