Note
Go to the end to download the full example code.
CovLayer Animation#
This animation visualizes how the CovLayer transforms multivariate time series data into SPD covariance matrices.
where \(X \in \mathbb{R}^{C \times T}\) is the input signal.
# sphinx_gallery_thumbnail_number = 1
Understanding CovLayer#
CovLayer is the “entry point” to SPD networks. It converts time series (like EEG signals) into covariance matrices, which capture:
Variances: Signal power in each channel
Covariances: Relationships between channels
The resulting matrix is symmetric positive definite (SPD), which can then be processed by SPDNet layers.
import sys
from pathlib import Path
import matplotlib.animation as animation
import matplotlib.pyplot as plt
import numpy as np
# Handle both direct execution and import
try:
_current_dir = Path(__file__).parent
except NameError:
_current_dir = Path.cwd() / "examples" / "visualizations"
if str(_current_dir) not in sys.path:
sys.path.insert(0, str(_current_dir))
from spd_visualization_utils import COLORS, create_ellipse_patch, setup_spd_plot
Setup and Data Generation#
np.random.seed(42)
n_channels = 2 # Using 2 channels for ellipsoid visualization
n_times = 200
fs = 100 # Sampling frequency
t = np.linspace(0, n_times / fs, n_times)
# Create correlated signals (simulating EEG)
# Channel 1: 10 Hz oscillation + noise
freq1 = 10
signal1 = np.sin(2 * np.pi * freq1 * t) + 0.3 * np.random.randn(n_times)
# Channel 2: Correlated with channel 1 + different frequency component
freq2 = 12
correlation = 0.7
signal2 = (
correlation * signal1
+ np.sqrt(1 - correlation**2) * np.sin(2 * np.pi * freq2 * t)
+ 0.3 * np.random.randn(n_times)
)
# Stack into signal matrix
X = np.stack([signal1, signal2]) # Shape: (2, n_times)
# Compute covariance matrix
X_centered = X - X.mean(axis=1, keepdims=True)
cov_matrix = (X_centered @ X_centered.T) / (n_times - 1)
print(f"Signal shape: {X.shape}")
print(f"Covariance matrix:\n{cov_matrix}")
print(f"Eigenvalues: {np.linalg.eigvalsh(cov_matrix)}")
Signal shape: (2, 200)
Covariance matrix:
[[0.55416524 0.39290976]
[0.39290976 0.6460468 ]]
Eigenvalues: [0.20451957 0.99569247]
Static Visualization: Multiple Trials#
First, let’s see how different correlations produce different ellipsoids.
fig_static, axes_static = plt.subplots(2, 4, figsize=(16, 8))
# Generate multiple "trials" with different correlations
correlations = [-0.8, -0.3, 0.3, 0.8]
np.random.seed(42)
for i, corr in enumerate(correlations):
# Generate correlated signals
s1 = np.random.randn(n_times) + np.sin(2 * np.pi * 10 * t)
s2 = (
corr * s1
+ np.sqrt(1 - corr**2) * np.random.randn(n_times)
+ np.sin(2 * np.pi * 12 * t)
)
trial_X = np.stack([s1, s2])
# Compute covariance
trial_X_centered = trial_X - trial_X.mean(axis=1, keepdims=True)
trial_cov = (trial_X_centered @ trial_X_centered.T) / (n_times - 1)
# Plot signal (EEG-style with vertical offset)
ax_top = axes_static[0, i]
channel_offset = 2.5 # Vertical separation between channels
ax_top.plot(
t[:100], trial_X[0, :100] + channel_offset, "b-", linewidth=1.5, label="Ch1"
)
ax_top.plot(
t[:100], trial_X[1, :100] - channel_offset, "r-", linewidth=1.5, label="Ch2"
)
ax_top.axhline(y=channel_offset, color="b", linestyle="--", alpha=0.2)
ax_top.axhline(y=-channel_offset, color="r", linestyle="--", alpha=0.2)
ax_top.set_title(f"Correlation = {corr:.1f}", fontsize=11, fontweight="bold")
ax_top.set_xlabel("Time (s)", fontsize=9)
ax_top.set_ylabel("Channel", fontsize=9)
ax_top.set_yticks([channel_offset, -channel_offset])
ax_top.set_yticklabels(["Ch1", "Ch2"])
ax_top.set_xlim(0, 1)
ax_top.set_ylim(-5, 5)
ax_top.grid(True, alpha=0.3, axis="x")
# Plot ellipsoid
ax_bot = axes_static[1, i]
setup_spd_plot(ax_bot, xlim=(-3, 3), ylim=(-3, 3), title="")
# Determine color based on correlation
if corr < 0:
color = plt.cm.coolwarm(0.2)
elif corr > 0:
color = plt.cm.coolwarm(0.8)
else:
color = plt.cm.coolwarm(0.5)
ellipse = create_ellipse_patch(
trial_cov,
(0, 0),
alpha=0.6,
color=color,
edgecolor="black",
linewidth=2,
)
ax_bot.add_patch(ellipse)
# Eigenvectors
eigvals, eigvecs = np.linalg.eigh(trial_cov)
for j in range(2):
vec = eigvecs[:, j] * np.sqrt(eigvals[j]) * 0.8
ax_bot.arrow(
0,
0,
vec[0],
vec[1],
head_width=0.12,
head_length=0.08,
fc="darkgray",
ec="darkgray",
linewidth=2,
)
ax_bot.text(
0, -2.5, f"lambda=[{eigvals[0]:.1f}, {eigvals[1]:.1f}]", ha="center", fontsize=9
)
fig_static.suptitle(
"CovLayer: Correlation -> Ellipsoid Shape", fontsize=14, fontweight="bold"
)
plt.tight_layout()

Regularization for SPD#
When the number of time points is small, we need regularization to ensure the covariance matrix is positive definite.
fig_reg, axes_reg = plt.subplots(1, 3, figsize=(14, 4))
# Show effect of regularization
epsilon_values = [0, 0.1, 0.5]
# Create a rank-deficient covariance (e.g., n_times < n_channels scenario)
np.random.seed(42)
X_short = np.random.randn(2, 3) # Only 3 time points for 2 channels
X_short_centered = X_short - X_short.mean(axis=1, keepdims=True)
cov_rank_def = (X_short_centered @ X_short_centered.T) / 2 # Rank-deficient
print("\nRank-deficient covariance (n_times=3, n_channels=2):")
print(f"Eigenvalues: {np.linalg.eigvalsh(cov_rank_def)}")
for i, eps in enumerate(epsilon_values):
ax = axes_reg[i]
# Regularized covariance
cov_reg = cov_rank_def + eps * np.eye(2)
eigvals = np.linalg.eigvalsh(cov_reg)
setup_spd_plot(
ax,
xlim=(-3, 3),
ylim=(-3, 3),
title=f"epsilon = {eps} (lambda = [{eigvals[0]:.2f}, {eigvals[1]:.2f}])",
)
if np.all(eigvals > 1e-10):
ellipse = create_ellipse_patch(
cov_reg,
(0, 0),
alpha=0.6,
color="green" if eps > 0 else "red",
edgecolor="black",
linewidth=2,
)
ax.add_patch(ellipse)
status = "SPD (valid)" if eps > 0 else "PSD (boundary)"
else:
status = "Singular (invalid)"
ax.text(
0,
-2.5,
status,
ha="center",
fontsize=11,
fontweight="bold",
color="green" if eps > 0 else "red",
)
fig_reg.suptitle(
"Regularization: Adding epsilon*I for Numerical Stability",
fontsize=14,
fontweight="bold",
)
plt.tight_layout()
![Regularization: Adding epsilon*I for Numerical Stability, epsilon = 0 (lambda = [0.15, 1.05]), epsilon = 0.1 (lambda = [0.25, 1.15]), epsilon = 0.5 (lambda = [0.65, 1.55])](../../../_images/sphx_glr_plot_covlayer_animation_002.png)
Rank-deficient covariance (n_times=3, n_channels=2):
Eigenvalues: [0.15107105 1.05210243]
Mathematical Explanation#
The CovLayer performs:
Properties of the output:
Symmetric: \(\Sigma = \Sigma^T\)
Positive semi-definite: All eigenvalues \(\geq 0\)
With regularization: Positive definite (all eigenvalues > 0)
The covariance matrix captures:
Diagonal elements (variances): Signal power per channel
Off-diagonal elements (covariances): Linear relationships
Ellipsoid interpretation:
Shape: Elongated = high variance along eigenvector direction
Orientation: Principal axes = eigenvectors
Size: Overall variance (trace)
Animation#
The animation shows CovLayer computing covariances in real-time using a sliding window over the time series.
# Create animation figure
fig_anim = plt.figure(figsize=(10, 4), dpi=60)
# Three subplots: time series, covariance matrix, ellipsoid
ax_signal = fig_anim.add_subplot(1, 3, 1)
ax_cov = fig_anim.add_subplot(1, 3, 2)
ax_ellipse = fig_anim.add_subplot(1, 3, 3)
# Animation parameters
n_frames = 5
pause_frames = 2
# Window for sliding covariance
window_size = 50
def animate(frame):
"""Animation frame update."""
ax_signal.clear()
ax_cov.clear()
ax_ellipse.clear()
# Progress
if frame < pause_frames:
# Show full signal
window_end = n_times
window_start = 0
phase = "Input: Multivariate Time Series"
show_cov = False
elif frame < pause_frames + n_frames:
# Animate sliding window
progress = (frame - pause_frames) / n_frames
window_end = int(window_size + progress * (n_times - window_size))
window_start = max(0, window_end - window_size)
phase = f"Computing covariance (t={window_start / fs:.1f}s to {window_end / fs:.1f}s)"
show_cov = True
else:
# Final state
window_end = n_times
window_start = n_times - window_size
phase = "Output: SPD Covariance Matrix"
show_cov = True
# --- Signal plot (EEG-style with vertical offset) ---
ax_signal.set_title(
"Input: Time Series (2 channels)", fontsize=12, fontweight="bold"
)
ax_signal.set_xlabel("Time (s)", fontsize=10)
ax_signal.set_ylabel("Channel", fontsize=10)
# Vertical offset for EEG-style display
channel_offset = 2.0
# Plot full signals with offset
ax_signal.plot(
t, X[0] + channel_offset, "b-", alpha=0.4, linewidth=1, label="Channel 1"
)
ax_signal.plot(
t, X[1] - channel_offset, "r-", alpha=0.4, linewidth=1, label="Channel 2"
)
# Baseline reference lines
ax_signal.axhline(y=channel_offset, color="b", linestyle="--", alpha=0.2)
ax_signal.axhline(y=-channel_offset, color="r", linestyle="--", alpha=0.2)
# Highlight current window
if show_cov:
window_t = t[window_start:window_end]
ax_signal.plot(
window_t,
X[0, window_start:window_end] + channel_offset,
"b-",
linewidth=2.5,
label="_nolegend_",
)
ax_signal.plot(
window_t,
X[1, window_start:window_end] - channel_offset,
"r-",
linewidth=2.5,
label="_nolegend_",
)
# Shade window
ax_signal.axvspan(t[window_start], t[window_end - 1], alpha=0.2, color="green")
ax_signal.set_xlim(0, t[-1])
ax_signal.set_ylim(-5, 5)
ax_signal.set_yticks([channel_offset, -channel_offset])
ax_signal.set_yticklabels(["Ch1", "Ch2"])
ax_signal.grid(True, alpha=0.3, axis="x")
# --- Covariance matrix plot ---
if show_cov:
# Compute windowed covariance
X_window = X[:, window_start:window_end]
X_window_centered = X_window - X_window.mean(axis=1, keepdims=True)
window_cov = (X_window_centered @ X_window_centered.T) / (X_window.shape[1] - 1)
ax_cov.set_title("Covariance Matrix", fontsize=12, fontweight="bold")
ax_cov.imshow(window_cov, cmap="RdBu_r", vmin=-1, vmax=2, aspect="equal")
ax_cov.set_xticks([0, 1])
ax_cov.set_yticks([0, 1])
ax_cov.set_xticklabels(["Ch1", "Ch2"])
ax_cov.set_yticklabels(["Ch1", "Ch2"])
# Annotate values
for i in range(2):
for j in range(2):
ax_cov.text(
j,
i,
f"{window_cov[i, j]:.2f}",
ha="center",
va="center",
fontsize=12,
color="white" if abs(window_cov[i, j]) > 0.8 else "black",
)
# Formula
ax_cov.text(
0.5,
-0.3,
r"$\Sigma = \frac{1}{T-1}(X-\bar{X})(X-\bar{X})^T$",
ha="center",
fontsize=10,
transform=ax_cov.transAxes,
)
else:
ax_cov.text(
0.5,
0.5,
"Waiting...\n\nCovariance will be\ncomputed from\ntime series",
ha="center",
va="center",
fontsize=12,
transform=ax_cov.transAxes,
)
ax_cov.set_xlim(0, 1)
ax_cov.set_ylim(0, 1)
ax_cov.axis("off")
# --- Ellipsoid visualization ---
setup_spd_plot(
ax_ellipse, xlim=(-3, 3), ylim=(-3, 3), title="SPD Manifold Representation"
)
if show_cov:
# Draw covariance as ellipse
ellipse = create_ellipse_patch(
window_cov,
(0, 0),
alpha=0.6,
color=COLORS["output"],
edgecolor="black",
linewidth=2,
)
ax_ellipse.add_patch(ellipse)
# Draw eigenvector axes
eigvals, eigvecs = np.linalg.eigh(window_cov)
for i in range(2):
vec = eigvecs[:, i] * np.sqrt(eigvals[i])
color = "blue" if i == 0 else "red"
ax_ellipse.arrow(
0,
0,
vec[0],
vec[1],
head_width=0.1,
head_length=0.08,
fc=color,
ec=color,
linewidth=2,
)
ax_ellipse.arrow(
0,
0,
-vec[0],
-vec[1],
head_width=0.1,
head_length=0.08,
fc=color,
ec=color,
linewidth=2,
)
# Labels
ax_ellipse.text(
0,
-2.5,
f"lambda = [{eigvals[0]:.2f}, {eigvals[1]:.2f}]",
ha="center",
fontsize=10,
bbox=dict(boxstyle="round", facecolor="wheat", alpha=0.5),
)
# Correlation visualization
corr = window_cov[0, 1] / np.sqrt(window_cov[0, 0] * window_cov[1, 1])
ax_ellipse.text(
0,
2.5,
f"Correlation: {corr:.2f}",
ha="center",
fontsize=10,
bbox=dict(boxstyle="round", facecolor="lightblue", alpha=0.5),
)
else:
ax_ellipse.text(
0,
0,
"SPD\nManifold",
ha="center",
va="center",
fontsize=14,
style="italic",
alpha=0.5,
)
fig_anim.suptitle(f"CovLayer — {phase}", fontsize=14, fontweight="bold")
return []
def init():
"""Initialize animation."""
return []
# Create the animation - must be assigned to a variable that persists
total_frames = 2 * pause_frames + n_frames
anim = animation.FuncAnimation(
fig_anim, animate, init_func=init, frames=total_frames, interval=100, blit=False
)
plt.tight_layout()
plt.show()
Total running time of the script: (0 minutes 8.199 seconds)