Repository navigation
Pad non-divisible windows by default: PatchTokenizer in LaBraM, CBraMod, LUNA; ZUNA default to pad - #1226
Conversation
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
The contract mishandles expected-error xfails, while the audit can report non-finite outputs and incorrect channel modalities as successful.
Review effort: Balanced
Findings: 2
Open (3)
What changed in this PR
Adds default padding for non-divisible windows in pretrained foundation models and introduces compatibility auditing.
Changes:
- Integrates
PatchTokenizerinto LaBraM, CBraMod, and LUNA; changes ZUNA’s default to padding. - Adds geometry contract tests and a Hub checkpoint audit script.
- Documents the compatibility design and behavior changes.
| File | Description |
|---|---|
braindecode/models/labram.py |
Supports padding, cropping, or rejecting partial patches. |
braindecode/models/cbramod.py |
Replaces reshaping with PatchTokenizer. |
braindecode/models/luna.py |
Tokenizes signals and masks consistently. |
braindecode/models/zuna.py |
Defaults non-divisible windows to padding. |
test/unit_tests/models/test_pretrained_compat.py |
Adds the geometry compatibility contract. |
test/unit_tests/models/test_foundation_models.py |
Updates ZUNA behavior tests and formatting. |
scripts/compat_audit.py |
Audits random and pretrained models across geometries. |
docs/design/pretrained_compatibility.md |
Defines the broader compatibility proposal. |
docs/whats_new.rst |
Records the new behavior and tooling. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| def chs_coords_only(n): | ||
| """n channels with synthetic head-frame coordinates and non-standard names.""" | ||
| chs = [] | ||
| for i in range(n): | ||
| th = 2 * math.pi * i / n | ||
| loc = np.zeros(12) | ||
| loc[:3] = [0.08 * math.cos(th), 0.08 * math.sin(th), 0.03] | ||
| chs.append({"ch_name": f"E{i + 1}", "kind": "eeg", "loc": loc}) |
| if sf: | ||
| geos["G5a"] = dict(chs_info=base, sfreq=sf, n_times=int(sf)) | ||
| geos["G5b"] = dict(chs_info=base, sfreq=sf, n_times=int(sf * 30)) | ||
| geos["G5c"] = dict(chs_info=base, sfreq=sf, n_times=nt + 37) |
| Status: proposal, branch `feat/pretrained-compat-d`, nothing merged. Companion audit: | ||
| `scripts/compat_audit.py` (geometry grid × 19 pretrained classes, random weights). |
fbc0d62 to
2fd78aa
Compare
|
Self-review (lazy-senior-dev pass) — the first push of this PR was three PRs in one coat plus two files that don't belong in the library. Split:
Still to come as their own small PRs (see #1227): the Parity after the split: outputs bit-identical to base on divisible windows, same state-dict keys, 119 passed on the four models' tests. |
…od, LUNA; ZUNA default on_non_divisible='pad'
2fd78aa to
16caba1
Compare
There was a problem hiding this comment.
Warning
Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.
Copilot review overview
Review effort: Lite
Findings: 4
Open (8)
Same concern as in LUNA:_prepare_inputis a private method. To avoid relying on tokenizer… · New_prepare_inputis a private method (leading underscore). Calling it from outsidePatchTokenizer… · New Add alternate sampling-rate cases to geometry contract Preserve declared channel modality in synthetic geometriesself.rearrangeis now aPatchTokenizer, not an einopsRearrange, so the attribute name is… · New The note says non-divisible windows 'previously ... raised a reshape error', but the PR description… · New This assertion is a bit brittle becausestate_dict()includes buffers (not just parameters) and… · New Update migration status to reflect implemented work
| X_patch: Tensor | ||
| [batch, n_chans, n_times//patch_size, patch_size] | ||
| """ | ||
| x = self.tokenizer._prepare_input(x) |
| x_signal = self.tokenizer._prepare_input(x_signal) | ||
| if mask is not None: | ||
| mask = self.tokenizer._prepare_input(mask) |
| del n_chans, chs_info, n_times, input_window_seconds, sfreq, n_outputs | ||
| self.rearrange = Rearrange( | ||
| "batch n_chans (n_patch patch_size) -> batch n_chans n_patch patch_size", | ||
| # Shared tokenizer: (batch, n_chans, n_times) -> (batch, n_chans, n_patch, patch_size), | ||
| # padding/cropping a non-divisible time axis at forward time. | ||
| self.rearrange = PatchTokenizer( | ||
| patch_size=patch_size, | ||
| n_times=self._n_times if self._n_times is not None else patch_size, | ||
| on_non_divisible=on_non_divisible, | ||
| ) |
| :class:`braindecode.modules.PatchTokenizer` and accept a window that is not a | ||
| multiple of ``patch_size`` by right zero-padding the last patch (with a warning), | ||
| through a new ``on_non_divisible={"pad", "crop", "error"}`` argument; previously | ||
| such windows raised a reshape error. :class:`braindecode.models.ZUNA` now defaults to |
| with torch.no_grad(): | ||
| out = model(x, ch_names=_TEN_TWENTY) if cls is Labram else model(x) | ||
| assert torch.isfinite(out).all() | ||
| assert not any("tokenizer" in k for k in model.state_dict()) |
There was a problem hiding this comment.
Warning
Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.
Copilot review overview
Review effort: Lite
Findings: 7
Open (12)
_SegmentPatchalso calls the privatePatchTokenizer._prepare_input. To avoid locking in… · New The PR description says decoder mode (_PatchEmbed, pre-patched input) is untouched, but this diff… · New_prepare_inputis a private method (leading underscore) ofPatchTokenizer, so depending on it… · New_prepare_inputis a private method (leading underscore). Calling it from outsidePatchTokenizer… Same concern as in LUNA:_prepare_inputis a private method. To avoid relying on tokenizer… Add alternate sampling-rate cases to geometry contract Preserve declared channel modality in synthetic geometriesself.rearrangenow holds aPatchTokenizerrather than an einopsRearrangelayer. This is… · New This assertion is a bit brittle becausestate_dict()includes buffers (not just parameters) and… The note says non-divisible windows 'previously ... raised a reshape error', but the PR description…self.rearrangeis now aPatchTokenizer, not an einopsRearrange, so the attribute name is… Update migration status to reflect implemented work
| X_patch: Tensor | ||
| [batch, n_chans, n_times//patch_size, patch_size] | ||
| """ | ||
| x = self.tokenizer._prepare_input(x) |
| "4D (batch, channels, n_patches, patch_size)." | ||
| ) | ||
|
|
||
| if n_times % self.patch_size != 0: | ||
| raise ValueError( | ||
| f"n_times ({n_times}) must be divisible by patch_size ({self.patch_size})." | ||
| ) | ||
| if n_channels % self.in_channels != 0: | ||
| raise ValueError( | ||
| "The input channel dimension " |
| x_signal = self.tokenizer._prepare_input(x_signal) | ||
| if mask is not None: | ||
| mask = self.tokenizer._prepare_input(mask) |
| # Shared tokenizer: (batch, n_chans, n_times) -> (batch, n_chans, n_patch, patch_size), | ||
| # padding/cropping a non-divisible time axis at forward time. | ||
| self.rearrange = PatchTokenizer( | ||
| patch_size=patch_size, | ||
| n_times=self._n_times if self._n_times is not None else patch_size, | ||
| on_non_divisible=on_non_divisible, | ||
| ) |
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## master #1226 +/- ##
==========================================
+ Coverage 87.84% 87.99% +0.14%
==========================================
Files 151 151
Lines 17602 17628 +26
==========================================
+ Hits 15462 15511 +49
+ Misses 2140 2117 -23 🚀 New features to boost your workflow:
|
Conflict resolution: - braindecode/models/cbramod.py: kept master's braindecode#1226 PatchTokenizer on_non_divisible padding and merged the PR's _knows_geometry() helper (superset of master's _n_times/_n_chans-not-None check) into the PatchTokenizer n_times arg, final_layer construction (both __init__ sites), reset_head, and _n_patch. - docs/whats_new.rst: kept both the braindecode#1233 CBraMod lazy-head bug-fix entry and master's braindecode#1207/braindecode#1212 Deep4Net/ShallowFBCSPNet bug-fix entries. - test/unit_tests/models/test_foundation_models.py: kept master's MAPA/PopT test blocks and the PR's test_cbramod_head_is_concrete_when_geometry_is_derived.
…ights (stacked on #1226) (#1228) * Pad non-divisible windows by default: PatchTokenizer in LaBraM, CBraMod, LUNA; ZUNA default on_non_divisible='pad' * Add a geometry-compatibility contract test for every model with released weights * TST stop muting MNE warnings in the geometry contract test mne.set_log_level('ERROR') at import silenced mne.utils.warn for the whole pytest worker, so 15 pytest.warns tests in test_models.py failed with DID NOT WARN when collected after this file. * TST simplify geometry contract test helpers (ponytail) Inline the 19-line TEN_TWENTY list as one split(), and fold the NOT_YET branch of test_geometry_contract into the forward branch (it ran the same assertion). Parametrize ids and strict xfail markers are unchanged.
…ined works (stacked on #1226) (#1233) * Pad non-divisible windows by default: PatchTokenizer in LaBraM, CBraMod, LUNA; ZUNA default on_non_divisible='pad' * CBraMod: concrete head whenever the geometry is known (chs_info / input_window_seconds) * CBraMod: tokenizer needs only n_times, keep its divisibility check without channels
…rs on_non_divisible (#1250) * FIX from_pretrained with explicit None geometry; LaBraM decoder honours on_non_divisible - from_pretrained(d, chs_info=None) / (d, n_chans=None) load the saved geometry again: the pinning checks the value, not the key (#1232). - Labram(neural_tokenizer=False) pads / crops / raises on a window not divisible by patch_size, like the tokenizer mode (#1226). - PatchTokenizer.prepare_input is public (_prepare_input kept as alias); LaBraM and LUNA call it. * MAINT PatchTokenizer: drop the unused _prepare_input alias * DOC whats_new entry for #1250 * FIX from_pretrained: drop explicit None geometry so config.json fills it


Summary
Four pretrained models raised on a window that is not a multiple of
patch_size(LaBraM: reshape error, CBraMod/LUNA: einops, ZUNA:on_non_divisible="error"default). They now pad the last patch with zeros by default (one warning), through the sharedPatchTokenizer, and raise only withon_non_divisible="error".First step of a short stack (next: a geometry-contract test over every pretrained model; then channel handling). Design discussion in the linked issue.
Changes
labram.py:_SegmentPatchholds a parameter-freePatchTokenizer(pads before the conv/reshape patcher); newon_non_divisiblekwarg onLabram. Decoder mode (_PatchEmbed, pre-patched input) untouched.cbramod.py: theRearrangebecomes aPatchTokenizer;_n_patch()sizes the flat head with padding.luna.py: tokenizer in_PatchEmbedNetworkandprepare_tokens(mask padded alongside the signal).zuna.py: defaulton_non_divisible"error"→"pad"; its test asserts the new default and the explicit opt-out.test_foundation_models.py: one check per model — pads with a warning, forwards finite, adds no state-dict key, raises with"error".docs/whats_new.rst.Testing
pytest test/unit_tests/models/test_foundation_models.py test/unit_tests/models/test_integration.py -k "labram or cbramod or luna or zuna"→ 119 passed, 17 skipped.Parity vs base
2426ddc3on divisible windows (seeded weights, same input): state-dict keys identical, outputs bit-identical (max |Δ| = 0.0) for all four → released checkpoints unaffected.ruff format/ruff check(0.14.9) on changed files: clean (twoF841pre-existing on master untouched).Not run: full suite (CI).
Regression tests
Style checks recorded
docs/whats_new.rstupdatedNotes for reviewers
Behaviour change: pad instead of raise for these four models (ZUNA's default flips).
on_non_divisible="error"restores strictness per model.PatchTokenizerhas no parameters, so no checkpoint conversion.