"""Component visualization functions."""
from __future__ import annotations
import numpy as np
from matplotlib.gridspec import GridSpec
from .. import _mne
from ._utils import _get_components, _get_info, _get_patterns, _get_scores
from .theme import (
COLORS,
DIVERGING_CMAP,
FONTS,
SEQUENTIAL_CMAP,
_finalize_fig,
get_series_color,
style_axes,
themed_figure,
themed_legend,
)
def _resolve_component_indices(
n_components,
n_available,
default_max,
):
"""Normalize component selection to an explicit list of indices."""
if n_components is None:
return list(range(min(default_max, n_available)))
if isinstance(n_components, int):
return list(range(min(n_components, n_available)))
indices = [int(idx) for idx in n_components]
invalid = [idx for idx in indices if idx < 0 or idx >= n_available]
if invalid:
raise ValueError(f"Component indices out of range: {invalid}")
return indices
[docs]
def plot_component_score_curve(
estimator,
mode="raw",
ax=None,
show=True,
fname=None,
):
"""Plot a 1D component score curve for a fitted estimator.
Parameters
----------
estimator : object
Fitted estimator exposing ``eigenvalues_`` or ``scores_``.
mode : {'raw', 'cumulative', 'ratio'}
Score display mode:
- ``'raw'``: raw score/eigenvalue per component.
- ``'cumulative'``: normalized cumulative sum.
- ``'ratio'``: same values as ``'raw'`` but labeled as a ratio view.
ax : matplotlib.axes.Axes | None
Target axes. If None, a new themed figure is created.
show : bool, default=True
If True, show the figure.
fname : path-like | None
Optional output path used to save the figure.
Returns
-------
fig : matplotlib.figure.Figure
Figure handle.
Raises
------
ValueError
If ``mode`` is invalid, or if scores are missing/invalid.
"""
valid_modes = {"raw", "cumulative", "ratio"}
if mode not in valid_modes:
raise ValueError(f"mode must be one of {sorted(valid_modes)}")
scores = _get_scores(estimator)
if scores is None:
raise ValueError("Estimator does not expose component scores.")
scores = np.asarray(scores, dtype=float)
if scores.ndim != 1 or scores.size == 0:
raise ValueError("Component scores must be a non-empty 1D array.")
if ax is None:
fig, ax = themed_figure(figsize=(7, 4))
else:
fig = ax.figure
x = np.arange(1, scores.size + 1)
if mode == "cumulative":
y = np.cumsum(scores)
y = y / y[-1]
ylabel = "Cumulative Score (Normalized)"
elif mode == "ratio":
y = scores
ylabel = "Power Ratio"
else:
y = scores
ylabel = "Score / Eigenvalue"
ax.plot(
x,
y,
".-",
color=COLORS["primary"],
linewidth=1.6,
markersize=5,
label="Scores",
)
if mode != "cumulative":
mean_score = np.mean(scores)
ax.axhline(
mean_score,
color=COLORS["muted"],
linestyle="--",
linewidth=0.9,
label=f"Mean ({mean_score:.3g})",
)
n_selected = getattr(estimator, "n_selected_", None)
if n_selected is None:
n_selected = getattr(estimator, "n_removed_", None)
if n_selected is not None and 0 < n_selected < scores.size:
ax.axvline(
n_selected + 0.5,
color=COLORS["accent"],
linestyle="--",
linewidth=1.0,
label=f"Cutoff ({n_selected})",
)
themed_legend(ax, loc="best")
ax.set_xlabel("Component")
ax.set_ylabel(ylabel)
ax.set_title("Component Scores")
style_axes(ax, grid=True)
return _finalize_fig(fig, show=show, fname=fname)
[docs]
def plot_window_score_traces(
scores,
threshold=None,
ax=None,
show=True,
fname=None,
):
"""Plot per-window score traces from a 2D score matrix.
Parameters
----------
scores : array-like of shape (n_windows, n_scores)
Score matrix to display.
threshold : float | None
Optional horizontal threshold line.
ax : matplotlib.axes.Axes | None
Target axes. If None, a new themed figure is created.
show : bool, default=True
If True, display the figure.
fname : path-like | None
Optional output path used to save the figure.
Returns
-------
fig : matplotlib.figure.Figure
Figure handle.
"""
scores = np.asarray(scores, dtype=float)
if scores.ndim != 2 or scores.shape[0] == 0:
raise ValueError("scores must be a non-empty 2D array.")
if ax is None:
fig, ax = themed_figure(figsize=(9, 4))
else:
fig = ax.figure
n_windows, n_scores = scores.shape
for idx in range(n_scores):
vals = scores[:, idx]
valid = np.isfinite(vals)
if not np.any(valid):
continue
ax.plot(
np.where(valid)[0],
vals[valid],
color=get_series_color(idx),
linewidth=1.2,
alpha=0.85,
label=f"Score {idx + 1}",
)
if threshold is not None:
ax.axhline(
float(threshold),
color=COLORS["accent"],
linestyle="--",
linewidth=1.0,
label=f"Threshold ({float(threshold):.3g})",
)
ax.set_xlabel("Window")
ax.set_ylabel("Score")
ax.set_title("Window Score Traces")
style_axes(ax, grid=True)
if n_scores <= 10 or threshold is not None:
themed_legend(ax, loc="best")
return _finalize_fig(fig, show=show, fname=fname)
[docs]
def plot_component_patterns(
estimator,
info=None,
picks=None,
n_components=None,
ax=None,
show=True,
fname=None,
):
"""Plot spatial component patterns.
Parameters
----------
estimator : object
Fitted estimator exposing ``patterns_``.
info : mne.Info | None
Measurement info used for topomap rendering.
picks : array-like of int | None
Channel indices used for topomap rendering. If None, no topomap is
attempted and the function falls back to channel-weight line plots.
n_components : int | sequence of int | None
Components to plot. If an int, plot the first ``n_components``.
ax : matplotlib.axes.Axes | None
Optional target axes. Supported only for the line-plot fallback or
when rendering a single topomap.
show : bool, default=True
If True, show the figure.
fname : path-like | None
Optional output path used to save the figure.
Returns
-------
fig : matplotlib.figure.Figure
Figure handle.
Raises
------
ValueError
If patterns are not 2D, if no components are selected, or when
``ax`` is passed while requesting multiple topomaps. Also raised when
``picks`` is provided without valid ``info``.
"""
patterns = np.asarray(_get_patterns(estimator))
if patterns.ndim != 2:
raise ValueError(
"patterns_ must be a 2D array of shape (n_channels, n_components)."
)
indices = _resolve_component_indices(
n_components,
patterns.shape[1],
default_max=6,
)
if not indices:
raise ValueError("No components selected for plotting.")
if picks is not None and info is None:
raise ValueError("info is required when picks is provided.")
if picks is not None:
_mne.require_mne("component pattern topomap visualization")
topo_info = _mne.mne.pick_info(info, picks)
if ax is not None:
if len(indices) != 1:
raise ValueError("ax can only be used when plotting a single topomap.")
fig = ax.figure
_mne.mne.viz.plot_topomap(
patterns[picks, indices[0]],
topo_info,
axes=ax,
show=False,
contours=4,
)
ax.set_title(f"Comp {indices[0]}")
return _finalize_fig(fig, show=show, fname=fname)
n_show = len(indices)
n_cols = min(4, n_show)
n_rows = int(np.ceil(n_show / n_cols))
fig, axes = themed_figure(
n_rows,
n_cols,
figsize=(3 * n_cols, 3 * n_rows),
squeeze=False,
)
flat_axes = axes.ravel()
for i, (plot_ax, comp_idx) in enumerate(zip(flat_axes, indices)):
_mne.mne.viz.plot_topomap(
patterns[picks, comp_idx],
topo_info,
axes=plot_ax,
show=False,
contours=4,
)
plot_ax.set_title(
f"Comp {comp_idx}",
fontsize=FONTS["tick"],
color=get_series_color(i),
)
for plot_ax in flat_axes[len(indices) :]:
plot_ax.axis("off")
fig.suptitle(
"Component Patterns", fontsize=FONTS["title"], fontweight="semibold"
)
return _finalize_fig(fig, show=show, fname=fname)
if ax is None:
fig, ax = themed_figure(figsize=(8, 4.5))
else:
fig = ax.figure
for i, comp_idx in enumerate(indices):
ax.plot(
patterns[:, comp_idx],
marker="o",
markersize=4,
linewidth=1.3,
alpha=0.85,
color=get_series_color(i),
label=f"Comp {comp_idx}",
)
ax.axhline(0, color=COLORS["muted"], linestyle="-", alpha=0.35)
ax.set_xlabel("Channel")
ax.set_ylabel("Pattern Weight")
ax.set_title("Component Patterns")
style_axes(ax, grid=True)
themed_legend(ax, loc="best")
return _finalize_fig(fig, show=show, fname=fname)
[docs]
def plot_component_summary(
estimator,
data=None,
info=None,
picks=None,
times=None,
sfreq=None,
n_components=None,
psd_fmax=None,
show=True,
plot_ci=True,
fname=None,
):
"""Plot a compact per-component summary dashboard.
Parameters
----------
estimator : object
Fitted estimator exposing component patterns and a transform/source API.
data : mne.io.BaseRaw | mne.BaseEpochs | ndarray | None
Input data used to compute component sources when they are not cached.
info : mne.Info | None
Sensor metadata for topomap rendering.
picks : array-like of int | None
Channel indices used for topomap rendering. If None, the pattern panel
uses a text placeholder instead of topomaps.
times : array-like of shape (n_times,) | None
Explicit time coordinates for source time-course panels. If None,
sample indices are used.
sfreq : float | None
Sampling frequency used for PSD computation when ``info`` is not
available. Required if ``info`` cannot be resolved.
n_components : int | sequence of int | None
Components to plot. If None, plot up to five components.
psd_fmax : float | None
Maximum frequency (Hz) shown in the PSD column. If None, defaults to
``min(100, sfreq / 2)`` to preserve previous behavior.
show : bool, default=True
If True, show the figure.
plot_ci : bool, default=True
If True and sources are epoched, overlay a 95% CI band based on SEM.
fname : path-like | None
Optional output path used to save the figure.
Returns
-------
fig : matplotlib.figure.Figure
Figure handle.
Raises
------
ValueError
If no components are selected, if ``psd_fmax`` is not positive, if
``times`` length mismatches source length, or if ``picks`` is provided
without valid ``info``. Also raised when neither ``info`` nor ``sfreq``
is provided.
"""
if picks is not None and info is None:
raise ValueError("info is required when picks is provided.")
info = _get_info(estimator, info)
patterns = np.asarray(_get_patterns(estimator))
sources = _get_components(estimator, data)
indices = _resolve_component_indices(
n_components,
patterns.shape[1],
default_max=5,
)
if not indices:
raise ValueError("No components selected for plotting.")
fig, root_ax = themed_figure(figsize=(12, 3 * len(indices)))
root_ax.remove()
gs = GridSpec(len(indices), 3, figure=fig, width_ratios=[1, 2, 1])
if info is not None:
sfreq_eff = float(info["sfreq"])
elif sfreq is not None:
sfreq_eff = float(sfreq)
else:
raise ValueError("sfreq is required when info is not available.")
if sfreq_eff <= 0:
raise ValueError("sfreq must be strictly positive.")
if times is None:
times_template = np.arange(sources.shape[1])
time_label = "Time (samples)"
else:
times_template = np.asarray(times)
if times_template.shape[0] != sources.shape[1]:
raise ValueError("times must have length equal to source n_times.")
time_label = "Time"
if psd_fmax is None:
psd_fmax = min(100.0, sfreq_eff / 2.0)
psd_fmax = float(psd_fmax)
if psd_fmax <= 0:
raise ValueError("psd_fmax must be strictly positive.")
psd_fmax = min(psd_fmax, sfreq_eff / 2.0)
for row_idx, comp_idx in enumerate(indices):
ax_topo = fig.add_subplot(gs[row_idx, 0])
if picks is not None:
_mne.require_mne("component pattern topomap visualization")
topo_info = _mne.mne.pick_info(info, picks)
# If the estimator was fitted on the exact subset of channels specified by picks,
# patterns is already the correct size.
if patterns.shape[0] == len(picks):
topo_data = patterns[:, comp_idx]
else:
topo_data = patterns[picks, comp_idx]
_mne.mne.viz.plot_topomap(topo_data, topo_info, axes=ax_topo, show=False)
ax_topo.set_title(f"Comp {comp_idx} Pattern")
else:
ax_topo.text(0.5, 0.5, "No topomap info", ha="center", va="center")
ax_topo.set_axis_off()
ax_time = fig.add_subplot(gs[row_idx, 1])
if sources.ndim == 3:
comp_data = sources[comp_idx]
mean_tc = comp_data.mean(axis=1)
ax_time.plot(times_template, mean_tc, label="Mean", color=COLORS["before"])
if plot_ci:
std_tc = comp_data.std(axis=1) / np.sqrt(comp_data.shape[1])
ax_time.fill_between(
times_template,
mean_tc - 2 * std_tc,
mean_tc + 2 * std_tc,
color=COLORS["muted"],
alpha=0.3,
label="95% CI (SEM)",
)
themed_legend(ax_time, loc="best")
else:
comp_data = sources[comp_idx]
ax_time.plot(times_template, comp_data, color=COLORS["before"])
ax_time.set_title(f"Comp {comp_idx} Time Course")
ax_time.set_xlabel(time_label)
style_axes(ax_time, grid=True)
ax_psd = fig.add_subplot(gs[row_idx, 2])
if sources.ndim == 3:
d_flat = sources[comp_idx].T
else:
d_flat = sources[comp_idx][np.newaxis, :]
_mne.require_mne("component PSD visualization")
from mne.time_frequency import psd_array_welch
psd_spec, freqs = psd_array_welch(
d_flat,
sfreq=sfreq_eff,
fmin=0,
fmax=psd_fmax,
n_fft=min(2048, d_flat.shape[-1]),
verbose=False,
)
mean_psd = np.mean(psd_spec, axis=0)
mean_psd = np.clip(mean_psd, a_min=1e-30, a_max=None)
ax_psd.semilogy(freqs, mean_psd, color=COLORS["primary"])
ax_psd.set_title("PSD")
ax_psd.set_xlabel("Frequency (Hz)")
ax_psd.set_xlim(0, psd_fmax)
style_axes(ax_psd, grid=True)
return _finalize_fig(fig, show=show, fname=fname)
[docs]
def plot_component_epochs_image(
estimator,
data=None,
n_components=None,
show=True,
fname=None,
):
"""Plot component activity as an epoch-by-time image.
Parameters
----------
estimator : object
Fitted estimator exposing component sources via cache or transform.
data : mne.io.BaseRaw | mne.BaseEpochs | ndarray | None
Input data used to compute sources when they are not cached.
n_components : int | sequence of int | None
Components to plot. If None, plot up to five components.
show : bool, default=True
If True, show the figure.
fname : path-like | None
Optional output path used to save the figure.
Returns
-------
fig : matplotlib.figure.Figure
Figure handle.
Raises
------
ValueError
If sources are not 2D/3D, or if no components are selected.
"""
sources = _get_components(estimator, data)
if sources.ndim == 2:
sources = sources[:, :, np.newaxis]
if sources.ndim != 3:
raise ValueError("Component sources must be 2D or 3D.")
indices = _resolve_component_indices(
n_components,
sources.shape[0],
default_max=5,
)
if not indices:
raise ValueError("No components selected for plotting.")
fig, axes = themed_figure(
len(indices),
1,
figsize=(8, 2 * len(indices)),
sharex=True,
squeeze=False,
)
axes = axes.ravel()
for ax, comp_idx in zip(axes, indices):
img = sources[comp_idx].T
ax.imshow(img, aspect="auto", origin="lower", cmap=DIVERGING_CMAP)
ax.set_title(f"Comp {comp_idx}")
ax.set_ylabel("Epochs")
axes[-1].set_xlabel("Time (samples)")
return _finalize_fig(fig, show=show, fname=fname)
[docs]
def plot_component_time_series(
estimator,
data=None,
n_components=None,
times=None,
show=True,
ax=None,
fname=None,
):
"""Plot stacked component time series with fixed vertical offsets.
Parameters
----------
estimator : object
Fitted estimator exposing component sources via cache or transform.
data : mne.io.BaseRaw | mne.BaseEpochs | ndarray | None
Input data used to compute sources when they are not cached.
n_components : int | sequence of int | None
Components to plot. If None, plot up to twenty components.
times : array-like of shape (n_times,) | None
Explicit time coordinates. If None, sample indices are used.
show : bool, default=True
If True, show the figure.
ax : matplotlib.axes.Axes | None
Optional target axes. If None, a new themed figure is created.
fname : path-like | None
Optional output path used to save the figure.
Returns
-------
fig : matplotlib.figure.Figure
Figure handle.
Raises
------
ValueError
If no components are selected or if ``times`` length mismatches source
length.
"""
sources = _get_components(estimator, data)
scores = _get_scores(estimator)
if sources.ndim == 3:
sources = sources.mean(axis=2)
indices = _resolve_component_indices(
n_components,
sources.shape[0],
default_max=20,
)
if not indices:
raise ValueError("No components selected for plotting.")
if ax is None:
fig, ax = themed_figure(figsize=(10, max(4.0, len(indices) * 0.5)))
else:
fig = ax.figure
if times is None:
time_axis = np.arange(sources.shape[1])
time_label = "Time (samples)"
else:
time_axis = np.asarray(times)
if time_axis.shape[0] != sources.shape[1]:
raise ValueError("times must have length equal to source n_times.")
time_label = "Time"
x_min = float(time_axis[0])
x_max = float(time_axis[-1])
x_pad = 0.03 * (x_max - x_min if x_max != x_min else 1.0)
label_x = x_max + x_pad * 0.25
offset_step = 3.0
for row_idx, comp_idx in enumerate(indices):
comp = sources[comp_idx]
std = np.std(comp)
if std < 1e-15:
std = 1.0
comp_norm = comp / std
offset = -row_idx * offset_step
color = get_series_color(row_idx)
ax.plot(time_axis, comp_norm + offset, color=color, linewidth=1.5)
label = f"Comp {comp_idx}"
if scores is not None and comp_idx < len(scores):
label += f" (λ={scores[comp_idx]:.2f})"
ax.text(
label_x, offset, label, va="center", fontsize=FONTS["tick"], color=color
)
ax.set_xlim(x_min, x_max + x_pad)
ax.set_yticks([])
ax.set_xlabel(time_label)
ax.set_title("Component Time Series")
ax.spines["left"].set_visible(False)
ax.spines["right"].set_visible(False)
ax.spines["top"].set_visible(False)
return _finalize_fig(fig, show=show, fname=fname)
[docs]
def plot_component_spectrogram(
component_data,
sfreq,
freqs=None,
fmax=50.0,
n_cycles=None,
title="Component Spectrogram",
ax=None,
show=True,
fname=None,
):
"""Plot a time-frequency power view for one component.
Parameters
----------
component_data : ndarray, shape (n_times,) or (n_epochs, n_times)
Single-component time series or repeated epochs of one component.
sfreq : float
Sampling frequency.
freqs : ndarray | None
Frequencies to compute. If None, frequencies are generated from
1 Hz to ``fmax`` (capped at Nyquist).
fmax : float | None
Upper frequency bound used when ``freqs`` is None.
Defaults to 50 Hz to preserve prior behavior.
n_cycles : float | ndarray | None
Number of cycles for multitaper estimation.
title : str
Axes title.
ax : matplotlib.axes.Axes | None
Optional target axes. If None, a new themed figure is created.
show : bool, default=True
If True, show the figure.
fname : path-like | None
Optional output path used to save the figure.
Returns
-------
fig : matplotlib.figure.Figure
Figure handle.
Raises
------
ValueError
If ``component_data`` is not 1D/2D, or if ``fmax`` is not positive
when ``freqs`` is None.
"""
_mne.require_mne("component spectrogram visualization")
component_data = np.asarray(component_data)
if component_data.ndim == 1:
data = component_data[np.newaxis, np.newaxis, :]
elif component_data.ndim == 2:
data = component_data[:, np.newaxis, :]
else:
raise ValueError("component_data must be 1D or 2D.")
if freqs is None:
if fmax is None:
upper = sfreq / 2.0
else:
upper = min(float(fmax), sfreq / 2.0)
if upper <= 0:
raise ValueError("fmax must be strictly positive when freqs is None.")
upper = max(2.0, upper)
freqs = np.arange(1.0, np.floor(upper) + 1.0, 1.0)
else:
freqs = np.asarray(freqs, dtype=float)
if n_cycles is None:
n_cycles = freqs / 4.0
from mne.time_frequency import tfr_array_multitaper
tfr = tfr_array_multitaper(
data,
sfreq=sfreq,
freqs=freqs,
n_cycles=n_cycles,
output="power",
verbose=False,
)
power = tfr[:, 0].mean(axis=0)
times = np.arange(power.shape[1]) / sfreq
if ax is None:
fig, ax = themed_figure(figsize=(10, 5))
else:
fig = ax.figure
im = ax.pcolormesh(times, freqs, power, shading="gouraud", cmap=SEQUENTIAL_CMAP)
ax.set_ylabel("Frequency (Hz)")
ax.set_xlabel("Time (s)")
ax.set_title(title)
fig.colorbar(im, ax=ax, label="Power")
style_axes(ax, grid=False)
return _finalize_fig(fig, show=show, fname=fname)