Skip to content

Commit 0c3c38f

Browse files
PierreGtchCopilot
andauthored
Fix backbone layers (#25)
* Use dev braindecode (will remove later) * Remove model_kwargs from backbone configs (kwargs are coming from hf_hub * Update sJEPA hub repo * Raise errors, not warnings when the backbones are not correctly configured * Add Labram's temporal_embedding to training_required_modules (does not work yet because temporal_embedding is a parameter, not a module) * Update uv loc for latest braindecode dev version * Use new Interpolated* backbones * update braindecode * Add training_required_parameters field on _BackboneBase Distinct from training_required_modules: this one accepts top-level nn.Parameter names (not Modules), e.g. Labram's temporal_embedding. Extend _check_layers_and_parameters_exist to validate against named_parameters(), and broaden the checkpoint loading allowed_names so missing-param entries can be covered by either field. * Add supports_training_required_parameters ClassVar and wire PEFT target_parameters Each finetuning method declares (via a ClassVar, not a model field, so it is immutable from the constructor under extra='forbid') whether it can keep top-level nn.Parameters trainable. LoRA/AdaLoRA/DoRA forward training_required_parameters to PEFT's target_parameters argument; Frozen/TwoStages add the parameter names to the unfreeze list. IA3 and OFT opt out (no target_parameters in their PEFT configs). * Validate finetuning compatibility with training_required_parameters The Experiment validator now reads finetuning.supports_training_required_parameters and rejects mismatched combinations. Also extend the ridge check to reject non-empty training_required_parameters (ridge cannot train extra parameters). * Migrate Labram default config to InterpolatedLaBraM and use training_required_parameters Completes the migration started in 3b4ed0e (which migrated BENDR and SignalJEPA). InterpolatedLaBraM accepts arbitrary montages by projecting onto the canonical 128-channel layout. temporal_embedding (still an nn.Parameter) now declared via training_required_parameters. * Add tests for training_required_parameters validation Three tests mirroring test_ridge_rejects_training_required_modules: ridge rejects training_required_parameters, IA3 rejects it, LoRA accepts it (LoRA forwards to PEFT target_parameters). * Document training_required_parameters in CHANGELOG * fix warning message Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent f5c29c3 commit 0c3c38f

8 files changed

Lines changed: 994 additions & 932 deletions

File tree

CHANGELOG.md

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

1010
### Added
1111
- Allow custom normalization methods by subclassing `Normalization`, now based on `exca.helpers.DiscriminatedModel`. Builtin subclasses pin their pre-existing `kind` value to preserve cached experiment UIDs ([#35](https://github.com/braindecode/OpenEEGBench/pull/35)).
12+
- Add `training_required_parameters` field on `_BackboneBase` for top-level `nn.Parameter` names that must remain trainable (e.g. Labram's `temporal_embedding`). Distinct from `training_required_modules`, which only accepts `nn.Module` names. Finetuning methods declare compatibility via the `supports_training_required_parameters` class variable; `IA3` and `OFT` opt out. `LoRA`/`AdaLoRA`/`DoRA` forward the list to PEFT's `target_parameters`; `Frozen`/`TwoStages` extend their unfreeze list ([#25](https://github.com/braindecode/OpenEEGBench/pull/25)).
1213

1314
## [0.4.0] - 2026-05-07
1415

open_eeg_bench/backbone.py

Lines changed: 93 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@
1818
import logging
1919
from typing import Any, Literal
2020
from importlib import import_module
21-
import warnings
2221

2322
from pydantic import BaseModel, ConfigDict, Field, model_validator
2423

@@ -58,12 +57,24 @@ class _BackboneBase(BaseModel):
5857
"since training extra layers is not comparable to training only a classification head."
5958
),
6059
)
60+
training_required_parameters: list[str] | None = Field(
61+
default=None,
62+
description=(
63+
"Top-level ``nn.Parameter`` names (NOT modules) that must remain trainable, "
64+
"typically because they cannot be loaded from the pretrained checkpoint "
65+
"(e.g. shape mismatch with the new ``n_chans``/``n_times``). "
66+
"Only finetuning methods whose "
67+
"``supports_training_required_parameters`` is True accept a non-empty list. "
68+
"Like ``training_required_modules``, models using this field are categorized "
69+
"separately in evaluations."
70+
),
71+
)
6172
normalization: Normalization = Field(
6273
default=NoNormalization(),
6374
description="Post-window normalization applied to each data window.",
6475
)
6576

66-
def get_training_required_modules(self):
77+
def get_training_required_modules(self) -> list[str]:
6778
out = [self.head_module_name]
6879
if self.training_required_modules:
6980
log.warning(
@@ -75,6 +86,16 @@ def get_training_required_modules(self):
7586
out += self.training_required_modules
7687
return out
7788

89+
def get_training_required_parameters(self) -> list[str]:
90+
if self.training_required_parameters:
91+
log.warning(
92+
"%s requires training extra parameters %s beyond the head. "
93+
"This model will be categorized separately in evaluations.",
94+
type(self).__name__,
95+
self.training_required_parameters,
96+
)
97+
return list(self.training_required_parameters or [])
98+
7899
def _build(
79100
self,
80101
n_chans: int,
@@ -92,6 +113,7 @@ def _check_layers_and_parameters_exist(self, model):
92113
This test can only be done once the model has been instantiated.
93114
"""
94115
module_names = {name for name, _ in model.named_modules()}
116+
param_names = {name for name, _ in model.named_parameters()}
95117
module_fields = {"head_module_name": [self.head_module_name]}
96118
if (
97119
self.peft_target_modules is not None
@@ -102,16 +124,26 @@ def _check_layers_and_parameters_exist(self, model):
102124
module_fields["peft_ff_modules"] = self.peft_ff_modules
103125
if self.training_required_modules is not None:
104126
module_fields["training_required_modules"] = self.training_required_modules
105-
for field, names in module_fields.items():
106-
for name in names:
107-
# Allow short names that match a suffix (e.g. "qkv" matches "encoder.layer.0.qkv")
108-
if name not in module_names and not any(
109-
n == name or n.endswith("." + name) for n in module_names
110-
):
111-
raise ValueError(
112-
f"{type(self).__name__}.{field} references module '{name}' "
113-
f"which does not exist in the model."
114-
)
127+
param_fields = {}
128+
if self.training_required_parameters is not None:
129+
param_fields["training_required_parameters"] = (
130+
self.training_required_parameters
131+
)
132+
133+
def _check(fields, valid_names, kind):
134+
for field, names in fields.items():
135+
for name in names:
136+
# Allow short names matching a suffix (e.g. "qkv" matches "encoder.layer.0.qkv")
137+
if name not in valid_names and not any(
138+
n == name or n.endswith("." + name) for n in valid_names
139+
):
140+
raise ValueError(
141+
f"{type(self).__name__}.{field} references {kind} '{name}' "
142+
f"which does not exist in the model."
143+
)
144+
145+
_check(module_fields, module_names, "module")
146+
_check(param_fields, param_names, "parameter")
115147

116148
def build(
117149
self,
@@ -277,39 +309,60 @@ def load_pretrained(self, model) -> None:
277309
len(skipped),
278310
)
279311

280-
# Sanity check on missing keys (model keys not loaded from checkpoint).
281-
param_names = {name for name, _ in model.named_parameters()}
282-
allowed_prefixes = [self.head_module_name]
283-
if self.training_required_modules:
284-
allowed_prefixes.extend(self.training_required_modules)
312+
allowed_names = (
313+
[self.head_module_name]
314+
+ (self.training_required_modules or [])
315+
+ (self.training_required_parameters or [])
316+
)
317+
318+
def covered(k: str) -> bool:
319+
return any(f".{m}." in f".{k}." for m in allowed_names)
285320

286-
def _is_allowed(key: str) -> bool:
287-
return any(key == p or key.startswith(p + ".") for p in allowed_prefixes)
321+
def describe(k: str) -> str:
322+
if k in state_dict:
323+
ckpt_shape = tuple(state_dict[k].shape)
324+
model_shape = tuple(model_state[k].shape)
325+
return f"{k} [shape mismatch: ckpt {ckpt_shape} vs model {model_shape}]"
326+
return f"{k} [absent from checkpoint]"
288327

289-
unexpected_params = [
290-
k for k in missing if k in param_names and not _is_allowed(k)
328+
param_names = {n for n, _ in model.named_parameters()}
329+
missing_params = [k for k in missing if k in param_names and not covered(k)]
330+
missing_buffers = [
331+
k for k in missing if k not in param_names and not covered(k)
291332
]
292-
missing_buffers = [k for k in missing if k not in param_names]
293-
294-
if unexpected_params:
295-
# TODO:
296-
# - FIX the models.
297-
# - Transform this warning into an error
298-
warnings.warn(
299-
f"Pretrained checkpoint is missing weights for backbone "
300-
f"parameters outside of head_module_name and "
301-
f"training_required_modules: {unexpected_params}. "
302-
f"These parameters keep random initialization, making results "
303-
f"seed-dependent. Either provide a checkpoint that covers them "
304-
f"or add the relevant modules to training_required_modules.",
305-
stacklevel=2,
333+
334+
if missing_params:
335+
raise ValueError(
336+
f"Pretrained checkpoint for {self.model_cls} is missing "
337+
f"{len(missing_params)} learnable parameter(s) that are neither "
338+
f"under head_module_name='{self.head_module_name}' nor under any "
339+
f"name in training_required_modules={self.training_required_modules} "
340+
f"or training_required_parameters={self.training_required_parameters}:\n"
341+
+ "\n".join(f" - {describe(k)}" for k in missing_params)
342+
+ "\nThese parameters would be silently initialized from scratch, "
343+
"which is almost certainly a config error. Either:\n"
344+
" (a) the checkpoint is incompatible with the declared architecture "
345+
"(check model_kwargs), or\n"
346+
" (b) these names should be declared in `training_required_modules` "
347+
"(for nn.Modules) or `training_required_parameters` (for top-level "
348+
"nn.Parameters) so they are explicitly trained from scratch (note: "
349+
"this will categorize the model separately in evaluations)."
306350
)
351+
307352
if missing_buffers:
308-
warnings.warn(
309-
f"Pretrained checkpoint does not cover the following "
310-
f"buffers: {missing_buffers}. They keep their default "
311-
f"initialization (typically deterministic).",
312-
stacklevel=2,
353+
log.warning(
354+
"Pretrained checkpoint for %s is missing %d buffer(s) that are "
355+
"not covered by the allowlist "
356+
"(head_module_name='%s', training_required_modules=%s, "
357+
"training_required_parameters=%s):\n%s\n"
358+
"Buffers are not trained, so missing values may be computed at "
359+
"init — but verify this is intentional.",
360+
self.model_cls,
361+
len(missing_buffers),
362+
self.head_module_name,
363+
self.training_required_modules,
364+
self.training_required_parameters,
365+
"\n".join(f" - {describe(k)}" for k in missing_buffers),
313366
)
314367

315368
@staticmethod

open_eeg_bench/default_configs/backbones.py

Lines changed: 7 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -12,17 +12,6 @@
1212
def biot(**overrides) -> PretrainedBackbone:
1313
defaults = dict(
1414
model_cls="braindecode.models.BIOT",
15-
model_kwargs=dict(
16-
embed_dim=256,
17-
num_heads=8,
18-
num_layers=4,
19-
drop_prob=0.5,
20-
att_drop_prob=0.2,
21-
att_layer_drop_prob=0.2,
22-
hop_length=100,
23-
max_seq_len=1024,
24-
return_feature=False,
25-
),
2615
# w1, w2 are the two Linear layers inside FeedForward blocks
2716
peft_ff_modules=["w1", "w2"],
2817
normalization=PercentileScale(q=95.0),
@@ -35,26 +24,12 @@ def biot(**overrides) -> PretrainedBackbone:
3524

3625
def labram(**overrides) -> PretrainedBackbone:
3726
defaults = dict(
38-
model_cls="braindecode.models.Labram",
39-
model_kwargs=dict(
40-
patch_size=200,
41-
embed_dim=200,
42-
num_layers=12,
43-
num_heads=10,
44-
mlp_ratio=4.0,
45-
qkv_bias=False,
46-
qk_scale=None,
47-
drop_prob=0.1,
48-
attn_drop_prob=0.0,
49-
drop_path_prob=0.1,
50-
use_abs_pos_emb=True,
51-
use_mean_pooling=True,
52-
init_scale=0.001,
53-
init_values=0.1,
54-
neural_tokenizer=True,
55-
),
27+
model_cls="braindecode.models.InterpolatedLaBraM",
5628
# mlp.0 and mlp.2 are the two Linear layers inside the MLP block
5729
peft_ff_modules=["mlp.0", "mlp.2"],
30+
# temporal_embedding is an nn.Parameter trained for n_times=3000 only
31+
# (shape mismatch with other n_times → must be retrained from scratch)
32+
training_required_parameters=["temporal_embedding"],
5833
normalization=DivideByConstant(factor=100.0),
5934
hub_repo="braindecode/labram-pretrained",
6035
)
@@ -64,26 +39,9 @@ def labram(**overrides) -> PretrainedBackbone:
6439

6540
def bendr(**overrides) -> PretrainedBackbone:
6641
defaults = dict(
67-
model_cls="braindecode.models.BENDR",
68-
model_kwargs=dict(
69-
encoder_h=512,
70-
contextualizer_hidden=3076,
71-
transformer_layers=8,
72-
transformer_heads=8,
73-
position_encoder_length=25,
74-
enc_width=(3, 2, 2, 2, 2, 2),
75-
enc_downsample=(3, 2, 2, 2, 2, 2),
76-
drop_prob=0.1,
77-
layer_drop=0.0,
78-
projection_head=False,
79-
start_token=-5,
80-
final_layer=True,
81-
n_chans_pretrained=20,
82-
encoder_only=True,
83-
),
42+
model_cls="braindecode.models.InterpolatedBENDR",
8443
# linear1, linear2 are the FFN layers in TransformerEncoderLayer
8544
peft_ff_modules=["linear1", "linear2"],
86-
training_required_modules=["channel_projection"],
8745
normalization=MinMaxScale(),
8846
hub_repo="braindecode/braindecode-bendr",
8947
)
@@ -94,20 +52,6 @@ def bendr(**overrides) -> PretrainedBackbone:
9452
def cbramod(**overrides) -> PretrainedBackbone:
9553
defaults = dict(
9654
model_cls="braindecode.models.CBraMod",
97-
model_kwargs=dict(
98-
patch_size=200,
99-
dim_feedforward=800,
100-
n_layer=12,
101-
nhead=8,
102-
emb_dim=200,
103-
drop_prob=0.1,
104-
channels_kernel_stride_padding_norm=[
105-
[25, 49, 25, 24, [5, 25]],
106-
[25, 3, 1, 1, [5, 25]],
107-
[25, 3, 1, 1, [5, 25]],
108-
],
109-
return_encoder_output=False,
110-
),
11155
# linear1, linear2 are the FFN layers in CrissCrossTransformerEncoderLayer
11256
peft_ff_modules=["linear1", "linear2"],
11357
normalization=DivideByConstant(factor=100.0),
@@ -119,29 +63,10 @@ def cbramod(**overrides) -> PretrainedBackbone:
11963

12064
def signal_jepa(**overrides) -> PretrainedBackbone:
12165
defaults = dict(
122-
model_cls="braindecode.models.SignalJEPA",
123-
model_kwargs=dict(
124-
feature_encoder__conv_layers_spec=[
125-
(8, 32, 8),
126-
(16, 2, 2),
127-
(32, 2, 2),
128-
(64, 2, 2),
129-
(64, 2, 2),
130-
],
131-
feature_encoder__mode="default",
132-
feature_encoder__conv_bias=False,
133-
pos_encoder__spat_dim=30,
134-
pos_encoder__time_dim=34,
135-
pos_encoder__sfreq_features=1.0,
136-
transformer__d_model=64,
137-
transformer__num_encoder_layers=8,
138-
transformer__num_decoder_layers=4,
139-
transformer__nhead=8,
140-
drop_prob=0.0,
141-
),
66+
model_cls="braindecode.models.InterpolatedSignalJEPA",
14267
# linear1, linear2 are the FFN layers in TransformerEncoderLayer
14368
peft_ff_modules=["linear1", "linear2"],
144-
checkpoint_url="https://huggingface.co/braindecode/SignalJEPA/resolve/main/signal-jepa_16s-60_adeuwv4s.pth",
69+
hub_repo="braindecode/signal-jepa",
14570
)
14671
defaults.update(overrides)
14772
return PretrainedBackbone(**defaults)
@@ -150,17 +75,6 @@ def signal_jepa(**overrides) -> PretrainedBackbone:
15075
def reve(**overrides) -> PretrainedBackbone:
15176
defaults = dict(
15277
model_cls="braindecode.models.REVE",
153-
model_kwargs=dict(
154-
embed_dim=512,
155-
depth=22,
156-
heads=8,
157-
head_dim=64,
158-
mlp_dim_ratio=2.66,
159-
use_geglu=True,
160-
patch_size=200,
161-
patch_overlap=20,
162-
attention_pooling=False,
163-
),
16478
# net.1, net.3 are the two Linear layers inside FeedForward.net
16579
peft_ff_modules=["net.1", "net.3"],
16680
normalization=WindowZScore(clip_sigma=15.0),

open_eeg_bench/experiment.py

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,13 +79,31 @@ def _check_consistency(self):
7979
if is_ridge and not isinstance(self.finetuning, Frozen):
8080
raise ValueError("Ridge probing requires Frozen finetuning.")
8181

82-
if is_ridge and self.backbone.training_required_modules:
82+
if is_ridge and (
83+
self.backbone.training_required_modules
84+
or self.backbone.training_required_parameters
85+
):
8386
raise ValueError(
8487
f"Ridge probing incompatible with backbone.training_required_modules="
85-
f"{self.backbone.training_required_modules}. "
88+
f"{self.backbone.training_required_modules} or "
89+
f"backbone.training_required_parameters="
90+
f"{self.backbone.training_required_parameters}. "
8691
f"These backbones need SGD training."
8792
)
8893

94+
if (
95+
self.backbone.training_required_parameters
96+
and not self.finetuning.supports_training_required_parameters
97+
):
98+
raise ValueError(
99+
f"{type(self.finetuning).__name__} is incompatible with "
100+
f"backbone.training_required_parameters="
101+
f"{self.backbone.training_required_parameters}. "
102+
f"This finetuning method can only train nn.Modules. "
103+
f"Use a method whose supports_training_required_parameters is True "
104+
f"(e.g. LoRA, AdaLoRA, DoRA, FullFinetune, Frozen, TwoStages)."
105+
)
106+
89107
if (
90108
not is_ridge
91109
and isinstance(self.finetuning, Frozen)

0 commit comments

Comments
 (0)