Skip to content

Commit eb8e59d

Browse files
Fix label leakage in BNCI2025_001, the montages gh-700 never shipped, and the NEMAR prefetch guard (#1161)
- BNCI2025_001: type the 7 kinematic channels (target position, hand velocity) as misc. read_raw_eeglab typed them eeg, so every paradigm fed the labels to the classifier as features. EOG named explicitly so the declared channel_types is true by construction. - Rodrigues2017, Cattan2019_PHMD and Cattan2019_VR: the montage gh-700 announced and never implemented. Fc5/Fc6 were the only two of their 16 names standard_1020 cannot resolve. - Cattan2019_PHMD interval [0, 1] -> [0, 60]; its own metadata records 60 s blocks. - NEMAR prefetch settles presence per subject, offline, instead of trusting any non-empty store. - moabb.analysis no longer imports plotting eagerly, so importing a dataset stops restyling the caller's global matplotlib rcParams. - The monthly download job pointed at a file that does not exist and collected nothing.
1 parent 24ee544 commit eb8e59d

13 files changed

Lines changed: 341 additions & 67 deletions

File tree

.github/workflows/download-test.yml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,5 +38,4 @@ jobs:
3838
- name: Run download tests
3939
run: |
4040
echo "Running tests"
41-
cd moabb/tests
42-
pytest -vv -s --tb=long --durations=0 --maxfail=5 --log-cli-level=INFO --dl-data download.py
41+
pytest -vv -s --tb=long --durations=0 --maxfail=5 --log-cli-level=INFO --dl-data moabb/tests/test_download.py

docs/source/whats_new.rst

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@ What's new
1818

1919
.. _current:
2020

21-
Version 1.7 (Source - GitHub)
22-
-------------------------------
21+
Version 1.6.1 (Source - GitHub)
22+
---------------------------------
2323

2424
Enhancements
2525
~~~~~~~~~~~~
@@ -35,7 +35,13 @@ Requirements
3535

3636
Bugs
3737
~~~~
38-
- None yet.
38+
- Point the monthly download job at ``moabb/tests/test_download.py``; it ran ``download.py``, a file that does not exist, so it collected nothing and has been failing every month while no ``@pytest.mark.download`` test executed anywhere in CI (by `Bruno Aristimunha`_).
39+
- Stop importing moabb from restyling the caller's matplotlib. ``moabb.analysis.plotting`` applies a seaborn theme to the global ``rcParams`` when it is imported, and ``moabb/analysis/__init__.py`` imported it eagerly -- so ``import moabb.datasets`` alone changed ``font.family``, ``axes.grid`` and ``axes.spines.*`` for every figure the caller drew afterwards, values matplotlib reads at axes creation and a caller therefore cannot undo. The path was indirect: :mod:`moabb.datasets.bids_interface` imports ``moabb.analysis.results`` for ``get_digest``, which runs that ``__init__``. The plotting imports are now deferred to the functions that use them and to a module-level ``__getattr__`` for the three re-exported plotting helpers, so every public name still resolves and an explicit ``import moabb.analysis.plotting`` still applies the theme, leaving MOABB's own figures unchanged (by `Bruno Aristimunha`_).
40+
- Type the seven non-EEG channels of :class:`moabb.datasets.BNCI2025_001` as ``misc``. ``read_raw_eeglab`` types anything it does not recognise as ``eeg``, so ``x``/``y``/``vx``/``vy``/``validity``/``targetPosX``/``targetPoxY`` -- the hand kinematics and the target position of the reaching task -- were picked as EEG by every paradigm and fed to classifiers as features, leaking the labels they encode. The declared ``n_channels`` (67) also disagreed with the dataset's own 60-electrode montage; it is now 71 with ``channel_types={"eeg": 60, "eog": 4, "misc": 7}``. Accuracies on this dataset will fall, which is the point (by `Bruno Aristimunha`_).
41+
- Give :class:`moabb.datasets.Rodrigues2017` the montage :gh:`700` announced but never shipped, the same omission as :class:`moabb.datasets.Cattan2019_PHMD` below: both share the 16-electrode setup, both spelled ``Fc5``/``Fc6`` -- the only two names ``standard_1020`` cannot resolve -- and neither loader called ``set_montage``. Its ``METADATA`` also declared ``standard_1010``, which is not a montage MNE can build (by `Bruno Aristimunha`_).
42+
- Give :class:`moabb.datasets.Cattan2019_PHMD` the montage :gh:`700` announced but never shipped: that PR fixed only the unit scaling, leaving the loader with no ``set_montage`` call at all. Its channel list also spelled the two frontal-central electrodes ``Fc5``/``Fc6``, the only two of its sixteen names that ``standard_1020`` cannot resolve; they are now ``FC5``/``FC6`` (by `Bruno Aristimunha`_).
43+
- Correct :class:`moabb.datasets.Cattan2019_PHMD` ``interval`` from ``[0, 1]`` to ``[0, 60]``. Each marker starts a one-minute relaxation block -- as the dataset's own ``block_duration_s=60.0`` records -- but ``SetRawAnnotations`` derives annotation durations from ``interval``, so every block was annotated as lasting one second. ``interval[0]`` is unchanged, so onsets do not move (by `Bruno Aristimunha`_).
44+
- Prefetch the NEMAR sourcedata store per subject rather than per dataset. The guard returned as soon as the store held anything, and the deposit's provenance manifest lands inside it, so a store holding only a manifest counted as complete. Presence is now settled per subject from the cached manifest, at no network cost: :func:`nemar.download` walks index, version, metadata and manifest before it consults ``trust_existing``, so a call with nothing to do is still four round-trips. Note that deposits whose manifest predates the ``subject`` field -- which is every deposit today -- are fetched as a whole tree, so for those the older whole-store rule was already right and is kept; the per-subject check matters once manifests record subjects, and today for a store left partial by an interrupted fetch (by `Bruno Aristimunha`_).
3945

4046
Code health
4147
~~~~~~~~~~~

moabb/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# flake8: noqa
2-
__version__ = "1.7.0dev0"
2+
__version__ = "1.6.1dev0"
33

44
from .benchmark import benchmark
55
from .utils import (

moabb/analysis/__init__.py

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
1+
import importlib
12
import logging
23
import os
34
import platform
45
from datetime import datetime
56

67
from mne.utils import _open_lock
78

8-
from moabb.analysis import plotting as plt
99
from moabb.analysis.chance_level import ( # noqa: F401
1010
adjusted_chance_level,
1111
chance_by_chance,
@@ -14,11 +14,6 @@
1414
compute_dataset_statistics,
1515
find_significant_differences,
1616
)
17-
from moabb.analysis.plotting import ( # noqa: F401
18-
codecarbon_plot,
19-
distribution_plot,
20-
emissions_summary,
21-
)
2217
from moabb.analysis.results import Results # noqa: F401
2318
from moabb.analysis.style import MOABB_PALETTE, apply_moabb_style # noqa: F401
2419
from moabb.analysis.timeline import ( # noqa: F401
@@ -73,6 +68,8 @@ def analyze(results, out_path, name="analysis", plot=False):
7368
else:
7469
analysis_path = os.path.join(out_path, name)
7570

71+
from moabb.analysis import plotting as plt
72+
7673
unique_ids = [plt._simplify_names(x) for x in results.pipeline.unique()]
7774
simplify = True
7875
if len(unique_ids) != len(set(unique_ids)):
@@ -97,3 +94,27 @@ def analyze(results, out_path, name="analysis", plot=False):
9794
fig.savefig(os.path.join(analysis_path, "scores.pdf"))
9895
fig = plt.summary_plot(P, T, simplify=simplify)
9996
fig.savefig(os.path.join(analysis_path, "ordering.pdf"))
97+
98+
99+
# ``plotting`` applies a seaborn theme to the global matplotlib rcParams at
100+
# import time. Re-exporting its names lazily keeps that off the path of anyone
101+
# who reaches this package for something else -- ``moabb.datasets`` imports
102+
# ``moabb.analysis.results`` for ``get_digest``, so an eager import here
103+
# restyled the figures of every caller who merely imported a dataset.
104+
_PLOTTING_EXPORTS = ("codecarbon_plot", "distribution_plot", "emissions_summary")
105+
106+
107+
def __getattr__(name):
108+
# ``plotting`` itself is included: the eager import used to bind it as an
109+
# attribute of this package, so ``moabb.analysis.plotting`` resolved without
110+
# the caller importing the submodule. Dropping that would be a break.
111+
if name == "plotting" or name in _PLOTTING_EXPORTS:
112+
# import_module, not ``from . import plotting``: the latter falls back to
113+
# getattr on this package, which re-enters __getattr__ forever.
114+
plotting = importlib.import_module("moabb.analysis.plotting")
115+
return plotting if name == "plotting" else getattr(plotting, name)
116+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
117+
118+
119+
def __dir__():
120+
return sorted(set(globals()) | set(_PLOTTING_EXPORTS) | {"plotting"})

moabb/datasets/alphawaves.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -91,16 +91,16 @@ class Rodrigues2017(BaseDataset):
9191
sampling_rate=512.0,
9292
n_channels=16,
9393
channel_types={"eeg": 16},
94-
montage="standard_1010",
94+
montage="standard_1020",
9595
hardware="g.tec g.USBamp",
9696
sensor_type="wet electrodes",
9797
reference="right earlobe",
9898
software="OpenViBE",
9999
filters="no digital filter",
100100
sensors=[
101101
"Cz",
102-
"Fc5",
103-
"Fc6",
102+
"FC5",
103+
"FC6",
104104
"Fp1",
105105
"Fp2",
106106
"Fz",
@@ -206,9 +206,9 @@ def _get_single_subject_data(self, subject):
206206
chnames = [
207207
"Fp1",
208208
"Fp2",
209-
"Fc5",
209+
"FC5",
210210
"Fz",
211-
"Fc6",
211+
"FC6",
212212
"T7",
213213
"Cz",
214214
"T8",
@@ -229,6 +229,7 @@ def _get_single_subject_data(self, subject):
229229
ch_names=chnames, sfreq=512, ch_types=chtypes, verbose=False
230230
)
231231
raw = mne.io.RawArray(data=X, info=info, verbose=False)
232+
raw.set_montage("standard_1020")
232233

233234
return {"0": {"0": raw}}
234235

moabb/datasets/base.py

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
active_sourcedata_store,
3535
nemar_dl,
3636
nemar_sourcedata_dl,
37+
nemar_sourcedata_is_local,
3738
nemar_store,
3839
)
3940
from moabb.datasets.preprocessing import FixedPipeline, SetRawAnnotations
@@ -1106,17 +1107,19 @@ def _prefetch_nemar_sourcedata(self, subjects, verbose=None):
11061107
``"upstream"`` skips NEMAR entirely, ``"nemar"`` treats a failure as
11071108
fatal rather than silently reaching the host the caller opted out
11081109
of, and ``"auto"`` warns per subject and leaves that subject to the
1109-
dataset's own downloader. A non-empty store is trusted as-is and
1110-
costs no network at all -- refresh or extend it with
1111-
:meth:`download` (``force_update=True`` to refetch).
1110+
dataset's own downloader. Subjects already in the store cost no
1111+
network at all: their presence is settled from the cached manifest,
1112+
without contacting NEMAR. Refresh one with :meth:`download`
1113+
(``force_update=True``).
11121114
"""
11131115
provider = get_download_provider()
11141116
if self.nemar_id is None or provider == "upstream":
11151117
return
1116-
store = self._sourcedata_store()
1117-
if store is not None and store.is_dir() and any(store.iterdir()):
1118-
return
1118+
# Resolved once: nemar_store() re-reads MNE's config file each call.
1119+
store_root = nemar_store(self.code, self.nemar_id)
11191120
for subject in subjects:
1121+
if nemar_sourcedata_is_local(store_root, self._nemar_subject_ids(subject)):
1122+
continue
11201123
try:
11211124
self.sourcedata_path(subject=subject, verbose=verbose)
11221125
except NemarDownloadError as exc:
@@ -1130,6 +1133,22 @@ def _prefetch_nemar_sourcedata(self, subjects, verbose=None):
11301133
stacklevel=2,
11311134
)
11321135

1136+
def _nemar_subject_ids(self, subject):
1137+
"""The identifiers a deposit may file this subject under.
1138+
1139+
Provenance manifests observed in the wild record raw MOABB ids, but a
1140+
dataset's ``nemar_subject_template`` documents how its deposit labels
1141+
subjects -- match both, exactly as :meth:`sourcedata_path` does.
1142+
"""
1143+
label = self._nemar_subject(subject)
1144+
# Return the scalar untouched unless the deposit really files this
1145+
# subject under a second label: a None label would stringify to "None"
1146+
# and collide with manifest entries that carry no subject at all, and
1147+
# wrapping a lone id in a list would change what downstream callers see.
1148+
if label is None or str(label) == str(subject):
1149+
return subject
1150+
return [subject, label]
1151+
11331152
def _sourcedata_store(self):
11341153
"""Local NEMAR sourcedata store to serve this dataset's loads from.
11351154
@@ -1184,9 +1203,7 @@ def sourcedata_path(self, subject=None, path=None, force_update=False, verbose=N
11841203
# a dataset's nemar_subject_template documents how its deposit labels
11851204
# subjects -- match both rather than betting on one convention.
11861205
if subject is not None:
1187-
label = self._nemar_subject(subject)
1188-
if label is not None and str(label) != str(subject):
1189-
subject = [subject, label]
1206+
subject = self._nemar_subject_ids(subject)
11901207
return nemar_sourcedata_dl(
11911208
self.nemar_id,
11921209
self.code,

moabb/datasets/bnci/bnci_2025.py

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,25 @@
4545
# Base URL for the BNCI 2025-001 dataset (hosted at TU Graz)
4646
BNCI_2025_001_URL = "https://lampx.tugraz.at/~bci/database/001-2025/"
4747

48+
# Channels the EEGLAB ``.set`` files carry alongside the EEG montage: hand
49+
# kinematics from the reaching task, the target position, and a validity flag.
50+
# ``read_raw_eeglab`` types anything it cannot recognise as ``eeg``, which would
51+
# feed the target position and hand velocity -- i.e. the labels -- to any
52+
# paradigm picking ``eeg``.
53+
_EOG_CHANNELS_001_2025 = ("EOGL1", "EOGL2", "EOGL3", "EOGR1")
54+
55+
_NON_EEG_CHANNELS_001_2025 = (
56+
"x",
57+
"y",
58+
"vx",
59+
"vy",
60+
"validity",
61+
"targetPosX",
62+
"targetPoxY", # sic: the published files misspell targetPosY
63+
"targetPosY", # ...accept the corrected spelling too
64+
)
65+
66+
4867
# Event code mapping for 001-2025 dataset
4968
# Format: XYZ where X=speed (1=slow, 2=fast), Y=distance (1=near, 2=far), Z=direction (1-4)
5069
# Direction codes: 1=up, 2=down, 3=left, 4=right
@@ -156,7 +175,18 @@ def _load_data_001_2025(
156175
return [str(set_file)]
157176

158177
# Load the EEGLAB file
159-
raw = mne.io.read_raw_eeglab(str(set_file), preload=True, verbose=verbose)
178+
raw = mne.io.read_raw_eeglab(
179+
str(set_file), eog=_EOG_CHANNELS_001_2025, preload=True, verbose=verbose
180+
)
181+
182+
# Type the non-EEG channels before the montage, so they are neither placed
183+
# on the scalp nor picked as EEG.
184+
# set_channel_types raises on a name that is not in info, so filter first.
185+
raw.set_channel_types(
186+
{ch: "misc" for ch in _NON_EEG_CHANNELS_001_2025 if ch in raw.ch_names},
187+
on_unit_change="ignore",
188+
verbose=False,
189+
)
160190

161191
# Remap annotation descriptions from numeric codes to descriptive names
162192
# The data contains codes like "111", "112", etc. which we map to
@@ -266,9 +296,9 @@ class BNCI2025_001(BNCIBaseDataset):
266296
METADATA = DatasetMetadata(
267297
acquisition=AcquisitionMetadata(
268298
sampling_rate=500.0,
269-
n_channels=67,
270-
channel_types={"eeg": 67, "eog": 4},
271-
montage="af7 af3 afz af4 af8 f7 f5 f3 f1 fz f2 f4 f6 f8 ft7 fc5 fc3 fc1 fcz fc2 fc4 fc6 ft8 t7 c5 c3 c1 cz c2 c4 c6 t8 tp7 cp5 cp3 cp1 cpz cp2 cp4 cp6 tp8 p7 p5 p3 p1 pz p2 p4 p6 p8 ppo1h ppo2h po7 po3 poz po4 po8 o1 oz o2",
299+
n_channels=71,
300+
channel_types={"eeg": 60, "eog": 4, "misc": 7},
301+
montage="standard_1005",
272302
sensor_type="EEG",
273303
hardware="BrainAmp",
274304
reference="common average",
@@ -1047,7 +1077,7 @@ class BNCI2025_002(BNCIBaseDataset):
10471077
sampling_rate=200.0,
10481078
n_channels=60,
10491079
channel_types={"eeg": 60, "eog": 4},
1050-
montage="af7 af3 afz af4 af8 f7 f5 f3 f1 fz f2 f4 f6 f8 ft7 fc5 fc3 fc1 fcz fc2 fc4 fc6 ft8 t7 c5 c3 c1 cz c2 c4 c6 t8 tp7 cp5 cp3 cp1 cpz cp2 cp4 cp6 tp8 p7 p5 p3 p1 pz p2 p4 p6 p8 ppo1h ppo2h po7 po3 poz po4 po8 o1 oz o2",
1080+
montage="standard_1005",
10511081
sensor_type="EEG",
10521082
hardware="actiCAP, Brain Products GmbH",
10531083
reference="right mastoid",

moabb/datasets/braininvaders.py

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -182,9 +182,9 @@ def _bi_get_subject_data(ds, subject): # noqa: C901
182182
chnames = [
183183
"Fp1",
184184
"Fp2",
185-
"Fc5",
185+
"FC5",
186186
"Fz",
187-
"Fc6",
187+
"FC6",
188188
"T7",
189189
"Cz",
190190
"T8",
@@ -209,10 +209,13 @@ def _bi_get_subject_data(ds, subject): # noqa: C901
209209
info = mne.create_info(
210210
ch_names=chnames, sfreq=sfreq, ch_types=chtypes, verbose=False
211211
)
212+
# Set it here rather than on each Raw: the Cattan2019-VR branch below
213+
# builds many Raws from this Info, and used to leave them with no
214+
# channel positions at all.
215+
info.set_montage(make_standard_montage("standard_1020"), on_missing="ignore")
212216

213217
if not ds.code == "Cattan2019-VR":
214218
raw = mne.io.RawArray(data=X, info=info, verbose=False)
215-
raw.set_montage(make_standard_montage("standard_1020"))
216219

217220
if ds.code == "BrainInvaders2012":
218221
# get rid of the Fz channel (it is the ground)
@@ -941,7 +944,7 @@ class BI2014a(BaseDataset):
941944
sampling_rate=512.0,
942945
n_channels=16,
943946
channel_types={"eeg": 16},
944-
montage="standard_1010",
947+
montage="standard_1020",
945948
hardware="g.USBamp (g.tec, Schiedlberg, Austria)",
946949
sensor_type="dry electrodes",
947950
reference="right earlobe",
@@ -1143,7 +1146,7 @@ class BI2014b(BaseDataset):
11431146
sampling_rate=512.0,
11441147
n_channels=32,
11451148
channel_types={"eeg": 32},
1146-
montage="standard_1010",
1149+
montage="standard_1020",
11471150
hardware="g.USBamp (g.tec, Schiedlberg, Austria)",
11481151
sensor_type="wet electrodes",
11491152
reference="right earlobe",
@@ -1356,7 +1359,7 @@ class BI2015a(BaseDataset):
13561359
sampling_rate=512.0,
13571360
n_channels=32,
13581361
channel_types={"eeg": 32},
1359-
montage="10-10",
1362+
montage="standard_1020",
13601363
hardware="g.USBamp (g.tec, Schiedlberg, Austria)",
13611364
sensor_type="wet electrodes",
13621365
reference="right earlobe",
@@ -1557,7 +1560,7 @@ class BI2015b(BaseDataset):
15571560
sampling_rate=512.0,
15581561
n_channels=32,
15591562
channel_types={"eeg": 32},
1560-
montage="10-10",
1563+
montage="standard_1020",
15611564
hardware="g.USBamp (g.tec, Schiedlberg, Austria)",
15621565
sensor_type="wet Silver/Silver Chloride electrodes",
15631566
reference="right earlobe",
@@ -1769,7 +1772,7 @@ class Cattan2019_VR(BaseDataset):
17691772
sampling_rate=512.0,
17701773
n_channels=16,
17711774
channel_types={"eeg": 16},
1772-
montage="10-10",
1775+
montage="standard_1020",
17731776
hardware="g.USBamp (g.tec, Schiedlberg, Austria)",
17741777
sensor_type="wet electrodes",
17751778
reference="right earlobe",
@@ -1778,9 +1781,9 @@ class Cattan2019_VR(BaseDataset):
17781781
sensors=[
17791782
"Fp1",
17801783
"Fp2",
1781-
"Fc5",
1784+
"FC5",
17821785
"Fz",
1783-
"Fc6",
1786+
"FC6",
17841787
"T7",
17851788
"Cz",
17861789
"T8",

0 commit comments

Comments
 (0)