Skip to content

Commit 19eeab4

Browse files
1 parent 637f4c1 commit 19eeab4

173 files changed

Lines changed: 4012 additions & 920 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
{
2+
"cells": [
3+
{
4+
"cell_type": "markdown",
5+
"metadata": {},
6+
"source": [
7+
"\n# Training on recordings with different channels (heterogeneous montages)\n\nEEG datasets often do not share the same channels: recordings come from\ndifferent caps, montages, or vendors. Stacking such recordings into a single\ntraining batch is normally impossible -- the signal tensors have different\nnumbers of channels and cannot be concatenated.\n\nThis example shows braindecode's tools for training a **position-aware** model\nacross recordings with different channel sets:\n\n* :meth:`~braindecode.datasets.BaseConcatDataset.set_return_ch_pos` makes every\n window carry its electrode positions ``(n_ch, 3)`` (x, y, z), so channel\n identity is encoded by *where* each electrode sits rather than by its row\n index.\n* :func:`~braindecode.datasets.pad_channels_collate` pads each batch to the\n largest channel count present and returns a boolean ``ch_mask`` marking the\n real (non-padded) channels.\n\nA model that consumes positions (and ignores padded channels via the mask) can\nthen train on the mixed collection. Here we use a tiny illustrative model;\nswap in any position-aware architecture (e.g. ``REVE``).\n :depth: 2\n"
8+
]
9+
},
10+
{
11+
"cell_type": "code",
12+
"execution_count": null,
13+
"metadata": {
14+
"collapsed": false
15+
},
16+
"outputs": [],
17+
"source": [
18+
"# Authors: The braindecode developers\n#\n# License: BSD (3-clause)\n\nimport mne\nimport numpy as np\nimport torch\nfrom torch import nn\nfrom torch.utils.data import DataLoader\n\nfrom braindecode import EEGClassifier\nfrom braindecode.datasets import (\n BaseConcatDataset,\n RawDataset,\n pad_channels_collate,\n)\nfrom braindecode.preprocessing import create_fixed_length_windows\n\nmne.set_log_level(\"ERROR\")"
19+
]
20+
},
21+
{
22+
"cell_type": "markdown",
23+
"metadata": {},
24+
"source": [
25+
"## Build two recordings with different channel sets\n\nWe synthesise two short recordings: one with 8 channels and one with 10, both\nusing ``standard_1020`` electrode positions. In practice these would be real\ndatasets loaded with different montages.\n\n"
26+
]
27+
},
28+
{
29+
"cell_type": "code",
30+
"execution_count": null,
31+
"metadata": {
32+
"collapsed": false
33+
},
34+
"outputs": [],
35+
"source": [
36+
"def make_recording(ch_names, label, seed):\n info = mne.create_info(ch_names, sfreq=100.0, ch_types=\"eeg\")\n data = np.random.RandomState(seed).randn(len(ch_names), 4000) * 1e-6\n raw = mne.io.RawArray(data, info)\n raw.set_montage(\"standard_1020\")\n return RawDataset(raw, description={\"label\": label})\n\n\nchs_a = [\"Fp1\", \"Fp2\", \"F3\", \"F4\", \"C3\", \"C4\", \"P3\", \"P4\"] # 8 channels\nchs_b = [\"Fz\", \"Cz\", \"Pz\", \"Oz\", \"T7\", \"T8\", \"O1\", \"O2\", \"F7\", \"F8\"] # 10 channels\n\nconcat = BaseConcatDataset(\n [\n make_recording(chs_a, label=0, seed=1),\n make_recording(chs_b, label=1, seed=2),\n make_recording(chs_a, label=0, seed=3),\n make_recording(chs_b, label=1, seed=4),\n ]\n)"
37+
]
38+
},
39+
{
40+
"cell_type": "markdown",
41+
"metadata": {},
42+
"source": [
43+
"## Window the recordings and enable channel positions\n\nAfter cutting fixed-length windows we assign the per-recording ``label`` as\nthe target and turn on position returning for the whole collection.\n\n"
44+
]
45+
},
46+
{
47+
"cell_type": "code",
48+
"execution_count": null,
49+
"metadata": {
50+
"collapsed": false
51+
},
52+
"outputs": [],
53+
"source": [
54+
"windows = create_fixed_length_windows(\n concat,\n window_size_samples=200,\n window_stride_samples=200,\n drop_last_window=True,\n preload=True,\n)\nwindows.set_target(\"label\")\nwindows.set_return_ch_pos(True)"
55+
]
56+
},
57+
{
58+
"cell_type": "markdown",
59+
"metadata": {},
60+
"source": [
61+
"## Inspect a heterogeneous batch\n\n``pad_channels_collate`` pads each batch to the largest channel count (10\nhere) and returns a boolean ``ch_mask``. Because we mix 8- and 10-channel\nrecordings, batches contain both -- the mask tells real channels from padding.\n\n"
62+
]
63+
},
64+
{
65+
"cell_type": "code",
66+
"execution_count": null,
67+
"metadata": {
68+
"collapsed": false
69+
},
70+
"outputs": [],
71+
"source": [
72+
"loader = DataLoader(\n windows, batch_size=4, shuffle=True, collate_fn=pad_channels_collate\n)\nX, y, crop_inds, ch_pos, ch_mask = next(iter(loader))\nprint(\"X :\", tuple(X.shape)) # (batch, max_ch, n_times)\nprint(\"ch_pos :\", tuple(ch_pos.shape)) # (batch, max_ch, 3)\nprint(\"ch_mask :\", tuple(ch_mask.shape)) # (batch, max_ch) bool\nprint(\"real channels per sample:\", ch_mask.sum(1).tolist())"
73+
]
74+
},
75+
{
76+
"cell_type": "markdown",
77+
"metadata": {},
78+
"source": [
79+
"## A minimal position-aware model\n\nThis tiny model is permutation-invariant over channels: it embeds each\nchannel from a small signal summary plus its (x, y, z) position, then\n**masked-mean-pools** over channels so padded channels are ignored. Any model\nthat accepts ``forward(x, pos=None, ch_mask=None)`` works the same way -- this\nis the signature braindecode routes positions and the mask into.\n\n"
80+
]
81+
},
82+
{
83+
"cell_type": "code",
84+
"execution_count": null,
85+
"metadata": {
86+
"collapsed": false
87+
},
88+
"outputs": [],
89+
"source": [
90+
"class TinyPositionalNet(nn.Module):\n def __init__(self, n_outputs=2, dim=32):\n super().__init__()\n self.embed = nn.Sequential(nn.Linear(6, dim), nn.ReLU(), nn.Linear(dim, dim))\n self.head = nn.Linear(dim, n_outputs)\n\n def forward(self, x, pos=None, ch_mask=None):\n # Per-channel signal summary: mean, std, max over time -> (B, C, 3).\n feat = torch.stack([x.mean(-1), x.std(-1), x.amax(-1)], dim=-1)\n if pos is None:\n pos = torch.zeros_like(feat)\n h = self.embed(torch.cat([feat, pos], dim=-1)) # (B, C, dim)\n if ch_mask is not None:\n m = ch_mask.unsqueeze(-1).float()\n h = (h * m).sum(1) / m.sum(1).clamp(min=1) # masked mean over channels\n else:\n h = h.mean(1)\n return self.head(h)"
91+
]
92+
},
93+
{
94+
"cell_type": "markdown",
95+
"metadata": {},
96+
"source": [
97+
"## Train with EEGClassifier\n\nWe pass ``pad_channels_collate`` as the iterator's ``collate_fn``. braindecode\nroutes the signal, positions and mask into the model's ``forward`` for you.\n\n"
98+
]
99+
},
100+
{
101+
"cell_type": "code",
102+
"execution_count": null,
103+
"metadata": {
104+
"collapsed": false
105+
},
106+
"outputs": [],
107+
"source": [
108+
"clf = EEGClassifier(\n TinyPositionalNet(n_outputs=2),\n max_epochs=3,\n batch_size=4,\n train_split=None,\n classes=[0, 1],\n iterator_train__collate_fn=pad_channels_collate,\n iterator_train__shuffle=True,\n iterator_train__drop_last=False,\n)\nclf.fit(windows, y=None)"
109+
]
110+
},
111+
{
112+
"cell_type": "markdown",
113+
"metadata": {},
114+
"source": [
115+
"## Next steps\n\nThe data layer here makes a heterogeneous collection **batchable** and feeds\npositions plus a channel mask to the model. How a model *normalizes* and\nconsumes variable channels (e.g. applying ``ch_mask`` inside attention, or\nmapping electrode positions to a canonical space) is model-specific and the\nnatural next step when adapting a real architecture such as ``REVE``.\n\n"
116+
]
117+
}
118+
],
119+
"metadata": {
120+
"kernelspec": {
121+
"display_name": "Python 3",
122+
"language": "python",
123+
"name": "python3"
124+
},
125+
"language_info": {
126+
"codemirror_mode": {
127+
"name": "ipython",
128+
"version": 3
129+
},
130+
"file_extension": ".py",
131+
"mimetype": "text/x-python",
132+
"name": "python",
133+
"nbconvert_exporter": "python",
134+
"pygments_lexer": "ipython3",
135+
"version": "3.12.13"
136+
}
137+
},
138+
"nbformat": 4,
139+
"nbformat_minor": 0
140+
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

0 commit comments

Comments
 (0)