* 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
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:Canonical forwards bit-identical (BENDR, SignalJEPA: max-abs 0.0); state_dict keys unchanged. Labram's names migration stays xfail (follow-up).