Skip to content

ChannelTokenizer: one module for the five channel strategies (#1227 step 5) - #1235

Merged
bruAristimunha merged 12 commits into
braindecode:masterfrom
bruAristimunha:feat/channel-tokenizer
Oct 6, 2026
Merged

bruAristimunha merged 12 commits into
braindecode:masterfrom
bruAristimunha:feat/channel-tokenizer

Conversation

@bruAristimunha

Copy link
Copy Markdown
Collaborator

Stacked on #1228 (contract test); part of #1227 step 5. Diff shrinks once #1228 merges.

Adds braindecode/modules/channel_tokenizer.py — one module, five strategies: names (alias table + on_unknown), coords (on_missing_loc), fixed_order (= ChannelInterpolationLayer), index_slots, agnostic. Resolved tensors are non-persistent buffers, so Hub checkpoints still load strict.

Wires three fixes, each flipping a strict-xfail cell in test_pretrained_compat.py:

  • BENDR adapts any montage through it instead of refusing a permutation → BENDR-G2/G3/G3b/G4
  • EEGDINO raises a declared error past its 19 slots → EEGDINO-G2
  • SignalJEPA no longer NaNs for names without coords → SignalJEPA-G3b

Canonical forwards bit-identical (BENDR, SignalJEPA: max-abs 0.0); state_dict keys unchanged. Labram's names migration stays xfail (follow-up).

bruAristimunha and others added 5 commits October 5, 2026 13:20
…od, LUNA; ZUNA default on_non_divisible='pad'
…code#1227 step 5)

Add braindecode/modules/channel_tokenizer.py gathering the five ways a
pretrained checkpoint identifies input channels: names (shared alias
table + on_unknown policy), coords (on_missing_loc policy), fixed_order
(= ChannelInterpolationLayer), index_slots, agnostic.

Wire the three fixes whose contract-test cells flip:
- BENDR adapts a non-canonical montage through ChannelTokenizer
  (fixed_order) instead of refusing it; the default/canonical path builds
  no tokenizer, so from_pretrained forwards are bit-identical (max-abs 0.0)
  and the state_dict keys are unchanged.
- EEGDINO already raises a declared error past its 19 slots (index_slots).
- SignalJEPA no longer returns NaN for names without coordinates: floor the
  degenerate coordinate span in _pos_encode_contineous.

test_pretrained_compat.py: fixed_order no longer forces a geometry raise;
remove the BENDR G2/G3/G3b/G4, EEGDINO G2 and SignalJEPA G3b xfail markers
(6 cells flip). Labram's names-vocabulary migration stays pending.
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.
Copilot AI balanced review requested due to automatic review settings October 5, 2026 15:48
@bruAristimunha bruAristimunha added the maintenance Bug fix / refactor / tests — not a new model label Oct 5, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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: 2 High severity · 6 Medium severity

Open (8)
What changed in this PR

Introduces a unified ChannelTokenizer module to consolidate channel-handling strategies across pretrained EEG models, and adds a geometry-compatibility contract test suite plus release notes documenting the migration.

Changes:

  • Add ChannelTokenizer implementing five channel strategies and export it from braindecode.modules.
  • Add test_pretrained_compat.py contract test to validate pretrained models across a geometry grid (forward vs declared errors).
  • Fix NaNs in SignalJEPA positional encoding for montages without usable coordinates; update BENDR to adapt non-canonical montages via ChannelTokenizer.
File Description
test/​unit_tests/​models/​test_pretrained_compat.py Adds a new geometry-compatibility contract test and strategy-based expectations.
docs/​whats_new.rst Documents the new contract test and the ChannelTokenizer migration/fixes.
braindecode/​modules/​channel_tokenizer.py New module implementing unified channel adaptation strategies and policies.
braindecode/​modules/​__init__.py Exposes ChannelTokenizer in the public modules API.
braindecode/​models/​signal_jepa.py Prevents NaNs when channel coordinates are missing by guarding a degenerate span.
braindecode/​models/​bendr.py Replaces strict chs_info matching with optional tokenizer-based adaptation for non-canonical montages.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread braindecode/models/signal_jepa.py Outdated
Comment on lines +1598 to +1603
# ``max_abs_coordinate`` -- and hence the span -- is zero. Flooring the
# denominator keeps the encoding finite (xx == 0 -> sin/cos of 0) instead
# of the NaN a 0/0 would produce. Montages with real coordinates have a
# nonzero span, so this leaves every pretrained-checkpoint path unchanged.
span = x_max - x_min
xx = (x - x_min) / span if span != 0 else 0.0
Comment on lines +181 to +189
if j is None:
if on_unknown == "error":
raise ValueError(
f"Channel {ch['ch_name']!r} is not in the model "
f"vocabulary ({len(vocabulary)} names) and no alias "
f"matches. Pass on_unknown='zero' to map it to a zero "
f"embedding, or supply a recognised channel name."
)
j = -1 # caller must treat -1 as a zero embedding
Comment on lines +246 to +247
if self.projection is not None:
return self.projection.to(x.dtype) @ x
Comment thread test/unit_tests/models/test_pretrained_compat.py Outdated
Comment on lines +111 to +112
# identifies channels (names / coords / fixed_order / index_slots / agnostic),
# ``min_n_times`` = smallest accepted window in samples.
sfreq=200,
n_times=800,
canon=BIOT_CHANNEL_ORDER,
channels="fixed_order_unchecked",
sfreq=200,
n_times=800,
canon=TEN_TWENTY,
channels="fixed_order_unchecked",
n_times=1000,
canon=None,
n_chans_fixed=45,
channels="fixed_order_unchecked",
Copilot AI balanced review requested due to automatic review settings October 5, 2026 18:40

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 High severity · 11 Medium severity · 1 Low severity

Open (16)

# of the NaN a 0/0 would produce. Montages with real coordinates have a
# nonzero span, so this leaves every pretrained-checkpoint path unchanged.
span = x_max - x_min
xx = (x - x_min) / span if span != 0 else 0.0
Comment on lines +179 to +191
for ch in src_chs_info:
j = lookup.get(_canon_name(ch["ch_name"]))
if j is None:
if on_unknown == "error":
raise ValueError(
f"Channel {ch['ch_name']!r} is not in the model "
f"vocabulary ({len(vocabulary)} names) and no alias "
f"matches. Pass on_unknown='zero' to map it to a zero "
f"embedding, or supply a recognised channel name."
)
j = -1 # caller must treat -1 as a zero embedding
out.append(j)
return torch.tensor(out, dtype=torch.long)
Comment on lines +217 to +226
if not has_coords:
if strategy == "coords" and on_missing_loc == "error":
raise ValueError(
"strategy='coords' with on_missing_loc='error': the montage "
"has no usable channel coordinates (all 'loc' are zero or "
"missing). Supply electrode positions or set "
"on_missing_loc='zero'."
)
# Names-only projection: one-hot for matched names, zero rows else.
return _name_match_matrix(src_chs_info, target_chs_info)
Comment on lines +108 to +110
# Declared behaviour per pretrained class: ``channels`` = how the checkpoint
# identifies channels (names / coords / fixed_order / index_slots / agnostic),
# ``min_n_times`` = smallest accepted window in samples.
sfreq=200,
n_times=800,
canon=BIOT_CHANNEL_ORDER,
channels="fixed_order_unchecked",
sfreq=200,
n_times=800,
canon=TEN_TWENTY,
channels="fixed_order_unchecked",
n_times=1000,
canon=None,
n_chans_fixed=45,
channels="fixed_order_unchecked",
unchanged -- the consuming model reads :attr:`channel_indices`.
"""
if self.projection is not None:
return self.projection.to(x.dtype) @ x
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.
…er helpers, trim comments

- drop the local copy of models.util.has_valid_locations (lazy import avoids the cycle)
- inline _resolve_names/_resolve_slots/_resolve_projection into __init__; the
  coordinate-less projection reuses the names lookup instead of its own loop
- strategy check via get_args(Strategy); no detach().clone() of a fresh buffer
- shorten BENDR/SignalJEPA comments, drop history comments in the compat test
No behaviour change: same matrices, indices, errors and state_dict keys.
Resolved docs/whats_new.rst by keeping both entries.
Copilot AI balanced review requested due to automatic review settings October 6, 2026 06:36

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Channel resolution can select incorrect embedding rows or ignore coordinate policies, affecting model inputs.

Review effort: Balanced
Findings: 4 High severity · 14 Medium severity · 1 Low severity

Open (19)
Resolved since last review (1)

Comment on lines +123 to +125
if strategy not in get_args(Strategy):
raise ValueError(f"Unknown channel strategy {strategy!r}.")
self.strategy = strategy
Comment on lines +164 to +168
# MNE spline, with a name-match short-circuit so a permutation
# is an exact one-hot.
self.projection = ChannelInterpolationLayer(
src_chs_info, target_chs_info, mode="name_match", method=method
).matrix
Comment on lines +176 to +177
else:
# No coordinates: one-hot for matched names, zero rows else.
Comment on lines +201 to +204
lookup = {_canon_name(n): i for i, n in enumerate(vocabulary)}
out = []
for name in names:
j = lookup.get(_canon_name(name))
@codecov

codecov Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 77.94118% with 15 lines in your changes missing coverage. Please review.
✅ Project coverage is 88.39%. Comparing base (28b51ff) to head (d4fc32a).

Additional details and impacted files
@@            Coverage Diff             @@
##           master    #1235      +/-   ##
==========================================
- Coverage   88.43%   88.39%   -0.04%     
==========================================
  Files         156      157       +1     
  Lines       19019    19085      +66     
==========================================
+ Hits        16819    16870      +51     
- Misses       2200     2215      +15     
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

test_pretrained_compat.py (add/add after braindecode#1228's squash merge): keep this PR's side, i.e. braindecode#1228's file with the six NOT_YET cells and the fixed_order raise removed, since ChannelTokenizer makes them pass. whats_new: keep both; drop the EEGDINO clause (this PR does not change EEGDINO).
Copilot AI balanced review requested due to automatic review settings October 6, 2026 10:29

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

The coordinate-less fallback bypasses existing channel-type validation and can silently treat non-EEG signals as EEG.

Review effort: Balanced
Findings: 4 High severity · 15 Medium severity · 1 Low severity

Open (20)

Comment on lines +161 to +163
from braindecode.models.util import has_valid_locations

if has_valid_locations(src_chs_info):
@bruAristimunha
bruAristimunha merged commit 1412f01 into braindecode:master Oct 6, 2026
26 of 29 checks passed
bruAristimunha added a commit that referenced this pull request Oct 7, 2026
* ENH channels: resolve stage (names, aliases, positions, types)

* ENH channels: strategy registry + exact/zero/nearest/idw/spline/field

* ENH channels: ChannelTokenizer layer with per-montage cache

Replaces modules/channel_tokenizer.py (#1235). BENDR's non-canonical path
now uses ChannelTokenizer(ChannelTarget('montage', ...), 'spline', reg=0),
the former fixed_order behaviour; the canonical path builds no tokenizer
(bit-identical).

* ENH models: channel_strategy in EEGModuleMixin

* ENH channels: source strategy (physics-anchored, trainable)

* ENH channels: wiener, region, latent strategies

* API remove Interpolated* models; BENDR on the channel layer

* FIX channels: finite sphere lead field for electrodes on the z axis

MNE's sphere EEG formula returns NaN for an electrode exactly above the
sphere centre (biosemi64 Cz at (0, 0, r)), so the source strategy produced
NaN maps for any biosemi64 input that needed a filled target. Shift such
points by 1 um.

* ENH montage: channel layer for BIOT, CodeBrain, MIRepNet

Each model declares a montage ChannelTarget (BIOT: the 18 monopolar
electrodes behind its bipolar derivations, which forward forms as
V(A) - V(B); CodeBrain: 10-20 19 channels; MIRepNet: the 45-channel
use_channels_names template of the original code) and takes
channel_strategy / channel_strategy_kwargs. Native is unchanged
(bit-identical, same state_dict) but warns (FutureWarning) when the
montage is not the canonical one.

* ENH slots/free: channel layer for EEGDINO, CBraMod, Brant, BrainBERT

EEGDINO fills its 19 slots from any montage under a channel strategy.
CBraMod, Brant and BrainBERT are free: sensor strategies pass x through,
source/latent feed parcels/latents. CBraMod masks the patches of channels
the layer marks as not observed. Brant and BrainBERT stay scriptable: the
layer is eager-only and native drops it.

* FIX channels: latent strategy passes a pass-through map through

LatentStrategy.build returns a pass-through map (no extra tensors) for a
positions target without a training montage; apply then failed on the
missing 'used' entry. Return x unchanged, as the base strategy does.

* ENH positions: channel layer for LUNA

* FIX channels: wiener builds maps after the model moves to a device (M1)

WienerStrategy read its cov / dense_positions buffers with .double().numpy(),
which raises a raw TypeError on MPS and on CUDA. Copy them to the CPU first,
and keep fitted buffers on the strategy's device. New test: fit, move to
cuda/mps, forward a montage not built yet (spline, trainable source, latent,
wiener); skipped without an accelerator.

* FIX channels: drop cached maps when a state dict is loaded (M3)

ChannelTokenizer cleared its per-montage cache only in fit(); loading another
fitted wiener state kept serving the old maps. Clear it in
_load_from_state_dict.

* FIX channels: source and field reconstruct in the input's own reference (M2)

source returned L_t S for reconstructed rows, with S killing constants: those
rows summed to 0 while copies summed to 1, so a common-mode / reference signal
reached copies but not reconstructed channels. field had the same mix (row
sums 0.09, -0.56, ...). Reconstructed rows now estimate target minus the mean
of the used inputs, plus that mean: every row sums to 1 and the whole output
stays in the input's reference.

The source fidelity test is parametrised over two truth references (average
of the 19 sites, and Fp1): 0.888 / 0.761, bounds 0.92 / 0.79. New test:
constant input -> constant output for nearest, idw, spline, field, source
(physics and trainable).

* ENH positions: channel layer for REVE

* ENH positions: channel layer for ZUNA

* FIX channels: finite sphere lead field for an electrode on the centre axis

biosemi64 Cz at (0, 0, 0.095) lies on the line through the sphere centre and a
grid dipole; MNE's sphere formula returns NaN rows there, which spread to every
'source' fill row. Move such electrodes by 1 um and recompute once.

* ENH ids: channel layer for LaBraM

channel_strategy / channel_strategy_kwargs; under a strategy the position
embedding is indexed by ChannelEncoding.channel_ids (finishing #1227's names
migration), forward takes chs_info (ch_names as a shortcut), and reconstructed
channels are masked as attention keys. Native path unchanged.

* ENH ids: channel layer for EEGPT

ids into the 62-name EEGPT_CHANNELS table; with chan_proj_type='none' the
encoder reads ChannelEncoding.channel_ids and masks reconstructed channels as
attention keys (SDPA key-padding mask), otherwise the channel projection maps
the reconstructed vocabulary to the 19 standard channels. Native unchanged.

* ENH ids: channel layer for STEEGFormer

ids into the montage vocabulary (lazy class attribute, so the Hub file is only
fetched when a strategy needs it); the channel embedding uses
ChannelEncoding.channel_ids. Native unchanged and never fetches the vocabulary
for its contract.

* ENH ids: channel layer for SignalJEPA and SignalJEPA_Contextual

Under a strategy the pre-training channel table is used and rows are looked
up with ChannelEncoding.channel_ids; feature encoder and contextual head are
sized to the channels the layer produces. The zero-span positional encoding
fix is kept. PostLocal / PreLocal stay native-only (no channel embedding).

* ENH ids: channel layer for MVPFormer

MVPFormer has no channel vocabulary (slots in input order): contract 'free'.
Sensor strategies pass channels through, 'source' / 'latent' feed parcels /
latents into the slots; the concat head is sized to them. Native unchanged.

* TST ids models on the channel layer

Contract, native == exact on the canonical montage (bit-identical), strict
load, config round trip, from_pretrained with a strategy, 10 strategies x
G1-G4 per model with declared errors, key-padding mask reaches LaBraM and
EEGPT attention, LaBraM NOT_YET compat cells under a strategy, sphere-axis
lead-field regression.

* ENH channels: warn on high row gain and low-support targets (m1)

Spec section 4: once per built montage (maps are cached), warn when a
reconstructed row has |w|_1 > 2 and when targets have support < 0.5 (no used
input within 21 mm). The copy and support blocks of ChannelStrategy.build move
into _copies / _fill_support helpers.

* ENH positions: channel layer for BaRISTA

* FIX models: fresh-key warning says fit() for wiener; wiener model round-trip (m3)

The fresh channel_tokenizer keys warning told wiener users to train the empty
covariance buffers; it now says to call channel_tokenizer.fit() for
strategies that are not trainable (uses ChannelStrategy.trainable). New
test_base cases: the warning wording, and a fitted wiener model through
save_pretrained / from_pretrained ((0, 0) buffer resized on load) gives equal
outputs on a montage that needs reconstruction.

* TST models: register the test-only trainable strategy in a fixture (m8)

Registering _test_trainable at import mutated the global strategy registry, so
a second import (xdist workers, importlib.reload) raised 'already registered'.
A fixture registers it per test and pops it on teardown.

* FIX channels: ids position fallback only for names unknown to 10-05 (m4)

_ids_subset matched every unmatched input channel to the vocabulary entry
within 15 mm, so a digitised known name (FCz 5 mm from Cz) was relabelled
silently. Apply the rule of _match: only names unknown to standard_1005 with
a user position fall back to position.

* ENH channels: warn when a strategy has no effect on a positions target (m5)

A 'positions' ChannelTarget without chs_info is a pass-through, so source,
spline, latent, ... returned the input unchanged without a word. Warn once at
construction (not for exact / zero) and point to ChannelTarget(chs_info=...).

* FIX channels: SphereHead raises a declared ValueError on MNE errors (m7)

make_forward_solution errors (e.g. a NaN position: RuntimeError 'Missing EEG
channel location') escaped raw. Re-raise as ValueError naming the template
head and the positions, like _mne_interp_matrix.

* ENH positions: channel layer for DIVER-1

* MAINT channels: share the trainable building blocks, drop dead state (m6)

- strategies/_nn.py holds _mlp and _channel_stats; source and latent import
  them (no learned -> source import, no duplicated stats code).
- source: drop extra['S'] (never read); compute _usable once per build.
- latent: reuse ChannelStrategy._copies / _fill_support so support cannot
  drift from the base rules, and warn on unusable input channels and
  low-support targets like the other strategies.
- head.py: ponytail note on the lead-field cache.
ChannelStrategy.trainable is now read by the fresh-key warning (m3).
ResolvedMontage.canon is kept: it is a public, tested field.

* ENH positions: channel layer for PopT

* TST BENDR: from_pretrained test tolerates the map-quality warnings

* API channels: ChannelStrategy.apply -> project, so nn.Module.apply works

ChannelStrategy.apply(x, m) shadowed nn.Module.apply(fn): any model.apply(fn)
on a model with a non-native strategy raised a TypeError. Rename it to
project(x, m), drop the named_children workarounds in LaBraM, STEEGFormer and
MVPFormer (back to self.apply(self._init_weights) as on master), and test
model.apply(fn) on BENDR and the ids models under spline, source and latent.

* ENH channels: public ChannelTokenizer.n_outputs; models stop reading _src/_map

n_outputs(chs_info=None, *, n_chans=None) gives the number of channels the
layer hands to the backbone: the map size for a known montage, else the
target size (montage/slots, ids under a reconstructing strategy), the
strategy's fixed size on free targets (source parcels, latent latents), or
n_chans. backbone_n_chans() uses it, and replaces the three _n_channel_tokens
copies in EEGPT, SignalJEPA and MVPFormer.

* MAINT models: one private channel-layer glue module

Fold models/_channel_positions.py (positions models) into
models/_channel_layer.py, with a single JIT_IGNORED constant.

* ENH channels: kinds= option; intracranial models stop relabelling to eeg

resolve_montage and ChannelTokenizer take kinds=('eeg',) (a subset of
ELECTRODE_KINDS = eeg, seeg, ecog, dbs); ResolvedMontage records each
channel's kind, and non-EEG kinds enter the cache key. ChannelTarget
resolves its own training montage with every electrode kind. The source
strategy raises the declared ValueError on non-EEG kinds, per montage.

The positions glue passes kinds=ELECTRODE_KINDS instead of relabelling
sEEG/ECoG/DBS to eeg (as_electrodes and the per-call kind check are gone;
the model-kind check for BaRISTA/PopT stays). EEGModuleMixin gains
_channel_kinds, set to ELECTRODE_KINDS on MVPFormer, Brant and BrainBERT:
MVPFormer's sEEG compat geometries now run every strategy except source.

* TST compat: channel contract from _channel_target, strategy x geometry grid

test_pretrained_compat reads each model's interface from its
_channel_target (a 'channels' entry only for a model without one) and gains
test_channel_strategy_contract: 19 models x 11 strategies x G1-G4 (G3b
included), each cell forwards a finite output or raises a declared
ValueError; strategies beyond native/exact/source are marked slow. The
Labram NOT_YET strict-xfail cells now run with channel_strategy='spline'
(STRATEGY_CELLS) and pass. MIRepNet's G1/G4 use MIREPNET_CHANNEL_ORDER,
so exact and wiener serve its canonical montage.

* MAINT channels: export the strategy classes; mypy-clean buffers and n_outputs

* DOC channel layer: user guide, API, whats_new

docs/user_guide/channel_strategies.rst (interfaces, strategy x interface
table, how to choose, caveats), linked from the Documentation toctree; a
Channels section in docs/api.rst; whats_new: enhancements, API changes
(Interpolated* removal and migration, ChannelTokenizer signature, rigid
models' FutureWarning, BIOT bipolar input native-only) and bug fixes A-G
plus the biosemi Cz NaN.

* MAINT channels: four files, scipy and MNE helpers, one core test file

Collapse modules/channels from 11 files to resolve, strategies, source and
tokenizer. Use scipy cdist and linalg.solve, braindecode.util
resolve_montage_name, and an lru_cache keyed on exact positions for the
sphere lead fields (SphereHead class gone). Drop ResolvedMontage.canon, the
fill_positions switch and the low-support warning (fired on every sparse
montage); keep the row-gain warning. Replace the three module test files by
test_channels.py: regressions A-G, permutation, constant input, trainable
source at init, cache on load, device and dtype.

* MAINT models: channel-layer glue in the mixin; prune the model tests

_init_channel_tokenizer sets channel_tokenizer to None under native, forks
the RNG and takes an optional target and kinds, so models stop repeating
fork_rng, the _channel_layer flag and native special cases. The positions
glue builds on it; encode_positions (unreachable check) is gone. The
channel_strategy parameters are documented once in EEGModuleMixin and
inherited. Tests: drop the five test_channel_layer_* files, the
strategy x geometry grid and test_interpolation; keep native strict loading
for the 19 models and one smoke per interface x strategy.

* DOC channel layer: shorter user guide, API and whats_new

* MAINT channel layer: one file, one matrix, one pre-hook

modules/channels.py (300 lines) replaces the channels/ package and
models/_channel_layer.py: MNE resolves names, legacy names, types and
positions; every strategy is one matrix from _mixing(); ChannelLayer
applies it and returns (x, observed). The mixin takes channel_strategy,
builds the backbone on the model's target montage and runs the layer in a
forward pre-hook, so the 19 models only declare the parameters (and a
target where the checkpoint has a canonical montage). Native is unchanged.

Cut: wiener, region, latent, attention masking of reconstructed channels,
exact as a vocabulary subset, the native FutureWarning, strategy registry.

* ENH channel layer: re-add the wiener, region and latent strategies

wiener: LMMSE from a covariance fitted with ChannelLayer.fit (persistent buffers). region: mean within 1.5x the median input spacing. latent: POYO-style cross-attention from target positions over input positions and statistics; copies stay exact. Native is unchanged (19/19 bit-identical).

* PERF channel layer: maps stay on the device, LRU of montages

- forward in steady state: identity check on the montage, then the
  device-resident buffers (no key, no .to(), no host copy); latent mean
  over inputs without boolean indexing (no sync), variance via
  (x - mean)^2 (6x faster on CPU)
- per-montage maps in an LRU (16) of device tensors that follows .to();
  cleared on fit() and load
- _resolve: lower-case standard_1005 table instead of create_info +
  set_montage; the target is resolved once in __init__
- source: reuse the deterministic Berg-Scherg fit (cold build -0.5 s)

Outputs unchanged vs edbb2a8 (bit-identical except latent, <4e-7).

* FIX channel layer: standard positions in the head frame of raw.info

Name-only channels took standard_1005 positions in the montage's own (mri)
frame, while raw.set_montage gives users the head frame (fiducial
transform): the same electrode by name and from raw.info differed by
46-57 mm, so every geometric strategy mixed two frames when inputs carry
positions and targets are names, and wiener fitted on raw.info montages
missed name-only targets. The standard table now applies the montage's
native-to-head transform (identical to set_montage's loc, 0 m over all 343
channels), and the BENDR / LaBraM target tables use the same transform.
Native outputs on the canonical montage are unchanged.

* FIX channel layer: maps built under inference_mode can train

A montage first seen under torch.inference_mode() (e.g. a validation
batch) cached inference tensors, and the next training step on that
montage raised "Inference tensors cannot be saved for backward" (latent,
trainable source). The one build path now runs with inference mode off.

* FIX channel layer: copy measured A-B targets, no inverse when nothing is missing, latent stats in float32

* FIX channel strategy: keyword input and ch_names in the pre-hook, config saves the input montage, cropped predict calls the module

* FIX SignalJEPA channel strategy: target the 62 pre-training channels

* TST channel layer: regressions for the #1241 review

* FIX channel strategy: forward applies the layer; never feed the backbone only zeros silently

- Under a channel strategy, `model.forward` is an instance attribute bound to
  `EEGModuleMixin._channel_forward` (replaces the forward pre-hook), so
  `model(x)` and `model.forward(x)` both map x exactly once (`chs_info=` /
  LaBraM `ch_names=` per call, x positional or by keyword). Native models have
  no instance attribute: bit-identical, TorchScript unchanged.
  `get_output_shape` calls the class forward (the backbone on its target).
- ChannelLayer: a target missing from the input that has no position cannot be
  reconstructed: warn naming it (e.g. BENDR SCALE). If no target is observed and
  none is reconstructed, raise a ValueError instead of returning zeros.
- Tests: forward == call (layer runs once), deepcopy and pickle round trip,
  no-observed-target declared, SCALE warning.

* FIX source strategy: centre the template sphere on standard_1005

The head-frame fix put the electrodes about 15 mm in front of MNE's default
sphere centre (0, 0, 0.04). The template head is now the sphere fitted to the
dense standard_1005 in the head frame (r0 (-1.0, 14.8, 39.2) mm, R 98.8 mm).
The hard-coded Berg-Scherg parameters are unchanged: they depend only on the
relative radii and conductivities (re-derived, identical).

Source error on another head (19 10-20 targets from 8 inputs, 3 random
dipoles): realistic sample BEM 1.289 -> 1.031, 4-shell sphere 0.771 -> 0.698.
New test bounds it on another 4-shell head (0.759; the old sphere: 0.820).

Mark the pickle round-trip in test_channels.py as nosec (Codacy B403/B301):
it unpickles the model it has just pickled.

* ENH SleepFM: native-only channel contract (polysomnography grouped by modality)

* DOC channel strategies: docstrfmt
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

maintenance Bug fix / refactor / tests — not a new model

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants