Note
Go to the end to download the full example code.
Testing Robustness of SPD Models with Riemannian PGD Attack#
This example demonstrates how to implement and evaluate the Riemannian Projected Gradient Descent (R-PGD) attack [Timoz et al., 2026] on models that operate on Symmetric Positive Definite (SPD) matrices using SPD Learn.
Citation#
If you use this tutorial or the R-PGD attack implementation in your research, please cite:
Timoz, D., de Surrel, T., & Yger, F. (2026). Riemannian adversarial attacks
on Symmetric Positive Definite matrices. In International Conference on
Acoustics, Speech, and Signal Processing (ICASSP).
BibTeX entry:
@inproceedings{timoz2026riemannian,
title={Riemannian adversarial attacks on Symmetric Positive Definite matrices},
author={Timoz, D. and de Surrel, T. and Yger, F.},
booktitle={International Conference on Acoustics, Speech, and Signal
Processing - ICASSP},
year={2026},
pages={1--8},
organization={Springer},
}
And please cite SPD Learn:
@article{aristimunha2025spdlearn,
title={SPDlearn: A Geometric Deep Learning Python Library for
Neural Decoding Through Trivialization},
author={Aristimunha, Bruno and Ju, Ce and Collas, Antoine and
Bouchard, Florent and Mian, Ammar and Thirion, Bertrand and
Chevallier, Sylvain and Kobler, Reinmar},
journal={To be submitted},
year={2026},
url={https://github.com/spdlearn/spd_learn}
}
Introduction#
Deep neural networks are vulnerable to adversarial attacks due to their high capacity and sensitivity to small, carefully crafted perturbations. These vulnerabilities can be especially concerning when the models operate on geometrically structured inputs like SPD matrices, where preserving the intrinsic properties of the data is crucial.
The objective is to construct a perturbation of a SPD matrix such that the Riemannian distance to the original sample is minimal, while the prediction is completely altered (misclassified).
There are two main types of attacks:
Black-box attacks: model parameters unknown, but predictions obtainable
White-box attacks: model parameters known, gradients computable
Since a model robust to white-box attacks is generally robust to black-box attacks, we focus on white-box attacks.
For background on SPD geometry, see Geometry Essentials.
The R-PGD Attack Algorithm#
One method behind adversarial attacks is to increase the loss function while respecting a budget constraint \(\epsilon\). The standard Projected Gradient Descent (PGD) attack performs gradient ascent with projections back onto a constraint ball.
However, Euclidean PGD fails on SPD matrices because:
Perturbations may leave the SPD manifold
The Euclidean distance doesn’t capture the intrinsic geometry
SPD Learn implements the Riemannian PGD attack that performs updates along the affine-invariant geometry:
where:
\(\exp_{\Sigma}\) is the exponential map ensuring we stay on the manifold
\(\Pi_{B_\epsilon}\) projects onto the geodesic ball of radius \(\epsilon\)
\(\text{grad } J\) is the Riemannian gradient of the loss
The projection onto the ball \(B_\epsilon(\Sigma_0) = \{\Sigma \in S^d_{++} | \delta_r(\Sigma_0, \Sigma) \leq \epsilon\}\) uses:
This ensures the attack respects the budget using Riemannian distance, which limits the loss of geometric information and helps preserve the semantics of the original sample.
Setup and Imports#
import warnings
import matplotlib.pyplot as plt
import torch
from braindecode import EEGClassifier
from moabb.datasets import BNCI2014_001
from moabb.paradigms import MotorImagery
from sklearn.metrics import accuracy_score
from sklearn.preprocessing import LabelEncoder
from skorch.callbacks import EpochScoring, GradientNormClipping
from skorch.dataset import ValidSplit
from spd_learn.functional import spd_rpgd_attack
from spd_learn.models import SPDNet
from spd_learn.modules import CovLayer
warnings.filterwarnings("ignore")
/opt/hostedtoolcache/Python/3.11.16/x64/lib/python3.11/site-packages/braindecode/models/eegpt.py:497: FutureWarning: Montage name 'standard_1020' is deprecated and will be removed in MNE 1.14. Use 'colin27_1020' instead.
montage = mne.channels.make_standard_montage("standard_1020")
/opt/hostedtoolcache/Python/3.11.16/x64/lib/python3.11/site-packages/braindecode/models/eegpt.py:1452: FutureWarning: Montage name 'standard_1020' is deprecated and will be removed in MNE 1.14. Use 'colin27_1020' instead.
montage = make_standard_montage("standard_1020")
Data Preparation#
We use the motor imagery dataset BNCI2014-001 from MOABB. Following the paper, we apply a band-pass filter with range [7; 35] Hz. We use a single subject for demonstration purposes.
subject_id = 1
dataset = BNCI2014_001()
paradigm = MotorImagery(n_classes=4, fmin=7, fmax=35)
# Prepare data
cov_layer = CovLayer()
X, labels, meta = paradigm.get_data(dataset=dataset, subjects=[subject_id])
X = torch.tensor(X, dtype=torch.float32)
X = cov_layer(X)
le = LabelEncoder()
y = le.fit_transform(labels)
# Split by session (inter-session setup as from the paper)
train_idx = meta.query("session == '0train'").index.to_numpy()
test_idx = meta.query("session == '1test'").index.to_numpy()
print(f"Training samples: {len(train_idx)}, Test samples: {len(test_idx)}")
print(f"SPD matrix shape: {X.shape[1]}x{X.shape[2]}")
Choosing from all possible events
Training samples: 288, Test samples: 288
SPD matrix shape: 22x22
Training the SPDNet Model#
SPDNet [Huang and Van Gool, 2017] is a neural network architecture designed to operate on SPD matrices. It introduces three layers that preserve the intrinsic geometry:
BiMap: bilinear projection \(f(\\Sigma) = W\\Sigma W^{\\top}\)ReEig: enforces positive eigenvalues via spectral rectificationLogEig: maps to tangent space for Euclidean classification
The model contains 2 BiMap and ReEig layers followed by LogEig and FC layers.
n_chans = 22
model = SPDNet(input_type="cov", n_chans=n_chans, n_outputs=4)
clf = EEGClassifier(
model,
criterion=torch.nn.CrossEntropyLoss,
optimizer=torch.optim.Adam,
optimizer__lr=1e-3,
train_split=ValidSplit(0.1, stratified=True, random_state=42),
batch_size=32,
max_epochs=20,
callbacks=[
(
"train_acc",
EpochScoring(
"accuracy", lower_is_better=False, on_train=True, name="train_acc"
),
),
("gradient_clip", GradientNormClipping(gradient_clip_value=1.0)),
],
verbose=1,
)
# Train
clf.fit(X[train_idx], y[train_idx])
epoch train_acc train_loss valid_acc valid_loss dur
------- ----------- ------------ ----------- ------------ ------
1 0.2070 1.4194 0.2759 1.3716 0.0754
2 0.3438 1.3502 0.4138 1.3288 0.0711
3 0.4141 1.3076 0.4483 1.2869 0.0669
4 0.5391 1.2624 0.4483 1.2424 0.0714
5 0.4688 1.2368 0.4828 1.2123 0.0695
6 0.5898 1.1901 0.4828 1.1755 0.0706
7 0.6758 1.1520 0.6207 1.1414 0.0677
8 0.7305 1.1181 0.6207 1.1194 0.0709
9 0.6133 1.0918 0.5517 1.0901 0.0696
10 0.5781 1.0696 0.5517 1.0625 0.0723
11 0.7539 1.0411 0.6897 1.0547 0.0672
12 0.7539 1.0197 0.6552 1.0253 0.0707
13 0.6836 1.0030 0.6552 1.0087 0.0673
14 0.6680 0.9831 0.5517 0.9885 0.0699
15 0.6953 0.9616 0.6897 0.9766 0.0702
16 0.8125 0.9436 0.6207 0.9646 0.0695
17 0.7383 0.9287 0.5517 0.9522 0.0708
18 0.7969 0.9151 0.7241 0.9425 0.0699
19 0.7695 0.9046 0.6552 0.9206 0.0685
20 0.7266 0.8889 0.5517 0.9184 0.0717
Implementing the Riemannian PGD Attack#
We now use SPD Learn’s spd_rpgd_attack()
to generate adversarial examples. The attack parameters are:
eps: Maximum perturbation radius (geodesic budget)n_iterations: Number of PGD iterations (paper uses 50)step_size: Step size \(\alpha\) for each iteration (paper uses 0.1)
Following the paper, convergence is reached when the loss variation falls below \(10^{-4}\).
criterion = torch.nn.CrossEntropyLoss()
# Select samples to attack
X_test = X[test_idx]
y_test = y[test_idx]
initial_accuracy = accuracy_score(y_test, clf.predict(X_test))
print(f"Initial accuracy on clean test data: {initial_accuracy * 100:.2f}%")
# Parameters for the attack (from the paper)
epsilon = 0.5 # Maximum perturbation radius (geodesic budget)
n_iterations = 50 # Number of PGD iterations
step_size = 0.1 # Step size for each iteration (alpha in the paper)
# Generate adversarial examples
X_adv = spd_rpgd_attack(
clf,
X_test,
torch.tensor(y_test),
eps=epsilon,
criterion=criterion,
n_iterations=n_iterations,
step_size=step_size,
)
# Evaluate the model on adversarial examples
adv_accuracy = accuracy_score(y_test, clf.predict(X_adv))
print(f"Accuracy on adversarial test data: {adv_accuracy * 100:.2f}%")
print(f"Attack success rate: {(1 - adv_accuracy / initial_accuracy) * 100:.2f}%")
Initial accuracy on clean test data: 66.32%
Accuracy on adversarial test data: 26.04%
Attack success rate: 60.73%
Evaluating Model Robustness Across Budgets#
We evaluate the model’s robustness by attacking with different perturbation budgets. As shown in the paper, R-PGD keeps progressing steadily until it reaches 100% success rate, unlike Euclidean PGD which saturates.
This is because Euclidean attacks must follow curved trajectories on the manifold using straight-line updates. The frequent projections back onto the manifold decrease the effectiveness of each update.
epsilons = torch.linspace(0.0, 2.5, steps=15)
accuracies = []
for eps in epsilons:
X_adv = spd_rpgd_attack(
clf,
X_test,
torch.tensor(y_test),
eps=eps.item(),
criterion=criterion,
n_iterations=n_iterations,
step_size=step_size,
)
adv_accuracy = accuracy_score(y_test, clf.predict(X_adv))
accuracies.append(adv_accuracy)
print(f"Epsilon: {eps.item():.2f}, Adversarial Accuracy: {adv_accuracy * 100:.2f}%")
Epsilon: 0.00, Adversarial Accuracy: 66.32%
Epsilon: 0.18, Adversarial Accuracy: 53.12%
Epsilon: 0.36, Adversarial Accuracy: 38.19%
Epsilon: 0.54, Adversarial Accuracy: 22.92%
Epsilon: 0.71, Adversarial Accuracy: 7.99%
Epsilon: 0.89, Adversarial Accuracy: 3.12%
Epsilon: 1.07, Adversarial Accuracy: 0.69%
Epsilon: 1.25, Adversarial Accuracy: 0.35%
Epsilon: 1.43, Adversarial Accuracy: 0.00%
Epsilon: 1.61, Adversarial Accuracy: 0.00%
Epsilon: 1.79, Adversarial Accuracy: 0.00%
Epsilon: 1.96, Adversarial Accuracy: 0.00%
Epsilon: 2.14, Adversarial Accuracy: 0.00%
Epsilon: 2.32, Adversarial Accuracy: 0.00%
Epsilon: 2.50, Adversarial Accuracy: 0.00%
Visualizing the Results#
We plot the adversarial accuracy as a function of the perturbation budget. The Riemannian distance can differ significantly from the Euclidean one and better captures the intrinsic geometry of the manifold, making it more appropriate in the context of adversarial attacks.
fig, ax = plt.subplots(figsize=(8, 5))
ax.plot(epsilons.numpy(), [acc * 100 for acc in accuracies], marker="o", linewidth=2)
ax.axhline(y=25, color="r", linestyle="--", label="Random chance (4 classes)")
ax.axhline(y=initial_accuracy * 100, color="g", linestyle="--", label="Clean accuracy")
ax.fill_between(
epsilons.numpy(),
[acc * 100 for acc in accuracies],
initial_accuracy * 100,
alpha=0.3,
)
ax.set_title("SPDNet Robustness under Riemannian PGD Attack", fontsize=14)
ax.set_xlabel(r"$\epsilon$ (Geodesic Perturbation Radius)", fontsize=12)
ax.set_ylabel("Adversarial Accuracy (%)", fontsize=12)
ax.legend(loc="upper right")
ax.grid(True, alpha=0.3)
ax.set_ylim(0, 100)
plt.tight_layout()
plt.show()

Comparing Clean vs Adversarial Predictions#
We can visualize how predictions change under adversarial perturbations.
clean_preds = clf.predict(X_test)
adv_preds = clf.predict(X_adv)
# Count prediction changes
changed_mask = clean_preds != adv_preds
n_changed = changed_mask.sum()
n_total = len(y_test)
print(f"\nPrediction changes under attack (epsilon={epsilon}):")
print(
f" Changed predictions: {n_changed}/{n_total} ({100 * n_changed / n_total:.1f}%)"
)
# Confusion between clean and adversarial
fig, axes = plt.subplots(1, 2, figsize=(12, 5))
# Clean predictions
from sklearn.metrics import ConfusionMatrixDisplay
ConfusionMatrixDisplay.from_predictions(
y_test, clean_preds, ax=axes[0], cmap="Blues", colorbar=False
)
axes[0].set_title(f"Clean Predictions\n(Accuracy: {initial_accuracy * 100:.1f}%)")
# Adversarial predictions
ConfusionMatrixDisplay.from_predictions(
y_test, adv_preds, ax=axes[1], cmap="Oranges", colorbar=False
)
axes[1].set_title(f"Adversarial Predictions\n(Accuracy: {adv_accuracy * 100:.1f}%)")
plt.tight_layout()
plt.show()

Prediction changes under attack (epsilon=0.5):
Changed predictions: 226/288 (78.5%)
Why Riemannian Attacks Outperform Euclidean Attacks#
The paper demonstrates that Euclidean PGD encounters increasing difficulty in completely degrading the model’s performance beyond a certain budget. This happens because:
At higher budgets, Euclidean attacks tend to move outside the SPD manifold
The mandatory projection step significantly reduces effective perturbation
Euclidean attacks approximate curved geodesics with straight-line updates
In contrast, R-PGD:
Stays on the manifold by construction (using exponential map)
Respects the intrinsic geometry with Riemannian gradients
Uses geodesic distance for meaningful budget constraints
The Riemannian PGD attack has the advantage of defining the attack budget using a Riemannian distance, which limits the loss of geometric information caused by the attack and helps preserve the semantics of the original sample.
Conclusion#
In this example, we demonstrated how to use SPD Learn to implement the Riemannian PGD attack on SPD models. Key takeaways:
R-PGD effectively reduces model accuracy while respecting manifold geometry
The attack uses geodesic distance for meaningful budget constraints
Unlike Euclidean attacks, R-PGD doesn’t saturate at higher budgets
This highlights the importance of robustness analysis in non-Euclidean learning and opens the door to future work on defense strategies for manifold deep models, such as Riemannian adversarial training or manifold regularization.
References#
Zhiwu Huang and Luc Van Gool. A riemannian network for spd matrix learning. In Proceedings of the AAAI Conference on Artificial Intelligence, volume 31, 2036–2042. 2017. URL: https://ojs.aaai.org/index.php/AAAI/article/view/10866.
D. Timoz, T. de Surrel, and F. Yger. Riemannian adversarial attacks on symmetric positive definite matrices. In International Conference on Acoustics, Speech, and Signal Processing - ICASSP, 1–8. Springer, 2026.
Total running time of the script: (2 minutes 18.368 seconds)