Skip to content

Commit 3e4d034

Browse files
authored
Fix degenerate MSE loss on regression datasets (issue #42) (#45)
Windowed regression targets are scalar-per-sample, so the collated batch target has shape (B,) while the regression model output is (B, 1). The default MSELoss silently broadcast (B, 1) against (B,) to (B, B), averaging over B^2 pairwise differences instead of the B element-wise ones. That broadcast loss equals the true element-wise MSE plus 2*cov(pred, target), i.e. it actively penalises prediction/target correlation and wrecks regression training, while the reported r2 metric (which ravels predictions) stayed correct — so the bug was silent. Give the SGD regression path an EEGRegressor subclass whose get_loss aligns the target's trailing dimension to the (B, 1) output. Classification (EEGClassifier) and ridge probing (already reshapes targets) are unaffected. Add tests/test_regression_loss.py guarding against the broadcast.
1 parent 6af5f87 commit 3e4d034

3 files changed

Lines changed: 101 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Fixed
11+
- Fix degenerate MSE loss on regression datasets (e.g. `seed_vig`): the scalar-per-window target has shape `(B,)` while the model output is `(B, 1)`, so `MSELoss` silently broadcast `(B, 1)` against `(B,)` to `(B, B)` and trained against `2 * cov(pred, target)` added to the true error — actively penalising prediction/target correlation. The SGD regression path (`Training`) now aligns the target's trailing dimension to the output before the loss. Classification and ridge probing were unaffected ([#42](https://github.com/braindecode/OpenEEGBench/issues/42)).
12+
1013
## [0.6.0] - 2026-06-02
1114

1215
### Added

open_eeg_bench/training.py

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,37 @@ def _build_callback(self):
7272
return WandbLogger(wandb_run=wandb_run, save_model=False)
7373

7474

75+
def _aligned_target_eeg_regressor():
76+
"""Build an ``EEGRegressor`` subclass that aligns the target's trailing
77+
dimension to the model output before computing the loss.
78+
79+
Windowed regression targets are scalar-per-sample, so the collated batch
80+
target has shape ``(B,)`` while the regression model output is ``(B, 1)``.
81+
The default ``MSELoss`` would otherwise *broadcast* ``(B, 1)`` against
82+
``(B,)`` to ``(B, B)`` and average over ``B**2`` pairwise differences
83+
instead of the ``B`` element-wise ones — a silent, degenerate objective.
84+
That broadcast loss equals the true element-wise MSE *plus*
85+
``2 * cov(pred, target)``, i.e. it actively penalises prediction/target
86+
correlation, which wrecks regression training. See issue #42.
87+
88+
The subclass is built lazily so importing this module stays cheap (no
89+
eager ``torch`` / ``braindecode`` import), consistent with the rest of the
90+
package.
91+
"""
92+
from braindecode import EEGRegressor
93+
from skorch.utils import to_tensor
94+
95+
class _AlignedTargetEEGRegressor(EEGRegressor):
96+
def get_loss(self, y_pred, y_true, *args, **kwargs):
97+
y_true = to_tensor(y_true, device=self.device)
98+
# Scalar regression target (B,) -> (B, 1) to match output (B, 1).
99+
if y_pred.dim() == 2 and y_pred.size(1) == 1 and y_true.dim() == 1:
100+
y_true = y_true.unsqueeze(1)
101+
return super().get_loss(y_pred, y_true, *args, **kwargs)
102+
103+
return _AlignedTargetEEGRegressor
104+
105+
75106
class Training(BaseModel):
76107
"""Training hyper-parameters and callback settings."""
77108

@@ -154,7 +185,7 @@ def build_learner(self, model, callbacks, n_classes, val_set, verbose=1, seed: i
154185
# training configs share the same `build_learner` signature.
155186
from torch.optim import AdamW
156187
from skorch.helper import predefined_split
157-
from braindecode import EEGClassifier, EEGRegressor
188+
from braindecode import EEGClassifier
158189

159190
is_regression = n_classes is None
160191
callbacks = list(callbacks)
@@ -174,9 +205,8 @@ def build_learner(self, model, callbacks, n_classes, val_set, verbose=1, seed: i
174205
iterator_valid__num_workers=self.num_workers,
175206
)
176207

177-
is_regression = n_classes is None
178208
if is_regression:
179-
learner = EEGRegressor(**common_kwargs)
209+
learner = _aligned_target_eeg_regressor()(**common_kwargs)
180210
else:
181211
classes = list(range(n_classes))
182212
learner = EEGClassifier(classes=classes, **common_kwargs)

tests/test_regression_loss.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
"""Regression-target loss shape: guard against the degenerate MSE broadcast.
2+
3+
See issue #42. A windowed regression target is a scalar per sample, so the
4+
collated batch target is shape ``(B,)`` while the regression model output is
5+
``(B, 1)``. The default ``MSELoss`` then *broadcasts* ``(B, 1)`` against
6+
``(B,)`` to ``(B, B)`` and averages ``B**2`` pairwise differences instead of
7+
the ``B`` element-wise ones — a silent, degenerate training objective.
8+
9+
The fix gives the target a trailing dimension inside the regression learner's
10+
loss so the comparison is element-wise.
11+
"""
12+
13+
import warnings
14+
15+
import torch
16+
import torch.nn as nn
17+
18+
from open_eeg_bench.training import Training
19+
20+
21+
def _build_regressor():
22+
"""Build a regression learner (n_classes=None) on a tiny dummy module."""
23+
val_set = torch.utils.data.TensorDataset(
24+
torch.zeros(4, 4), torch.zeros(4)
25+
)
26+
learner = Training(device="cpu").build_learner(
27+
model=nn.Linear(4, 1),
28+
callbacks=[],
29+
n_classes=None,
30+
val_set=val_set,
31+
verbose=0,
32+
)
33+
learner.initialize()
34+
return learner
35+
36+
37+
def test_regression_loss_is_elementwise_not_broadcast():
38+
"""get_loss on (B,1) pred vs (B,) target must be element-wise MSE."""
39+
learner = _build_regressor()
40+
y_pred = torch.zeros(8, 1)
41+
y_true = torch.arange(8, dtype=torch.float32) # shape (B,)
42+
43+
with warnings.catch_warnings():
44+
# The broadcasting UserWarning must NOT fire.
45+
warnings.simplefilter("error")
46+
loss = learner.get_loss(y_pred, y_true)
47+
48+
# Element-wise MSE of (0 - y_true)**2 == mean(y_true**2).
49+
expected = (y_true**2).mean()
50+
assert torch.isclose(loss, expected), (
51+
f"loss={float(loss)} (broadcast MSE over B*B pairs) "
52+
f"!= element-wise {float(expected)}"
53+
)
54+
55+
56+
def test_regression_loss_matches_2d_target():
57+
"""A (B,) target must give the same loss as an already-(B,1) target."""
58+
learner = _build_regressor()
59+
torch.manual_seed(0)
60+
y_pred = torch.randn(8, 1)
61+
y_true = torch.randn(8)
62+
63+
loss_1d = learner.get_loss(y_pred, y_true)
64+
loss_2d = learner.get_loss(y_pred, y_true.reshape(8, 1))
65+
assert torch.isclose(loss_1d, loss_2d)

0 commit comments

Comments
 (0)