Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ jobs:
.venv/bin/python -c 'import numpy, scipy, sklearn; print(numpy.__version__, scipy.__version__, sklearn.__version__)'
- name: Verify bciciv-2a preprocessing contract
run: .venv/bin/python -m unittest tasks/bciciv-2a/checks/test_preprocessing_contract.py
- name: Verify sleep-edf preprocessing contract
run: .venv/bin/python -m unittest tasks/sleep-edf/checks/test_preprocessing_contract.py

validate:
strategy:
Expand Down
9 changes: 5 additions & 4 deletions tasks/sleep-edf/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,8 @@ mirror for cross-subject LOSO training) is off by default; opt in with

Design a PyTorch `nn.Module` **SleepAgentModel** that classifies a
`(B, 1, 3000)` tensor of preprocessed Fpz-Cz sleep EEG (30 s epoch at
100 Hz) into 5 sleep stages (Wake / N1 / N2 / N3 / REM). The submitted
file goes to:
100 Hz, microvolts) into 5 sleep stages (Wake / N1 / N2 / N3 / REM).
The submitted file goes to:

- `EEG_sleep/sleep_agent_model.py` in the agent's workspace.

Expand Down Expand Up @@ -92,8 +92,9 @@ subject 16-19 EDF or hypnogram — those recordings come from
- Loads all recordings for subjects 0-13 / 14-15 / 16-19, applying the
frozen preprocessing (pick `EEG Fpz-Cz`, 0.3-35 Hz bandpass, resample
to 100 Hz, head/tail Wake crop to 30 min each, 30 s non-overlapping
epoching, `Sleep stage 3` and `Sleep stage 4` merged into label 3,
`Movement time` / `Sleep stage ?` dropped).
epoching, scale MNE volts to microvolts, `Sleep stage 3` and
`Sleep stage 4` merged into label 3, `Movement time` / `Sleep stage ?`
dropped).
- Trains once with Adam lr=1e-3, batch 64, class-weighted CrossEntropyLoss,
up to 20 epochs, early-stops on val loss (patience 5).
- Predicts every 30 s epoch on subjects 16-19; writes
Expand Down
6 changes: 3 additions & 3 deletions tasks/sleep-edf/checks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,9 @@ Otherwise it calls `evaluate_external.py $PWD`, which:
- Loads all recordings for subjects 0-13 (train), 14-15 (val), and
16-19 (test), applying the frozen preprocessing (pick `EEG Fpz-Cz`,
0.3-35 Hz bandpass, resample to 100 Hz, head/tail Wake crop to
30 min each, 30 s non-overlapping epoching, `Sleep stage 3` and
`Sleep stage 4` merged into label 3, `Movement time` / `Sleep stage ?`
dropped).
30 min each, 30 s non-overlapping epoching, scale MNE volts to
microvolts, `Sleep stage 3` and `Sleep stage 4` merged into label 3,
`Movement time` / `Sleep stage ?` dropped).
- Trains a fresh `SleepAgentModel()` with Adam lr=1e-3, batch 64,
class-weighted CrossEntropyLoss (inverse-frequency weights over
the train pool), up to 20 epochs, early-stops on val loss with
Expand Down
81 changes: 81 additions & 0 deletions tasks/sleep-edf/checks/test_preprocessing_contract.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
#!/usr/bin/env python3
"""Unit tests for the sleep-edf scorer's frozen preprocessing contract."""

from __future__ import annotations

import importlib.util
import sys
import types
import unittest
from pathlib import Path

import numpy as np


def _load_runner_module():
"""Import contract helpers without requiring the evaluator's torch/MNE stack."""
mne = types.ModuleType("mne")
mne.set_log_level = lambda _level: None
mne.io = types.SimpleNamespace()

torch = types.ModuleType("torch")
torch_nn = types.ModuleType("torch.nn")
torch.nn = torch_nn

sys.modules["mne"] = mne
sys.modules["torch"] = torch
sys.modules["torch.nn"] = torch_nn

path = Path(__file__).with_name("train_and_infer.py")
spec = importlib.util.spec_from_file_location("sleep_edf_train_and_infer", path)
module = importlib.util.module_from_spec(spec)
assert spec.loader is not None
spec.loader.exec_module(module)
return module


RUNNER = _load_runner_module()


class PreprocessingContractTest(unittest.TestCase):
def test_label_mapping_merges_stage3_and_stage4_into_n3(self):
mapping = RUNNER.LABEL_MAPPING
self.assertEqual(
mapping,
{
"Sleep stage W": 0,
"Sleep stage 1": 1,
"Sleep stage 2": 2,
"Sleep stage 3": 3,
"Sleep stage 4": 3,
"Sleep stage R": 4,
},
)
# The five model logits map directly to labels 0..4 in W/N1/N2/N3/REM order.
self.assertEqual(sorted(set(mapping.values())), [0, 1, 2, 3, 4])

def test_mne_volts_are_scaled_to_float32_microvolts(self):
volts = np.asarray([1.0e-6, -22.9e-6], dtype=np.float64)

microvolts = RUNNER._to_microvolts(volts)

self.assertEqual(microvolts.dtype, np.float32)
np.testing.assert_allclose(microvolts, [1.0, -22.9], rtol=1e-6)

def test_model_interface_is_30s_at_100hz(self):
self.assertEqual(RUNNER.WINDOW_S, 30.0)
self.assertEqual(RUNNER.SFREQ_TARGET, 100.0)
self.assertEqual(RUNNER.N_TIMES, 3000)
self.assertEqual(RUNNER.N_CLASSES, 5)
self.assertEqual(RUNNER.EEG_CHANNEL, "EEG Fpz-Cz")
self.assertEqual(RUNNER.CROP_WAKE_MINS, 30)

def test_training_hyperparameters_match_prompt(self):
self.assertEqual(RUNNER.LR, 1e-3)
self.assertEqual(RUNNER.BATCH_SIZE, 64)
self.assertEqual(RUNNER.MAX_EPOCHS, 20)
self.assertEqual(RUNNER.EARLY_STOP_PATIENCE, 5)


if __name__ == "__main__":
unittest.main()
17 changes: 13 additions & 4 deletions tasks/sleep-edf/checks/train_and_infer.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@
2. For every subject in --train-subjects and --val-subjects, loads both
PSG + Hypnogram recordings, does the frozen preprocessing (pick
`EEG Fpz-Cz`, 0.3-35 Hz bandpass, resample to 100 Hz, crop head/tail
Wake to 30 min each, cut into non-overlapping 30 s epochs) → per-epoch
features (1, 3000) plus labels 0..4 from the hypnogram annotations.
Wake to 30 min each, cut into non-overlapping 30 s epochs, scale
MNE volts to microvolts) → per-epoch features (1, 3000) plus labels
0..4 from the hypnogram annotations.
3. Concatenates train epochs across subjects 0-13 → one big pool with a
class-weighted CrossEntropyLoss. Trains a fresh SleepAgentModel with
Adam lr=1e-3, batch 64, up to 20 epochs, early-stops on val loss
Expand Down Expand Up @@ -51,6 +52,7 @@
N_TIMES = int(round(WINDOW_S * SFREQ_TARGET)) # 3000
EEG_CHANNEL = "EEG Fpz-Cz"
CROP_WAKE_MINS = 30
MICROVOLTS_PER_VOLT = 1e6

# Hypnogram annotation → BPB label mapping.
LABEL_MAPPING = {
Expand All @@ -71,12 +73,19 @@
SEED = 42


def _to_microvolts(data: np.ndarray) -> np.ndarray:
"""Convert MNE's SI-unit EEG arrays to microvolts (the sleep-staging
community convention — MOABB/braindecode expose µV-scale arrays)."""
return (np.asarray(data) * MICROVOLTS_PER_VOLT).astype(np.float32)


def load_subject_recording(psg_path: Path, hyp_path: Path) -> tuple[np.ndarray, np.ndarray]:
"""Return (X: (N,1,3000), y: (N,) labels 0..4) for one PSG + Hypnogram pair.

Applies: single-channel pick, 0.3-35 Hz filter, resample to 100 Hz,
head/tail Wake crop (30 min each), 30 s non-overlapping epoching,
label mapping. `Movement time` / `Sleep stage ?` epochs are dropped.
label mapping, V→µV scaling. `Movement time` / `Sleep stage ?` epochs
are dropped.
"""
if not psg_path.exists():
raise RuntimeError(f"missing PSG file: {psg_path}")
Expand Down Expand Up @@ -120,7 +129,7 @@ def load_subject_recording(psg_path: Path, hyp_path: Path) -> tuple[np.ndarray,
tmin=0.0, tmax=WINDOW_S - 1.0 / SFREQ_TARGET,
baseline=None, preload=True, proj=False, verbose="ERROR",
)
X = epochs.get_data().astype(np.float32)
X = _to_microvolts(epochs.get_data())
# inv: event_id integer → BPB label
id_to_desc = {v: k for k, v in event_id.items()}
y = np.asarray(
Expand Down
Loading
Loading