From fa7a25e8ed296a0cc65cf47a385695d986b5e5c2 Mon Sep 17 00:00:00 2001 From: Vihaan Agrawal <247244351+vihaan101@users.noreply.github.com> Date: Tue, 7 Jul 2026 20:22:33 +0530 Subject: [PATCH 01/21] feat: add EEGBCI helper functions --- .../implementation_plan.md | 1682 +++++++++++++++++ pyhealth/tasks/eegbci.py | 230 +++ tests/core/test_eegbci.py | 117 ++ 3 files changed, 2029 insertions(+) create mode 100644 docs/eeg_pattern_discovery/implementation_plan.md create mode 100644 pyhealth/tasks/eegbci.py create mode 100644 tests/core/test_eegbci.py diff --git a/docs/eeg_pattern_discovery/implementation_plan.md b/docs/eeg_pattern_discovery/implementation_plan.md new file mode 100644 index 000000000..7f2d777a7 --- /dev/null +++ b/docs/eeg_pattern_discovery/implementation_plan.md @@ -0,0 +1,1682 @@ +# EEG Pattern Discovery Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +Status: ENGINEERING REVIEWED, ready for implementation +Date: 2026-07-07 +Source docs: +- `docs/eeg_pattern_discovery/brainstorm.md` +- `docs/eeg_pattern_discovery/design.md` + +**Goal:** Add a first-class EEGBCI dataset and two EEGBCI tasks to PyHealth so real PhysioNet motor movement/imagery windows can be used for supervised classification and CELM-style exploratory pattern discovery. + +**High-Level Summary:** We are turning the standalone CELM EEG pattern-discovery pipeline into a reusable PyHealth dataset, task, and example for real PhysioNet EEGBCI motor movement/imagery data. The research question is whether simple frequency profiles in short EEG windows can surface moment-level brain-state hypotheses that task labels alone miss. The practical problem is that PyHealth has EEG models and task infrastructure, but no first-class EEGBCI path that produces labeled windows, bandpower features, cautious interpretation metadata, and real-data validation without forcing normal CI to download raw EDF files. + +**Architecture:** `EEGBCIDataset` builds one metadata row per subject/run EDF, following the existing `TUABDataset` and `TUEVDataset` CSV pattern. `EEGMotorImageryEEGBCI` reads EDF annotations and emits fixed windows for task-label prediction; `EEGBCIPatternDiscovery` extends the same windows with Welch bandpower features and cautious interpretation metadata. Offline unit tests mock MNE and EDF reads; an opt-in smoke test downloads one real EEGBCI run. + +**Tech Stack:** PyHealth `BaseDataset` and `BaseTask`, MNE EEGBCI loader, NumPy, SciPy Welch PSD, pandas, torch, pytest/unittest, Sphinx RST docs. + +## Global Constraints + +- Do not claim brain-state hypotheses are clinical diagnoses. +- Decode EEGBCI `T1` and `T2` using run number. +- Normal CI must not download PhysioNet data. +- Keep raw EEG downloads outside the repo. +- Preserve compatibility with PyHealth models by making channel and sampling strategy explicit. +- Use real EEGBCI data in examples and opt-in smoke tests. +- Default pattern-discovery windows are 2 seconds. +- Default model-facing channel mode is a stable 16-channel subset; `channel_mode="all"` keeps all EEG channels. +- Default resampling is `200 Hz`; `resample_rate=None` keeps the EDF sample rate. + +--- + +## File Structure + +Create: + +- `pyhealth/datasets/eegbci.py`: EEGBCI metadata dataset. Calls `mne.datasets.eegbci.load_data` only when `download=True`, otherwise discovers existing `SxxxRxx.edf` files under `root`. +- `pyhealth/datasets/configs/eegbci.yaml`: one-table metadata config for `eegbci-pyhealth.csv`. +- `pyhealth/tasks/eegbci.py`: run-aware labels, channel selection, annotation windowing, bandpower extraction, interpretation rules, and task classes. +- `tests/core/test_eegbci.py`: offline unit tests plus skipped-by-default real-data smoke test. +- `examples/eeg/eegbci/README.md`: runnable example instructions, output schema, caveats. +- `examples/eeg/eegbci/eegbci_pattern_discovery.py`: PyHealth example that writes CELM-equivalent CSV and Markdown summary. +- `docs/api/datasets/pyhealth.datasets.EEGBCIDataset.rst`: dataset API page. +- `docs/api/tasks/pyhealth.tasks.eegbci.rst`: EEGBCI task API page. + +Modify: + +- `pyhealth/datasets/__init__.py`: export `EEGBCIDataset`. +- `pyhealth/tasks/__init__.py`: export `EEGMotorImageryEEGBCI` and `EEGBCIPatternDiscovery`. +- `docs/api/datasets.rst`: include the EEGBCI dataset page. +- `docs/api/tasks.rst`: include the EEGBCI task page. + +Defer: + +- `examples/eeg/eegbci/eegbci_embedding_comparison.py`: useful later, but out of scope for the first implementation. The first pass should ship dataset, tasks, tests, docs, and the CELM-equivalent example. + +Resolved decisions: + +- First implementation scope: dataset, tasks, tests, docs, and CELM-equivalent example only. +- Default channel mode: 16-channel compatibility, with `channel_mode="all"` available for 64-channel experiments. +- Dependency model: use existing project-level `mne~=1.10.0`; do not add an optional EEG extra in this pass. +- Real-data validation: no committed `.npz` fixture in the first pass; use mocked offline tests plus the opt-in real-data smoke test. +- Model boundary: the first pattern-discovery pipeline uses signal processing and deterministic interpretation, not a neural model. PyHealth model training and pretrained embeddings are enabled by the task outputs but deferred from the first deliverable. +- Analysis stage: the first analysis stage is the CELM-equivalent CSV and Markdown report produced by `examples/eeg/eegbci/eegbci_pattern_discovery.py`. + +## Recommended Execution Path + +Start from a feature branch, not `main`: + +```bash +git checkout -b codex/eegbci-pattern-discovery +``` + +If the implementation agent uses an isolated worktree, create or switch to that branch inside the worktree before editing. Keep each task boundary commit on this branch so review and rollback stay clean. + +Use one main implementation session to own the feature end to end. Do not split concurrent coding across multiple sub-agents because `pyhealth/tasks/eegbci.py` and `tests/core/test_eegbci.py` are shared by most tasks, and parallel edits would create interface drift. + +Execute the plan sequentially: + +1. Task 1: pure EEGBCI helpers and tests. +2. Task 2: `EEGBCIDataset`, metadata config, dataset export, and tests. +3. Task 3: EEGBCI task classes, annotation windowing, sample schema, task export, and tests. +4. Task 4: skipped-by-default real-data smoke test. +5. Task 5: CELM-equivalent analysis example. +6. Task 6: API docs and import smoke test. +7. Final verification: run the default test command, import smoke, optional real-data smoke test, optional example command, and `graphify update .`. + +Use sub-agents only for bounded review or investigation after a section is implemented. Good sub-agent assignments: + +- Review `pyhealth/tasks/eegbci.py` for label/window/channel contract drift. +- Inspect existing PyHealth docs/export patterns before Task 6. +- Investigate real EEGBCI channel names if the opt-in smoke test fails. +- Review the example output schema against the correctness oracle. + +Do not give different sections to independent sessions unless each session starts from the previous section's committed result. If separate sessions are used, hand off after Tasks 1, 2, 3, 4, and 5 only, with tests passing and commits made at each boundary. + +## Data Flow + +1. User instantiates `EEGBCIDataset(root, subjects, runs, download)`. +2. Dataset writes or reuses `/eegbci-pyhealth.csv` with one row per EDF run. +3. `BaseDataset` loads rows into patient events from `pyhealth/datasets/configs/eegbci.yaml`. +4. User calls `dataset.set_task(EEGMotorImageryEEGBCI(...))` or `dataset.set_task(EEGBCIPatternDiscovery(...))`. +5. Task reads each `signal_file` with MNE, picks EEG channels, filters, optionally resamples, decodes annotations, and slices full fixed-length windows. +6. Motor-imagery task returns supervised samples with `label`. +7. Pattern-discovery task returns the same samples plus `bandpower`, `brain_state_hypothesis`, `confidence`, `quality_flags`, and `interpretation`. +8. Example script converts samples to CSV rows and writes a Markdown summary grouped by task label and hypothesis. + +## Correctness Oracle + +An implementation is complete only when these checks pass: + +- `task_label_for_event(3, "T1") == "execute_left_fist"` and `task_label_for_event(4, "T1") == "imagine_left_fist"`, proving run-aware EEGBCI decoding. +- Synthetic 10 Hz input produces `dominant_band == "alpha"` and high `alpha_relative`, proving bandpower extraction works. +- `EEGBCIDataset.prepare_metadata()` writes one row per requested subject/run with `patient_id`, `record_id`, `subject_id`, `run`, `run_type`, `signal_file`, and `source`. +- `EEGMotorImageryEEGBCI(compute_stft=False)` returns fixed-shape `signal` tensors, decoded `task_label`, numeric `label`, `trial_id`, timing fields, and `sample_rate`. +- `EEGBCIPatternDiscovery(compute_stft=False)` returns every supervised field plus `bandpower`, `brain_state_hypothesis`, `confidence`, `quality_flags`, and `interpretation`. +- Default `pytest tests/core/test_eegbci.py -v` passes without network access and skips the real-data smoke test. +- `PYHEALTH_RUN_REAL_EEGBCI=1 pytest tests/core/test_eegbci.py::TestEEGBCIRealDataSmoke -v` passes against subject `1`, run `3`. +- The example command writes `eegbci_pattern_windows.csv` and `eegbci_pattern_summary.md`, with at least one row containing task label, dominant band, hypothesis, confidence, and non-clinical interpretation text. + +## Analysis Stage Contract + +The first analysis stage lives in `examples/eeg/eegbci/eegbci_pattern_discovery.py`. It is not just a demo script. It is the artifact generator that proves the pipeline can answer the research question on real windows. + +Inputs: + +- `EEGBCIDataset` +- `EEGBCIPatternDiscovery` +- CLI flags: `--root`, `--subjects`, `--runs`, `--output-dir`, `--max-windows`, `--download` + +Outputs: + +- `eegbci_pattern_windows.csv`: one row per window, including subject/run metadata, event code, decoded task label, bandpower values, relative powers, dominant band, ratios, hypothesis, confidence, quality flags, and interpretation. +- `eegbci_pattern_summary.md`: aggregate counts by task label and brain-state hypothesis, plus the non-clinical caveat. + +Question answered: + +- Do the frequency-profile hypotheses agree with, sharpen, or flag possible disagreement with the experimental EEGBCI labels? + +Deferred analysis: + +- Neural embedding comparison. +- Clustering or atlas generation. +- TFM token motif reports. +- Subject-shift reliability reports. + +## Shared Interfaces + +These names are fixed across tasks: + +```python +EEGBCI_RUN_TYPES: dict[int, str] +EEGBCI_COMPAT_CHANNELS: tuple[str, ...] +EEGBCI_LABELS: dict[str, int] + +def normalize_eegbci_channel_name(name: str) -> str: ... +def run_type_for_run(run: int) -> str: ... +def label_family_for_run(run: int) -> str: ... +def task_label_for_event(run: int, event_code: str) -> str: ... +def numeric_label_for_task(task_label: str) -> int: ... +def select_eegbci_channels(data: np.ndarray, ch_names: list[str], channel_mode: str = "compat16") -> tuple[np.ndarray, list[str]]: ... +def iter_annotation_windows(raw: mne.io.BaseRaw, run: int, window_size: float = 2.0) -> list[dict[str, Any]]: ... +def compute_band_powers(data: np.ndarray, sfreq: float) -> dict[str, float | str]: ... +def interpret_band_profile(features: dict[str, float | str]) -> dict[str, str]: ... +def normalize_signal(signal: np.ndarray, mode: str | None) -> np.ndarray: ... +``` + +Task classes: + +```python +class EEGMotorImageryEEGBCI(BaseTask): + task_name = "EEGBCI_motor_imagery" + input_schema = {"signal": "tensor", "stft": "tensor"} + output_schema = {"label": "multiclass"} + +class EEGBCIPatternDiscovery(EEGMotorImageryEEGBCI): + task_name = "EEGBCI_pattern_discovery" +``` + +## Task 1: Pure EEGBCI Helpers + +**Files:** + +- Create: `pyhealth/tasks/eegbci.py` +- Create: `tests/core/test_eegbci.py` + +**Interfaces:** + +- Consumes: NumPy and SciPy signal processing. +- Produces: `run_type_for_run`, `label_family_for_run`, `task_label_for_event`, `numeric_label_for_task`, `select_eegbci_channels`, `compute_band_powers`, `interpret_band_profile`, `normalize_signal`. + +- [x] **Step 1: Write failing tests for run-aware labels** + +Add these tests to `tests/core/test_eegbci.py`: + +```python +import unittest + +from pyhealth.tasks.eegbci import ( + label_family_for_run, + numeric_label_for_task, + run_type_for_run, + task_label_for_event, +) + + +class TestEEGBCIHelpers(unittest.TestCase): + def test_run_type_for_run(self): + self.assertEqual(run_type_for_run(3), "motor_execution_left_right") + self.assertEqual(run_type_for_run(4), "motor_imagery_left_right") + self.assertEqual(run_type_for_run(5), "motor_execution_fists_feet") + self.assertEqual(run_type_for_run(6), "motor_imagery_fists_feet") + self.assertEqual(run_type_for_run(14), "motor_imagery_fists_feet") + + def test_task_label_for_event_is_run_aware(self): + self.assertEqual(task_label_for_event(3, "T0"), "rest") + self.assertEqual(task_label_for_event(3, "T1"), "execute_left_fist") + self.assertEqual(task_label_for_event(3, "T2"), "execute_right_fist") + self.assertEqual(task_label_for_event(4, "T1"), "imagine_left_fist") + self.assertEqual(task_label_for_event(4, "T2"), "imagine_right_fist") + self.assertEqual(task_label_for_event(5, "T1"), "execute_both_fists") + self.assertEqual(task_label_for_event(5, "T2"), "execute_both_feet") + self.assertEqual(task_label_for_event(6, "T1"), "imagine_both_fists") + self.assertEqual(task_label_for_event(6, "T2"), "imagine_both_feet") + + def test_label_family_and_numeric_labels(self): + self.assertEqual(label_family_for_run(3), "motor_execution") + self.assertEqual(label_family_for_run(4), "motor_imagery") + self.assertEqual(numeric_label_for_task("rest"), 0) + self.assertEqual(numeric_label_for_task("execute_left_fist"), 1) + self.assertEqual(numeric_label_for_task("imagine_both_feet"), 8) + + def test_invalid_run_and_event_raise_clear_errors(self): + with self.assertRaisesRegex(ValueError, "Unsupported EEGBCI run"): + run_type_for_run(2) + with self.assertRaisesRegex(ValueError, "Unsupported EEGBCI event"): + task_label_for_event(3, "BAD") +``` + +- [x] **Step 2: Run tests to verify import failure** + +Run: + +```bash +pytest tests/core/test_eegbci.py::TestEEGBCIHelpers -v +``` + +Expected: FAIL with `ModuleNotFoundError: No module named 'pyhealth.tasks.eegbci'`. + +- [x] **Step 3: Implement label helpers** + +Add to `pyhealth/tasks/eegbci.py`: + +```python +from __future__ import annotations + +from typing import Any, Dict, List, Tuple + +import numpy as np + + +EEGBCI_RUN_TYPES = { + 3: "motor_execution_left_right", + 4: "motor_imagery_left_right", + 5: "motor_execution_fists_feet", + 6: "motor_imagery_fists_feet", + 7: "motor_execution_left_right", + 8: "motor_imagery_left_right", + 9: "motor_execution_fists_feet", + 10: "motor_imagery_fists_feet", + 11: "motor_execution_left_right", + 12: "motor_imagery_left_right", + 13: "motor_execution_fists_feet", + 14: "motor_imagery_fists_feet", +} + +EEGBCI_LABELS = { + "rest": 0, + "execute_left_fist": 1, + "execute_right_fist": 2, + "imagine_left_fist": 3, + "imagine_right_fist": 4, + "execute_both_fists": 5, + "execute_both_feet": 6, + "imagine_both_fists": 7, + "imagine_both_feet": 8, +} + + +def run_type_for_run(run: int) -> str: + try: + return EEGBCI_RUN_TYPES[int(run)] + except KeyError as exc: + raise ValueError(f"Unsupported EEGBCI run: {run}") from exc + + +def label_family_for_run(run: int) -> str: + run_type = run_type_for_run(run) + if "execution" in run_type: + return "motor_execution" + if "imagery" in run_type: + return "motor_imagery" + return "baseline" + + +def task_label_for_event(run: int, event_code: str) -> str: + code = str(event_code).strip() + if code == "T0": + return "rest" + run_type = run_type_for_run(run) + mapping = { + "motor_execution_left_right": { + "T1": "execute_left_fist", + "T2": "execute_right_fist", + }, + "motor_imagery_left_right": { + "T1": "imagine_left_fist", + "T2": "imagine_right_fist", + }, + "motor_execution_fists_feet": { + "T1": "execute_both_fists", + "T2": "execute_both_feet", + }, + "motor_imagery_fists_feet": { + "T1": "imagine_both_fists", + "T2": "imagine_both_feet", + }, + } + try: + return mapping[run_type][code] + except KeyError as exc: + raise ValueError(f"Unsupported EEGBCI event {event_code!r} for run {run}") from exc + + +def numeric_label_for_task(task_label: str) -> int: + try: + return EEGBCI_LABELS[task_label] + except KeyError as exc: + raise ValueError(f"Unsupported EEGBCI task label: {task_label}") from exc +``` + +- [x] **Step 4: Run label tests** + +Run: + +```bash +pytest tests/core/test_eegbci.py::TestEEGBCIHelpers -v +``` + +Expected: PASS for the four label tests. + +- [x] **Step 5: Add failing tests for channel selection and normalization** + +Append to `TestEEGBCIHelpers`: + +```python + def test_select_eegbci_channels_compat16(self): + from pyhealth.tasks.eegbci import EEGBCI_COMPAT_CHANNELS, select_eegbci_channels + + ch_names = list(EEGBCI_COMPAT_CHANNELS) + ["EXTRA"] + data = np.arange(len(ch_names) * 100, dtype=float).reshape(len(ch_names), 100) + selected, selected_names = select_eegbci_channels(data, ch_names, "compat16") + self.assertEqual(selected.shape, (16, 100)) + self.assertEqual(selected_names, list(EEGBCI_COMPAT_CHANNELS)) + np.testing.assert_allclose(selected[0], data[0]) + + def test_select_eegbci_channels_all(self): + from pyhealth.tasks.eegbci import select_eegbci_channels + + data = np.ones((64, 50)) + ch_names = [f"CH{i}" for i in range(64)] + selected, selected_names = select_eegbci_channels(data, ch_names, "all") + self.assertEqual(selected.shape, (64, 50)) + self.assertEqual(selected_names, ch_names) + + def test_select_eegbci_channels_missing_channel_raises(self): + from pyhealth.tasks.eegbci import select_eegbci_channels + + with self.assertRaisesRegex(ValueError, "Missing EEGBCI channels"): + select_eegbci_channels(np.ones((2, 20)), ["C3", "C4"], "compat16") + + def test_normalize_signal_95th_percentile(self): + from pyhealth.tasks.eegbci import normalize_signal + + signal = np.array([[0.0, 1.0, 2.0, 100.0], [0.0, -2.0, 2.0, 4.0]]) + normalized = normalize_signal(signal, "95th_percentile") + self.assertEqual(normalized.shape, signal.shape) + self.assertLess(np.max(np.abs(normalized[0])), 2.0) +``` + +- [x] **Step 6: Implement channel and normalization helpers** + +Add below the label helpers: + +```python +EEGBCI_COMPAT_CHANNELS = ( + "FC5", + "FC3", + "FC1", + "FC2", + "FC4", + "FC6", + "C5", + "C3", + "C1", + "C2", + "C4", + "C6", + "CP5", + "CP3", + "CP4", + "CP6", +) + + +def normalize_eegbci_channel_name(name: str) -> str: + clean = name.upper().replace(".", "").replace("EEG ", "").replace("-REF", "") + aliases = { + "T9": "FT9", + "T10": "FT10", + } + return aliases.get(clean, clean) + + +def select_eegbci_channels( + data: np.ndarray, + ch_names: List[str], + channel_mode: str = "compat16", +) -> Tuple[np.ndarray, List[str]]: + if channel_mode == "all": + return data, list(ch_names) + if channel_mode != "compat16": + raise ValueError("channel_mode must be one of {'compat16', 'all'}") + + normalized_to_index = { + normalize_eegbci_channel_name(name): idx for idx, name in enumerate(ch_names) + } + missing = [ch for ch in EEGBCI_COMPAT_CHANNELS if ch not in normalized_to_index] + if missing: + raise ValueError(f"Missing EEGBCI channels for compat16 mode: {missing}") + indices = [normalized_to_index[ch] for ch in EEGBCI_COMPAT_CHANNELS] + return data[indices], list(EEGBCI_COMPAT_CHANNELS) + + +def normalize_signal(signal: np.ndarray, mode: str | None) -> np.ndarray: + if mode is None: + return signal + if mode == "95th_percentile": + scale = np.quantile( + np.abs(signal), q=0.95, axis=-1, method="linear", keepdims=True + ) + return signal / (scale + 1e-8) + if mode == "div_by_100": + return signal / 100.0 + raise ValueError("normalization must be one of {None, '95th_percentile', 'div_by_100'}") +``` + +- [x] **Step 7: Run helper tests** + +Run: + +```bash +pytest tests/core/test_eegbci.py::TestEEGBCIHelpers -v +``` + +Expected: PASS. + +- [x] **Step 8: Add failing tests for bandpower and interpretation** + +Append to `TestEEGBCIHelpers`: + +```python + def test_compute_band_powers_detects_alpha_sinusoid(self): + from pyhealth.tasks.eegbci import compute_band_powers + + sfreq = 200.0 + times = np.arange(0, 2, 1 / sfreq) + alpha = np.sin(2 * np.pi * 10 * times) + data = np.stack([alpha, alpha]) + features = compute_band_powers(data, sfreq) + self.assertEqual(features["dominant_band"], "alpha") + self.assertGreater(features["alpha_relative"], 0.5) + self.assertGreater(features["alpha_beta_ratio"], 1.0) + + def test_compute_band_powers_detects_beta_sinusoid(self): + from pyhealth.tasks.eegbci import compute_band_powers + + sfreq = 200.0 + times = np.arange(0, 2, 1 / sfreq) + beta = np.sin(2 * np.pi * 20 * times) + data = np.stack([beta, beta]) + features = compute_band_powers(data, sfreq) + self.assertEqual(features["dominant_band"], "beta") + self.assertGreater(features["beta_relative"], 0.5) + + def test_interpret_band_profile_returns_cautious_metadata(self): + from pyhealth.tasks.eegbci import interpret_band_profile + + interpretation = interpret_band_profile( + { + "dominant_band": "alpha", + "alpha_relative": 0.65, + "beta_relative": 0.10, + "theta_relative": 0.10, + "gamma_relative": 0.05, + "alpha_beta_ratio": 6.5, + "theta_beta_ratio": 1.0, + } + ) + self.assertEqual(interpretation["brain_state_hypothesis"], "relaxed_or_idle") + self.assertIn(interpretation["confidence"], {"low", "medium", "high"}) + self.assertIn("consistent with", interpretation["interpretation"]) +``` + +- [x] **Step 9: Implement bandpower and interpretation helpers** + +Add below the channel helpers: + +```python +BANDS = { + "delta": (0.5, 4.0), + "theta": (4.0, 8.0), + "alpha": (8.0, 13.0), + "beta": (13.0, 30.0), + "gamma": (30.0, 45.0), +} + + +def compute_band_powers(data: np.ndarray, sfreq: float) -> Dict[str, float | str]: + from scipy.signal import welch + + if data.ndim != 2: + raise ValueError("data must have shape (channels, time)") + nperseg = min(data.shape[-1], int(sfreq * 2)) + freqs, psd = welch(data, fs=sfreq, nperseg=nperseg, axis=-1) + mean_psd = psd.mean(axis=0) + + features: Dict[str, float | str] = {} + total_power = 0.0 + band_values: Dict[str, float] = {} + for band, (low, high) in BANDS.items(): + mask = (freqs >= low) & (freqs < high) + value = float(np.trapz(mean_psd[mask], freqs[mask])) if np.any(mask) else 0.0 + features[f"{band}_power"] = value + band_values[band] = value + total_power += value + + denom = total_power + 1e-12 + for band, value in band_values.items(): + features[f"{band}_relative"] = float(value / denom) + + features["dominant_band"] = max(band_values, key=band_values.get) + features["alpha_beta_ratio"] = float( + band_values["alpha"] / (band_values["beta"] + 1e-12) + ) + features["theta_beta_ratio"] = float( + band_values["theta"] / (band_values["beta"] + 1e-12) + ) + return features + + +def interpret_band_profile(features: Dict[str, float | str]) -> Dict[str, str]: + dominant = str(features["dominant_band"]) + alpha_rel = float(features.get("alpha_relative", 0.0)) + beta_rel = float(features.get("beta_relative", 0.0)) + theta_rel = float(features.get("theta_relative", 0.0)) + gamma_rel = float(features.get("gamma_relative", 0.0)) + alpha_beta = float(features.get("alpha_beta_ratio", 0.0)) + theta_beta = float(features.get("theta_beta_ratio", 0.0)) + + quality_flags: List[str] = [] + hypothesis = "mixed_frequency_profile" + confidence = "low" + + if dominant == "alpha" and alpha_rel >= 0.45 and alpha_beta >= 2.0: + hypothesis = "relaxed_or_idle" + confidence = "medium" + elif dominant == "beta" and beta_rel >= 0.35: + hypothesis = "active_sensorimotor_processing" + confidence = "medium" + elif dominant == "theta" and theta_rel >= 0.35 and theta_beta >= 1.5: + hypothesis = "slow_wave_or_drowsy_pattern" + confidence = "medium" + elif dominant == "gamma" and gamma_rel >= 0.30: + hypothesis = "high_frequency_or_artifact_pattern" + confidence = "low" + quality_flags.append("possible_muscle_artifact") + + if confidence == "low": + quality_flags.append("low_confidence") + + return { + "brain_state_hypothesis": hypothesis, + "confidence": confidence, + "quality_flags": ";".join(quality_flags) if quality_flags else "none", + "interpretation": ( + f"The segment is consistent with {hypothesis} based on a " + f"{dominant}-dominant frequency profile. This is exploratory signal " + "metadata, not evidence of cognition or a clinical diagnosis." + ), + } +``` + +- [x] **Step 10: Run helper tests** + +Run: + +```bash +pytest tests/core/test_eegbci.py::TestEEGBCIHelpers -v +``` + +Expected: PASS. + +- [x] **Step 11: Commit task 1** + +Run: + +```bash +git add pyhealth/tasks/eegbci.py tests/core/test_eegbci.py +git commit -m "feat: add EEGBCI helper functions" +``` + +## Task 2: EEGBCI Dataset + +**Files:** + +- Create: `pyhealth/datasets/eegbci.py` +- Create: `pyhealth/datasets/configs/eegbci.yaml` +- Modify: `pyhealth/datasets/__init__.py` +- Modify: `tests/core/test_eegbci.py` + +**Interfaces:** + +- Consumes: helper `run_type_for_run` from task 1, MNE EEGBCI loader when `download=True`. +- Produces: `EEGBCIDataset(root, dataset_name=None, config_path=None, subjects=None, runs=None, download=False, **kwargs)`. +- Produces metadata file: `/eegbci-pyhealth.csv`. + +- [ ] **Step 1: Add failing dataset metadata tests** + +Append to `tests/core/test_eegbci.py`: + +```python +import tempfile +from pathlib import Path +from unittest.mock import patch + +import pandas as pd + +from pyhealth.datasets.eegbci import EEGBCIDataset + + +class TestEEGBCIDataset(unittest.TestCase): + def test_prepare_metadata_with_existing_files(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + edf = root / "files" / "eegmmidb" / "1.0.0" / "S001" / "S001R03.edf" + edf.parent.mkdir(parents=True) + edf.write_bytes(b"") + + ds = EEGBCIDataset.__new__(EEGBCIDataset) + ds.root = str(root) + ds.subjects = [1] + ds.runs = [3] + ds.download = False + ds.prepare_metadata() + + csv_path = root / "eegbci-pyhealth.csv" + self.assertTrue(csv_path.exists()) + df = pd.read_csv(csv_path) + self.assertEqual(len(df), 1) + self.assertEqual(df.loc[0, "patient_id"], "S001") + self.assertEqual(df.loc[0, "record_id"], "R03") + self.assertEqual(df.loc[0, "subject_id"], 1) + self.assertEqual(df.loc[0, "run"], 3) + self.assertEqual(df.loc[0, "run_type"], "motor_execution_left_right") + self.assertEqual(df.loc[0, "source"], "physionet_eegbci") + + def test_prepare_metadata_download_uses_mne_loader(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + fake_path = root / "S001R04.edf" + fake_path.write_bytes(b"") + ds = EEGBCIDataset.__new__(EEGBCIDataset) + ds.root = str(root) + ds.subjects = [1] + ds.runs = [4] + ds.download = True + + with patch( + "pyhealth.datasets.eegbci.mne.datasets.eegbci.load_data", + return_value=[str(fake_path)], + ) as load_data: + ds.prepare_metadata() + + load_data.assert_called_once_with(1, [4], path=str(root)) + df = pd.read_csv(root / "eegbci-pyhealth.csv") + self.assertEqual(df.loc[0, "record_id"], "R04") + self.assertEqual(df.loc[0, "run_type"], "motor_imagery_left_right") + + def test_prepare_metadata_missing_local_file_raises(self): + with tempfile.TemporaryDirectory() as tmp: + ds = EEGBCIDataset.__new__(EEGBCIDataset) + ds.root = tmp + ds.subjects = [1] + ds.runs = [3] + ds.download = False + with self.assertRaisesRegex(FileNotFoundError, "download=True"): + ds.prepare_metadata() + + def test_default_task_returns_pattern_discovery(self): + from pyhealth.tasks.eegbci import EEGBCIPatternDiscovery + + ds = EEGBCIDataset.__new__(EEGBCIDataset) + self.assertIsInstance(ds.default_task, EEGBCIPatternDiscovery) +``` + +- [ ] **Step 2: Run dataset tests to verify failure** + +Run: + +```bash +pytest tests/core/test_eegbci.py::TestEEGBCIDataset -v +``` + +Expected: FAIL with `ModuleNotFoundError: No module named 'pyhealth.datasets.eegbci'`. + +- [ ] **Step 3: Implement `EEGBCIDataset`** + +Create `pyhealth/datasets/eegbci.py`: + +```python +from __future__ import annotations + +import logging +from pathlib import Path +from typing import Optional + +import mne +import pandas as pd + +from .base_dataset import BaseDataset +from pyhealth.tasks.eegbci import EEGBCIPatternDiscovery, run_type_for_run + +logger = logging.getLogger(__name__) + + +class EEGBCIDataset(BaseDataset): + """PhysioNet EEG Motor Movement/Imagery metadata dataset.""" + + def __init__( + self, + root: str, + dataset_name: Optional[str] = None, + config_path: Optional[str] = None, + subjects: Optional[list[int]] = None, + runs: Optional[list[int]] = None, + download: bool = False, + **kwargs, + ) -> None: + if config_path is None: + config_path = Path(__file__).parent / "configs" / "eegbci.yaml" + self.root = root + self.subjects = subjects or [1, 2, 3] + self.runs = runs or list(range(3, 15)) + self.download = download + self.prepare_metadata() + super().__init__( + root=root, + tables=["records"], + dataset_name=dataset_name or "eegbci", + config_path=config_path, + **kwargs, + ) + + def _find_local_edf(self, subject: int, run: int) -> Path | None: + root = Path(self.root) + pattern = f"S{subject:03d}R{run:02d}.edf" + matches = sorted(root.rglob(pattern)) + return matches[0] if matches else None + + def prepare_metadata(self) -> None: + root = Path(self.root) + csv_path = root / "eegbci-pyhealth.csv" + if csv_path.exists(): + return + + rows: list[dict] = [] + for subject in self.subjects: + paths_by_run: dict[int, Path] = {} + if self.download: + downloaded = mne.datasets.eegbci.load_data( + subject, self.runs, path=str(root) + ) + for path in downloaded: + p = Path(path) + for run in self.runs: + if p.name == f"S{subject:03d}R{run:02d}.edf": + paths_by_run[run] = p + for run in self.runs: + signal_file = paths_by_run.get(run) or self._find_local_edf(subject, run) + if signal_file is None: + raise FileNotFoundError( + f"Missing EEGBCI EDF for subject {subject}, run {run}. " + "Pass download=True to fetch it with MNE." + ) + rows.append( + { + "patient_id": f"S{subject:03d}", + "record_id": f"R{run:02d}", + "subject_id": int(subject), + "run": int(run), + "run_type": run_type_for_run(run), + "signal_file": str(signal_file), + "source": "physionet_eegbci", + } + ) + + df = pd.DataFrame(rows) + df.sort_values(["subject_id", "run"], inplace=True) + df.reset_index(drop=True, inplace=True) + csv_path.parent.mkdir(parents=True, exist_ok=True) + df.to_csv(csv_path, index=False) + logger.info("Wrote EEGBCI metadata to %s", csv_path) + + @property + def default_task(self) -> EEGBCIPatternDiscovery: + return EEGBCIPatternDiscovery() +``` + +- [ ] **Step 4: Add dataset config** + +Create `pyhealth/datasets/configs/eegbci.yaml`: + +```yaml +version: "1.0.0" +tables: + records: + file_path: "eegbci-pyhealth.csv" + patient_id: "patient_id" + timestamp: null + attributes: + - "record_id" + - "subject_id" + - "run" + - "run_type" + - "signal_file" + - "source" +``` + +- [ ] **Step 5: Export dataset** + +Modify `pyhealth/datasets/__init__.py`: + +```python +from pyhealth.datasets.eegbci import EEGBCIDataset +``` + +Place the import beside other EEG dataset exports. + +- [ ] **Step 6: Run dataset tests** + +Run: + +```bash +pytest tests/core/test_eegbci.py::TestEEGBCIDataset -v +``` + +Expected: PASS. + +- [ ] **Step 7: Commit task 2** + +Run: + +```bash +git add pyhealth/datasets/eegbci.py pyhealth/datasets/configs/eegbci.yaml pyhealth/datasets/__init__.py tests/core/test_eegbci.py +git commit -m "feat: add EEGBCI dataset" +``` + +## Task 3: EEGBCI Task Classes + +**Files:** + +- Modify: `pyhealth/tasks/eegbci.py` +- Modify: `pyhealth/tasks/__init__.py` +- Modify: `tests/core/test_eegbci.py` + +**Interfaces:** + +- Consumes: dataset metadata events with `signal_file`, `run`, `record_id`, `run_type`. +- Produces: `EEGMotorImageryEEGBCI` and `EEGBCIPatternDiscovery` samples. + +- [ ] **Step 1: Add task schema tests** + +Append to `tests/core/test_eegbci.py`: + +```python +from dataclasses import dataclass +from typing import List + +import numpy as np + +from pyhealth.tasks.eegbci import EEGBCIPatternDiscovery, EEGMotorImageryEEGBCI + + +@dataclass +class _EEGBCIEvent: + signal_file: str + record_id: str = "R03" + subject_id: int = 1 + run: int = 3 + run_type: str = "motor_execution_left_right" + source: str = "physionet_eegbci" + + +class _EEGBCIPatient: + def __init__(self, patient_id: str, events: List[_EEGBCIEvent]): + self.patient_id = patient_id + self._events = events + + def get_events(self, event_type=None) -> List[_EEGBCIEvent]: + if event_type not in (None, "records"): + return [] + return self._events + + +class TestEEGBCITasks(unittest.TestCase): + def test_task_schema_attributes(self): + task = EEGMotorImageryEEGBCI() + self.assertEqual(task.task_name, "EEGBCI_motor_imagery") + self.assertEqual(task.input_schema, {"signal": "tensor", "stft": "tensor"}) + self.assertEqual(task.output_schema, {"label": "multiclass"}) + + def test_task_schema_without_stft(self): + task = EEGMotorImageryEEGBCI(compute_stft=False) + self.assertEqual(task.input_schema, {"signal": "tensor"}) + + def test_pattern_discovery_schema_attributes(self): + task = EEGBCIPatternDiscovery(compute_stft=False) + self.assertEqual(task.task_name, "EEGBCI_pattern_discovery") + self.assertEqual(task.input_schema, {"signal": "tensor"}) +``` + +- [ ] **Step 2: Run schema tests to verify failure** + +Run: + +```bash +pytest tests/core/test_eegbci.py::TestEEGBCITasks -v +``` + +Expected: FAIL because classes do not exist. + +- [ ] **Step 3: Implement task constructors** + +Add to `pyhealth/tasks/eegbci.py`: + +```python +import torch +import mne + +from pyhealth.tasks import BaseTask + + +class EEGMotorImageryEEGBCI(BaseTask): + task_name: str = "EEGBCI_motor_imagery" + input_schema: Dict[str, str] = {"signal": "tensor", "stft": "tensor"} + output_schema: Dict[str, str] = {"label": "multiclass"} + + def __init__( + self, + window_size: float = 2.0, + resample_rate: float | None = 200, + bandpass_filter: Tuple[float, float] | None = (0.5, 45.0), + channel_mode: str = "compat16", + normalization: str | None = "95th_percentile", + compute_stft: bool = True, + ) -> None: + super().__init__() + self.window_size = window_size + self.resample_rate = resample_rate + self.bandpass_filter = bandpass_filter + self.channel_mode = channel_mode + self.normalization = normalization + self.compute_stft = compute_stft + if not compute_stft: + self.input_schema = {"signal": "tensor"} + + +class EEGBCIPatternDiscovery(EEGMotorImageryEEGBCI): + task_name: str = "EEGBCI_pattern_discovery" +``` + +- [ ] **Step 4: Run schema tests** + +Run: + +```bash +pytest tests/core/test_eegbci.py::TestEEGBCITasks -v +``` + +Expected: PASS for schema tests. + +- [ ] **Step 5: Add failing annotation window tests** + +Append to `TestEEGBCITasks`: + +```python + def test_iter_annotation_windows_uses_full_2s_windows(self): + import mne + from pyhealth.tasks.eegbci import iter_annotation_windows + + sfreq = 200.0 + raw = mne.io.RawArray( + np.zeros((2, int(sfreq * 6))), + mne.create_info(["C3", "C4"], sfreq=sfreq, ch_types=["eeg", "eeg"]), + verbose="error", + ) + raw.set_annotations( + mne.Annotations(onset=[0.5, 2.0], duration=[1.0, 3.0], description=["T0", "T1"]) + ) + windows = iter_annotation_windows(raw, run=3, window_size=2.0) + self.assertEqual(len(windows), 1) + self.assertEqual(windows[0]["event_code"], "T1") + self.assertEqual(windows[0]["task_label"], "execute_left_fist") + self.assertEqual(windows[0]["start_sample"], 400) + self.assertEqual(windows[0]["end_sample"], 800) +``` + +- [ ] **Step 6: Implement annotation windowing** + +Add before the task classes: + +```python +def iter_annotation_windows( + raw: mne.io.BaseRaw, + run: int, + window_size: float = 2.0, +) -> List[Dict[str, Any]]: + sfreq = float(raw.info["sfreq"]) + window_samples = int(round(window_size * sfreq)) + windows: List[Dict[str, Any]] = [] + for idx, annotation in enumerate(raw.annotations): + event_code = str(annotation["description"]) + if event_code not in {"T0", "T1", "T2"}: + continue + start_sample = int(round(float(annotation["onset"]) * sfreq)) + duration_samples = int(round(float(annotation["duration"]) * sfreq)) + n_full_windows = duration_samples // window_samples + for window_idx in range(n_full_windows): + s0 = start_sample + window_idx * window_samples + s1 = s0 + window_samples + windows.append( + { + "trial_id": f"ann{idx:04d}_win{window_idx:03d}", + "event_code": event_code, + "task_label": task_label_for_event(run, event_code), + "label_family": label_family_for_run(run), + "label": numeric_label_for_task(task_label_for_event(run, event_code)), + "start_time": s0 / sfreq, + "end_time": s1 / sfreq, + "start_sample": s0, + "end_sample": s1, + } + ) + return windows +``` + +- [ ] **Step 7: Run annotation tests** + +Run: + +```bash +pytest tests/core/test_eegbci.py::TestEEGBCITasks::test_iter_annotation_windows_uses_full_2s_windows -v +``` + +Expected: PASS. + +- [ ] **Step 8: Add failing sample-generation tests** + +Append to `TestEEGBCITasks`: + +```python + def test_motor_imagery_task_returns_samples_from_raw(self): + import mne + + sfreq = 200.0 + raw = mne.io.RawArray( + np.ones((16, int(sfreq * 5))), + mne.create_info( + list(__import__("pyhealth.tasks.eegbci", fromlist=["EEGBCI_COMPAT_CHANNELS"]).EEGBCI_COMPAT_CHANNELS), + sfreq=sfreq, + ch_types=["eeg"] * 16, + ), + verbose="error", + ) + raw.set_annotations(mne.Annotations(onset=[0.0], duration=[2.0], description=["T1"])) + patient = _EEGBCIPatient("S001", [_EEGBCIEvent(signal_file="dummy.edf")]) + task = EEGMotorImageryEEGBCI(compute_stft=False, resample_rate=None, bandpass_filter=None) + + with patch("pyhealth.tasks.eegbci.mne.io.read_raw_edf", return_value=raw): + samples = task(patient) + + self.assertEqual(len(samples), 1) + sample = samples[0] + self.assertEqual(sample["patient_id"], "S001") + self.assertEqual(sample["record_id"], "R03") + self.assertEqual(sample["event_code"], "T1") + self.assertEqual(sample["task_label"], "execute_left_fist") + self.assertEqual(sample["label"], 1) + self.assertEqual(tuple(sample["signal"].shape), (16, 400)) + + def test_pattern_discovery_adds_bandpower_metadata(self): + import mne + from pyhealth.tasks.eegbci import EEGBCI_COMPAT_CHANNELS + + sfreq = 200.0 + times = np.arange(0, 2, 1 / sfreq) + alpha = np.sin(2 * np.pi * 10 * times) + raw = mne.io.RawArray( + np.tile(alpha, (16, 1)), + mne.create_info(list(EEGBCI_COMPAT_CHANNELS), sfreq=sfreq, ch_types=["eeg"] * 16), + verbose="error", + ) + raw.set_annotations(mne.Annotations(onset=[0.0], duration=[2.0], description=["T0"])) + patient = _EEGBCIPatient("S001", [_EEGBCIEvent(signal_file="dummy.edf")]) + task = EEGBCIPatternDiscovery(compute_stft=False, resample_rate=None, bandpass_filter=None) + + with patch("pyhealth.tasks.eegbci.mne.io.read_raw_edf", return_value=raw): + samples = task(patient) + + self.assertEqual(len(samples), 1) + sample = samples[0] + self.assertEqual(sample["bandpower"]["dominant_band"], "alpha") + self.assertEqual(sample["brain_state_hypothesis"], "relaxed_or_idle") + self.assertIn("interpretation", sample) +``` + +- [ ] **Step 9: Implement EDF reading and sample generation** + +Add methods inside `EEGMotorImageryEEGBCI`: + +```python + def read_raw(self, signal_file: str) -> mne.io.BaseRaw: + raw = mne.io.read_raw_edf(signal_file, preload=True, verbose="error") + raw.pick_types(eeg=True, stim=False, exclude=[]) + if self.bandpass_filter is not None: + raw.filter( + l_freq=self.bandpass_filter[0], + h_freq=self.bandpass_filter[1], + verbose="error", + ) + if self.resample_rate is not None: + raw.resample(self.resample_rate, n_jobs=1, verbose="error") + return raw + + def _base_samples_from_patient(self, patient: Any) -> List[Dict[str, Any]]: + samples: List[Dict[str, Any]] = [] + for event in patient.get_events("records"): + raw = self.read_raw(event.signal_file) + data = raw.get_data(units="uV") + selected, selected_names = select_eegbci_channels( + data, raw.ch_names, self.channel_mode + ) + selected = normalize_signal(selected, self.normalization) + sfreq = float(raw.info["sfreq"]) + for idx, window in enumerate( + iter_annotation_windows(raw, int(event.run), self.window_size) + ): + signal_np = selected[:, window["start_sample"] : window["end_sample"]] + if signal_np.shape[-1] != int(round(self.window_size * sfreq)): + continue + signal = torch.FloatTensor(signal_np) + sample = { + "patient_id": patient.patient_id, + "record_id": event.record_id, + "subject_id": int(event.subject_id), + "run": int(event.run), + "run_type": event.run_type, + "signal_file": event.signal_file, + "trial_id": f"{patient.patient_id}_{event.record_id}_{idx:04d}", + "event_code": window["event_code"], + "task_label": window["task_label"], + "label_family": window["label_family"], + "label": int(window["label"]), + "signal": signal, + "channel_names": selected_names, + "start_time": window["start_time"], + "end_time": window["end_time"], + "sample_rate": sfreq, + } + if self.compute_stft: + from pyhealth.models.tfm_tokenizer import get_stft_torch + + sample["stft"] = get_stft_torch(signal.unsqueeze(0)).squeeze(0) + samples.append(sample) + raw.close() + return samples + + def __call__(self, patient: Any) -> List[Dict[str, Any]]: + return self._base_samples_from_patient(patient) +``` + +Override `__call__` in `EEGBCIPatternDiscovery`: + +```python + def __call__(self, patient: Any) -> List[Dict[str, Any]]: + samples = self._base_samples_from_patient(patient) + for sample in samples: + features = compute_band_powers( + sample["signal"].detach().cpu().numpy(), + float(sample["sample_rate"]), + ) + interpretation = interpret_band_profile(features) + sample["bandpower"] = features + sample.update(interpretation) + return samples +``` + +- [ ] **Step 10: Export tasks** + +Modify `pyhealth/tasks/__init__.py`: + +```python +from pyhealth.tasks.eegbci import EEGBCIPatternDiscovery, EEGMotorImageryEEGBCI +``` + +Place it near other EEG task exports. + +- [ ] **Step 11: Run task tests** + +Run: + +```bash +pytest tests/core/test_eegbci.py::TestEEGBCITasks -v +``` + +Expected: PASS. + +- [ ] **Step 12: Commit task 3** + +Run: + +```bash +git add pyhealth/tasks/eegbci.py pyhealth/tasks/__init__.py tests/core/test_eegbci.py +git commit -m "feat: add EEGBCI tasks" +``` + +## Task 4: Real-Data Smoke Test + +**Files:** + +- Modify: `tests/core/test_eegbci.py` + +**Interfaces:** + +- Consumes: `PYHEALTH_RUN_REAL_EEGBCI=1`. +- Produces: skipped-by-default network/data smoke test. + +- [ ] **Step 1: Add skipped-by-default smoke test** + +Append to `tests/core/test_eegbci.py`: + +```python +import os + + +@unittest.skipUnless( + os.environ.get("PYHEALTH_RUN_REAL_EEGBCI") == "1", + "Set PYHEALTH_RUN_REAL_EEGBCI=1 to download and test real EEGBCI data.", +) +class TestEEGBCIRealDataSmoke(unittest.TestCase): + def test_real_eegbci_subject_1_run_3_pattern_discovery(self): + with tempfile.TemporaryDirectory() as tmp: + dataset = EEGBCIDataset(root=tmp, subjects=[1], runs=[3], download=True) + sample_dataset = dataset.set_task( + EEGBCIPatternDiscovery(compute_stft=False, window_size=2.0) + ) + self.assertGreater(len(sample_dataset), 0) + sample = sample_dataset[0] + self.assertIn("signal", sample) + self.assertEqual(sample["signal"].shape[0], 16) + self.assertIn(sample["task_label"], set(EEGBCI_LABELS)) + self.assertIn("bandpower", sample) + self.assertIn("brain_state_hypothesis", sample) +``` + +- [ ] **Step 2: Run default tests and confirm skip** + +Run: + +```bash +pytest tests/core/test_eegbci.py -v +``` + +Expected: PASS with `TestEEGBCIRealDataSmoke` skipped. + +- [ ] **Step 3: Run opt-in smoke test when network access is acceptable** + +Run: + +```bash +PYHEALTH_RUN_REAL_EEGBCI=1 pytest tests/core/test_eegbci.py::TestEEGBCIRealDataSmoke -v +``` + +Expected: PASS after MNE downloads subject `1`, run `3`. + +- [ ] **Step 4: Commit task 4** + +Run: + +```bash +git add tests/core/test_eegbci.py +git commit -m "test: add opt-in EEGBCI real-data smoke test" +``` + +## Task 5: CELM-Equivalent Example + +**Files:** + +- Create: `examples/eeg/eegbci/README.md` +- Create: `examples/eeg/eegbci/eegbci_pattern_discovery.py` + +**Interfaces:** + +- Consumes: `EEGBCIDataset` and `EEGBCIPatternDiscovery`. +- CLI flags: `--root`, `--subjects`, `--runs`, `--output-dir`, `--max-windows`, `--download`. +- Produces: `eegbci_pattern_windows.csv` and `eegbci_pattern_summary.md`. + +- [ ] **Step 1: Create example script** + +Create `examples/eeg/eegbci/eegbci_pattern_discovery.py`: + +```python +from __future__ import annotations + +import argparse +from collections import Counter +from pathlib import Path + +import pandas as pd + +from pyhealth.datasets import EEGBCIDataset +from pyhealth.tasks import EEGBCIPatternDiscovery + + +def parse_int_list(value: str) -> list[int]: + items: list[int] = [] + for part in value.split(","): + if "-" in part: + start, end = part.split("-", 1) + items.extend(range(int(start), int(end) + 1)) + else: + items.append(int(part)) + return items + + +def sample_to_row(sample: dict) -> dict: + bandpower = sample["bandpower"] + return { + "patient_id": sample["patient_id"], + "record_id": sample["record_id"], + "subject_id": sample["subject_id"], + "run": sample["run"], + "run_type": sample["run_type"], + "trial_id": sample["trial_id"], + "event_code": sample["event_code"], + "task_label": sample["task_label"], + "label_family": sample["label_family"], + "label": sample["label"], + "start_time": sample["start_time"], + "end_time": sample["end_time"], + "dominant_band": bandpower["dominant_band"], + "alpha_beta_ratio": bandpower["alpha_beta_ratio"], + "theta_beta_ratio": bandpower["theta_beta_ratio"], + "brain_state_hypothesis": sample["brain_state_hypothesis"], + "confidence": sample["confidence"], + "quality_flags": sample["quality_flags"], + "interpretation": sample["interpretation"], + **{key: value for key, value in bandpower.items() if key.endswith("_power")}, + **{key: value for key, value in bandpower.items() if key.endswith("_relative")}, + } + + +def write_summary(rows: list[dict], path: Path) -> None: + task_counts = Counter(row["task_label"] for row in rows) + hypothesis_counts = Counter(row["brain_state_hypothesis"] for row in rows) + lines = [ + "# EEGBCI Pattern Discovery Summary", + "", + "Brain-state hypotheses are exploratory signal metadata, not clinical diagnoses.", + "", + f"Processed windows: {len(rows)}", + "", + "## Task Labels", + "", + ] + for label, count in task_counts.most_common(): + lines.append(f"- {label}: {count}") + lines.extend(["", "## Brain-State Hypotheses", ""]) + for label, count in hypothesis_counts.most_common(): + lines.append(f"- {label}: {count}") + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--root", default="~/.cache/pyhealth/eegbci") + parser.add_argument("--subjects", default="1,2,3") + parser.add_argument("--runs", default="3-14") + parser.add_argument("--output-dir", default="outputs/eegbci_pattern_discovery") + parser.add_argument("--max-windows", type=int, default=None) + parser.add_argument("--download", action="store_true") + args = parser.parse_args() + + output_dir = Path(args.output_dir).expanduser() + output_dir.mkdir(parents=True, exist_ok=True) + + dataset = EEGBCIDataset( + root=str(Path(args.root).expanduser()), + subjects=parse_int_list(args.subjects), + runs=parse_int_list(args.runs), + download=args.download, + ) + sample_dataset = dataset.set_task(EEGBCIPatternDiscovery(compute_stft=False)) + + rows = [] + for idx, sample in enumerate(sample_dataset): + if args.max_windows is not None and idx >= args.max_windows: + break + rows.append(sample_to_row(sample)) + + csv_path = output_dir / "eegbci_pattern_windows.csv" + summary_path = output_dir / "eegbci_pattern_summary.md" + pd.DataFrame(rows).to_csv(csv_path, index=False) + write_summary(rows, summary_path) + print(f"Wrote {csv_path}") + print(f"Wrote {summary_path}") + + +if __name__ == "__main__": + main() +``` + +- [ ] **Step 2: Create README** + +Create `examples/eeg/eegbci/README.md`: + +````markdown +# EEGBCI Pattern Discovery + +This example uses `EEGBCIDataset` and `EEGBCIPatternDiscovery` to create +2-second EEGBCI windows with task labels, Welch bandpower features, and cautious +frequency-profile interpretations. + +The interpretations are exploratory signal metadata. They are not clinical +diagnoses and do not prove a subject's cognition. + +Run a tiny real-data example: + +```bash +python examples/eeg/eegbci/eegbci_pattern_discovery.py \ + --subjects 1 \ + --runs 3 \ + --max-windows 20 \ + --download +``` + +Outputs are written to `outputs/eegbci_pattern_discovery/` by default: + +- `eegbci_pattern_windows.csv` +- `eegbci_pattern_summary.md` +```` + +- [ ] **Step 3: Run example on a tiny subset** + +Run: + +```bash +python examples/eeg/eegbci/eegbci_pattern_discovery.py --subjects 1 --runs 3 --max-windows 20 --download +``` + +Expected: + +```text +Wrote outputs/eegbci_pattern_discovery/eegbci_pattern_windows.csv +Wrote outputs/eegbci_pattern_discovery/eegbci_pattern_summary.md +``` + +- [ ] **Step 4: Commit task 5** + +Run: + +```bash +git add examples/eeg/eegbci/README.md examples/eeg/eegbci/eegbci_pattern_discovery.py +git commit -m "docs: add EEGBCI pattern discovery example" +``` + +## Task 6: API Documentation + +**Files:** + +- Create: `docs/api/datasets/pyhealth.datasets.EEGBCIDataset.rst` +- Create: `docs/api/tasks/pyhealth.tasks.eegbci.rst` +- Modify: `docs/api/datasets.rst` +- Modify: `docs/api/tasks.rst` + +**Interfaces:** + +- Consumes: public classes and helpers from tasks 2 and 3. +- Produces: Sphinx API pages. + +- [ ] **Step 1: Add dataset API page** + +Create `docs/api/datasets/pyhealth.datasets.EEGBCIDataset.rst`: + +```rst +pyhealth.datasets.EEGBCIDataset +================================ + +.. autoclass:: pyhealth.datasets.EEGBCIDataset + :members: + :undoc-members: + :show-inheritance: +``` + +- [ ] **Step 2: Add task API page** + +Create `docs/api/tasks/pyhealth.tasks.eegbci.rst`: + +```rst +pyhealth.tasks.eegbci +===================== + +.. automodule:: pyhealth.tasks.eegbci + :members: + :undoc-members: + :show-inheritance: +``` + +- [ ] **Step 3: Include pages in API indexes** + +Add the dataset page to the relevant `.. toctree::` in `docs/api/datasets.rst`: + +```rst + datasets/pyhealth.datasets.EEGBCIDataset +``` + +Add the task page to the relevant `.. toctree::` in `docs/api/tasks.rst`: + +```rst + tasks/pyhealth.tasks.eegbci +``` + +- [ ] **Step 4: Run docs import smoke** + +Run: + +```bash +python - <<'PY' +from pyhealth.datasets import EEGBCIDataset +from pyhealth.tasks import EEGBCIPatternDiscovery, EEGMotorImageryEEGBCI +print(EEGBCIDataset.__name__) +print(EEGMotorImageryEEGBCI.__name__) +print(EEGBCIPatternDiscovery.__name__) +PY +``` + +Expected: + +```text +EEGBCIDataset +EEGMotorImageryEEGBCI +EEGBCIPatternDiscovery +``` + +- [ ] **Step 5: Commit task 6** + +Run: + +```bash +git add docs/api/datasets/pyhealth.datasets.EEGBCIDataset.rst docs/api/tasks/pyhealth.tasks.eegbci.rst docs/api/datasets.rst docs/api/tasks.rst +git commit -m "docs: add EEGBCI API docs" +``` + +## Final Verification + +Run after all tasks: + +```bash +pytest tests/core/test_eegbci.py -v +python - <<'PY' +from pyhealth.datasets import EEGBCIDataset +from pyhealth.tasks import EEGBCIPatternDiscovery, EEGMotorImageryEEGBCI +print("imports ok") +PY +``` + +Optional network/data verification: + +```bash +PYHEALTH_RUN_REAL_EEGBCI=1 pytest tests/core/test_eegbci.py::TestEEGBCIRealDataSmoke -v +python examples/eeg/eegbci/eegbci_pattern_discovery.py --subjects 1 --runs 3 --max-windows 20 --download +``` + +Graph update after code changes: + +```bash +graphify update . +``` + +## Engineering Review + +Review mode: GStack `/plan-eng-review` +Review date: 2026-07-07 +Review inputs: `brainstorm.md`, `design.md`, existing PyHealth EEG dataset/task files, current plan. + +### Architecture + +Decision: keep the dataset as metadata-only and put EDF reading/windowing in tasks. + +Reason: this matches `pyhealth/datasets/tuab.py`, `pyhealth/datasets/tuev.py`, and `pyhealth/tasks/temple_university_EEG_tasks.py`. It also keeps MNE Raw objects out of cached dataset metadata. + +### Data Flow + +Risk found: the previous draft did not specify whether annotations are windowed before or after resampling. + +Resolution: read/filter/resample first, then window from `raw.annotations` using the post-resample `raw.info["sfreq"]`. MNE keeps annotation onsets in seconds, so sample indices must be computed only after the final sample rate is known. + +### Channel Strategy + +Risk found: defaulting to all 64 EEGBCI channels would break many existing EEG model assumptions. + +Resolution: default `channel_mode="compat16"` with named central motor channels. Allow `channel_mode="all"` for research use. Do not reuse TUAB/TUEV bipolar montage code because EEGBCI channel names and montage semantics differ. + +### Label Semantics + +Risk found: `T1` and `T2` are easy to decode incorrectly because their meaning depends on run number. + +Resolution: keep `task_label_for_event(run, event_code)` as a pure helper with direct unit tests. Do not inline this mapping in task code. + +### Edge Cases + +- Missing local EDF with `download=False`: raise `FileNotFoundError` that explicitly suggests `download=True`. +- Unsupported run outside `3-14`: raise `ValueError`. +- Non-`T0`/`T1`/`T2` annotations: skip. +- Annotation shorter than `window_size`: emit no sample. +- Last partial window inside an annotation: skip to preserve fixed tensor shapes. +- Missing compatibility channels: raise `ValueError` listing missing channel names. +- `resample_rate=None`: preserve original sample rate and record it in each sample. +- `compute_stft=False`: remove `stft` from `input_schema`. +- Normal CI: never runs the real-data smoke test unless `PYHEALTH_RUN_REAL_EEGBCI=1`. + +### Test Coverage + +The plan covers: + +- Run-aware labels. +- Channel selection. +- Bandpower on synthetic alpha and beta sinusoids. +- Interpretation metadata and non-clinical wording. +- Dataset metadata generation with local files and mocked MNE download. +- Task sample schema with MNE `RawArray`. +- Skipped-by-default real data test. +- Example smoke path. + +Remaining risk: MNE's real EEGBCI channel labels may vary slightly from the expected names. The opt-in real-data smoke test is the guardrail for that. If it fails, adjust `normalize_eegbci_channel_name` rather than weakening the default channel contract. + +### Performance + +The first implementation reads EDF files inside task execution, matching current PyHealth EEG tasks. This is acceptable for the small example and keeps the first pass simple. If users process many subjects/runs repeatedly, add a later cache for preprocessed windows rather than doing it in this first feature. + +### Scope Decision + +Approved first scope: Approach B only. + +Do not include the optional embedding comparison in the initial implementation. It depends on model/checkpoint/channel compatibility decisions that should be made after the core dataset and task path works on real data. + +## Progress Log + +- 2026-07-07: Converted draft requirements/design into a concrete TDD implementation plan using `superpowers:writing-plans`. +- 2026-07-07: Ran automatic engineering review and incorporated decisions on dataset/task boundaries, channel strategy, annotation timing, edge cases, and test coverage. +- 2026-07-07: Task 1 complete on branch `eegbci-pattern-discovery`; helper tests pass with `.venv/bin/python -m pytest tests/core/test_eegbci.py::TestEEGBCIHelpers -v`. diff --git a/pyhealth/tasks/eegbci.py b/pyhealth/tasks/eegbci.py new file mode 100644 index 000000000..2aee9abf4 --- /dev/null +++ b/pyhealth/tasks/eegbci.py @@ -0,0 +1,230 @@ +from __future__ import annotations + +from typing import Any, Dict, List, Tuple + +import numpy as np + +EEGBCI_RUN_TYPES = { + 3: "motor_execution_left_right", + 4: "motor_imagery_left_right", + 5: "motor_execution_fists_feet", + 6: "motor_imagery_fists_feet", + 7: "motor_execution_left_right", + 8: "motor_imagery_left_right", + 9: "motor_execution_fists_feet", + 10: "motor_imagery_fists_feet", + 11: "motor_execution_left_right", + 12: "motor_imagery_left_right", + 13: "motor_execution_fists_feet", + 14: "motor_imagery_fists_feet", +} + +EEGBCI_LABELS = { + "rest": 0, + "execute_left_fist": 1, + "execute_right_fist": 2, + "imagine_left_fist": 3, + "imagine_right_fist": 4, + "execute_both_fists": 5, + "execute_both_feet": 6, + "imagine_both_fists": 7, + "imagine_both_feet": 8, +} + + +def run_type_for_run(run: int) -> str: + try: + return EEGBCI_RUN_TYPES[int(run)] + except KeyError as exc: + raise ValueError(f"Unsupported EEGBCI run: {run}") from exc + + +def label_family_for_run(run: int) -> str: + run_type = run_type_for_run(run) + if "execution" in run_type: + return "motor_execution" + if "imagery" in run_type: + return "motor_imagery" + return "baseline" + + +def task_label_for_event(run: int, event_code: str) -> str: + code = str(event_code).strip() + if code == "T0": + return "rest" + run_type = run_type_for_run(run) + mapping = { + "motor_execution_left_right": { + "T1": "execute_left_fist", + "T2": "execute_right_fist", + }, + "motor_imagery_left_right": { + "T1": "imagine_left_fist", + "T2": "imagine_right_fist", + }, + "motor_execution_fists_feet": { + "T1": "execute_both_fists", + "T2": "execute_both_feet", + }, + "motor_imagery_fists_feet": { + "T1": "imagine_both_fists", + "T2": "imagine_both_feet", + }, + } + try: + return mapping[run_type][code] + except KeyError as exc: + raise ValueError(f"Unsupported EEGBCI event {event_code!r} for run {run}") from exc + + +def numeric_label_for_task(task_label: str) -> int: + try: + return EEGBCI_LABELS[task_label] + except KeyError as exc: + raise ValueError(f"Unsupported EEGBCI task label: {task_label}") from exc + + +EEGBCI_COMPAT_CHANNELS = ( + "FC5", + "FC3", + "FC1", + "FC2", + "FC4", + "FC6", + "C5", + "C3", + "C1", + "C2", + "C4", + "C6", + "CP5", + "CP3", + "CP4", + "CP6", +) + + +def normalize_eegbci_channel_name(name: str) -> str: + clean = name.upper().replace(".", "").replace("EEG ", "").replace("-REF", "") + aliases = { + "T9": "FT9", + "T10": "FT10", + } + return aliases.get(clean, clean) + + +def select_eegbci_channels( + data: np.ndarray, + ch_names: List[str], + channel_mode: str = "compat16", +) -> Tuple[np.ndarray, List[str]]: + if channel_mode == "all": + return data, list(ch_names) + if channel_mode != "compat16": + raise ValueError("channel_mode must be one of {'compat16', 'all'}") + + normalized_to_index = { + normalize_eegbci_channel_name(name): idx for idx, name in enumerate(ch_names) + } + missing = [ch for ch in EEGBCI_COMPAT_CHANNELS if ch not in normalized_to_index] + if missing: + raise ValueError(f"Missing EEGBCI channels for compat16 mode: {missing}") + indices = [normalized_to_index[ch] for ch in EEGBCI_COMPAT_CHANNELS] + return data[indices], list(EEGBCI_COMPAT_CHANNELS) + + +def normalize_signal(signal: np.ndarray, mode: str | None) -> np.ndarray: + if mode is None: + return signal + if mode == "95th_percentile": + scale = np.quantile( + np.abs(signal), q=0.95, axis=-1, method="linear", keepdims=True + ) + return signal / (scale + 1e-8) + if mode == "div_by_100": + return signal / 100.0 + raise ValueError("normalization must be one of {None, '95th_percentile', 'div_by_100'}") + + +BANDS = { + "delta": (0.5, 4.0), + "theta": (4.0, 8.0), + "alpha": (8.0, 13.0), + "beta": (13.0, 30.0), + "gamma": (30.0, 45.0), +} + + +def compute_band_powers(data: np.ndarray, sfreq: float) -> Dict[str, float | str]: + from scipy.signal import welch + + if data.ndim != 2: + raise ValueError("data must have shape (channels, time)") + nperseg = min(data.shape[-1], int(sfreq * 2)) + freqs, psd = welch(data, fs=sfreq, nperseg=nperseg, axis=-1) + mean_psd = psd.mean(axis=0) + + features: Dict[str, float | str] = {} + total_power = 0.0 + band_values: Dict[str, float] = {} + for band, (low, high) in BANDS.items(): + mask = (freqs >= low) & (freqs < high) + value = float(np.trapezoid(mean_psd[mask], freqs[mask])) if np.any(mask) else 0.0 + features[f"{band}_power"] = value + band_values[band] = value + total_power += value + + denom = total_power + 1e-12 + for band, value in band_values.items(): + features[f"{band}_relative"] = float(value / denom) + + features["dominant_band"] = max(band_values, key=band_values.get) + features["alpha_beta_ratio"] = float( + band_values["alpha"] / (band_values["beta"] + 1e-12) + ) + features["theta_beta_ratio"] = float( + band_values["theta"] / (band_values["beta"] + 1e-12) + ) + return features + + +def interpret_band_profile(features: Dict[str, float | str]) -> Dict[str, str]: + dominant = str(features["dominant_band"]) + alpha_rel = float(features.get("alpha_relative", 0.0)) + beta_rel = float(features.get("beta_relative", 0.0)) + theta_rel = float(features.get("theta_relative", 0.0)) + gamma_rel = float(features.get("gamma_relative", 0.0)) + alpha_beta = float(features.get("alpha_beta_ratio", 0.0)) + theta_beta = float(features.get("theta_beta_ratio", 0.0)) + + quality_flags: List[str] = [] + hypothesis = "mixed_frequency_profile" + confidence = "low" + + if dominant == "alpha" and alpha_rel >= 0.45 and alpha_beta >= 2.0: + hypothesis = "relaxed_or_idle" + confidence = "medium" + elif dominant == "beta" and beta_rel >= 0.35: + hypothesis = "active_sensorimotor_processing" + confidence = "medium" + elif dominant == "theta" and theta_rel >= 0.35 and theta_beta >= 1.5: + hypothesis = "slow_wave_or_drowsy_pattern" + confidence = "medium" + elif dominant == "gamma" and gamma_rel >= 0.30: + hypothesis = "high_frequency_or_artifact_pattern" + confidence = "low" + quality_flags.append("possible_muscle_artifact") + + if confidence == "low": + quality_flags.append("low_confidence") + + return { + "brain_state_hypothesis": hypothesis, + "confidence": confidence, + "quality_flags": ";".join(quality_flags) if quality_flags else "none", + "interpretation": ( + f"The segment is consistent with {hypothesis} based on a " + f"{dominant}-dominant frequency profile. This is exploratory signal " + "metadata, not evidence of cognition or a clinical diagnosis." + ), + } diff --git a/tests/core/test_eegbci.py b/tests/core/test_eegbci.py new file mode 100644 index 000000000..53ebc10dd --- /dev/null +++ b/tests/core/test_eegbci.py @@ -0,0 +1,117 @@ +import unittest + +import numpy as np + +from pyhealth.tasks.eegbci import ( + label_family_for_run, + numeric_label_for_task, + run_type_for_run, + task_label_for_event, +) + + +class TestEEGBCIHelpers(unittest.TestCase): + def test_run_type_for_run(self): + self.assertEqual(run_type_for_run(3), "motor_execution_left_right") + self.assertEqual(run_type_for_run(4), "motor_imagery_left_right") + self.assertEqual(run_type_for_run(5), "motor_execution_fists_feet") + self.assertEqual(run_type_for_run(6), "motor_imagery_fists_feet") + self.assertEqual(run_type_for_run(14), "motor_imagery_fists_feet") + + def test_task_label_for_event_is_run_aware(self): + self.assertEqual(task_label_for_event(3, "T0"), "rest") + self.assertEqual(task_label_for_event(3, "T1"), "execute_left_fist") + self.assertEqual(task_label_for_event(3, "T2"), "execute_right_fist") + self.assertEqual(task_label_for_event(4, "T1"), "imagine_left_fist") + self.assertEqual(task_label_for_event(4, "T2"), "imagine_right_fist") + self.assertEqual(task_label_for_event(5, "T1"), "execute_both_fists") + self.assertEqual(task_label_for_event(5, "T2"), "execute_both_feet") + self.assertEqual(task_label_for_event(6, "T1"), "imagine_both_fists") + self.assertEqual(task_label_for_event(6, "T2"), "imagine_both_feet") + + def test_label_family_and_numeric_labels(self): + self.assertEqual(label_family_for_run(3), "motor_execution") + self.assertEqual(label_family_for_run(4), "motor_imagery") + self.assertEqual(numeric_label_for_task("rest"), 0) + self.assertEqual(numeric_label_for_task("execute_left_fist"), 1) + self.assertEqual(numeric_label_for_task("imagine_both_feet"), 8) + + def test_invalid_run_and_event_raise_clear_errors(self): + with self.assertRaisesRegex(ValueError, "Unsupported EEGBCI run"): + run_type_for_run(2) + with self.assertRaisesRegex(ValueError, "Unsupported EEGBCI event"): + task_label_for_event(3, "BAD") + + def test_select_eegbci_channels_compat16(self): + from pyhealth.tasks.eegbci import EEGBCI_COMPAT_CHANNELS, select_eegbci_channels + + ch_names = list(EEGBCI_COMPAT_CHANNELS) + ["EXTRA"] + data = np.arange(len(ch_names) * 100, dtype=float).reshape(len(ch_names), 100) + selected, selected_names = select_eegbci_channels(data, ch_names, "compat16") + self.assertEqual(selected.shape, (16, 100)) + self.assertEqual(selected_names, list(EEGBCI_COMPAT_CHANNELS)) + np.testing.assert_allclose(selected[0], data[0]) + + def test_select_eegbci_channels_all(self): + from pyhealth.tasks.eegbci import select_eegbci_channels + + data = np.ones((64, 50)) + ch_names = [f"CH{i}" for i in range(64)] + selected, selected_names = select_eegbci_channels(data, ch_names, "all") + self.assertEqual(selected.shape, (64, 50)) + self.assertEqual(selected_names, ch_names) + + def test_select_eegbci_channels_missing_channel_raises(self): + from pyhealth.tasks.eegbci import select_eegbci_channels + + with self.assertRaisesRegex(ValueError, "Missing EEGBCI channels"): + select_eegbci_channels(np.ones((2, 20)), ["C3", "C4"], "compat16") + + def test_normalize_signal_95th_percentile(self): + from pyhealth.tasks.eegbci import normalize_signal + + signal = np.array([[0.0, 1.0, 2.0, 100.0], [0.0, -2.0, 2.0, 4.0]]) + normalized = normalize_signal(signal, "95th_percentile") + self.assertEqual(normalized.shape, signal.shape) + self.assertLess(np.max(np.abs(normalized[0])), 2.0) + + def test_compute_band_powers_detects_alpha_sinusoid(self): + from pyhealth.tasks.eegbci import compute_band_powers + + sfreq = 200.0 + times = np.arange(0, 2, 1 / sfreq) + alpha = np.sin(2 * np.pi * 10 * times) + data = np.stack([alpha, alpha]) + features = compute_band_powers(data, sfreq) + self.assertEqual(features["dominant_band"], "alpha") + self.assertGreater(features["alpha_relative"], 0.5) + self.assertGreater(features["alpha_beta_ratio"], 1.0) + + def test_compute_band_powers_detects_beta_sinusoid(self): + from pyhealth.tasks.eegbci import compute_band_powers + + sfreq = 200.0 + times = np.arange(0, 2, 1 / sfreq) + beta = np.sin(2 * np.pi * 20 * times) + data = np.stack([beta, beta]) + features = compute_band_powers(data, sfreq) + self.assertEqual(features["dominant_band"], "beta") + self.assertGreater(features["beta_relative"], 0.5) + + def test_interpret_band_profile_returns_cautious_metadata(self): + from pyhealth.tasks.eegbci import interpret_band_profile + + interpretation = interpret_band_profile( + { + "dominant_band": "alpha", + "alpha_relative": 0.65, + "beta_relative": 0.10, + "theta_relative": 0.10, + "gamma_relative": 0.05, + "alpha_beta_ratio": 6.5, + "theta_beta_ratio": 1.0, + } + ) + self.assertEqual(interpretation["brain_state_hypothesis"], "relaxed_or_idle") + self.assertIn(interpretation["confidence"], {"low", "medium", "high"}) + self.assertIn("consistent with", interpretation["interpretation"]) From cd51d05022ff063e6fcc5df3932292d9f8692db3 Mon Sep 17 00:00:00 2001 From: Vihaan Agrawal <247244351+vihaan101@users.noreply.github.com> Date: Tue, 7 Jul 2026 20:24:41 +0530 Subject: [PATCH 02/21] feat: add EEGBCI dataset --- .../implementation_plan.md | 15 +-- pyhealth/datasets/__init__.py | 1 + pyhealth/datasets/configs/eegbci.yaml | 13 +++ pyhealth/datasets/eegbci.py | 96 +++++++++++++++++++ pyhealth/tasks/eegbci.py | 4 + tests/core/test_eegbci.py | 72 ++++++++++++++ 6 files changed, 194 insertions(+), 7 deletions(-) create mode 100644 pyhealth/datasets/configs/eegbci.yaml create mode 100644 pyhealth/datasets/eegbci.py diff --git a/docs/eeg_pattern_discovery/implementation_plan.md b/docs/eeg_pattern_discovery/implementation_plan.md index 7f2d777a7..a6dba5adf 100644 --- a/docs/eeg_pattern_discovery/implementation_plan.md +++ b/docs/eeg_pattern_discovery/implementation_plan.md @@ -635,7 +635,7 @@ git commit -m "feat: add EEGBCI helper functions" - Produces: `EEGBCIDataset(root, dataset_name=None, config_path=None, subjects=None, runs=None, download=False, **kwargs)`. - Produces metadata file: `/eegbci-pyhealth.csv`. -- [ ] **Step 1: Add failing dataset metadata tests** +- [x] **Step 1: Add failing dataset metadata tests** Append to `tests/core/test_eegbci.py`: @@ -714,7 +714,7 @@ class TestEEGBCIDataset(unittest.TestCase): self.assertIsInstance(ds.default_task, EEGBCIPatternDiscovery) ``` -- [ ] **Step 2: Run dataset tests to verify failure** +- [x] **Step 2: Run dataset tests to verify failure** Run: @@ -724,7 +724,7 @@ pytest tests/core/test_eegbci.py::TestEEGBCIDataset -v Expected: FAIL with `ModuleNotFoundError: No module named 'pyhealth.datasets.eegbci'`. -- [ ] **Step 3: Implement `EEGBCIDataset`** +- [x] **Step 3: Implement `EEGBCIDataset`** Create `pyhealth/datasets/eegbci.py`: @@ -827,7 +827,7 @@ class EEGBCIDataset(BaseDataset): return EEGBCIPatternDiscovery() ``` -- [ ] **Step 4: Add dataset config** +- [x] **Step 4: Add dataset config** Create `pyhealth/datasets/configs/eegbci.yaml`: @@ -847,7 +847,7 @@ tables: - "source" ``` -- [ ] **Step 5: Export dataset** +- [x] **Step 5: Export dataset** Modify `pyhealth/datasets/__init__.py`: @@ -857,7 +857,7 @@ from pyhealth.datasets.eegbci import EEGBCIDataset Place the import beside other EEG dataset exports. -- [ ] **Step 6: Run dataset tests** +- [x] **Step 6: Run dataset tests** Run: @@ -867,7 +867,7 @@ pytest tests/core/test_eegbci.py::TestEEGBCIDataset -v Expected: PASS. -- [ ] **Step 7: Commit task 2** +- [x] **Step 7: Commit task 2** Run: @@ -1680,3 +1680,4 @@ Do not include the optional embedding comparison in the initial implementation. - 2026-07-07: Converted draft requirements/design into a concrete TDD implementation plan using `superpowers:writing-plans`. - 2026-07-07: Ran automatic engineering review and incorporated decisions on dataset/task boundaries, channel strategy, annotation timing, edge cases, and test coverage. - 2026-07-07: Task 1 complete on branch `eegbci-pattern-discovery`; helper tests pass with `.venv/bin/python -m pytest tests/core/test_eegbci.py::TestEEGBCIHelpers -v`. +- 2026-07-07: Task 2 complete; dataset metadata tests pass with `.venv/bin/python -m pytest tests/core/test_eegbci.py::TestEEGBCIDataset -v`. diff --git a/pyhealth/datasets/__init__.py b/pyhealth/datasets/__init__.py index c29955e7d..3b7d6213a 100644 --- a/pyhealth/datasets/__init__.py +++ b/pyhealth/datasets/__init__.py @@ -82,6 +82,7 @@ def __init__(self, *args, **kwargs): split_by_visit, split_by_visit_conformal, ) +from .eegbci import EEGBCIDataset from .tuab import TUABDataset from .tuev import TUEVDataset from .utils import ( diff --git a/pyhealth/datasets/configs/eegbci.yaml b/pyhealth/datasets/configs/eegbci.yaml new file mode 100644 index 000000000..edb0e5b7a --- /dev/null +++ b/pyhealth/datasets/configs/eegbci.yaml @@ -0,0 +1,13 @@ +version: "1.0.0" +tables: + records: + file_path: "eegbci-pyhealth.csv" + patient_id: "patient_id" + timestamp: null + attributes: + - "record_id" + - "subject_id" + - "run" + - "run_type" + - "signal_file" + - "source" diff --git a/pyhealth/datasets/eegbci.py b/pyhealth/datasets/eegbci.py new file mode 100644 index 000000000..afd7f3c08 --- /dev/null +++ b/pyhealth/datasets/eegbci.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +import logging +from pathlib import Path +from typing import Optional + +import mne +import pandas as pd + +from .base_dataset import BaseDataset +from pyhealth.tasks.eegbci import EEGBCIPatternDiscovery, run_type_for_run + +logger = logging.getLogger(__name__) + + +class EEGBCIDataset(BaseDataset): + """PhysioNet EEG Motor Movement/Imagery metadata dataset.""" + + def __init__( + self, + root: str, + dataset_name: Optional[str] = None, + config_path: Optional[str] = None, + subjects: Optional[list[int]] = None, + runs: Optional[list[int]] = None, + download: bool = False, + **kwargs, + ) -> None: + if config_path is None: + config_path = Path(__file__).parent / "configs" / "eegbci.yaml" + self.root = root + self.subjects = subjects or [1, 2, 3] + self.runs = runs or list(range(3, 15)) + self.download = download + self.prepare_metadata() + super().__init__( + root=root, + tables=["records"], + dataset_name=dataset_name or "eegbci", + config_path=config_path, + **kwargs, + ) + + def _find_local_edf(self, subject: int, run: int) -> Path | None: + root = Path(self.root) + pattern = f"S{subject:03d}R{run:02d}.edf" + matches = sorted(root.rglob(pattern)) + return matches[0] if matches else None + + def prepare_metadata(self) -> None: + root = Path(self.root) + csv_path = root / "eegbci-pyhealth.csv" + if csv_path.exists(): + return + + rows: list[dict] = [] + for subject in self.subjects: + paths_by_run: dict[int, Path] = {} + if self.download: + downloaded = mne.datasets.eegbci.load_data( + subject, self.runs, path=str(root) + ) + for path in downloaded: + p = Path(path) + for run in self.runs: + if p.name == f"S{subject:03d}R{run:02d}.edf": + paths_by_run[run] = p + for run in self.runs: + signal_file = paths_by_run.get(run) or self._find_local_edf(subject, run) + if signal_file is None: + raise FileNotFoundError( + f"Missing EEGBCI EDF for subject {subject}, run {run}. " + "Pass download=True to fetch it with MNE." + ) + rows.append( + { + "patient_id": f"S{subject:03d}", + "record_id": f"R{run:02d}", + "subject_id": int(subject), + "run": int(run), + "run_type": run_type_for_run(run), + "signal_file": str(signal_file), + "source": "physionet_eegbci", + } + ) + + df = pd.DataFrame(rows) + df.sort_values(["subject_id", "run"], inplace=True) + df.reset_index(drop=True, inplace=True) + csv_path.parent.mkdir(parents=True, exist_ok=True) + df.to_csv(csv_path, index=False) + logger.info("Wrote EEGBCI metadata to %s", csv_path) + + @property + def default_task(self) -> EEGBCIPatternDiscovery: + return EEGBCIPatternDiscovery() diff --git a/pyhealth/tasks/eegbci.py b/pyhealth/tasks/eegbci.py index 2aee9abf4..5d3c10832 100644 --- a/pyhealth/tasks/eegbci.py +++ b/pyhealth/tasks/eegbci.py @@ -228,3 +228,7 @@ def interpret_band_profile(features: Dict[str, float | str]) -> Dict[str, str]: "metadata, not evidence of cognition or a clinical diagnosis." ), } + + +class EEGBCIPatternDiscovery: + """Placeholder default task until the EEGBCI task implementation is added.""" diff --git a/tests/core/test_eegbci.py b/tests/core/test_eegbci.py index 53ebc10dd..1f362f5ea 100644 --- a/tests/core/test_eegbci.py +++ b/tests/core/test_eegbci.py @@ -1,6 +1,10 @@ import unittest +import tempfile +from pathlib import Path +from unittest.mock import patch import numpy as np +import pandas as pd from pyhealth.tasks.eegbci import ( label_family_for_run, @@ -115,3 +119,71 @@ def test_interpret_band_profile_returns_cautious_metadata(self): self.assertEqual(interpretation["brain_state_hypothesis"], "relaxed_or_idle") self.assertIn(interpretation["confidence"], {"low", "medium", "high"}) self.assertIn("consistent with", interpretation["interpretation"]) + + +from pyhealth.datasets.eegbci import EEGBCIDataset + + +class TestEEGBCIDataset(unittest.TestCase): + def test_prepare_metadata_with_existing_files(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + edf = root / "files" / "eegmmidb" / "1.0.0" / "S001" / "S001R03.edf" + edf.parent.mkdir(parents=True) + edf.write_bytes(b"") + + ds = EEGBCIDataset.__new__(EEGBCIDataset) + ds.root = str(root) + ds.subjects = [1] + ds.runs = [3] + ds.download = False + ds.prepare_metadata() + + csv_path = root / "eegbci-pyhealth.csv" + self.assertTrue(csv_path.exists()) + df = pd.read_csv(csv_path) + self.assertEqual(len(df), 1) + self.assertEqual(df.loc[0, "patient_id"], "S001") + self.assertEqual(df.loc[0, "record_id"], "R03") + self.assertEqual(df.loc[0, "subject_id"], 1) + self.assertEqual(df.loc[0, "run"], 3) + self.assertEqual(df.loc[0, "run_type"], "motor_execution_left_right") + self.assertEqual(df.loc[0, "source"], "physionet_eegbci") + + def test_prepare_metadata_download_uses_mne_loader(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + fake_path = root / "S001R04.edf" + fake_path.write_bytes(b"") + ds = EEGBCIDataset.__new__(EEGBCIDataset) + ds.root = str(root) + ds.subjects = [1] + ds.runs = [4] + ds.download = True + + with patch( + "pyhealth.datasets.eegbci.mne.datasets.eegbci.load_data", + return_value=[str(fake_path)], + ) as load_data: + ds.prepare_metadata() + + load_data.assert_called_once_with(1, [4], path=str(root)) + df = pd.read_csv(root / "eegbci-pyhealth.csv") + self.assertEqual(df.loc[0, "record_id"], "R04") + self.assertEqual(df.loc[0, "run_type"], "motor_imagery_left_right") + + def test_prepare_metadata_missing_local_file_raises(self): + with tempfile.TemporaryDirectory() as tmp: + ds = EEGBCIDataset.__new__(EEGBCIDataset) + ds.root = tmp + ds.subjects = [1] + ds.runs = [3] + ds.download = False + with self.assertRaisesRegex(FileNotFoundError, "download=True"): + ds.prepare_metadata() + + def test_default_task_returns_pattern_discovery(self): + from pyhealth.tasks.eegbci import EEGBCIPatternDiscovery + + ds = EEGBCIDataset.__new__(EEGBCIDataset) + self.assertIsInstance(ds.default_task, EEGBCIPatternDiscovery) From e5689942f9873b08d2cf2bd00b9455d374b60421 Mon Sep 17 00:00:00 2001 From: Vihaan Agrawal <247244351+vihaan101@users.noreply.github.com> Date: Tue, 7 Jul 2026 20:28:24 +0530 Subject: [PATCH 03/21] feat: add EEGBCI tasks --- .../implementation_plan.md | 25 ++-- pyhealth/tasks/__init__.py | 1 + pyhealth/tasks/eegbci.py | 139 +++++++++++++++++- tests/core/test_eegbci.py | 121 +++++++++++++++ 4 files changed, 272 insertions(+), 14 deletions(-) diff --git a/docs/eeg_pattern_discovery/implementation_plan.md b/docs/eeg_pattern_discovery/implementation_plan.md index a6dba5adf..625502974 100644 --- a/docs/eeg_pattern_discovery/implementation_plan.md +++ b/docs/eeg_pattern_discovery/implementation_plan.md @@ -889,7 +889,7 @@ git commit -m "feat: add EEGBCI dataset" - Consumes: dataset metadata events with `signal_file`, `run`, `record_id`, `run_type`. - Produces: `EEGMotorImageryEEGBCI` and `EEGBCIPatternDiscovery` samples. -- [ ] **Step 1: Add task schema tests** +- [x] **Step 1: Add task schema tests** Append to `tests/core/test_eegbci.py`: @@ -940,7 +940,7 @@ class TestEEGBCITasks(unittest.TestCase): self.assertEqual(task.input_schema, {"signal": "tensor"}) ``` -- [ ] **Step 2: Run schema tests to verify failure** +- [x] **Step 2: Run schema tests to verify failure** Run: @@ -950,7 +950,7 @@ pytest tests/core/test_eegbci.py::TestEEGBCITasks -v Expected: FAIL because classes do not exist. -- [ ] **Step 3: Implement task constructors** +- [x] **Step 3: Implement task constructors** Add to `pyhealth/tasks/eegbci.py`: @@ -990,7 +990,7 @@ class EEGBCIPatternDiscovery(EEGMotorImageryEEGBCI): task_name: str = "EEGBCI_pattern_discovery" ``` -- [ ] **Step 4: Run schema tests** +- [x] **Step 4: Run schema tests** Run: @@ -1000,7 +1000,7 @@ pytest tests/core/test_eegbci.py::TestEEGBCITasks -v Expected: PASS for schema tests. -- [ ] **Step 5: Add failing annotation window tests** +- [x] **Step 5: Add failing annotation window tests** Append to `TestEEGBCITasks`: @@ -1026,7 +1026,7 @@ Append to `TestEEGBCITasks`: self.assertEqual(windows[0]["end_sample"], 800) ``` -- [ ] **Step 6: Implement annotation windowing** +- [x] **Step 6: Implement annotation windowing** Add before the task classes: @@ -1065,7 +1065,7 @@ def iter_annotation_windows( return windows ``` -- [ ] **Step 7: Run annotation tests** +- [x] **Step 7: Run annotation tests** Run: @@ -1075,7 +1075,7 @@ pytest tests/core/test_eegbci.py::TestEEGBCITasks::test_iter_annotation_windows_ Expected: PASS. -- [ ] **Step 8: Add failing sample-generation tests** +- [x] **Step 8: Add failing sample-generation tests** Append to `TestEEGBCITasks`: @@ -1135,7 +1135,7 @@ Append to `TestEEGBCITasks`: self.assertIn("interpretation", sample) ``` -- [ ] **Step 9: Implement EDF reading and sample generation** +- [x] **Step 9: Implement EDF reading and sample generation** Add methods inside `EEGMotorImageryEEGBCI`: @@ -1216,7 +1216,7 @@ Override `__call__` in `EEGBCIPatternDiscovery`: return samples ``` -- [ ] **Step 10: Export tasks** +- [x] **Step 10: Export tasks** Modify `pyhealth/tasks/__init__.py`: @@ -1226,7 +1226,7 @@ from pyhealth.tasks.eegbci import EEGBCIPatternDiscovery, EEGMotorImageryEEGBCI Place it near other EEG task exports. -- [ ] **Step 11: Run task tests** +- [x] **Step 11: Run task tests** Run: @@ -1236,7 +1236,7 @@ pytest tests/core/test_eegbci.py::TestEEGBCITasks -v Expected: PASS. -- [ ] **Step 12: Commit task 3** +- [x] **Step 12: Commit task 3** Run: @@ -1681,3 +1681,4 @@ Do not include the optional embedding comparison in the initial implementation. - 2026-07-07: Ran automatic engineering review and incorporated decisions on dataset/task boundaries, channel strategy, annotation timing, edge cases, and test coverage. - 2026-07-07: Task 1 complete on branch `eegbci-pattern-discovery`; helper tests pass with `.venv/bin/python -m pytest tests/core/test_eegbci.py::TestEEGBCIHelpers -v`. - 2026-07-07: Task 2 complete; dataset metadata tests pass with `.venv/bin/python -m pytest tests/core/test_eegbci.py::TestEEGBCIDataset -v`. +- 2026-07-07: Task 3 complete; `.venv/bin/python -m pytest tests/core/test_eegbci.py -v` passes with 21 tests. diff --git a/pyhealth/tasks/__init__.py b/pyhealth/tasks/__init__.py index cc95ef94e..1a0db720f 100644 --- a/pyhealth/tasks/__init__.py +++ b/pyhealth/tasks/__init__.py @@ -69,6 +69,7 @@ EEGEventsTUEV, EEGAbnormalTUAB ) +from .eegbci import EEGBCIPatternDiscovery, EEGMotorImageryEEGBCI from .variant_classification import ( MutationPathogenicityPrediction, VariantClassificationClinVar, diff --git a/pyhealth/tasks/eegbci.py b/pyhealth/tasks/eegbci.py index 5d3c10832..f24ce13c7 100644 --- a/pyhealth/tasks/eegbci.py +++ b/pyhealth/tasks/eegbci.py @@ -2,7 +2,11 @@ from typing import Any, Dict, List, Tuple +import mne import numpy as np +import torch + +from pyhealth.tasks import BaseTask EEGBCI_RUN_TYPES = { 3: "motor_execution_left_right", @@ -230,5 +234,136 @@ def interpret_band_profile(features: Dict[str, float | str]) -> Dict[str, str]: } -class EEGBCIPatternDiscovery: - """Placeholder default task until the EEGBCI task implementation is added.""" +def iter_annotation_windows( + raw: mne.io.BaseRaw, + run: int, + window_size: float = 2.0, +) -> List[Dict[str, Any]]: + sfreq = float(raw.info["sfreq"]) + window_samples = int(round(window_size * sfreq)) + windows: List[Dict[str, Any]] = [] + for idx, annotation in enumerate(raw.annotations): + event_code = str(annotation["description"]) + if event_code not in {"T0", "T1", "T2"}: + continue + start_sample = int(round(float(annotation["onset"]) * sfreq)) + duration_samples = int(round(float(annotation["duration"]) * sfreq)) + n_full_windows = duration_samples // window_samples + for window_idx in range(n_full_windows): + s0 = start_sample + window_idx * window_samples + s1 = s0 + window_samples + task_label = task_label_for_event(run, event_code) + windows.append( + { + "trial_id": f"ann{idx:04d}_win{window_idx:03d}", + "event_code": event_code, + "task_label": task_label, + "label_family": label_family_for_run(run), + "label": numeric_label_for_task(task_label), + "start_time": s0 / sfreq, + "end_time": s1 / sfreq, + "start_sample": s0, + "end_sample": s1, + } + ) + return windows + + +class EEGMotorImageryEEGBCI(BaseTask): + task_name: str = "EEGBCI_motor_imagery" + input_schema: Dict[str, str] = {"signal": "tensor", "stft": "tensor"} + output_schema: Dict[str, str] = {"label": "multiclass"} + + def __init__( + self, + window_size: float = 2.0, + resample_rate: float | None = 200, + bandpass_filter: Tuple[float, float] | None = (0.5, 45.0), + channel_mode: str = "compat16", + normalization: str | None = "95th_percentile", + compute_stft: bool = True, + ) -> None: + super().__init__() + self.window_size = window_size + self.resample_rate = resample_rate + self.bandpass_filter = bandpass_filter + self.channel_mode = channel_mode + self.normalization = normalization + self.compute_stft = compute_stft + if not compute_stft: + self.input_schema = {"signal": "tensor"} + + def __call__(self, patient: Any) -> List[Dict[str, Any]]: + return self._base_samples_from_patient(patient) + + def read_raw(self, signal_file: str) -> mne.io.BaseRaw: + raw = mne.io.read_raw_edf(signal_file, preload=True, verbose="error") + raw.pick_types(eeg=True, stim=False, exclude=[]) + if self.bandpass_filter is not None: + raw.filter( + l_freq=self.bandpass_filter[0], + h_freq=self.bandpass_filter[1], + verbose="error", + ) + if self.resample_rate is not None: + raw.resample(self.resample_rate, n_jobs=1, verbose="error") + return raw + + def _base_samples_from_patient(self, patient: Any) -> List[Dict[str, Any]]: + samples: List[Dict[str, Any]] = [] + for event in patient.get_events("records"): + raw = self.read_raw(event.signal_file) + data = raw.get_data(units="uV") + selected, selected_names = select_eegbci_channels( + data, raw.ch_names, self.channel_mode + ) + selected = normalize_signal(selected, self.normalization) + sfreq = float(raw.info["sfreq"]) + for idx, window in enumerate( + iter_annotation_windows(raw, int(event.run), self.window_size) + ): + signal_np = selected[:, window["start_sample"] : window["end_sample"]] + if signal_np.shape[-1] != int(round(self.window_size * sfreq)): + continue + signal = torch.FloatTensor(signal_np) + sample = { + "patient_id": patient.patient_id, + "record_id": event.record_id, + "subject_id": int(event.subject_id), + "run": int(event.run), + "run_type": event.run_type, + "signal_file": event.signal_file, + "trial_id": f"{patient.patient_id}_{event.record_id}_{idx:04d}", + "event_code": window["event_code"], + "task_label": window["task_label"], + "label_family": window["label_family"], + "label": int(window["label"]), + "signal": signal, + "channel_names": selected_names, + "start_time": window["start_time"], + "end_time": window["end_time"], + "sample_rate": sfreq, + } + if self.compute_stft: + from pyhealth.models.tfm_tokenizer import get_stft_torch + + sample["stft"] = get_stft_torch(signal.unsqueeze(0)).squeeze(0) + samples.append(sample) + raw.close() + return samples + + +class EEGBCIPatternDiscovery(EEGMotorImageryEEGBCI): + task_name: str = "EEGBCI_pattern_discovery" + + def __call__(self, patient: Any) -> List[Dict[str, Any]]: + samples = self._base_samples_from_patient(patient) + for sample in samples: + features = compute_band_powers( + sample["signal"].detach().cpu().numpy(), + float(sample["sample_rate"]), + ) + interpretation = interpret_band_profile(features) + sample["bandpower"] = features + sample.update(interpretation) + return samples diff --git a/tests/core/test_eegbci.py b/tests/core/test_eegbci.py index 1f362f5ea..21877da23 100644 --- a/tests/core/test_eegbci.py +++ b/tests/core/test_eegbci.py @@ -1,6 +1,8 @@ import unittest import tempfile +from dataclasses import dataclass from pathlib import Path +from typing import List from unittest.mock import patch import numpy as np @@ -187,3 +189,122 @@ def test_default_task_returns_pattern_discovery(self): ds = EEGBCIDataset.__new__(EEGBCIDataset) self.assertIsInstance(ds.default_task, EEGBCIPatternDiscovery) + + +from pyhealth.tasks.eegbci import EEGBCIPatternDiscovery, EEGMotorImageryEEGBCI + + +@dataclass +class _EEGBCIEvent: + signal_file: str + record_id: str = "R03" + subject_id: int = 1 + run: int = 3 + run_type: str = "motor_execution_left_right" + source: str = "physionet_eegbci" + + +class _EEGBCIPatient: + def __init__(self, patient_id: str, events: List[_EEGBCIEvent]): + self.patient_id = patient_id + self._events = events + + def get_events(self, event_type=None) -> List[_EEGBCIEvent]: + if event_type not in (None, "records"): + return [] + return self._events + + +class TestEEGBCITasks(unittest.TestCase): + def test_task_schema_attributes(self): + task = EEGMotorImageryEEGBCI() + self.assertEqual(task.task_name, "EEGBCI_motor_imagery") + self.assertEqual(task.input_schema, {"signal": "tensor", "stft": "tensor"}) + self.assertEqual(task.output_schema, {"label": "multiclass"}) + + def test_task_schema_without_stft(self): + task = EEGMotorImageryEEGBCI(compute_stft=False) + self.assertEqual(task.input_schema, {"signal": "tensor"}) + + def test_pattern_discovery_schema_attributes(self): + task = EEGBCIPatternDiscovery(compute_stft=False) + self.assertEqual(task.task_name, "EEGBCI_pattern_discovery") + self.assertEqual(task.input_schema, {"signal": "tensor"}) + + def test_iter_annotation_windows_uses_full_2s_windows(self): + import mne + from pyhealth.tasks.eegbci import iter_annotation_windows + + sfreq = 200.0 + raw = mne.io.RawArray( + np.zeros((2, int(sfreq * 6))), + mne.create_info(["C3", "C4"], sfreq=sfreq, ch_types=["eeg", "eeg"]), + verbose="error", + ) + raw.set_annotations( + mne.Annotations(onset=[0.5, 2.0], duration=[1.0, 3.0], description=["T0", "T1"]) + ) + windows = iter_annotation_windows(raw, run=3, window_size=2.0) + self.assertEqual(len(windows), 1) + self.assertEqual(windows[0]["event_code"], "T1") + self.assertEqual(windows[0]["task_label"], "execute_left_fist") + self.assertEqual(windows[0]["start_sample"], 400) + self.assertEqual(windows[0]["end_sample"], 800) + + def test_motor_imagery_task_returns_samples_from_raw(self): + import mne + + sfreq = 200.0 + raw = mne.io.RawArray( + np.ones((16, int(sfreq * 5))), + mne.create_info( + list( + __import__( + "pyhealth.tasks.eegbci", fromlist=["EEGBCI_COMPAT_CHANNELS"] + ).EEGBCI_COMPAT_CHANNELS + ), + sfreq=sfreq, + ch_types=["eeg"] * 16, + ), + verbose="error", + ) + raw.set_annotations(mne.Annotations(onset=[0.0], duration=[2.0], description=["T1"])) + patient = _EEGBCIPatient("S001", [_EEGBCIEvent(signal_file="dummy.edf")]) + task = EEGMotorImageryEEGBCI(compute_stft=False, resample_rate=None, bandpass_filter=None) + + with patch("pyhealth.tasks.eegbci.mne.io.read_raw_edf", return_value=raw): + samples = task(patient) + + self.assertEqual(len(samples), 1) + sample = samples[0] + self.assertEqual(sample["patient_id"], "S001") + self.assertEqual(sample["record_id"], "R03") + self.assertEqual(sample["event_code"], "T1") + self.assertEqual(sample["task_label"], "execute_left_fist") + self.assertEqual(sample["label"], 1) + self.assertEqual(tuple(sample["signal"].shape), (16, 400)) + + def test_pattern_discovery_adds_bandpower_metadata(self): + import mne + from pyhealth.tasks.eegbci import EEGBCI_COMPAT_CHANNELS + + sfreq = 200.0 + times = np.arange(0, 2, 1 / sfreq) + alpha = np.sin(2 * np.pi * 10 * times) + raw = mne.io.RawArray( + np.tile(alpha, (16, 1)), + mne.create_info(list(EEGBCI_COMPAT_CHANNELS), sfreq=sfreq, ch_types=["eeg"] * 16), + verbose="error", + ) + raw.set_annotations(mne.Annotations(onset=[0.0], duration=[2.0], description=["T0"])) + patient = _EEGBCIPatient("S001", [_EEGBCIEvent(signal_file="dummy.edf")]) + task = EEGBCIPatternDiscovery(compute_stft=False, resample_rate=None, bandpass_filter=None) + + with patch("pyhealth.tasks.eegbci.mne.io.read_raw_edf", return_value=raw): + samples = task(patient) + + self.assertEqual(len(samples), 1) + sample = samples[0] + self.assertEqual(sample["bandpower"]["dominant_band"], "alpha") + self.assertEqual(sample["brain_state_hypothesis"], "relaxed_or_idle") + self.assertIn("interpretation", sample) From c713eecc799ce7b7d5e2930f7a25005fcb48e737 Mon Sep 17 00:00:00 2001 From: Vihaan Agrawal <247244351+vihaan101@users.noreply.github.com> Date: Tue, 7 Jul 2026 20:31:26 +0530 Subject: [PATCH 04/21] test: add opt-in EEGBCI real-data smoke test --- .../implementation_plan.md | 9 +++---- pyhealth/datasets/eegbci.py | 2 +- tests/core/test_eegbci.py | 24 ++++++++++++++++++- 3 files changed, 29 insertions(+), 6 deletions(-) diff --git a/docs/eeg_pattern_discovery/implementation_plan.md b/docs/eeg_pattern_discovery/implementation_plan.md index 625502974..391b58b29 100644 --- a/docs/eeg_pattern_discovery/implementation_plan.md +++ b/docs/eeg_pattern_discovery/implementation_plan.md @@ -1256,7 +1256,7 @@ git commit -m "feat: add EEGBCI tasks" - Consumes: `PYHEALTH_RUN_REAL_EEGBCI=1`. - Produces: skipped-by-default network/data smoke test. -- [ ] **Step 1: Add skipped-by-default smoke test** +- [x] **Step 1: Add skipped-by-default smoke test** Append to `tests/core/test_eegbci.py`: @@ -1284,7 +1284,7 @@ class TestEEGBCIRealDataSmoke(unittest.TestCase): self.assertIn("brain_state_hypothesis", sample) ``` -- [ ] **Step 2: Run default tests and confirm skip** +- [x] **Step 2: Run default tests and confirm skip** Run: @@ -1294,7 +1294,7 @@ pytest tests/core/test_eegbci.py -v Expected: PASS with `TestEEGBCIRealDataSmoke` skipped. -- [ ] **Step 3: Run opt-in smoke test when network access is acceptable** +- [x] **Step 3: Run opt-in smoke test when network access is acceptable** Run: @@ -1304,7 +1304,7 @@ PYHEALTH_RUN_REAL_EEGBCI=1 pytest tests/core/test_eegbci.py::TestEEGBCIRealDataS Expected: PASS after MNE downloads subject `1`, run `3`. -- [ ] **Step 4: Commit task 4** +- [x] **Step 4: Commit task 4** Run: @@ -1682,3 +1682,4 @@ Do not include the optional embedding comparison in the initial implementation. - 2026-07-07: Task 1 complete on branch `eegbci-pattern-discovery`; helper tests pass with `.venv/bin/python -m pytest tests/core/test_eegbci.py::TestEEGBCIHelpers -v`. - 2026-07-07: Task 2 complete; dataset metadata tests pass with `.venv/bin/python -m pytest tests/core/test_eegbci.py::TestEEGBCIDataset -v`. - 2026-07-07: Task 3 complete; `.venv/bin/python -m pytest tests/core/test_eegbci.py -v` passes with 21 tests. +- 2026-07-07: Task 4 complete; normal EEGBCI tests pass with 21 passed/1 skipped, and `PYHEALTH_RUN_REAL_EEGBCI=1 .venv/bin/python -m pytest tests/core/test_eegbci.py::TestEEGBCIRealDataSmoke -v` passes against subject 1 run 3. diff --git a/pyhealth/datasets/eegbci.py b/pyhealth/datasets/eegbci.py index afd7f3c08..6a69b2aa8 100644 --- a/pyhealth/datasets/eegbci.py +++ b/pyhealth/datasets/eegbci.py @@ -58,7 +58,7 @@ def prepare_metadata(self) -> None: paths_by_run: dict[int, Path] = {} if self.download: downloaded = mne.datasets.eegbci.load_data( - subject, self.runs, path=str(root) + subject, self.runs, path=str(root), update_path=False ) for path in downloaded: p = Path(path) diff --git a/tests/core/test_eegbci.py b/tests/core/test_eegbci.py index 21877da23..c4c799eff 100644 --- a/tests/core/test_eegbci.py +++ b/tests/core/test_eegbci.py @@ -1,3 +1,4 @@ +import os import unittest import tempfile from dataclasses import dataclass @@ -9,6 +10,7 @@ import pandas as pd from pyhealth.tasks.eegbci import ( + EEGBCI_LABELS, label_family_for_run, numeric_label_for_task, run_type_for_run, @@ -169,7 +171,7 @@ def test_prepare_metadata_download_uses_mne_loader(self): ) as load_data: ds.prepare_metadata() - load_data.assert_called_once_with(1, [4], path=str(root)) + load_data.assert_called_once_with(1, [4], path=str(root), update_path=False) df = pd.read_csv(root / "eegbci-pyhealth.csv") self.assertEqual(df.loc[0, "record_id"], "R04") self.assertEqual(df.loc[0, "run_type"], "motor_imagery_left_right") @@ -308,3 +310,23 @@ def test_pattern_discovery_adds_bandpower_metadata(self): self.assertEqual(sample["bandpower"]["dominant_band"], "alpha") self.assertEqual(sample["brain_state_hypothesis"], "relaxed_or_idle") self.assertIn("interpretation", sample) + + +@unittest.skipUnless( + os.environ.get("PYHEALTH_RUN_REAL_EEGBCI") == "1", + "Set PYHEALTH_RUN_REAL_EEGBCI=1 to download and test real EEGBCI data.", +) +class TestEEGBCIRealDataSmoke(unittest.TestCase): + def test_real_eegbci_subject_1_run_3_pattern_discovery(self): + with tempfile.TemporaryDirectory() as tmp: + dataset = EEGBCIDataset(root=tmp, subjects=[1], runs=[3], download=True) + sample_dataset = dataset.set_task( + EEGBCIPatternDiscovery(compute_stft=False, window_size=2.0) + ) + self.assertGreater(len(sample_dataset), 0) + sample = sample_dataset[0] + self.assertIn("signal", sample) + self.assertEqual(sample["signal"].shape[0], 16) + self.assertIn(sample["task_label"], set(EEGBCI_LABELS)) + self.assertIn("bandpower", sample) + self.assertIn("brain_state_hypothesis", sample) From 0a22e42584f37f0639a81a5fda1a2cccf3b01bed Mon Sep 17 00:00:00 2001 From: Vihaan Agrawal <247244351+vihaan101@users.noreply.github.com> Date: Tue, 7 Jul 2026 20:33:51 +0530 Subject: [PATCH 05/21] docs: add EEGBCI pattern discovery example --- .../implementation_plan.md | 9 +- examples/eeg/eegbci/README.md | 23 ++++ .../eeg/eegbci/eegbci_pattern_discovery.py | 113 ++++++++++++++++++ 3 files changed, 141 insertions(+), 4 deletions(-) create mode 100644 examples/eeg/eegbci/README.md create mode 100644 examples/eeg/eegbci/eegbci_pattern_discovery.py diff --git a/docs/eeg_pattern_discovery/implementation_plan.md b/docs/eeg_pattern_discovery/implementation_plan.md index 391b58b29..c5dbb4ee9 100644 --- a/docs/eeg_pattern_discovery/implementation_plan.md +++ b/docs/eeg_pattern_discovery/implementation_plan.md @@ -1326,7 +1326,7 @@ git commit -m "test: add opt-in EEGBCI real-data smoke test" - CLI flags: `--root`, `--subjects`, `--runs`, `--output-dir`, `--max-windows`, `--download`. - Produces: `eegbci_pattern_windows.csv` and `eegbci_pattern_summary.md`. -- [ ] **Step 1: Create example script** +- [x] **Step 1: Create example script** Create `examples/eeg/eegbci/eegbci_pattern_discovery.py`: @@ -1441,7 +1441,7 @@ if __name__ == "__main__": main() ``` -- [ ] **Step 2: Create README** +- [x] **Step 2: Create README** Create `examples/eeg/eegbci/README.md`: @@ -1471,7 +1471,7 @@ Outputs are written to `outputs/eegbci_pattern_discovery/` by default: - `eegbci_pattern_summary.md` ```` -- [ ] **Step 3: Run example on a tiny subset** +- [x] **Step 3: Run example on a tiny subset** Run: @@ -1486,7 +1486,7 @@ Wrote outputs/eegbci_pattern_discovery/eegbci_pattern_windows.csv Wrote outputs/eegbci_pattern_discovery/eegbci_pattern_summary.md ``` -- [ ] **Step 4: Commit task 5** +- [x] **Step 4: Commit task 5** Run: @@ -1683,3 +1683,4 @@ Do not include the optional embedding comparison in the initial implementation. - 2026-07-07: Task 2 complete; dataset metadata tests pass with `.venv/bin/python -m pytest tests/core/test_eegbci.py::TestEEGBCIDataset -v`. - 2026-07-07: Task 3 complete; `.venv/bin/python -m pytest tests/core/test_eegbci.py -v` passes with 21 tests. - 2026-07-07: Task 4 complete; normal EEGBCI tests pass with 21 passed/1 skipped, and `PYHEALTH_RUN_REAL_EEGBCI=1 .venv/bin/python -m pytest tests/core/test_eegbci.py::TestEEGBCIRealDataSmoke -v` passes against subject 1 run 3. +- 2026-07-07: Task 5 complete; `.venv/bin/python examples/eeg/eegbci/eegbci_pattern_discovery.py --subjects 1 --runs 3 --max-windows 20 --download` writes a 20-row CSV and Markdown summary. diff --git a/examples/eeg/eegbci/README.md b/examples/eeg/eegbci/README.md new file mode 100644 index 000000000..225670eb6 --- /dev/null +++ b/examples/eeg/eegbci/README.md @@ -0,0 +1,23 @@ +# EEGBCI Pattern Discovery + +This example uses `EEGBCIDataset` and `EEGBCIPatternDiscovery` to create +2-second EEGBCI windows with task labels, Welch bandpower features, and cautious +frequency-profile interpretations. + +The interpretations are exploratory signal metadata. They are not clinical +diagnoses and do not prove a subject's cognition. + +Run a tiny real-data example: + +```bash +python examples/eeg/eegbci/eegbci_pattern_discovery.py \ + --subjects 1 \ + --runs 3 \ + --max-windows 20 \ + --download +``` + +Outputs are written to `outputs/eegbci_pattern_discovery/` by default: + +- `eegbci_pattern_windows.csv` +- `eegbci_pattern_summary.md` diff --git a/examples/eeg/eegbci/eegbci_pattern_discovery.py b/examples/eeg/eegbci/eegbci_pattern_discovery.py new file mode 100644 index 000000000..b3188514d --- /dev/null +++ b/examples/eeg/eegbci/eegbci_pattern_discovery.py @@ -0,0 +1,113 @@ +from __future__ import annotations + +import argparse +import sys +from collections import Counter +from pathlib import Path + +import pandas as pd + +REPO_ROOT = Path(__file__).resolve().parents[3] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from pyhealth.datasets import EEGBCIDataset +from pyhealth.tasks import EEGBCIPatternDiscovery + + +def parse_int_list(value: str) -> list[int]: + items: list[int] = [] + for part in value.split(","): + if "-" in part: + start, end = part.split("-", 1) + items.extend(range(int(start), int(end) + 1)) + else: + items.append(int(part)) + return items + + +def sample_to_row(sample: dict) -> dict: + bandpower = sample["bandpower"] + return { + "patient_id": sample["patient_id"], + "record_id": sample["record_id"], + "subject_id": sample["subject_id"], + "run": sample["run"], + "run_type": sample["run_type"], + "trial_id": sample["trial_id"], + "event_code": sample["event_code"], + "task_label": sample["task_label"], + "label_family": sample["label_family"], + "label": sample["label"], + "start_time": sample["start_time"], + "end_time": sample["end_time"], + "dominant_band": bandpower["dominant_band"], + "alpha_beta_ratio": bandpower["alpha_beta_ratio"], + "theta_beta_ratio": bandpower["theta_beta_ratio"], + "brain_state_hypothesis": sample["brain_state_hypothesis"], + "confidence": sample["confidence"], + "quality_flags": sample["quality_flags"], + "interpretation": sample["interpretation"], + **{key: value for key, value in bandpower.items() if key.endswith("_power")}, + **{key: value for key, value in bandpower.items() if key.endswith("_relative")}, + } + + +def write_summary(rows: list[dict], path: Path) -> None: + task_counts = Counter(row["task_label"] for row in rows) + hypothesis_counts = Counter(row["brain_state_hypothesis"] for row in rows) + lines = [ + "# EEGBCI Pattern Discovery Summary", + "", + "Brain-state hypotheses are exploratory signal metadata, not clinical diagnoses.", + "", + f"Processed windows: {len(rows)}", + "", + "## Task Labels", + "", + ] + for label, count in task_counts.most_common(): + lines.append(f"- {label}: {count}") + lines.extend(["", "## Brain-State Hypotheses", ""]) + for label, count in hypothesis_counts.most_common(): + lines.append(f"- {label}: {count}") + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--root", default="~/.cache/pyhealth/eegbci") + parser.add_argument("--subjects", default="1,2,3") + parser.add_argument("--runs", default="3-14") + parser.add_argument("--output-dir", default="outputs/eegbci_pattern_discovery") + parser.add_argument("--max-windows", type=int, default=None) + parser.add_argument("--download", action="store_true") + args = parser.parse_args() + + output_dir = Path(args.output_dir).expanduser() + output_dir.mkdir(parents=True, exist_ok=True) + + dataset = EEGBCIDataset( + root=str(Path(args.root).expanduser()), + subjects=parse_int_list(args.subjects), + runs=parse_int_list(args.runs), + download=args.download, + ) + sample_dataset = dataset.set_task(EEGBCIPatternDiscovery(compute_stft=False)) + + rows = [] + for idx, sample in enumerate(sample_dataset): + if args.max_windows is not None and idx >= args.max_windows: + break + rows.append(sample_to_row(sample)) + + csv_path = output_dir / "eegbci_pattern_windows.csv" + summary_path = output_dir / "eegbci_pattern_summary.md" + pd.DataFrame(rows).to_csv(csv_path, index=False) + write_summary(rows, summary_path) + print(f"Wrote {csv_path}") + print(f"Wrote {summary_path}") + + +if __name__ == "__main__": + main() From 1aa8044f76ab9708077a917dc6909bef5848a6ba Mon Sep 17 00:00:00 2001 From: Vihaan Agrawal <247244351+vihaan101@users.noreply.github.com> Date: Tue, 7 Jul 2026 20:36:11 +0530 Subject: [PATCH 06/21] docs: add EEGBCI API docs --- docs/api/datasets.rst | 1 + docs/api/datasets/pyhealth.datasets.EEGBCIDataset.rst | 7 +++++++ docs/api/tasks.rst | 1 + docs/api/tasks/pyhealth.tasks.eegbci.rst | 7 +++++++ docs/eeg_pattern_discovery/implementation_plan.md | 11 ++++++----- 5 files changed, 22 insertions(+), 5 deletions(-) create mode 100644 docs/api/datasets/pyhealth.datasets.EEGBCIDataset.rst create mode 100644 docs/api/tasks/pyhealth.tasks.eegbci.rst diff --git a/docs/api/datasets.rst b/docs/api/datasets.rst index a23efb3d2..592aed487 100644 --- a/docs/api/datasets.rst +++ b/docs/api/datasets.rst @@ -241,6 +241,7 @@ Available Datasets datasets/pyhealth.datasets.COVID19CXRDataset datasets/pyhealth.datasets.ChestXray14Dataset datasets/pyhealth.datasets.PhysioNetDeIDDataset + datasets/pyhealth.datasets.EEGBCIDataset datasets/pyhealth.datasets.TUABDataset datasets/pyhealth.datasets.TUEVDataset datasets/pyhealth.datasets.ClinVarDataset diff --git a/docs/api/datasets/pyhealth.datasets.EEGBCIDataset.rst b/docs/api/datasets/pyhealth.datasets.EEGBCIDataset.rst new file mode 100644 index 000000000..8f4d427e9 --- /dev/null +++ b/docs/api/datasets/pyhealth.datasets.EEGBCIDataset.rst @@ -0,0 +1,7 @@ +pyhealth.datasets.EEGBCIDataset +================================ + +.. autoclass:: pyhealth.datasets.EEGBCIDataset + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/api/tasks.rst b/docs/api/tasks.rst index 8724176a8..c7910e626 100644 --- a/docs/api/tasks.rst +++ b/docs/api/tasks.rst @@ -223,6 +223,7 @@ Available Tasks Sleep Staging Sleep Staging (SleepEDF) Temple University EEG Tasks + EEGBCI Tasks Sleep Staging v2 Benchmark EHRShot ChestX-ray14 Binary Classification diff --git a/docs/api/tasks/pyhealth.tasks.eegbci.rst b/docs/api/tasks/pyhealth.tasks.eegbci.rst new file mode 100644 index 000000000..b2682057f --- /dev/null +++ b/docs/api/tasks/pyhealth.tasks.eegbci.rst @@ -0,0 +1,7 @@ +pyhealth.tasks.eegbci +===================== + +.. automodule:: pyhealth.tasks.eegbci + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/eeg_pattern_discovery/implementation_plan.md b/docs/eeg_pattern_discovery/implementation_plan.md index c5dbb4ee9..c1464f9a5 100644 --- a/docs/eeg_pattern_discovery/implementation_plan.md +++ b/docs/eeg_pattern_discovery/implementation_plan.md @@ -1509,7 +1509,7 @@ git commit -m "docs: add EEGBCI pattern discovery example" - Consumes: public classes and helpers from tasks 2 and 3. - Produces: Sphinx API pages. -- [ ] **Step 1: Add dataset API page** +- [x] **Step 1: Add dataset API page** Create `docs/api/datasets/pyhealth.datasets.EEGBCIDataset.rst`: @@ -1523,7 +1523,7 @@ pyhealth.datasets.EEGBCIDataset :show-inheritance: ``` -- [ ] **Step 2: Add task API page** +- [x] **Step 2: Add task API page** Create `docs/api/tasks/pyhealth.tasks.eegbci.rst`: @@ -1537,7 +1537,7 @@ pyhealth.tasks.eegbci :show-inheritance: ``` -- [ ] **Step 3: Include pages in API indexes** +- [x] **Step 3: Include pages in API indexes** Add the dataset page to the relevant `.. toctree::` in `docs/api/datasets.rst`: @@ -1551,7 +1551,7 @@ Add the task page to the relevant `.. toctree::` in `docs/api/tasks.rst`: tasks/pyhealth.tasks.eegbci ``` -- [ ] **Step 4: Run docs import smoke** +- [x] **Step 4: Run docs import smoke** Run: @@ -1573,7 +1573,7 @@ EEGMotorImageryEEGBCI EEGBCIPatternDiscovery ``` -- [ ] **Step 5: Commit task 6** +- [x] **Step 5: Commit task 6** Run: @@ -1684,3 +1684,4 @@ Do not include the optional embedding comparison in the initial implementation. - 2026-07-07: Task 3 complete; `.venv/bin/python -m pytest tests/core/test_eegbci.py -v` passes with 21 tests. - 2026-07-07: Task 4 complete; normal EEGBCI tests pass with 21 passed/1 skipped, and `PYHEALTH_RUN_REAL_EEGBCI=1 .venv/bin/python -m pytest tests/core/test_eegbci.py::TestEEGBCIRealDataSmoke -v` passes against subject 1 run 3. - 2026-07-07: Task 5 complete; `.venv/bin/python examples/eeg/eegbci/eegbci_pattern_discovery.py --subjects 1 --runs 3 --max-windows 20 --download` writes a 20-row CSV and Markdown summary. +- 2026-07-07: Task 6 complete; docs import smoke prints `EEGBCIDataset`, `EEGMotorImageryEEGBCI`, and `EEGBCIPatternDiscovery`. From 74123961f3e46d449f4e08d402f902ad5d88a053 Mon Sep 17 00:00:00 2001 From: Vihaan Agrawal <247244351+vihaan101@users.noreply.github.com> Date: Tue, 7 Jul 2026 20:38:46 +0530 Subject: [PATCH 07/21] chore: record EEGBCI verification --- docs/eeg_pattern_discovery/implementation_plan.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/eeg_pattern_discovery/implementation_plan.md b/docs/eeg_pattern_discovery/implementation_plan.md index c1464f9a5..8c6cb008d 100644 --- a/docs/eeg_pattern_discovery/implementation_plan.md +++ b/docs/eeg_pattern_discovery/implementation_plan.md @@ -1685,3 +1685,4 @@ Do not include the optional embedding comparison in the initial implementation. - 2026-07-07: Task 4 complete; normal EEGBCI tests pass with 21 passed/1 skipped, and `PYHEALTH_RUN_REAL_EEGBCI=1 .venv/bin/python -m pytest tests/core/test_eegbci.py::TestEEGBCIRealDataSmoke -v` passes against subject 1 run 3. - 2026-07-07: Task 5 complete; `.venv/bin/python examples/eeg/eegbci/eegbci_pattern_discovery.py --subjects 1 --runs 3 --max-windows 20 --download` writes a 20-row CSV and Markdown summary. - 2026-07-07: Task 6 complete; docs import smoke prints `EEGBCIDataset`, `EEGMotorImageryEEGBCI`, and `EEGBCIPatternDiscovery`. +- 2026-07-07: Final verification complete; default EEGBCI tests pass with 21 passed/1 skipped, import smoke prints `imports ok`, opt-in real-data smoke passes, the example writes verified 20-row artifacts, and `graphify update .` refreshed the code graph. From 88469adf40c188e33bd218156000c0af6cb474b2 Mon Sep 17 00:00:00 2001 From: Vihaan Agrawal <247244351+vihaan101@users.noreply.github.com> Date: Wed, 8 Jul 2026 11:49:43 +0530 Subject: [PATCH 08/21] docs: refine EEGBCI moment report design --- .../moment_report_continuation_plan.md | 413 ++++++++++++++++++ .../moment_report_refined_design.md | 323 ++++++++++++++ 2 files changed, 736 insertions(+) create mode 100644 docs/eeg_pattern_discovery/moment_report_continuation_plan.md create mode 100644 docs/eeg_pattern_discovery/moment_report_refined_design.md diff --git a/docs/eeg_pattern_discovery/moment_report_continuation_plan.md b/docs/eeg_pattern_discovery/moment_report_continuation_plan.md new file mode 100644 index 000000000..bb4a46f44 --- /dev/null +++ b/docs/eeg_pattern_discovery/moment_report_continuation_plan.md @@ -0,0 +1,413 @@ +# EEGBCI Moment Report Continuation Plan + +Date: 2026-07-08 +Status: ready for implementation +Branch: eegbci-pattern-discovery + +## Context + +The previous EEGBCI implementation plan has already been implemented. + +Do not restart from scratch. + +The existing implementation already added: + +- `EEGBCIDataset` +- `EEGMotorImageryEEGBCI` +- `EEGBCIPatternDiscovery` +- EEGBCI dataset/task exports +- EEGBCI docs/API pages +- offline unit tests +- skipped-by-default real-data smoke test +- `examples/eeg/eegbci/eegbci_pattern_discovery.py` +- `examples/eeg/eegbci/README.md` + +The problem is not missing core implementation. The problem is output quality. + +The current generated artifacts in `outputs/eegbci_pattern_discovery/` are weak: + +- Markdown summary reports only counts. +- All inspected windows collapse to `mixed_frequency_profile`. +- All inspected windows have `confidence=low`. +- All inspected windows have `quality_flags=low_confidence`. +- The summary does not compare task labels with inferred frequency-pattern states. +- The repeated row text `"This is exploratory signal metadata..."` is not useful. + +## New North Star + +Answer this question: + +> What is the brain doing in each moment, according to its frequency patterns? + +More precisely: + +> For each 2-second EEG segment, infer the most likely functional brain-state +> hypothesis from its frequency-band profile, then compare that hypothesis to the +> experimental task label. + +This moves the output from: + +> "Did the code produce a CSV?" + +to: + +> "At this moment, does the EEG look idle, motor-engaged, slow-wave dominant, +> artifact-like, mixed, or ambiguous, and does that match the task?" + +## Source Documents + +- `docs/eeg_pattern_discovery/brainstorm.md` +- `docs/eeg_pattern_discovery/design.md` +- `docs/eeg_pattern_discovery/pattern_analysis_redesign.md` +- `docs/eeg_pattern_discovery/implementation_plan.md` +- local CEO plan: + `/Users/vihaanagrawal/.gstack/projects/sunlabuiuc-PyHealth/ceo-plans/2026-07-08-eegbci-moment-report.md` + +## Scope + +Implement only the moment-report upgrade. + +Primary files: + +- `examples/eeg/eegbci/eegbci_pattern_discovery.py` +- `tests/core/test_eegbci.py` +- `examples/eeg/eegbci/README.md` +- this continuation plan, as progress is made + +Avoid touching: + +- `pyhealth/datasets/eegbci.py` +- `pyhealth/tasks/eegbci.py` +- dataset/task exports +- API RST docs + +Only touch `pyhealth/tasks/eegbci.py` if implementation proves a field must become +part of the reusable task API. Current decision: it should not. + +## Explicit Non-Goals + +Do not implement: + +- the full static brain-state atlas +- HTML report +- pretrained embedding comparison +- clustering or motif discovery +- subject-shift reliability suite +- clinical or cognitive claims +- new package dependencies +- new PyHealth model-training APIs +- a rewrite of the existing EEGBCI dataset/task work + +## CEO Review Decisions + +Mode: Selective Expansion. + +Baseline approach: analysis-grade moment report now, with the future atlas as the +north star. + +Accepted additions: + +1. Rest-normalized evidence. +2. Parseable quality flags. +3. Representative window cards. +4. Analysis versioning. + +Deferred: + +- HTML report. It belongs to the later atlas phase after CSV/Markdown prove useful. + +Boundary decision: + +- Keep moment-report fields example-only. +- Do not add `state_hypothesis`, `evidence_score`, rest-normalized deltas, + `task_state_relation`, or related fields to `EEGBCIPatternDiscovery` samples in + this PR. + +Reason: + +- These fields depend on cross-window context such as rest baselines and task-label + comparisons. They belong in the artifact generator, not the reusable per-sample + PyHealth task. + +## Product Thesis + +The useful artifact is not "a CSV with bandpower columns." + +The useful artifact is a moment-by-moment EEG state ledger. + +Each 2-second segment should tell a researcher: + +- what the subject was instructed to do +- what the EEG frequency profile looked like +- which functional state hypothesis best fits that profile +- how strong or weak the evidence is +- whether the frequency pattern supports, adds detail to, disagrees with, or is + ambiguous relative to the experimental task label + +## Required CSV Fields + +Keep existing useful fields: + +- `patient_id` +- `record_id` +- `subject_id` +- `run` +- `run_type` +- `trial_id` +- `event_code` +- `task_label` +- `label_family` +- `label` +- `eegbci_label` +- `model_label` +- `start_time` +- `end_time` +- bandpower absolute and relative fields +- `dominant_band` +- `alpha_beta_ratio` +- `theta_beta_ratio` +- `brain_state_hypothesis` +- `confidence` +- `quality_flags` +- `interpretation` + +Add: + +- `analysis_version` +- `state_hypothesis` +- `state_confidence` +- `evidence_score` +- `evidence_summary` +- `rest_reference_scope` +- `rest_delta_relative_delta` +- `rest_theta_relative_delta` +- `rest_alpha_relative_delta` +- `rest_beta_relative_delta` +- `rest_gamma_relative_delta` +- `task_state_relation` +- `task_state_rationale` +- `task_state_confidence` +- `is_low_confidence` +- `is_possible_artifact` +- `is_mixed_or_ambiguous` + +Use: + +```python +ANALYSIS_VERSION = "eegbci_pattern_moment_report_v1" +``` + +## Functional State Vocabulary + +Use these report-level state names: + +| State | Meaning | +| --- | --- | +| `idle_alpha_profile` | Alpha is elevated and alpha/beta is high enough to look idle-like. | +| `sensorimotor_engagement_profile` | Beta or low-gamma evidence is elevated enough to look motor-engaged. | +| `slow_wave_dominant_pattern` | Delta/theta evidence dominates, without implying cognition or diagnosis. | +| `possible_artifact_profile` | Gamma spike, extreme power, or noisy profile suggests inspection. | +| `mixed_ambiguous_profile` | No state wins cleanly or several weak signals conflict. | + +Do not use `slow_wave_drowsy_profile`. It sounds too cognitive/clinical. + +## Rest Baseline Rules + +Rest-normalized evidence is required. + +Compute baselines before applying `--max-windows` whenever possible. + +Baseline fallback order: + +1. Same subject and same run rest windows. +2. Same subject, all requested runs, rest windows. +3. All requested subjects/runs, rest windows. +4. `unavailable`. + +If no baseline exists: + +- set `rest_reference_scope = "unavailable"` +- set rest-normalized deltas to `NaN` or blank +- state the limitation in Markdown + +Do not silently use a missing baseline. + +## Task-State Relation Rules + +Use this deterministic first-pass table: + +| Task family | State hypothesis | Relation | +| --- | --- | --- | +| rest | `idle_alpha_profile` | `supports_label` | +| rest | `mixed_ambiguous_profile` | `ambiguous` | +| rest | `possible_artifact_profile` | `not_applicable` | +| motor execution | `sensorimotor_engagement_profile` | `supports_label` | +| motor imagery | `sensorimotor_engagement_profile` | `adds_detail` | +| any motor task | `idle_alpha_profile` | `disagrees` | +| any task | `slow_wave_dominant_pattern` | `adds_detail` | +| any task | `possible_artifact_profile` | `not_applicable` | +| any task | `mixed_ambiguous_profile` | `ambiguous` | + +Add one sentence of rationale in `task_state_rationale`. + +## Representative Window Cards + +Markdown summary must include deterministic representative windows. + +Cards to include when present: + +- strongest idle-like window +- strongest motor-engaged window +- strongest slow-wave dominant window +- strongest artifact-like window +- most ambiguous window +- strongest task/state disagreement + +Selection rules: + +1. Pick highest `evidence_score` for each state class. +2. Tie-break by higher `state_confidence`. +3. Tie-break by earliest `subject_id`, then `run`, then `start_time`. +4. If a state class is absent, omit it and list it as absent. +5. Add one disagreement card using the highest-evidence row where + `task_state_relation == "disagrees"`. + +Each card should include: + +- subject +- run +- trial id +- task label +- time range +- state +- evidence score +- dominant band +- relative band values +- rest-normalized deltas +- task-state relation +- confidence +- quality flags +- one-line rationale + +## Markdown Summary Contract + +The summary must not start with the generic exploratory caveat. + +Required sections: + +1. Executive result. +2. Run configuration. +3. Window coverage. +4. Moment-state summary. +5. Task label x state matrix. +6. Rest-normalized bandpower summary. +7. Confidence and quality audit. +8. Representative windows. +9. Limitations. +10. Next checks. + +The non-clinical warning should live in `Limitations`, for example: + +> These labels are signal-pattern summaries from short EEG windows. They are not +> clinical findings and should not be read as evidence of a subject's cognition. + +The summary must explicitly say when: + +- every window is low confidence +- every window maps to the same state +- no rest baseline is available +- output was capped by `--max-windows` + +## Implementation Shape + +Keep helper functions pure and testable. + +Recommended shape inside `examples/eeg/eegbci/eegbci_pattern_discovery.py`: + +```python +ANALYSIS_VERSION = "eegbci_pattern_moment_report_v1" + +def build_rest_baselines(rows): ... +def annotate_moment_rows(rows, baselines): ... +def derive_state_hypothesis(row): ... +def derive_quality_columns(row): ... +def task_state_relation(row): ... +def select_representative_windows(rows): ... +def render_summary(rows, config): ... +``` + +Avoid turning `write_summary()` into a giant function. + +Do not create new modules unless this file becomes genuinely hard to read. + +## Test Plan + +Add focused tests around report helpers using synthetic rows. + +Required tests: + +- rest baseline fallback: + - same-run rest + - same-subject all-run rest + - global rest + - unavailable baseline +- parseable quality flags: + - booleans match `quality_flags` + - ambiguous state sets `is_mixed_or_ambiguous` +- task-state comparison: + - deterministic relation table + - rationale is non-empty +- representative windows: + - deterministic selection + - tie-breaks are stable + - absent state classes are handled +- analysis version: + - appears in every CSV row + - appears near the top of Markdown +- interpretation language: + - row-level interpretation does not contain + `"This is exploratory signal metadata"` +- empty/edge outputs: + - no rest windows + - all low-confidence rows + - all same-state rows + - `--max-windows=0` + +Keep existing tests for dataset/task behavior. + +## Verification Commands + +Use the project venv. Plain `python` may not exist in this workspace. + +```bash +.venv/bin/python -m pytest tests/core/test_eegbci.py -v + +.venv/bin/python examples/eeg/eegbci/eegbci_pattern_discovery.py \ + --subjects 1 \ + --runs 3 \ + --max-windows 20 \ + --download +``` + +Then validate: + +- CSV contains all required moment-report columns. +- Markdown contains all required sections. +- Markdown does not start with the generic exploratory caveat. +- Markdown includes representative windows. +- Markdown explains all-low-confidence or all-ambiguous outcomes when they happen. + +After code changes: + +```bash +graphify update . +``` + +## Progress Log + +- 2026-07-08: Created continuation plan after `/office-hours` and + `/plan-ceo-review`. This plan explicitly continues from the already-implemented + EEGBCI dataset/task/example work and scopes only the moment-report upgrade. +- 2026-07-08: Refined and challenged the continuation plan through + `superpowers:brainstorming`. Wrote the approved design to + `docs/eeg_pattern_discovery/moment_report_refined_design.md`. diff --git a/docs/eeg_pattern_discovery/moment_report_refined_design.md b/docs/eeg_pattern_discovery/moment_report_refined_design.md new file mode 100644 index 000000000..b7b0f845f --- /dev/null +++ b/docs/eeg_pattern_discovery/moment_report_refined_design.md @@ -0,0 +1,323 @@ +# EEGBCI Moment Report Refined Design + +Date: 2026-07-08 +Status: approved for implementation planning +Scope: EEGBCI pattern-discovery moment-report upgrade + +## Purpose + +The existing EEGBCI dataset, tasks, tests, docs, and example have already been +implemented. The remaining problem is artifact quality: the generated CSV and +Markdown prove that the pipeline runs, but they do not yet answer the analysis +question. + +The upgraded artifact should answer: + +> What is the brain doing in each moment, according to its frequency patterns? + +More precisely, for each 2-second EEG segment, the report should infer the most +likely frequency-profile state hypothesis, expose the evidence for that +hypothesis, and compare it with the experimental EEGBCI task label. + +The design should refine and challenge the continuation plan, not restart the +EEGBCI integration. + +## Recommended Approach + +Use an example-owned analysis layer with pure helper functions. + +Keep `EEGBCIDataset`, `EEGMotorImageryEEGBCI`, and `EEGBCIPatternDiscovery` +stable. Do not add report-only fields to the reusable PyHealth task API unless +implementation proves that a field is intrinsically per-window and reusable +outside the report. + +The report flow should be: + +```text +EEGBCIDataset + -> EEGBCIPatternDiscovery + -> sample_to_row() + -> build_rest_baselines() + -> annotate_moment_rows() + -> write CSV + -> render_summary() + -> write Markdown +``` + +The current public task fields remain useful compatibility data: + +- `brain_state_hypothesis` +- `confidence` +- `quality_flags` +- `interpretation` + +The upgraded report should primarily use new example-level fields: + +- `analysis_version` +- `state_hypothesis` +- `state_confidence` +- `evidence_score` +- `evidence_summary` +- `rest_reference_scope` +- `rest_delta_relative_delta` +- `rest_theta_relative_delta` +- `rest_alpha_relative_delta` +- `rest_beta_relative_delta` +- `rest_gamma_relative_delta` +- `task_state_relation` +- `task_state_rationale` +- `task_state_confidence` +- parseable quality booleans + +## Approaches Considered + +### A. Summary-Only Patch + +Only rewrite `write_summary()` and leave CSV rows mostly unchanged. + +This is small and low-risk, but it does not fix the core defect. Rows can still +collapse into weak `mixed_frequency_profile` metadata with no inspectable +evidence. Reject this as underpowered. + +### B. Example-Owned Analysis Helpers + +Add pure helpers in `examples/eeg/eegbci/eegbci_pattern_discovery.py` for rest +baselines, state scoring, quality flags, task/state comparison, representative +window selection, and Markdown rendering. + +This is the recommended path. It keeps cross-window analysis out of the reusable +task API while making the generated artifact substantially more useful. The main +risk is example-file growth, so helpers must be named, pure, and directly tested. + +### C. Promote Moment Fields Into The Task + +Add `state_hypothesis`, `evidence_score`, rest deltas, and task/state comparison +to `EEGBCIPatternDiscovery` samples. + +Reject this for now. Rest baselines and task/state comparisons depend on +cross-window context. PyHealth tasks currently emit independent samples, so this +would blur the abstraction boundary. + +## Architecture + +The upgraded moment report is an analysis layer owned by the example. The public +dataset and task API should remain stable. + +`--max-windows` should cap the final artifact, but rest baselines should be built +from all available requested rows when feasible. Otherwise a small capped run can +accidentally remove rest evidence and make the analysis look weaker than the +data. If full baseline collection is not feasible, the report must say the +baseline was computed from capped rows. + +Keep helper functions pure and testable: + +```python +ANALYSIS_VERSION = "eegbci_pattern_moment_report_v1" + +def build_rest_baselines(rows): ... +def annotate_moment_rows(rows, baselines): ... +def derive_state_hypothesis(row): ... +def derive_quality_columns(row): ... +def derive_task_state_relation(row): ... +def select_representative_windows(rows): ... +def render_summary(rows, config): ... +``` + +Do not create a shared module in this phase. If the example later becomes too +large, these helpers can move to an example utility module without changing the +public PyHealth API. + +## State Vocabulary + +Use frequency-profile names, not cognitive or clinical claims. + +| State | Meaning | +| --- | --- | +| `idle_alpha_profile` | Alpha is elevated and alpha/beta is high enough to look idle-like. | +| `sensorimotor_engagement_profile` | Beta or low-gamma evidence is elevated enough to look motor-engaged. | +| `slow_wave_dominant_pattern` | Delta/theta evidence dominates without implying cognition or diagnosis. | +| `possible_artifact_profile` | Gamma spike, extreme power, or noisy profile suggests inspection. | +| `mixed_ambiguous_profile` | No state wins cleanly or several weak signals conflict. | + +Do not use `slow_wave_drowsy_profile`; it implies a cognitive state. + +## Evidence Scoring + +The state scorer should combine: + +- relative band shares +- absolute ratios such as alpha/beta and theta/beta +- rest-normalized deltas when available +- quality and artifact checks +- margin between the winning state and alternatives + +The output should include: + +- `state_hypothesis` +- `state_confidence` +- `evidence_score` +- `evidence_summary` + +Confidence should not rise merely because a state wins. It should require a +meaningful margin over alternatives or useful rest-normalized evidence. If all +state scores are weak, choose `mixed_ambiguous_profile` with low confidence and +explain near misses in `evidence_summary`. + +## Rest Baselines + +Compute rest baselines before applying the final `--max-windows` cap whenever +possible. + +Fallback order: + +1. Same subject and same run rest windows. +2. Same subject, all requested runs, rest windows. +3. All requested subjects/runs, rest windows. +4. `unavailable`. + +Each annotated row must record `rest_reference_scope`. If no baseline exists: + +- set `rest_reference_scope = "unavailable"` +- set rest-normalized deltas to blank or NaN +- state the limitation in Markdown + +Do not silently substitute missing rest evidence. + +## Task-State Relation + +Compare the inferred frequency-profile state with the EEGBCI experimental task +label using a deterministic first-pass table. + +| Task family | State hypothesis | Relation | +| --- | --- | --- | +| rest | `idle_alpha_profile` | `supports_label` | +| rest | `mixed_ambiguous_profile` | `ambiguous` | +| rest | `possible_artifact_profile` | `not_applicable` | +| motor execution | `sensorimotor_engagement_profile` | `supports_label` | +| motor imagery | `sensorimotor_engagement_profile` | `adds_detail` | +| any motor task | `idle_alpha_profile` | `disagrees` | +| any task | `slow_wave_dominant_pattern` | `adds_detail` | +| any task | `possible_artifact_profile` | `not_applicable` | +| any task | `mixed_ambiguous_profile` | `ambiguous` | + +Each row should include a concise `task_state_rationale` and +`task_state_confidence`. + +## Markdown Report Contract + +The Markdown report should be a compact analysis result, not a run receipt. + +Required sections: + +1. Executive result. +2. Run configuration. +3. Window coverage. +4. Moment-state summary. +5. Task label x state matrix. +6. Rest-normalized bandpower summary. +7. Confidence and quality audit. +8. Representative windows. +9. Limitations. +10. Next checks. + +The executive result must explicitly say when: + +- every row is low confidence +- every row maps to the same state +- no rest baseline is available +- output was capped by `--max-windows` +- no windows were produced + +Move the non-clinical warning to `Limitations`. Do not repeat it in every row. + +## Representative Windows + +Select deterministic representative cards when present: + +- strongest idle-like window +- strongest motor-engaged window +- strongest slow-wave dominant window +- strongest artifact-like window +- most ambiguous window +- strongest task/state disagreement + +Selection rules: + +1. Pick highest `evidence_score` for each state class. +2. Tie-break by higher `state_confidence`. +3. Tie-break by earliest `subject_id`, then `run`, then `start_time`. +4. Omit absent state classes and list them as absent. +5. For the most ambiguous card, pick the `mixed_ambiguous_profile` row with the + lowest `evidence_score`, then use the same stable tie-breaks. +6. Add one disagreement card using the highest-evidence row where + `task_state_relation == "disagrees"`. + +Cards should show evidence and uncertainty so "strongest" does not imply the +evidence is strong in an absolute sense. + +## Edge Cases + +Handle these explicitly: + +- No rows: write an empty CSV with expected columns and a Markdown report + explaining no windows were produced. +- `--max-windows=0`: valid command, no final artifact rows, no crash. +- No rest windows: baseline scope is `unavailable`, rest deltas are blank or + NaN, and the Markdown states the limitation. +- All rows low confidence: state this in the executive result and audit section. +- All rows same state: state that the analysis collapsed and recommend broader + coverage or threshold review. +- All rows ambiguous: treat as a valid weak result, not a silent failure. +- Missing optional legacy task fields: tolerate them where possible. +- Missing required metadata fields: fail clearly. + +## Non-Goals + +Do not implement: + +- HTML report output +- pretrained embedding comparison +- clustering or motif discovery +- a full static brain-state atlas +- clinical or cognitive claims +- new package dependencies +- changes to dataset/task exports or API RST pages +- a rewrite of the existing EEGBCI dataset/task work + +## Test Strategy + +Use synthetic rows for report-helper tests. Do not download EEGBCI data in normal +tests. + +Required test areas: + +- rest baseline fallback: same-run, same-subject, global, unavailable +- state scoring: alpha-like, beta/motor-like, slow-wave, artifact-like, + ambiguous +- confidence: weak winner remains low confidence; strong margin can become + medium +- task-state relation: deterministic relation and non-empty rationale +- quality booleans: `is_low_confidence`, `is_possible_artifact`, and + `is_mixed_or_ambiguous` +- representative windows: deterministic selection and stable tie-breaks +- report rendering: required sections and limitations warning +- CSV schema: `analysis_version` and moment-report fields in every row +- empty and capped outputs: clear Markdown and no crash + +Threshold tests should use obvious synthetic examples rather than overfitting to +exact real EEGBCI values. + +## Documentation Updates + +Update: + +- `examples/eeg/eegbci/README.md` +- `docs/eeg_pattern_discovery/moment_report_continuation_plan.md` + +The README should describe the upgraded CSV and Markdown report fields. The +continuation plan should record implementation progress as work proceeds. + +## Approval + +This refined design was reviewed section by section during brainstorming and +approved for implementation planning on 2026-07-08. From d15a5338bb6e8ccdad3ae24dffba8644e98179cc Mon Sep 17 00:00:00 2001 From: Vihaan Agrawal <247244351+vihaan101@users.noreply.github.com> Date: Wed, 8 Jul 2026 12:17:38 +0530 Subject: [PATCH 09/21] Add EEGBCI moment report constants --- .../moment_report_continuation_plan.md | 25 + .../moment_report_implementation_plan.md | 1702 +++++++++++++++++ .../eeg/eegbci/eegbci_pattern_discovery.py | 17 +- tests/core/test_eegbci.py | 129 ++ 4 files changed, 1872 insertions(+), 1 deletion(-) create mode 100644 docs/eeg_pattern_discovery/moment_report_implementation_plan.md diff --git a/docs/eeg_pattern_discovery/moment_report_continuation_plan.md b/docs/eeg_pattern_discovery/moment_report_continuation_plan.md index bb4a46f44..6d4d6bd9c 100644 --- a/docs/eeg_pattern_discovery/moment_report_continuation_plan.md +++ b/docs/eeg_pattern_discovery/moment_report_continuation_plan.md @@ -411,3 +411,28 @@ graphify update . - 2026-07-08: Refined and challenged the continuation plan through `superpowers:brainstorming`. Wrote the approved design to `docs/eeg_pattern_discovery/moment_report_refined_design.md`. +- 2026-07-08: Converted the approved refined design into + `docs/eeg_pattern_discovery/moment_report_implementation_plan.md` using + `superpowers:writing-plans`. Ran GStack `/plan-eng-review` against that plan + before code implementation; the review approved the example-owned architecture + and tightened empty CSV handling with stable `OUTPUT_COLUMNS`. +- 2026-07-08: Added an extensive correctness test matrix to the implementation + plan, covering rest fallback, state scoring, task-state relation, quality + booleans, representative windows, Markdown rendering, empty CSV behavior, + `--max-windows=0`, uncapped baselines, and end-to-end artifact checks. +- 2026-07-08: Added a post-implementation review gate to the implementation + plan. The recommended workflow is same-chat orchestration, independent + sub-agent or fresh review context for GStack `/review`, Markdown review output + at `docs/eeg_pattern_discovery/moment_report_review.md`, then main-chat fix + implementation and re-verification. +- 2026-07-08: Tightened the post-implementation review gate: final GStack + `/review` must run in an independent sub-agent or separate session, not inline + in the same reasoning thread that implemented the feature. +- 2026-07-08: Added an autonomous execution contract to the implementation plan. + It now explicitly requires Tasks 1-10, focused and full verification, artifact + checks, `graphify update .`, independent GStack `/review`, review Markdown, + accepted-fix implementation, and post-fix re-verification before completion. +- 2026-07-08: Started moment-report implementation. Task 1 is complete: + added the synthetic moment-row fixture test, added `ANALYSIS_VERSION`, + `REPORT_BANDS`, and `STATE_CONFIDENCE_RANK`, confirmed the focused test failed + before implementation and passed after implementation. diff --git a/docs/eeg_pattern_discovery/moment_report_implementation_plan.md b/docs/eeg_pattern_discovery/moment_report_implementation_plan.md new file mode 100644 index 000000000..d9b01e6d4 --- /dev/null +++ b/docs/eeg_pattern_discovery/moment_report_implementation_plan.md @@ -0,0 +1,1702 @@ +# EEGBCI Moment Report Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Upgrade the EEGBCI example artifact from a run receipt into a moment-by-moment frequency-pattern report with rest-normalized evidence, state hypotheses, task-state comparison, representative windows, and explicit limitations. + +**Architecture:** Keep `EEGBCIDataset`, `EEGMotorImageryEEGBCI`, and `EEGBCIPatternDiscovery` stable. Add an example-owned analysis layer in `examples/eeg/eegbci/eegbci_pattern_discovery.py` that converts per-window task samples into rows, computes rest baselines across the requested rows, annotates rows, writes CSV, and renders a Markdown analysis report. + +**Tech Stack:** Python, pandas, standard library `collections.Counter`, existing PyHealth EEGBCI dataset/task APIs, `unittest` tests in `tests/core/test_eegbci.py`. + +## Global Constraints + +- Do not add new package dependencies. +- Do not change dataset/task exports or API RST pages for this moment-report pass. +- Keep report-only fields example-level unless implementation proves they are intrinsically per-window and reusable outside the report. +- Do not use clinical or cognitive claims. +- Use `ANALYSIS_VERSION = "eegbci_pattern_moment_report_v1"`. +- Normal tests must use synthetic rows and must not download EEGBCI data. +- `--max-windows` caps the final artifact rows, not the baseline source rows. +- After code changes, run `graphify update .`. + +--- + +## File Structure + +- Modify `examples/eeg/eegbci/eegbci_pattern_discovery.py`: add pure report helper functions, update `main()` data flow, write enriched CSV, render Markdown report. +- Modify `tests/core/test_eegbci.py`: add tests for report helpers using synthetic rows. +- Modify `examples/eeg/eegbci/README.md`: document the upgraded CSV and Markdown report. +- Modify `docs/eeg_pattern_discovery/moment_report_continuation_plan.md`: keep the progress log current. + +Do not modify: + +- `pyhealth/datasets/eegbci.py` +- `pyhealth/tasks/eegbci.py` +- `pyhealth/datasets/__init__.py` +- `pyhealth/tasks/__init__.py` +- `docs/api/datasets/pyhealth.datasets.EEGBCIDataset.rst` +- `docs/api/tasks/pyhealth.tasks.eegbci.rst` +- `docs/api/datasets.rst` +- `docs/api/tasks.rst` + +## Data Flow + +```text +EEGBCIDataset + -> dataset.set_task(EEGBCIPatternDiscovery(compute_stft=False)) + -> collect all requested sample_to_row(sample) rows + -> build_rest_baselines(all_rows) + -> annotate_moment_rows(all_rows, baselines) + -> apply --max-windows cap to annotated rows + -> write eegbci_pattern_windows.csv + -> render_summary(capped_rows, config) + -> write eegbci_pattern_summary.md +``` + +## Autonomous Execution Contract + +This plan is intended to be executed end to end by an implementation agent. +It is not a shell script, but it is a complete execution contract. + +Required execution flow: + +```text +1. Execute Tasks 1-10 in order. +2. For each task: + - write the failing tests first + - run the named focused test command and confirm failure + - implement the minimal code/doc change + - run the named focused test command and confirm pass + - update `docs/eeg_pattern_discovery/moment_report_continuation_plan.md` + when progress meaningfully changes +3. After Task 10: + - run `.venv/bin/python -m pytest tests/core/test_eegbci.py -v` + - run the real-data example command when network/data access is available + - validate generated CSV and Markdown artifacts + - run `graphify update .` +4. Dispatch an independent sub-agent or separate session to run GStack `/review`. +5. Require the independent reviewer to write + `docs/eeg_pattern_discovery/moment_report_review.md`. +6. Main implementation chat reads the review document, applies accepted fixes, + and records deferred or rejected findings with rationale. +7. Rerun focused tests for every fix, then full EEGBCI tests and artifact checks. +8. Update `moment_report_review.md` with fix log, post-fix verification, and + final verdict. +9. Report completion only after code, docs, tests, artifacts, graph update, and + review fixes are complete. +``` + +Use `superpowers:subagent-driven-development` when available for Task 1-10 +implementation. Use one independent sub-agent per task or small task group when +the task can be reviewed independently. Keep the main chat responsible for +reviewing sub-agent output, applying final patches, and running verification. + +Do not stop for user input unless: + +- a required dependency or data source is unavailable +- tests fail in a way that contradicts the approved design +- the independent review finds a scope change that would modify the public + PyHealth dataset/task APIs +- a destructive or externally visible action would be required + +## Task 1: Report Constants And Synthetic Test Fixture + +**Files:** +- Modify: `examples/eeg/eegbci/eegbci_pattern_discovery.py` +- Modify: `tests/core/test_eegbci.py` + +**Interfaces:** +- Produces: `ANALYSIS_VERSION: str` +- Produces: `REPORT_BANDS: tuple[str, ...]` +- Produces: test helper `_moment_row(**overrides) -> dict` + +- [x] **Step 1: Write failing import and fixture test** + +Add this test class near the end of `tests/core/test_eegbci.py`, before `TestEEGBCIRealDataSmoke`: + +```python +class TestEEGBCIMomentReportHelpers(unittest.TestCase): + def _moment_row(self, **overrides): + row = { + "patient_id": "S001", + "record_id": "R03", + "subject_id": 1, + "run": 3, + "run_type": "motor_execution_left_right", + "trial_id": "S001_R03_T0_0", + "event_code": "T0", + "task_label": "rest", + "label_family": "rest", + "label": 0, + "eegbci_label": 0, + "model_label": 0, + "start_time": 0.0, + "end_time": 2.0, + "dominant_band": "alpha", + "delta_relative": 0.05, + "theta_relative": 0.10, + "alpha_relative": 0.55, + "beta_relative": 0.20, + "gamma_relative": 0.10, + "alpha_beta_ratio": 2.75, + "theta_beta_ratio": 0.50, + "brain_state_hypothesis": "relaxed_or_idle", + "confidence": "medium", + "quality_flags": "", + "interpretation": "Alpha-dominant profile.", + } + row.update(overrides) + return row + + def test_analysis_version_constant(self): + from examples.eeg.eegbci.eegbci_pattern_discovery import ANALYSIS_VERSION + + self.assertEqual(ANALYSIS_VERSION, "eegbci_pattern_moment_report_v1") +``` + +- [x] **Step 2: Run test to verify it fails** + +Run: + +```bash +.venv/bin/python -m pytest tests/core/test_eegbci.py::TestEEGBCIMomentReportHelpers::test_analysis_version_constant -v +``` + +Expected: fail with `ImportError` or `AttributeError` for missing `ANALYSIS_VERSION`. + +- [x] **Step 3: Add constants** + +Add below the PyHealth imports in `examples/eeg/eegbci/eegbci_pattern_discovery.py`: + +```python +ANALYSIS_VERSION = "eegbci_pattern_moment_report_v1" +REPORT_BANDS = ("delta", "theta", "alpha", "beta", "gamma") +STATE_CONFIDENCE_RANK = {"low": 0, "medium": 1, "high": 2} +``` + +- [x] **Step 4: Run test to verify it passes** + +Run: + +```bash +.venv/bin/python -m pytest tests/core/test_eegbci.py::TestEEGBCIMomentReportHelpers::test_analysis_version_constant -v +``` + +Expected: pass. + +## Task 2: Rest Baseline Builder + +**Files:** +- Modify: `examples/eeg/eegbci/eegbci_pattern_discovery.py` +- Modify: `tests/core/test_eegbci.py` + +**Interfaces:** +- Consumes: row dicts with `subject_id`, `run`, `task_label`, and `{band}_relative` +- Produces: `build_rest_baselines(rows: list[dict]) -> dict` + +The returned dict must include: + +```python +{ + "same_subject_run": {(1, 3): {"delta_relative": 0.05, ...}}, + "same_subject_all_runs": {1: {"delta_relative": 0.06, ...}}, + "global_rest": {"delta_relative": 0.07, ...}, +} +``` + +- [ ] **Step 1: Write failing baseline tests** + +Add these tests to `TestEEGBCIMomentReportHelpers`: + +```python + def test_build_rest_baselines_uses_rest_rows_only(self): + from examples.eeg.eegbci.eegbci_pattern_discovery import build_rest_baselines + + rows = [ + self._moment_row(task_label="rest", subject_id=1, run=3, alpha_relative=0.50), + self._moment_row(task_label="execute_left_fist", subject_id=1, run=3, alpha_relative=0.90), + self._moment_row(task_label="rest", subject_id=1, run=4, alpha_relative=0.70), + ] + + baselines = build_rest_baselines(rows) + + self.assertAlmostEqual( + baselines["same_subject_run"][(1, 3)]["alpha_relative"], 0.50 + ) + self.assertAlmostEqual( + baselines["same_subject_all_runs"][1]["alpha_relative"], 0.60 + ) + self.assertAlmostEqual(baselines["global_rest"]["alpha_relative"], 0.60) + + def test_build_rest_baselines_handles_no_rest_rows(self): + from examples.eeg.eegbci.eegbci_pattern_discovery import build_rest_baselines + + rows = [self._moment_row(task_label="execute_left_fist", label_family="motor_execution")] + + baselines = build_rest_baselines(rows) + + self.assertEqual(baselines["same_subject_run"], {}) + self.assertEqual(baselines["same_subject_all_runs"], {}) + self.assertIsNone(baselines["global_rest"]) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: + +```bash +.venv/bin/python -m pytest \ + tests/core/test_eegbci.py::TestEEGBCIMomentReportHelpers::test_build_rest_baselines_uses_rest_rows_only \ + tests/core/test_eegbci.py::TestEEGBCIMomentReportHelpers::test_build_rest_baselines_handles_no_rest_rows \ + -v +``` + +Expected: fail because `build_rest_baselines` is missing. + +- [ ] **Step 3: Implement baseline helpers** + +Add below `sample_to_row()`: + +```python +def _mean_band_values(rows: list[dict]) -> dict: + means = {} + for band in REPORT_BANDS: + key = f"{band}_relative" + values = [float(row[key]) for row in rows if row.get(key) not in ("", None)] + if values: + means[key] = sum(values) / len(values) + return means + + +def build_rest_baselines(rows: list[dict]) -> dict: + rest_rows = [row for row in rows if row.get("task_label") == "rest"] + same_subject_run = {} + same_subject_all_runs = {} + + subject_run_keys = sorted({(row["subject_id"], row["run"]) for row in rest_rows}) + for key in subject_run_keys: + subject_id, run = key + grouped = [ + row for row in rest_rows + if row["subject_id"] == subject_id and row["run"] == run + ] + same_subject_run[key] = _mean_band_values(grouped) + + subject_keys = sorted({row["subject_id"] for row in rest_rows}) + for subject_id in subject_keys: + grouped = [row for row in rest_rows if row["subject_id"] == subject_id] + same_subject_all_runs[subject_id] = _mean_band_values(grouped) + + return { + "same_subject_run": same_subject_run, + "same_subject_all_runs": same_subject_all_runs, + "global_rest": _mean_band_values(rest_rows) if rest_rows else None, + } +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: + +```bash +.venv/bin/python -m pytest tests/core/test_eegbci.py::TestEEGBCIMomentReportHelpers -k "baseline or analysis_version" -v +``` + +Expected: pass. + +## Task 3: Rest Fallback And State Scoring + +**Files:** +- Modify: `examples/eeg/eegbci/eegbci_pattern_discovery.py` +- Modify: `tests/core/test_eegbci.py` + +**Interfaces:** +- Consumes: `build_rest_baselines()` output +- Produces: `_baseline_for_row(row: dict, baselines: dict) -> tuple[str, dict | None]` +- Produces: `derive_state_hypothesis(row: dict) -> dict` + +`derive_state_hypothesis()` returns: + +```python +{ + "state_hypothesis": "idle_alpha_profile", + "state_confidence": "medium", + "evidence_score": 0.72, + "evidence_summary": "alpha=0.55; beta=0.20; gamma=0.10; alpha_beta=2.75; margin=0.21", +} +``` + +- [ ] **Step 1: Write failing rest fallback and state tests** + +Add these tests: + +```python + def test_annotate_rest_fallback_scopes(self): + from examples.eeg.eegbci.eegbci_pattern_discovery import ( + annotate_moment_rows, + build_rest_baselines, + ) + + rows = [ + self._moment_row(task_label="rest", subject_id=1, run=3, alpha_relative=0.50), + self._moment_row(task_label="rest", subject_id=1, run=4, alpha_relative=0.70), + self._moment_row(task_label="execute_left_fist", label_family="motor_execution", subject_id=1, run=3, alpha_relative=0.80), + self._moment_row(task_label="execute_left_fist", label_family="motor_execution", subject_id=1, run=5, alpha_relative=0.80), + self._moment_row(task_label="execute_left_fist", label_family="motor_execution", subject_id=2, run=8, alpha_relative=0.80), + ] + + annotated = annotate_moment_rows(rows, build_rest_baselines(rows)) + + self.assertEqual(annotated[2]["rest_reference_scope"], "same_subject_run") + self.assertEqual(annotated[3]["rest_reference_scope"], "same_subject_all_runs") + self.assertEqual(annotated[4]["rest_reference_scope"], "global_rest") + + def test_derive_state_hypothesis_detects_profiles(self): + from examples.eeg.eegbci.eegbci_pattern_discovery import derive_state_hypothesis + + cases = [ + (self._moment_row(alpha_relative=0.60, beta_relative=0.12, gamma_relative=0.05, alpha_beta_ratio=5.0), "idle_alpha_profile"), + (self._moment_row(alpha_relative=0.12, beta_relative=0.48, gamma_relative=0.16, alpha_beta_ratio=0.25), "sensorimotor_engagement_profile"), + (self._moment_row(delta_relative=0.42, theta_relative=0.36, alpha_relative=0.08, beta_relative=0.08), "slow_wave_dominant_pattern"), + (self._moment_row(gamma_relative=0.48, alpha_relative=0.10, beta_relative=0.12), "possible_artifact_profile"), + (self._moment_row(delta_relative=0.18, theta_relative=0.20, alpha_relative=0.22, beta_relative=0.21, gamma_relative=0.19, alpha_beta_ratio=1.05), "mixed_ambiguous_profile"), + ] + + for row, expected in cases: + with self.subTest(expected=expected): + result = derive_state_hypothesis(row) + self.assertEqual(result["state_hypothesis"], expected) + self.assertIn(result["state_confidence"], {"low", "medium", "high"}) + self.assertGreaterEqual(result["evidence_score"], 0.0) + self.assertLessEqual(result["evidence_score"], 1.0) + self.assertIn("alpha=", result["evidence_summary"]) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: + +```bash +.venv/bin/python -m pytest tests/core/test_eegbci.py::TestEEGBCIMomentReportHelpers -k "fallback or profiles" -v +``` + +Expected: fail because `annotate_moment_rows` and `derive_state_hypothesis` are missing. + +- [ ] **Step 3: Implement fallback and scoring** + +Add below `build_rest_baselines()`: + +```python +def _baseline_for_row(row: dict, baselines: dict) -> tuple[str, dict | None]: + subject_run_key = (row["subject_id"], row["run"]) + if subject_run_key in baselines["same_subject_run"]: + return "same_subject_run", baselines["same_subject_run"][subject_run_key] + if row["subject_id"] in baselines["same_subject_all_runs"]: + return "same_subject_all_runs", baselines["same_subject_all_runs"][row["subject_id"]] + if baselines["global_rest"]: + return "global_rest", baselines["global_rest"] + return "unavailable", None + + +def _clip01(value: float) -> float: + return max(0.0, min(1.0, value)) + + +def derive_state_hypothesis(row: dict) -> dict: + delta = float(row.get("delta_relative", 0.0) or 0.0) + theta = float(row.get("theta_relative", 0.0) or 0.0) + alpha = float(row.get("alpha_relative", 0.0) or 0.0) + beta = float(row.get("beta_relative", 0.0) or 0.0) + gamma = float(row.get("gamma_relative", 0.0) or 0.0) + alpha_beta = float(row.get("alpha_beta_ratio", 0.0) or 0.0) + theta_beta = float(row.get("theta_beta_ratio", 0.0) or 0.0) + + scores = { + "idle_alpha_profile": _clip01((alpha - 0.25) + min(alpha_beta / 8.0, 0.40)), + "sensorimotor_engagement_profile": _clip01((beta - 0.20) + max(gamma - 0.12, 0.0) + max(0.0, 1.5 - alpha_beta) / 6.0), + "slow_wave_dominant_pattern": _clip01((delta + theta) - 0.45 + min(theta_beta / 8.0, 0.20)), + "possible_artifact_profile": _clip01((gamma - 0.22) * 2.0 + max(delta - 0.50, 0.0)), + } + ordered = sorted(scores.items(), key=lambda item: item[1], reverse=True) + winner, winning_score = ordered[0] + runner_up = ordered[1][1] + margin = winning_score - runner_up + + if winning_score < 0.20 or margin < 0.08: + state = "mixed_ambiguous_profile" + evidence_score = round(max(winning_score, 0.10), 3) + confidence = "low" + else: + state = winner + evidence_score = round(winning_score, 3) + if winning_score >= 0.65 and margin >= 0.20: + confidence = "high" + elif winning_score >= 0.35 and margin >= 0.12: + confidence = "medium" + else: + confidence = "low" + + return { + "state_hypothesis": state, + "state_confidence": confidence, + "evidence_score": evidence_score, + "evidence_summary": ( + f"delta={delta:.3f}; theta={theta:.3f}; alpha={alpha:.3f}; " + f"beta={beta:.3f}; gamma={gamma:.3f}; alpha_beta={alpha_beta:.3f}; " + f"margin={margin:.3f}" + ), + } +``` + +- [ ] **Step 4: Run tests to verify current expected partial failure** + +Run: + +```bash +.venv/bin/python -m pytest tests/core/test_eegbci.py::TestEEGBCIMomentReportHelpers -k "profiles" -v +``` + +Expected: pass. The fallback test still fails until `annotate_moment_rows()` exists in Task 5. + +## Task 4: Task-State Relation And Quality Booleans + +**Files:** +- Modify: `examples/eeg/eegbci/eegbci_pattern_discovery.py` +- Modify: `tests/core/test_eegbci.py` + +**Interfaces:** +- Produces: `derive_task_state_relation(row: dict) -> dict` +- Produces: `derive_quality_columns(row: dict) -> dict` + +- [ ] **Step 1: Write failing tests** + +Add: + +```python + def test_task_state_relation_table_is_deterministic(self): + from examples.eeg.eegbci.eegbci_pattern_discovery import derive_task_state_relation + + cases = [ + ("rest", "rest", "idle_alpha_profile", "supports_label"), + ("rest", "rest", "mixed_ambiguous_profile", "ambiguous"), + ("rest", "rest", "possible_artifact_profile", "not_applicable"), + ("execute_left_fist", "motor_execution", "sensorimotor_engagement_profile", "supports_label"), + ("imagine_left_fist", "motor_imagery", "sensorimotor_engagement_profile", "adds_detail"), + ("execute_left_fist", "motor_execution", "idle_alpha_profile", "disagrees"), + ("imagine_left_fist", "motor_imagery", "slow_wave_dominant_pattern", "adds_detail"), + ] + + for task_label, label_family, state, expected in cases: + with self.subTest(state=state, label_family=label_family): + result = derive_task_state_relation( + self._moment_row( + task_label=task_label, + label_family=label_family, + state_hypothesis=state, + ) + ) + self.assertEqual(result["task_state_relation"], expected) + self.assertIn(result["task_state_confidence"], {"low", "medium", "high"}) + self.assertGreater(len(result["task_state_rationale"]), 20) + + def test_quality_booleans_are_parseable(self): + from examples.eeg.eegbci.eegbci_pattern_discovery import derive_quality_columns + + flags = derive_quality_columns( + self._moment_row( + state_hypothesis="possible_artifact_profile", + state_confidence="low", + quality_flags="low_confidence; high_gamma", + ) + ) + + self.assertTrue(flags["is_low_confidence"]) + self.assertTrue(flags["is_possible_artifact"]) + self.assertFalse(flags["is_mixed_or_ambiguous"]) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: + +```bash +.venv/bin/python -m pytest tests/core/test_eegbci.py::TestEEGBCIMomentReportHelpers -k "relation_table or quality_booleans" -v +``` + +Expected: fail because helper functions are missing. + +- [ ] **Step 3: Implement relation and quality helpers** + +Add: + +```python +def derive_task_state_relation(row: dict) -> dict: + label_family = row.get("label_family", "") + task_label = row.get("task_label", "") + state = row.get("state_hypothesis", "") + + if state == "possible_artifact_profile": + relation = "not_applicable" + confidence = "medium" + rationale = "Artifact-like frequency evidence is flagged for inspection instead of task-label comparison." + elif state == "mixed_ambiguous_profile": + relation = "ambiguous" + confidence = "low" + rationale = "No frequency-profile state won clearly enough to compare strongly with the task label." + elif task_label == "rest" and state == "idle_alpha_profile": + relation = "supports_label" + confidence = "medium" + rationale = "The idle-like alpha profile is consistent with a rest-labeled EEGBCI window." + elif label_family == "motor_execution" and state == "sensorimotor_engagement_profile": + relation = "supports_label" + confidence = "medium" + rationale = "The motor-engaged frequency profile is consistent with an execution-labeled window." + elif label_family == "motor_imagery" and state == "sensorimotor_engagement_profile": + relation = "adds_detail" + confidence = "medium" + rationale = "The motor-engaged frequency profile adds signal detail to an imagery-labeled window." + elif label_family in {"motor_execution", "motor_imagery"} and state == "idle_alpha_profile": + relation = "disagrees" + confidence = "medium" + rationale = "The idle-like alpha profile does not align with a motor-labeled EEGBCI window." + elif state == "slow_wave_dominant_pattern": + relation = "adds_detail" + confidence = "low" + rationale = "The slow-wave dominant pattern adds frequency detail but is not a direct task match." + else: + relation = "ambiguous" + confidence = "low" + rationale = "The task label and frequency-profile state do not have a stronger deterministic mapping." + + return { + "task_state_relation": relation, + "task_state_rationale": rationale, + "task_state_confidence": confidence, + } + + +def derive_quality_columns(row: dict) -> dict: + flags = str(row.get("quality_flags", "")) + state = row.get("state_hypothesis", "") + confidence = row.get("state_confidence", row.get("confidence", "")) + return { + "is_low_confidence": confidence == "low" or "low_confidence" in flags, + "is_possible_artifact": state == "possible_artifact_profile" or "artifact" in flags or "high_gamma" in flags, + "is_mixed_or_ambiguous": state == "mixed_ambiguous_profile" or "ambiguous" in flags, + } +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: + +```bash +.venv/bin/python -m pytest tests/core/test_eegbci.py::TestEEGBCIMomentReportHelpers -k "relation_table or quality_booleans" -v +``` + +Expected: pass. + +## Task 5: Row Annotation And CSV Schema + +**Files:** +- Modify: `examples/eeg/eegbci/eegbci_pattern_discovery.py` +- Modify: `tests/core/test_eegbci.py` + +**Interfaces:** +- Consumes: `derive_state_hypothesis()`, `derive_task_state_relation()`, `derive_quality_columns()`, `_baseline_for_row()` +- Produces: `annotate_moment_rows(rows: list[dict], baselines: dict) -> list[dict]` +- Produces: `BASE_OUTPUT_COLUMNS: tuple[str, ...]` +- Produces: `MOMENT_REPORT_COLUMNS: tuple[str, ...]` +- Produces: `OUTPUT_COLUMNS: tuple[str, ...]` + +- [ ] **Step 1: Write failing annotation tests** + +Add: + +```python + def test_annotate_moment_rows_adds_required_fields(self): + from examples.eeg.eegbci.eegbci_pattern_discovery import ( + ANALYSIS_VERSION, + annotate_moment_rows, + build_rest_baselines, + ) + + rows = [ + self._moment_row(task_label="rest", alpha_relative=0.50, beta_relative=0.20), + self._moment_row(task_label="execute_left_fist", label_family="motor_execution", alpha_relative=0.20, beta_relative=0.45), + ] + + annotated = annotate_moment_rows(rows, build_rest_baselines(rows)) + + row = annotated[1] + self.assertEqual(row["analysis_version"], ANALYSIS_VERSION) + self.assertIn(row["state_hypothesis"], { + "idle_alpha_profile", + "sensorimotor_engagement_profile", + "slow_wave_dominant_pattern", + "possible_artifact_profile", + "mixed_ambiguous_profile", + }) + self.assertIn("rest_alpha_relative_delta", row) + self.assertAlmostEqual(row["rest_alpha_relative_delta"], -0.30) + self.assertIn("task_state_relation", row) + self.assertIn("task_state_rationale", row) + self.assertIn("is_low_confidence", row) + + def test_annotate_moment_rows_marks_unavailable_rest(self): + from examples.eeg.eegbci.eegbci_pattern_discovery import ( + annotate_moment_rows, + build_rest_baselines, + ) + + rows = [self._moment_row(task_label="execute_left_fist", label_family="motor_execution")] + + annotated = annotate_moment_rows(rows, build_rest_baselines(rows)) + + self.assertEqual(annotated[0]["rest_reference_scope"], "unavailable") + self.assertEqual(annotated[0]["rest_alpha_relative_delta"], "") +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: + +```bash +.venv/bin/python -m pytest tests/core/test_eegbci.py::TestEEGBCIMomentReportHelpers -k "annotate_moment_rows or fallback_scopes" -v +``` + +Expected: fail until `annotate_moment_rows()` exists. + +- [ ] **Step 3: Implement annotation and schema** + +Add below `derive_quality_columns()`: + +```python +BASE_OUTPUT_COLUMNS = ( + "patient_id", + "record_id", + "subject_id", + "run", + "run_type", + "trial_id", + "event_code", + "task_label", + "label_family", + "label", + "eegbci_label", + "model_label", + "start_time", + "end_time", + "dominant_band", + "alpha_beta_ratio", + "theta_beta_ratio", + "brain_state_hypothesis", + "confidence", + "quality_flags", + "interpretation", + "delta_power", + "theta_power", + "alpha_power", + "beta_power", + "gamma_power", + "delta_relative", + "theta_relative", + "alpha_relative", + "beta_relative", + "gamma_relative", +) + +MOMENT_REPORT_COLUMNS = ( + "analysis_version", + "state_hypothesis", + "state_confidence", + "evidence_score", + "evidence_summary", + "rest_reference_scope", + "rest_delta_relative_delta", + "rest_theta_relative_delta", + "rest_alpha_relative_delta", + "rest_beta_relative_delta", + "rest_gamma_relative_delta", + "task_state_relation", + "task_state_rationale", + "task_state_confidence", + "is_low_confidence", + "is_possible_artifact", + "is_mixed_or_ambiguous", +) + +OUTPUT_COLUMNS = BASE_OUTPUT_COLUMNS + MOMENT_REPORT_COLUMNS + + +def annotate_moment_rows(rows: list[dict], baselines: dict) -> list[dict]: + annotated = [] + for row in rows: + next_row = dict(row) + scope, baseline = _baseline_for_row(next_row, baselines) + next_row["analysis_version"] = ANALYSIS_VERSION + next_row["rest_reference_scope"] = scope + + for band in REPORT_BANDS: + source_key = f"{band}_relative" + delta_key = f"rest_{band}_relative_delta" + if baseline and source_key in baseline and next_row.get(source_key) not in ("", None): + next_row[delta_key] = round(float(next_row[source_key]) - float(baseline[source_key]), 6) + else: + next_row[delta_key] = "" + + next_row.update(derive_state_hypothesis(next_row)) + next_row.update(derive_task_state_relation(next_row)) + next_row.update(derive_quality_columns(next_row)) + annotated.append(next_row) + return annotated +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: + +```bash +.venv/bin/python -m pytest tests/core/test_eegbci.py::TestEEGBCIMomentReportHelpers -k "annotate_moment_rows or fallback_scopes" -v +``` + +Expected: pass. + +## Task 6: Representative Window Selection + +**Files:** +- Modify: `examples/eeg/eegbci/eegbci_pattern_discovery.py` +- Modify: `tests/core/test_eegbci.py` + +**Interfaces:** +- Produces: `select_representative_windows(rows: list[dict]) -> dict` + +Return shape: + +```python +{ + "cards": {"strongest_idle_like": row, "most_ambiguous": row, ...}, + "absent": ["strongest_artifact_like", ...], +} +``` + +- [ ] **Step 1: Write failing representative selection tests** + +Add: + +```python + def test_select_representative_windows_is_deterministic(self): + from examples.eeg.eegbci.eegbci_pattern_discovery import select_representative_windows + + rows = [ + self._moment_row(subject_id=2, run=4, start_time=6.0, state_hypothesis="idle_alpha_profile", state_confidence="medium", evidence_score=0.80), + self._moment_row(subject_id=1, run=3, start_time=4.0, state_hypothesis="idle_alpha_profile", state_confidence="medium", evidence_score=0.80), + self._moment_row(subject_id=1, run=3, start_time=8.0, state_hypothesis="sensorimotor_engagement_profile", state_confidence="high", evidence_score=0.90), + self._moment_row(subject_id=1, run=3, start_time=10.0, state_hypothesis="mixed_ambiguous_profile", state_confidence="low", evidence_score=0.12), + self._moment_row(subject_id=1, run=3, start_time=12.0, state_hypothesis="idle_alpha_profile", task_state_relation="disagrees", state_confidence="medium", evidence_score=0.70), + ] + + selected = select_representative_windows(rows) + + self.assertEqual(selected["cards"]["strongest_idle_like"]["subject_id"], 1) + self.assertEqual(selected["cards"]["strongest_motor_engaged"]["state_hypothesis"], "sensorimotor_engagement_profile") + self.assertEqual(selected["cards"]["most_ambiguous"]["start_time"], 10.0) + self.assertEqual(selected["cards"]["strongest_task_state_disagreement"]["task_state_relation"], "disagrees") + self.assertIn("strongest_artifact_like", selected["absent"]) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: + +```bash +.venv/bin/python -m pytest tests/core/test_eegbci.py::TestEEGBCIMomentReportHelpers::test_select_representative_windows_is_deterministic -v +``` + +Expected: fail because `select_representative_windows` is missing. + +- [ ] **Step 3: Implement representative selection** + +Add: + +```python +def _stable_row_key(row: dict) -> tuple: + return ( + row.get("subject_id", 0), + row.get("run", 0), + float(row.get("start_time", 0.0) or 0.0), + ) + + +def _strongest_row(rows: list[dict]) -> dict | None: + if not rows: + return None + return sorted( + rows, + key=lambda row: ( + -float(row.get("evidence_score", 0.0) or 0.0), + -STATE_CONFIDENCE_RANK.get(row.get("state_confidence", "low"), 0), + *_stable_row_key(row), + ), + )[0] + + +def select_representative_windows(rows: list[dict]) -> dict: + definitions = { + "strongest_idle_like": "idle_alpha_profile", + "strongest_motor_engaged": "sensorimotor_engagement_profile", + "strongest_slow_wave": "slow_wave_dominant_pattern", + "strongest_artifact_like": "possible_artifact_profile", + } + cards = {} + absent = [] + + for card_name, state in definitions.items(): + candidate = _strongest_row([row for row in rows if row.get("state_hypothesis") == state]) + if candidate is None: + absent.append(card_name) + else: + cards[card_name] = candidate + + ambiguous = [row for row in rows if row.get("state_hypothesis") == "mixed_ambiguous_profile"] + if ambiguous: + cards["most_ambiguous"] = sorted( + ambiguous, + key=lambda row: ( + float(row.get("evidence_score", 0.0) or 0.0), + -STATE_CONFIDENCE_RANK.get(row.get("state_confidence", "low"), 0), + *_stable_row_key(row), + ), + )[0] + else: + absent.append("most_ambiguous") + + disagreement = _strongest_row( + [row for row in rows if row.get("task_state_relation") == "disagrees"] + ) + if disagreement is None: + absent.append("strongest_task_state_disagreement") + else: + cards["strongest_task_state_disagreement"] = disagreement + + return {"cards": cards, "absent": absent} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: + +```bash +.venv/bin/python -m pytest tests/core/test_eegbci.py::TestEEGBCIMomentReportHelpers::test_select_representative_windows_is_deterministic -v +``` + +Expected: pass. + +## Task 7: Markdown Summary Renderer + +**Files:** +- Modify: `examples/eeg/eegbci/eegbci_pattern_discovery.py` +- Modify: `tests/core/test_eegbci.py` + +**Interfaces:** +- Produces: `render_summary(rows: list[dict], config: dict) -> str` +- Updates: `write_summary(rows: list[dict], path: Path, config: dict) -> None` + +- [ ] **Step 1: Write failing renderer tests** + +Add: + +```python + def test_render_summary_contains_required_sections_and_limitations(self): + from examples.eeg.eegbci.eegbci_pattern_discovery import ( + ANALYSIS_VERSION, + annotate_moment_rows, + build_rest_baselines, + render_summary, + ) + + rows = [ + self._moment_row(task_label="execute_left_fist", label_family="motor_execution") + ] + annotated = annotate_moment_rows(rows, build_rest_baselines(rows)) + summary = render_summary( + annotated, + { + "subjects": [1], + "runs": [3], + "max_windows": 1, + "baseline_row_count": 1, + "output_was_capped": True, + }, + ) + + self.assertIn(ANALYSIS_VERSION, summary.splitlines()[2]) + for heading in [ + "## Executive Result", + "## Run Configuration", + "## Window Coverage", + "## Moment-State Summary", + "## Task Label x State Matrix", + "## Rest-Normalized Bandpower Summary", + "## Confidence and Quality Audit", + "## Representative Windows", + "## Limitations", + "## Next Checks", + ]: + self.assertIn(heading, summary) + self.assertIn("No rest baseline was available", summary) + self.assertIn("Output was capped by `--max-windows`", summary) + self.assertNotIn("Brain-state hypotheses are exploratory signal metadata", summary.splitlines()[2]) + + def test_render_summary_handles_empty_rows(self): + from examples.eeg.eegbci.eegbci_pattern_discovery import render_summary + + summary = render_summary( + [], + { + "subjects": [1], + "runs": [3], + "max_windows": 0, + "baseline_row_count": 0, + "output_was_capped": True, + }, + ) + + self.assertIn("No windows were produced", summary) + self.assertIn("## Limitations", summary) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: + +```bash +.venv/bin/python -m pytest tests/core/test_eegbci.py::TestEEGBCIMomentReportHelpers -k "render_summary" -v +``` + +Expected: fail because `render_summary` is missing. + +- [ ] **Step 3: Implement renderer** + +Replace existing `write_summary()` with: + +```python +def _format_count_lines(counter: Counter) -> list[str]: + if not counter: + return ["- None"] + return [f"- {label}: {count}" for label, count in counter.most_common()] + + +def _format_card(row: dict) -> list[str]: + bands = ", ".join( + f"{band}={float(row.get(f'{band}_relative', 0.0) or 0.0):.3f}" + for band in REPORT_BANDS + ) + deltas = ", ".join( + f"{band}={row.get(f'rest_{band}_relative_delta', '')}" + for band in REPORT_BANDS + ) + return [ + f"- Subject {row.get('subject_id')} run {row.get('run')} trial {row.get('trial_id')}", + f" - Task: {row.get('task_label')} from {row.get('start_time')}s to {row.get('end_time')}s", + f" - State: {row.get('state_hypothesis')} ({row.get('state_confidence')}, evidence {row.get('evidence_score')})", + f" - Dominant band: {row.get('dominant_band')}; relative bands: {bands}", + f" - Rest deltas: {deltas}; scope: {row.get('rest_reference_scope')}", + f" - Task relation: {row.get('task_state_relation')} ({row.get('task_state_confidence')})", + f" - Flags: low_confidence={row.get('is_low_confidence')}, possible_artifact={row.get('is_possible_artifact')}, mixed_or_ambiguous={row.get('is_mixed_or_ambiguous')}", + f" - Rationale: {row.get('task_state_rationale')}", + ] + + +def render_summary(rows: list[dict], config: dict) -> str: + state_counts = Counter(row.get("state_hypothesis", "missing") for row in rows) + task_counts = Counter(row.get("task_label", "missing") for row in rows) + confidence_counts = Counter(row.get("state_confidence", "missing") for row in rows) + relation_counts = Counter(row.get("task_state_relation", "missing") for row in rows) + unavailable_rest = sum(row.get("rest_reference_scope") == "unavailable" for row in rows) + low_confidence = sum(bool(row.get("is_low_confidence")) for row in rows) + artifacts = sum(bool(row.get("is_possible_artifact")) for row in rows) + ambiguous = sum(bool(row.get("is_mixed_or_ambiguous")) for row in rows) + representatives = select_representative_windows(rows) + + executive = [] + if not rows: + executive.append("No windows were produced for the requested configuration.") + else: + top_state, top_state_count = state_counts.most_common(1)[0] + executive.append( + f"Processed {len(rows)} windows. Most common state: `{top_state}` ({top_state_count}/{len(rows)})." + ) + if low_confidence == len(rows): + executive.append("Every window is low confidence.") + if len(state_counts) == 1: + executive.append("Every window maps to the same state; broaden coverage or review thresholds.") + if unavailable_rest == len(rows): + executive.append("No rest baseline was available for the emitted rows.") + if config.get("output_was_capped"): + executive.append("Output was capped by `--max-windows`.") + + lines = [ + "# EEGBCI Pattern Discovery Moment Report", + "", + f"Analysis version: `{ANALYSIS_VERSION}`", + "", + "## Executive Result", + "", + *[f"- {item}" for item in executive], + "", + "## Run Configuration", + "", + f"- Subjects: {config.get('subjects')}", + f"- Runs: {config.get('runs')}", + f"- Max windows: {config.get('max_windows')}", + f"- Baseline source rows: {config.get('baseline_row_count')}", + "", + "## Window Coverage", + "", + f"- Output windows: {len(rows)}", + f"- Task labels: {dict(task_counts)}", + "", + "## Moment-State Summary", + "", + *_format_count_lines(state_counts), + "", + "## Task Label x State Matrix", + "", + ] + + matrix = Counter( + (row.get("task_label", "missing"), row.get("state_hypothesis", "missing")) + for row in rows + ) + if matrix: + for (task_label, state), count in sorted(matrix.items()): + lines.append(f"- {task_label} x {state}: {count}") + else: + lines.append("- None") + + lines.extend([ + "", + "## Rest-Normalized Bandpower Summary", + "", + f"- Rows with unavailable rest baseline: {unavailable_rest}", + ]) + for band in REPORT_BANDS: + key = f"rest_{band}_relative_delta" + values = [float(row[key]) for row in rows if row.get(key) not in ("", None)] + if values: + lines.append(f"- {band}: mean delta {sum(values) / len(values):.3f}") + else: + lines.append(f"- {band}: unavailable") + + lines.extend([ + "", + "## Confidence and Quality Audit", + "", + f"- State confidence: {dict(confidence_counts)}", + f"- Task-state relations: {dict(relation_counts)}", + f"- Low-confidence rows: {low_confidence}", + f"- Possible artifact rows: {artifacts}", + f"- Mixed or ambiguous rows: {ambiguous}", + "", + "## Representative Windows", + "", + ]) + if representatives["cards"]: + for card_name, row in representatives["cards"].items(): + lines.append(f"### {card_name.replace('_', ' ').title()}") + lines.extend(_format_card(row)) + lines.append("") + else: + lines.append("- None") + if representatives["absent"]: + lines.append(f"- Absent representative classes: {', '.join(representatives['absent'])}") + + lines.extend([ + "", + "## Limitations", + "", + "- These labels are signal-pattern summaries from short EEG windows. They are not clinical findings and should not be read as evidence of a subject's cognition.", + ]) + if unavailable_rest: + lines.append("- No rest baseline was available for at least one emitted row.") + if config.get("output_was_capped"): + lines.append("- The output was capped, so the artifact may not represent all requested windows.") + + lines.extend([ + "", + "## Next Checks", + "", + "- Run with broader subjects/runs to verify that state diversity improves.", + "- Inspect possible artifact rows before drawing conclusions from state counts.", + "- Compare rest-normalized deltas against the raw relative band shares.", + ]) + return "\n".join(lines).rstrip() + "\n" + + +def write_summary(rows: list[dict], path: Path, config: dict) -> None: + path.write_text(render_summary(rows, config), encoding="utf-8") +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: + +```bash +.venv/bin/python -m pytest tests/core/test_eegbci.py::TestEEGBCIMomentReportHelpers -k "render_summary" -v +``` + +Expected: pass. + +## Task 8: Main Flow, Empty CSV, And Max-Windows Semantics + +**Files:** +- Modify: `examples/eeg/eegbci/eegbci_pattern_discovery.py` +- Modify: `tests/core/test_eegbci.py` + +**Interfaces:** +- Updates: `main()` so all requested rows are collected before truncation. +- Produces: empty CSV with stable columns when `--max-windows=0`. + +- [ ] **Step 1: Write failing schema test** + +Add: + +```python + def test_moment_report_columns_are_declared(self): + from examples.eeg.eegbci.eegbci_pattern_discovery import ( + MOMENT_REPORT_COLUMNS, + OUTPUT_COLUMNS, + ) + + for column in [ + "patient_id", + "task_label", + "alpha_relative", + "analysis_version", + "state_hypothesis", + "state_confidence", + "evidence_score", + "evidence_summary", + "rest_reference_scope", + "rest_alpha_relative_delta", + "task_state_relation", + "task_state_rationale", + "task_state_confidence", + "is_low_confidence", + "is_possible_artifact", + "is_mixed_or_ambiguous", + ]: + self.assertIn(column, OUTPUT_COLUMNS) + self.assertIn("analysis_version", MOMENT_REPORT_COLUMNS) +``` + +- [ ] **Step 2: Run focused helper tests** + +Run: + +```bash +.venv/bin/python -m pytest tests/core/test_eegbci.py::TestEEGBCIMomentReportHelpers -v +``` + +Expected: pass after previous tasks and the schema declaration. + +- [ ] **Step 3: Update main flow** + +Replace the row collection and output block in `main()` with: + +```python + all_rows = [sample_to_row(sample) for sample in sample_dataset] + baselines = build_rest_baselines(all_rows) + annotated_rows = annotate_moment_rows(all_rows, baselines) + output_rows = ( + annotated_rows[: args.max_windows] + if args.max_windows is not None + else annotated_rows + ) + output_was_capped = ( + args.max_windows is not None and len(annotated_rows) > len(output_rows) + ) + + csv_path = output_dir / "eegbci_pattern_windows.csv" + summary_path = output_dir / "eegbci_pattern_summary.md" + pd.DataFrame(output_rows, columns=OUTPUT_COLUMNS).to_csv(csv_path, index=False) + write_summary( + output_rows, + summary_path, + { + "subjects": parse_int_list(args.subjects), + "runs": parse_int_list(args.runs), + "max_windows": args.max_windows, + "baseline_row_count": len(all_rows), + "output_was_capped": output_was_capped, + }, + ) + print(f"Wrote {csv_path}") + print(f"Wrote {summary_path}") +``` + +This deliberately uses `OUTPUT_COLUMNS` even when `output_rows` is empty, so +`--max-windows=0` and no-window runs still produce a parseable CSV contract. + +- [ ] **Step 4: Run full EEGBCI unit test file** + +Run: + +```bash +.venv/bin/python -m pytest tests/core/test_eegbci.py -v +``` + +Expected: pass, with the real-data smoke test skipped unless `PYHEALTH_RUN_REAL_EEGBCI=1`. + +## Task 9: README And Progress Documentation + +**Files:** +- Modify: `examples/eeg/eegbci/README.md` +- Modify: `docs/eeg_pattern_discovery/moment_report_continuation_plan.md` + +**Interfaces:** +- Produces: README section describing upgraded moment report fields and limitations. +- Produces: continuation plan progress entry for implementation. + +- [ ] **Step 1: Update README output description** + +Replace the CSV paragraph in `examples/eeg/eegbci/README.md` with: + +```markdown +The CSV has one row per emitted 2-second window. Key columns include subject/run +metadata, `event_code`, decoded `task_label`, raw EEGBCI numeric label +(`eegbci_label` / `label`), PyHealth model-local label (`model_label`), +absolute window timing, band powers, relative band powers, `dominant_band`, +frequency ratios, legacy `brain_state_hypothesis`, `confidence`, +`quality_flags`, and `interpretation`. + +The moment-report columns add analysis-grade fields: + +- `analysis_version` +- `state_hypothesis`, `state_confidence`, and `evidence_score` +- `evidence_summary` +- `rest_reference_scope` and rest-normalized relative band deltas +- `task_state_relation`, `task_state_rationale`, and `task_state_confidence` +- `is_low_confidence`, `is_possible_artifact`, and `is_mixed_or_ambiguous` + +The Markdown report summarizes state counts, task-label/state agreement, +rest-normalized bandpower deltas, confidence and quality flags, representative +windows, limitations, and next checks. These labels are signal-pattern +summaries from short EEG windows, not clinical findings or evidence of a +subject's cognition. +``` + +- [ ] **Step 2: Update continuation plan progress** + +Append to `docs/eeg_pattern_discovery/moment_report_continuation_plan.md`: + +```markdown +- 2026-07-08: Converted the refined design into + `docs/eeg_pattern_discovery/moment_report_implementation_plan.md` and ran + GStack `/plan-eng-review` against the plan before code implementation. +``` + +- [ ] **Step 3: Verify docs mention the plan** + +Run: + +```bash +rg "moment_report_implementation_plan|GStack `/plan-eng-review`" docs/eeg_pattern_discovery examples/eeg/eegbci/README.md +``` + +Expected: matches in the continuation plan and README content. + +## Task 10: Manual Artifact Verification + +**Files:** +- No planned code changes unless verification exposes a bug. + +**Interfaces:** +- Verifies: local synthetic/unit coverage and real-data example behavior. + +- [ ] **Step 1: Run unit tests** + +Run: + +```bash +.venv/bin/python -m pytest tests/core/test_eegbci.py -v +``` + +Expected: pass, with real-data smoke skipped unless explicitly enabled. + +- [ ] **Step 2: Run the example on a tiny real-data request** + +Run: + +```bash +.venv/bin/python examples/eeg/eegbci/eegbci_pattern_discovery.py \ + --subjects 1 \ + --runs 3 \ + --max-windows 20 \ + --download +``` + +Expected: + +- `outputs/eegbci_pattern_discovery/eegbci_pattern_windows.csv` is written. +- `outputs/eegbci_pattern_discovery/eegbci_pattern_summary.md` is written. +- CSV includes all `MOMENT_REPORT_COLUMNS`. +- Markdown includes all required sections. +- Markdown does not start with the old generic exploratory caveat. + +- [ ] **Step 3: Inspect artifact schema** + +Run: + +```bash +.venv/bin/python - <<'PY' +import pandas as pd +df = pd.read_csv("outputs/eegbci_pattern_discovery/eegbci_pattern_windows.csv") +required = { + "analysis_version", + "state_hypothesis", + "state_confidence", + "evidence_score", + "evidence_summary", + "rest_reference_scope", + "rest_alpha_relative_delta", + "task_state_relation", + "task_state_rationale", + "task_state_confidence", + "is_low_confidence", + "is_possible_artifact", + "is_mixed_or_ambiguous", +} +missing = sorted(required - set(df.columns)) +print("rows", len(df)) +print("missing", missing) +assert not missing +assert (df["analysis_version"] == "eegbci_pattern_moment_report_v1").all() +PY +``` + +Expected: + +```text +rows 20 +missing [] +``` + +- [ ] **Step 4: Inspect Markdown contract** + +Run: + +```bash +rg "Executive Result|Run Configuration|Window Coverage|Moment-State Summary|Task Label x State Matrix|Rest-Normalized Bandpower Summary|Confidence and Quality Audit|Representative Windows|Limitations|Next Checks" outputs/eegbci_pattern_discovery/eegbci_pattern_summary.md +``` + +Expected: all required headings match. + +- [ ] **Step 5: Update Graphify** + +Run: + +```bash +graphify update . +``` + +Expected: graph update completes. Dirty `graphify-out/` files are expected. + +## Extensive Correctness Test Matrix + +Add these tests to `TestEEGBCIMomentReportHelpers` while implementing Tasks 1-8. +The goal is to prove the report helpers are correct under realistic edge cases, +not just that the happy path produces rows. + +| Test name | Fixture | Assertions | +| --- | --- | --- | +| `test_analysis_version_constant` | Import `ANALYSIS_VERSION`. | Exact value is `eegbci_pattern_moment_report_v1`. | +| `test_moment_report_columns_are_declared` | Import `OUTPUT_COLUMNS` and `MOMENT_REPORT_COLUMNS`. | Base row columns and report columns are present; `analysis_version` is in `MOMENT_REPORT_COLUMNS`. | +| `test_build_rest_baselines_uses_rest_rows_only` | Rest and non-rest rows for one subject across two runs. | Non-rest rows do not affect rest averages; same-run, same-subject, and global means are correct. | +| `test_build_rest_baselines_handles_no_rest_rows` | Only motor rows. | Same-run and same-subject baseline maps are empty; global baseline is `None`. | +| `test_annotate_rest_fallback_scopes` | One same-run rest candidate, one same-subject fallback, and one global fallback. | Rows receive `same_subject_run`, `same_subject_all_runs`, and `global_rest` in that order. | +| `test_annotate_moment_rows_marks_unavailable_rest` | Motor-only rows. | `rest_reference_scope == "unavailable"` and all rest delta columns are blank strings. | +| `test_rest_delta_values_are_band_specific` | Rest row with different values for each band plus one motor row. | `rest_delta_relative_delta`, `rest_theta_relative_delta`, `rest_alpha_relative_delta`, `rest_beta_relative_delta`, and `rest_gamma_relative_delta` equal motor minus rest for the matching band. | +| `test_derive_state_hypothesis_detects_idle_alpha_profile` | High alpha, low beta/gamma, high alpha/beta. | State is `idle_alpha_profile`; confidence is not outside `low`, `medium`, `high`; score is between 0 and 1. | +| `test_derive_state_hypothesis_detects_sensorimotor_engagement_profile` | High beta or low-gamma, low alpha/beta. | State is `sensorimotor_engagement_profile`; evidence summary includes beta and alpha/beta values. | +| `test_derive_state_hypothesis_detects_slow_wave_dominant_pattern` | Delta plus theta dominates. | State is `slow_wave_dominant_pattern`; no cognitive or clinical wording appears in evidence summary. | +| `test_derive_state_hypothesis_detects_possible_artifact_profile` | Gamma spike or extreme high-frequency share. | State is `possible_artifact_profile`; `derive_quality_columns()` marks `is_possible_artifact`. | +| `test_derive_state_hypothesis_marks_weak_margin_ambiguous` | Balanced band shares with no clear winner. | State is `mixed_ambiguous_profile`; confidence is `low`. | +| `test_state_confidence_requires_margin` | Two rows with same winning state, one clear margin and one narrow margin. | Narrow-margin row has lower confidence than clear-margin row. | +| `test_task_state_relation_table_is_deterministic` | Rows covering rest, motor execution, motor imagery, slow-wave, artifact, and ambiguous states. | Relation values match the approved decision table; every rationale is non-empty. | +| `test_task_state_relation_idle_motor_disagrees` | Motor task row with `idle_alpha_profile`. | Relation is `disagrees`; confidence is parseable; rationale mentions motor-labeled mismatch without clinical claims. | +| `test_task_state_relation_artifact_not_applicable` | Any task row with `possible_artifact_profile`. | Relation is `not_applicable`; rationale says inspection rather than task comparison. | +| `test_quality_booleans_are_parseable` | Low-confidence artifact row with text flags. | `is_low_confidence` and `is_possible_artifact` are true; ambiguous flag follows state/flag input. | +| `test_quality_booleans_do_not_depend_on_string_parsing_only` | Row with `state_hypothesis="mixed_ambiguous_profile"` and empty `quality_flags`. | `is_mixed_or_ambiguous` is true because the state is ambiguous. | +| `test_annotate_moment_rows_adds_required_fields` | One rest row and one motor row. | Every `MOMENT_REPORT_COLUMNS` entry exists in every annotated row. | +| `test_annotate_moment_rows_preserves_legacy_fields` | Row with existing `brain_state_hypothesis`, `confidence`, `quality_flags`, and `interpretation`. | Legacy fields remain unchanged after annotation. | +| `test_annotate_moment_rows_does_not_mutate_input_rows` | Keep a copy of input rows before annotation. | Original rows do not gain report fields after `annotate_moment_rows()`. | +| `test_select_representative_windows_is_deterministic` | Multiple candidate rows with tied evidence/confidence and different subject/run/start time. | Stable tie-break chooses earliest subject, run, and start time. | +| `test_select_representative_windows_lists_absent_classes` | Rows missing artifact and slow-wave classes. | Missing representative card names appear in `absent`. | +| `test_select_representative_windows_picks_lowest_evidence_ambiguous` | Multiple ambiguous rows with different evidence scores. | `most_ambiguous` chooses the lowest evidence score, then stable tie-breaks. | +| `test_select_representative_windows_picks_strongest_disagreement` | Multiple disagreement rows. | Highest evidence disagreement is selected; ties use confidence and stable ordering. | +| `test_render_summary_contains_required_sections_and_limitations` | Annotated row with unavailable rest and capped config. | All required headings appear; missing rest and cap limitations appear. | +| `test_render_summary_handles_empty_rows` | Empty row list with `max_windows=0`. | Summary says no windows were produced and still includes Limitations and Next Checks. | +| `test_render_summary_reports_all_low_confidence` | Rows where every `is_low_confidence` is true. | Executive Result states every window is low confidence. | +| `test_render_summary_reports_all_same_state` | Rows all mapped to one state. | Executive Result states every window maps to the same state. | +| `test_render_summary_reports_task_state_matrix` | Rows spanning at least two task labels and two states. | Matrix contains each task/state count deterministically. | +| `test_render_summary_includes_representative_window_details` | Annotated rows with at least one representative card. | Card includes subject, run, trial id, time range, state, evidence, dominant band, rest deltas, relation, confidence, flags, and rationale. | +| `test_render_summary_moves_nonclinical_warning_to_limitations` | Any non-empty summary. | Warning appears under `## Limitations` and not as the opening body text. | +| `test_summary_text_does_not_repeat_old_row_level_caveat` | Annotated rows with legacy interpretation text. | `"This is exploratory signal metadata"` is absent from generated summary and new row-level interpretation. | +| `test_empty_dataframe_uses_output_columns` | Build `pd.DataFrame([], columns=OUTPUT_COLUMNS)`. | Empty CSV header contains base and report columns. | +| `test_main_max_windows_zero_writes_empty_artifacts` | Patch dataset/task iteration to produce rows, invoke `main()` with `--max-windows 0` and temp output dir. | CSV has zero rows with `OUTPUT_COLUMNS`; Markdown says no windows were produced and output was capped. | +| `test_main_baseline_uses_uncapped_rows` | Patch task iteration so rest row appears after the first emitted row; run with `--max-windows 1`. | Emitted row can still receive non-`unavailable` rest baseline from rows beyond the cap. | +| `test_main_writes_analysis_version_to_every_csv_row` | Patch task iteration to produce two rows. | CSV `analysis_version` column exists and every value equals `ANALYSIS_VERSION`. | +| `test_parse_int_list_accepts_ranges_and_singletons` | Existing parser with `"1,3-5"`. | Result is `[1, 3, 4, 5]`. | +| `test_parse_int_list_rejects_invalid_input_loudly` | Existing parser with `"a"` or malformed range. | Raises `ValueError`; no silent fallback. | + +Run the complete helper suite with: + +```bash +.venv/bin/python -m pytest tests/core/test_eegbci.py::TestEEGBCIMomentReportHelpers -v +``` + +Run the full EEGBCI test file with: + +```bash +.venv/bin/python -m pytest tests/core/test_eegbci.py -v +``` + +Manual real-data artifact checks still belong in Task 10 because they validate +the end-to-end generated CSV and Markdown, not just pure helper behavior. + +## Post-Implementation Review Gate + +After implementation and verification finish, run a fresh review before calling +the work complete. + +Recommended workflow: + +```text +main implementation chat + -> complete Tasks 1-10 + -> run unit tests, artifact checks, and graphify update + -> dispatch independent sub-agent or separate session for GStack /review + -> independent reviewer writes review Markdown + -> main implementation chat applies fixes + -> rerun focused tests and full EEGBCI tests +``` + +Use the strongest available review path: + +1. Prefer GStack `/review` for the final pre-landing diff review because it is + designed to inspect the branch against the base branch and catch regressions, + trust-boundary mistakes, structural issues, and missing tests. +2. If `/review` is unavailable or the review is not PR/diff-shaped, use the + `bug-review` skill against the changed files and generated artifacts. +3. If both are available, run `/review` first, then use `bug-review` only for + focused follow-up on risky areas the review identifies. + +The review must create: + +- `docs/eeg_pattern_discovery/moment_report_review.md` + +Required review document sections: + +```markdown +# EEGBCI Moment Report Implementation Review + +Date: 2026-07-08 +Branch: eegbci-pattern-discovery +Review path: GStack /review + +## Scope Reviewed + +## Verification Commands + +## Findings + +## Required Fixes + +## Fix Implementation Log + +## Post-Fix Verification + +## Final Verdict +``` + +Review independence rule: + +- The same chat can orchestrate the whole workflow because it has the project + context and plan history. +- The actual GStack `/review` must run in an independent sub-agent or separate + session. Do not run the final review inline in the same reasoning thread that + implemented the feature. +- The independent reviewer must inspect the diff cold, write + `docs/eeg_pattern_discovery/moment_report_review.md`, and stop after the + review document is complete. +- Fixes should be implemented by the main implementation chat after the review + is written, so there is one accountable thread for code changes and + verification. + +Do not mark the implementation complete until: + +- review findings are written to Markdown +- each accepted finding is fixed or explicitly documented as deferred +- focused tests for each fix pass +- `.venv/bin/python -m pytest tests/core/test_eegbci.py -v` passes +- generated CSV and Markdown artifacts still satisfy the contract +- `graphify update .` has been run after code changes + +## Self-Review + +### Spec Coverage + +- Rest-normalized evidence: Task 2, Task 3, Task 5, Task 7. +- Parseable quality flags: Task 4, Task 5, Task 7. +- Representative windows: Task 6, Task 7. +- Analysis versioning: Task 1, Task 5, Task 7, Task 8. +- Task-state comparison: Task 4, Task 5, Task 7. +- Stable dataset/task API boundary: Global Constraints and File Structure. +- Empty/capped output behavior: Task 7, Task 8, Task 10. +- README and continuation docs: Task 9. + +### Placeholder Scan + +No implementation steps use TBD, TODO, or open-ended "handle edge cases" instructions. Each test and implementation step includes concrete code or exact commands. + +### Type Consistency + +The plan consistently uses: + +- `build_rest_baselines(rows) -> dict` +- `derive_state_hypothesis(row) -> dict` +- `derive_task_state_relation(row) -> dict` +- `derive_quality_columns(row) -> dict` +- `annotate_moment_rows(rows, baselines) -> list[dict]` +- `select_representative_windows(rows) -> dict` +- `render_summary(rows, config) -> str` +- `write_summary(rows, path, config) -> None` + +## GSTACK ENGINEERING REVIEW + +Status: DONE + +### Review Scope + +Reviewed this implementation plan against: + +- `docs/eeg_pattern_discovery/moment_report_refined_design.md` +- `docs/eeg_pattern_discovery/moment_report_continuation_plan.md` +- `/Users/vihaanagrawal/.gstack/projects/sunlabuiuc-PyHealth/ceo-plans/2026-07-08-eegbci-moment-report.md` +- current `examples/eeg/eegbci/eegbci_pattern_discovery.py` +- current `tests/core/test_eegbci.py` + +### Architecture Verdict + +The architecture boundary is right. + +```text +Reusable PyHealth APIs Example-owned artifact layer +---------------------- ---------------------------- +EEGBCIDataset + -> EEGMotorImageryEEGBCI samples + -> EEGBCIPatternDiscovery bandpower + sample_to_row() + build_rest_baselines() + annotate_moment_rows() + select_representative_windows() + render_summary() + CSV + Markdown +``` + +The plan avoids putting cross-window concepts into `EEGBCIPatternDiscovery`. +That matters because rest baselines, capped output status, and task-state +comparison are artifact-level context, not independent per-sample labels. + +### Data Flow Review + +The critical data-flow fix is present: + +```text +collect all requested rows + -> compute rest baselines + -> annotate all rows + -> apply --max-windows + -> write outputs +``` + +This prevents `--max-windows` from accidentally deleting the rest evidence +needed to interpret non-rest rows. + +### Findings And Required Adjustments + +| Severity | Finding | Plan Adjustment | +| --- | --- | --- | +| High | Empty CSV handling cannot depend on `sample_dataset[0]`; `--max-windows=0` and no-window requests are valid edge cases. | Added `BASE_OUTPUT_COLUMNS`, `MOMENT_REPORT_COLUMNS`, and `OUTPUT_COLUMNS`; Task 8 now writes `pd.DataFrame(output_rows, columns=OUTPUT_COLUMNS)`. | +| Medium | The example may collect more rows than it emits. For the default subjects/runs this is acceptable, but users should see the baseline source count. | `render_summary()` config includes `baseline_row_count`; Markdown Run Configuration reports it. | +| Medium | State scoring thresholds are heuristic and could become brittle if tests overfit exact values. | Tests use obvious synthetic profiles and assert categories plus score bounds, not exact score formulas. | +| Low | The implementation plan adds many helpers in one example file. That is acceptable for this phase, but file growth needs containment. | Helpers are pure, named, and directly tested. No shared module until the example becomes genuinely hard to maintain. | + +### Edge Case Coverage + +```text +No rows + -> render_summary([]) says no windows were produced + -> CSV writes stable OUTPUT_COLUMNS + +No rest rows + -> rest_reference_scope = "unavailable" + -> rest deltas = "" + -> Markdown limitation states missing rest baseline + +All low confidence + -> Executive Result says every window is low confidence + -> Confidence and Quality Audit counts low-confidence rows + +All same state + -> Executive Result says every window maps to the same state + -> Next Checks recommends broader coverage or threshold review + +Capped output + -> baselines computed before cap + -> Executive Result says output was capped +``` + +### Test Coverage Review + +The plan has the right test shape: + +- Pure helper tests are synthetic and offline. +- Dataset/task tests remain intact. +- Rest fallback covers same subject/run, same subject/all runs, global rest, and unavailable. +- Task-state relation table is deterministic. +- Representative selection tests tie-break on evidence, confidence, subject, run, and start time. +- Markdown tests assert required sections and important limitations. +- Manual verification checks the real generated CSV/Markdown artifact. + +One implementation note: when writing the tests, keep the helper class before +`TestEEGBCIRealDataSmoke` so normal test runs do not inherit the real-data skip. + +### Performance And Blast Radius + +Blast radius is low. The plan touches one example script, one existing test file, +one README, and this planning doc. No model APIs, dataset APIs, task exports, or +Sphinx API pages change. + +The only meaningful performance tradeoff is collecting all requested rows before +applying `--max-windows`. That is the correct tradeoff for this artifact because +baseline quality is the point of the report. If future users request large +subject/run sets, add a separate `--baseline-max-windows` or streaming baseline +path later. Do not complicate this PR now. + +### Recommendation + +Proceed with implementation exactly in this plan order. The first implementation +checkpoint should be after Task 5, because that is where the CSV contract and +core data flow become real. The second checkpoint should be after Task 8, before +real-data manual verification. + +## GSTACK REVIEW REPORT + +| Review | Trigger | Why | Runs | Status | Findings | +|--------|---------|-----|------|--------|----------| +| Eng Review | `/plan-eng-review` | Architecture, data flow, edge cases, tests | 1 | DONE | Added stable output schema requirement; confirmed example-owned analysis boundary and baseline-before-cap flow. | + +**VERDICT:** APPROVED FOR IMPLEMENTATION after the `OUTPUT_COLUMNS` adjustment above. diff --git a/examples/eeg/eegbci/eegbci_pattern_discovery.py b/examples/eeg/eegbci/eegbci_pattern_discovery.py index b3188514d..6fcd2ac6a 100644 --- a/examples/eeg/eegbci/eegbci_pattern_discovery.py +++ b/examples/eeg/eegbci/eegbci_pattern_discovery.py @@ -15,6 +15,17 @@ from pyhealth.tasks import EEGBCIPatternDiscovery +ANALYSIS_VERSION = "eegbci_pattern_moment_report_v1" +REPORT_BANDS = ("delta", "theta", "alpha", "beta", "gamma") +STATE_CONFIDENCE_RANK = {"low": 0, "medium": 1, "high": 2} + + +def scalar_value(value): + if hasattr(value, "item"): + return value.item() + return value + + def parse_int_list(value: str) -> list[int]: items: list[int] = [] for part in value.split(","): @@ -28,6 +39,8 @@ def parse_int_list(value: str) -> list[int]: def sample_to_row(sample: dict) -> dict: bandpower = sample["bandpower"] + model_label = scalar_value(sample["label"]) + eegbci_label = scalar_value(sample.get("eegbci_label", model_label)) return { "patient_id": sample["patient_id"], "record_id": sample["record_id"], @@ -38,7 +51,9 @@ def sample_to_row(sample: dict) -> dict: "event_code": sample["event_code"], "task_label": sample["task_label"], "label_family": sample["label_family"], - "label": sample["label"], + "label": eegbci_label, + "eegbci_label": eegbci_label, + "model_label": model_label, "start_time": sample["start_time"], "end_time": sample["end_time"], "dominant_band": bandpower["dominant_band"], diff --git a/tests/core/test_eegbci.py b/tests/core/test_eegbci.py index c4c799eff..df604e6a1 100644 --- a/tests/core/test_eegbci.py +++ b/tests/core/test_eegbci.py @@ -8,6 +8,7 @@ import numpy as np import pandas as pd +import torch from pyhealth.tasks.eegbci import ( EEGBCI_LABELS, @@ -154,6 +155,24 @@ def test_prepare_metadata_with_existing_files(self): self.assertEqual(df.loc[0, "run_type"], "motor_execution_left_right") self.assertEqual(df.loc[0, "source"], "physionet_eegbci") + def test_prepare_metadata_rebuilds_for_changed_selection(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + first = root / "files" / "eegmmidb" / "1.0.0" / "S001" / "S001R03.edf" + second = root / "files" / "eegmmidb" / "1.0.0" / "S002" / "S002R04.edf" + first.parent.mkdir(parents=True) + second.parent.mkdir(parents=True) + first.write_bytes(b"") + second.write_bytes(b"") + + EEGBCIDataset(root=str(root), subjects=[1], runs=[3], download=False) + EEGBCIDataset(root=str(root), subjects=[2], runs=[4], download=False) + + df = pd.read_csv(root / "eegbci-pyhealth.csv") + self.assertEqual(len(df), 1) + self.assertEqual(df.loc[0, "subject_id"], 2) + self.assertEqual(df.loc[0, "run"], 4) + def test_prepare_metadata_download_uses_mne_loader(self): with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) @@ -192,6 +211,48 @@ def test_default_task_returns_pattern_discovery(self): ds = EEGBCIDataset.__new__(EEGBCIDataset) self.assertIsInstance(ds.default_task, EEGBCIPatternDiscovery) + def test_dataset_set_task_offline_integration(self): + import mne + from pyhealth.tasks.eegbci import EEGBCI_COMPAT_CHANNELS, EEGMotorImageryEEGBCI + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + edf = root / "files" / "eegmmidb" / "1.0.0" / "S001" / "S001R03.edf" + edf.parent.mkdir(parents=True) + edf.write_bytes(b"") + sfreq = 200.0 + raw = mne.io.RawArray( + np.ones((16, int(sfreq * 2))), + mne.create_info( + list(EEGBCI_COMPAT_CHANNELS), sfreq=sfreq, ch_types=["eeg"] * 16 + ), + verbose="error", + ) + raw.set_annotations( + mne.Annotations(onset=[0.0], duration=[2.0], description=["T1"]) + ) + dataset = EEGBCIDataset( + root=str(root), + subjects=[1], + runs=[3], + download=False, + cache_dir=root / "cache", + ) + + with patch("pyhealth.tasks.eegbci.mne.io.read_raw_edf", return_value=raw): + sample_dataset = dataset.set_task( + EEGMotorImageryEEGBCI( + compute_stft=False, resample_rate=None, bandpass_filter=None + ), + num_workers=1, + ) + + self.assertEqual(len(sample_dataset), 1) + sample = sample_dataset[0] + self.assertEqual(sample["task_label"], "execute_left_fist") + self.assertEqual(sample["eegbci_label"], 1) + self.assertEqual(tuple(sample["signal"].shape), (16, 400)) + from pyhealth.tasks.eegbci import EEGBCIPatternDiscovery, EEGMotorImageryEEGBCI @@ -284,8 +345,37 @@ def test_motor_imagery_task_returns_samples_from_raw(self): self.assertEqual(sample["event_code"], "T1") self.assertEqual(sample["task_label"], "execute_left_fist") self.assertEqual(sample["label"], 1) + self.assertEqual(sample["eegbci_label"], 1) self.assertEqual(tuple(sample["signal"].shape), (16, 400)) + def test_stft_uses_current_sample_rate(self): + import mne + from pyhealth.tasks.eegbci import EEGBCI_COMPAT_CHANNELS + + sfreq = 100.0 + raw = mne.io.RawArray( + np.ones((16, int(sfreq * 2))), + mne.create_info( + list(EEGBCI_COMPAT_CHANNELS), sfreq=sfreq, ch_types=["eeg"] * 16 + ), + verbose="error", + ) + raw.set_annotations( + mne.Annotations(onset=[0.0], duration=[2.0], description=["T1"]) + ) + patient = _EEGBCIPatient("S001", [_EEGBCIEvent(signal_file="dummy.edf")]) + task = EEGMotorImageryEEGBCI(resample_rate=None, bandpass_filter=None) + + with patch("pyhealth.tasks.eegbci.mne.io.read_raw_edf", return_value=raw): + with patch( + "pyhealth.models.tfm_tokenizer.get_stft_torch", + return_value=torch.zeros((1, 16, 50, 1)), + ) as get_stft: + samples = task(patient) + + self.assertEqual(len(samples), 1) + self.assertEqual(get_stft.call_args.kwargs["resampling_rate"], 100) + def test_pattern_discovery_adds_bandpower_metadata(self): import mne from pyhealth.tasks.eegbci import EEGBCI_COMPAT_CHANNELS @@ -312,6 +402,45 @@ def test_pattern_discovery_adds_bandpower_metadata(self): self.assertIn("interpretation", sample) +class TestEEGBCIMomentReportHelpers(unittest.TestCase): + def _moment_row(self, **overrides): + row = { + "patient_id": "S001", + "record_id": "R03", + "subject_id": 1, + "run": 3, + "run_type": "motor_execution_left_right", + "trial_id": "S001_R03_T0_0", + "event_code": "T0", + "task_label": "rest", + "label_family": "rest", + "label": 0, + "eegbci_label": 0, + "model_label": 0, + "start_time": 0.0, + "end_time": 2.0, + "dominant_band": "alpha", + "delta_relative": 0.05, + "theta_relative": 0.10, + "alpha_relative": 0.55, + "beta_relative": 0.20, + "gamma_relative": 0.10, + "alpha_beta_ratio": 2.75, + "theta_beta_ratio": 0.50, + "brain_state_hypothesis": "relaxed_or_idle", + "confidence": "medium", + "quality_flags": "", + "interpretation": "Alpha-dominant profile.", + } + row.update(overrides) + return row + + def test_analysis_version_constant(self): + from examples.eeg.eegbci.eegbci_pattern_discovery import ANALYSIS_VERSION + + self.assertEqual(ANALYSIS_VERSION, "eegbci_pattern_moment_report_v1") + + @unittest.skipUnless( os.environ.get("PYHEALTH_RUN_REAL_EEGBCI") == "1", "Set PYHEALTH_RUN_REAL_EEGBCI=1 to download and test real EEGBCI data.", From 60a3f22e883b6d859567c8c3f48632bf4b587633 Mon Sep 17 00:00:00 2001 From: Vihaan Agrawal <247244351+vihaan101@users.noreply.github.com> Date: Wed, 8 Jul 2026 12:18:48 +0530 Subject: [PATCH 10/21] Add EEGBCI rest baseline helpers --- .../moment_report_continuation_plan.md | 3 ++ .../moment_report_implementation_plan.md | 8 ++-- .../eeg/eegbci/eegbci_pattern_discovery.py | 37 +++++++++++++++++++ tests/core/test_eegbci.py | 36 ++++++++++++++++++ 4 files changed, 80 insertions(+), 4 deletions(-) diff --git a/docs/eeg_pattern_discovery/moment_report_continuation_plan.md b/docs/eeg_pattern_discovery/moment_report_continuation_plan.md index 6d4d6bd9c..37bd10195 100644 --- a/docs/eeg_pattern_discovery/moment_report_continuation_plan.md +++ b/docs/eeg_pattern_discovery/moment_report_continuation_plan.md @@ -436,3 +436,6 @@ graphify update . added the synthetic moment-row fixture test, added `ANALYSIS_VERSION`, `REPORT_BANDS`, and `STATE_CONFIDENCE_RANK`, confirmed the focused test failed before implementation and passed after implementation. +- 2026-07-08: Task 2 is complete: added synthetic rest-baseline tests, confirmed + the missing-helper failure, implemented `build_rest_baselines()` and + `_mean_band_values()`, and verified rest-only averaging plus no-rest fallback. diff --git a/docs/eeg_pattern_discovery/moment_report_implementation_plan.md b/docs/eeg_pattern_discovery/moment_report_implementation_plan.md index d9b01e6d4..85cf388b5 100644 --- a/docs/eeg_pattern_discovery/moment_report_implementation_plan.md +++ b/docs/eeg_pattern_discovery/moment_report_implementation_plan.md @@ -204,7 +204,7 @@ The returned dict must include: } ``` -- [ ] **Step 1: Write failing baseline tests** +- [x] **Step 1: Write failing baseline tests** Add these tests to `TestEEGBCIMomentReportHelpers`: @@ -240,7 +240,7 @@ Add these tests to `TestEEGBCIMomentReportHelpers`: self.assertIsNone(baselines["global_rest"]) ``` -- [ ] **Step 2: Run tests to verify they fail** +- [x] **Step 2: Run tests to verify they fail** Run: @@ -253,7 +253,7 @@ Run: Expected: fail because `build_rest_baselines` is missing. -- [ ] **Step 3: Implement baseline helpers** +- [x] **Step 3: Implement baseline helpers** Add below `sample_to_row()`: @@ -294,7 +294,7 @@ def build_rest_baselines(rows: list[dict]) -> dict: } ``` -- [ ] **Step 4: Run tests to verify they pass** +- [x] **Step 4: Run tests to verify they pass** Run: diff --git a/examples/eeg/eegbci/eegbci_pattern_discovery.py b/examples/eeg/eegbci/eegbci_pattern_discovery.py index 6fcd2ac6a..5a7d2de93 100644 --- a/examples/eeg/eegbci/eegbci_pattern_discovery.py +++ b/examples/eeg/eegbci/eegbci_pattern_discovery.py @@ -68,6 +68,43 @@ def sample_to_row(sample: dict) -> dict: } +def _mean_band_values(rows: list[dict]) -> dict: + means = {} + for band in REPORT_BANDS: + key = f"{band}_relative" + values = [float(row[key]) for row in rows if row.get(key) not in ("", None)] + if values: + means[key] = sum(values) / len(values) + return means + + +def build_rest_baselines(rows: list[dict]) -> dict: + rest_rows = [row for row in rows if row.get("task_label") == "rest"] + same_subject_run = {} + same_subject_all_runs = {} + + subject_run_keys = sorted({(row["subject_id"], row["run"]) for row in rest_rows}) + for key in subject_run_keys: + subject_id, run = key + grouped = [ + row + for row in rest_rows + if row["subject_id"] == subject_id and row["run"] == run + ] + same_subject_run[key] = _mean_band_values(grouped) + + subject_keys = sorted({row["subject_id"] for row in rest_rows}) + for subject_id in subject_keys: + grouped = [row for row in rest_rows if row["subject_id"] == subject_id] + same_subject_all_runs[subject_id] = _mean_band_values(grouped) + + return { + "same_subject_run": same_subject_run, + "same_subject_all_runs": same_subject_all_runs, + "global_rest": _mean_band_values(rest_rows) if rest_rows else None, + } + + def write_summary(rows: list[dict], path: Path) -> None: task_counts = Counter(row["task_label"] for row in rows) hypothesis_counts = Counter(row["brain_state_hypothesis"] for row in rows) diff --git a/tests/core/test_eegbci.py b/tests/core/test_eegbci.py index df604e6a1..bc1dc0d64 100644 --- a/tests/core/test_eegbci.py +++ b/tests/core/test_eegbci.py @@ -440,6 +440,42 @@ def test_analysis_version_constant(self): self.assertEqual(ANALYSIS_VERSION, "eegbci_pattern_moment_report_v1") + def test_build_rest_baselines_uses_rest_rows_only(self): + from examples.eeg.eegbci.eegbci_pattern_discovery import build_rest_baselines + + rows = [ + self._moment_row(task_label="rest", subject_id=1, run=3, alpha_relative=0.50), + self._moment_row( + task_label="execute_left_fist", subject_id=1, run=3, alpha_relative=0.90 + ), + self._moment_row(task_label="rest", subject_id=1, run=4, alpha_relative=0.70), + ] + + baselines = build_rest_baselines(rows) + + self.assertAlmostEqual( + baselines["same_subject_run"][(1, 3)]["alpha_relative"], 0.50 + ) + self.assertAlmostEqual( + baselines["same_subject_all_runs"][1]["alpha_relative"], 0.60 + ) + self.assertAlmostEqual(baselines["global_rest"]["alpha_relative"], 0.60) + + def test_build_rest_baselines_handles_no_rest_rows(self): + from examples.eeg.eegbci.eegbci_pattern_discovery import build_rest_baselines + + rows = [ + self._moment_row( + task_label="execute_left_fist", label_family="motor_execution" + ) + ] + + baselines = build_rest_baselines(rows) + + self.assertEqual(baselines["same_subject_run"], {}) + self.assertEqual(baselines["same_subject_all_runs"], {}) + self.assertIsNone(baselines["global_rest"]) + @unittest.skipUnless( os.environ.get("PYHEALTH_RUN_REAL_EEGBCI") == "1", From d133868238892a856d92209ab82b2fca08cce4b5 Mon Sep 17 00:00:00 2001 From: Vihaan Agrawal <247244351+vihaan101@users.noreply.github.com> Date: Wed, 8 Jul 2026 12:20:12 +0530 Subject: [PATCH 11/21] Add EEGBCI state scoring helpers --- .../moment_report_continuation_plan.md | 5 + .../moment_report_implementation_plan.md | 8 +- .../eeg/eegbci/eegbci_pattern_discovery.py | 69 +++++++++++++ tests/core/test_eegbci.py | 97 +++++++++++++++++++ 4 files changed, 175 insertions(+), 4 deletions(-) diff --git a/docs/eeg_pattern_discovery/moment_report_continuation_plan.md b/docs/eeg_pattern_discovery/moment_report_continuation_plan.md index 37bd10195..0e8aa1700 100644 --- a/docs/eeg_pattern_discovery/moment_report_continuation_plan.md +++ b/docs/eeg_pattern_discovery/moment_report_continuation_plan.md @@ -439,3 +439,8 @@ graphify update . - 2026-07-08: Task 2 is complete: added synthetic rest-baseline tests, confirmed the missing-helper failure, implemented `build_rest_baselines()` and `_mean_band_values()`, and verified rest-only averaging plus no-rest fallback. +- 2026-07-08: Task 3 is complete for the planned partial checkpoint: added rest + fallback and state-profile tests, confirmed missing-helper failures, + implemented `_baseline_for_row()`, `_clip01()`, and `derive_state_hypothesis()`, + and verified the state-profile focused test. The fallback-scope test remains + expected to pass in Task 5 when `annotate_moment_rows()` is added. diff --git a/docs/eeg_pattern_discovery/moment_report_implementation_plan.md b/docs/eeg_pattern_discovery/moment_report_implementation_plan.md index 85cf388b5..f6b206381 100644 --- a/docs/eeg_pattern_discovery/moment_report_implementation_plan.md +++ b/docs/eeg_pattern_discovery/moment_report_implementation_plan.md @@ -326,7 +326,7 @@ Expected: pass. } ``` -- [ ] **Step 1: Write failing rest fallback and state tests** +- [x] **Step 1: Write failing rest fallback and state tests** Add these tests: @@ -372,7 +372,7 @@ Add these tests: self.assertIn("alpha=", result["evidence_summary"]) ``` -- [ ] **Step 2: Run tests to verify they fail** +- [x] **Step 2: Run tests to verify they fail** Run: @@ -382,7 +382,7 @@ Run: Expected: fail because `annotate_moment_rows` and `derive_state_hypothesis` are missing. -- [ ] **Step 3: Implement fallback and scoring** +- [x] **Step 3: Implement fallback and scoring** Add below `build_rest_baselines()`: @@ -448,7 +448,7 @@ def derive_state_hypothesis(row: dict) -> dict: } ``` -- [ ] **Step 4: Run tests to verify current expected partial failure** +- [x] **Step 4: Run tests to verify current expected partial failure** Run: diff --git a/examples/eeg/eegbci/eegbci_pattern_discovery.py b/examples/eeg/eegbci/eegbci_pattern_discovery.py index 5a7d2de93..159a641f7 100644 --- a/examples/eeg/eegbci/eegbci_pattern_discovery.py +++ b/examples/eeg/eegbci/eegbci_pattern_discovery.py @@ -105,6 +105,75 @@ def build_rest_baselines(rows: list[dict]) -> dict: } +def _baseline_for_row(row: dict, baselines: dict) -> tuple[str, dict | None]: + subject_run_key = (row["subject_id"], row["run"]) + if subject_run_key in baselines["same_subject_run"]: + return "same_subject_run", baselines["same_subject_run"][subject_run_key] + if row["subject_id"] in baselines["same_subject_all_runs"]: + return "same_subject_all_runs", baselines["same_subject_all_runs"][row["subject_id"]] + if baselines["global_rest"]: + return "global_rest", baselines["global_rest"] + return "unavailable", None + + +def _clip01(value: float) -> float: + return max(0.0, min(1.0, value)) + + +def derive_state_hypothesis(row: dict) -> dict: + delta = float(row.get("delta_relative", 0.0) or 0.0) + theta = float(row.get("theta_relative", 0.0) or 0.0) + alpha = float(row.get("alpha_relative", 0.0) or 0.0) + beta = float(row.get("beta_relative", 0.0) or 0.0) + gamma = float(row.get("gamma_relative", 0.0) or 0.0) + alpha_beta = float(row.get("alpha_beta_ratio", 0.0) or 0.0) + theta_beta = float(row.get("theta_beta_ratio", 0.0) or 0.0) + + scores = { + "idle_alpha_profile": _clip01((alpha - 0.25) + min(alpha_beta / 8.0, 0.40)), + "sensorimotor_engagement_profile": _clip01( + (beta - 0.20) + + max(gamma - 0.12, 0.0) + + max(0.0, 1.5 - alpha_beta) / 6.0 + ), + "slow_wave_dominant_pattern": _clip01( + (delta + theta) - 0.45 + min(theta_beta / 8.0, 0.20) + ), + "possible_artifact_profile": _clip01( + (gamma - 0.22) * 2.0 + max(delta - 0.50, 0.0) + ), + } + ordered = sorted(scores.items(), key=lambda item: item[1], reverse=True) + winner, winning_score = ordered[0] + runner_up = ordered[1][1] + margin = winning_score - runner_up + + if winning_score < 0.20 or margin < 0.08: + state = "mixed_ambiguous_profile" + evidence_score = round(max(winning_score, 0.10), 3) + confidence = "low" + else: + state = winner + evidence_score = round(winning_score, 3) + if winning_score >= 0.65 and margin >= 0.20: + confidence = "high" + elif winning_score >= 0.35 and margin >= 0.12: + confidence = "medium" + else: + confidence = "low" + + return { + "state_hypothesis": state, + "state_confidence": confidence, + "evidence_score": evidence_score, + "evidence_summary": ( + f"delta={delta:.3f}; theta={theta:.3f}; alpha={alpha:.3f}; " + f"beta={beta:.3f}; gamma={gamma:.3f}; alpha_beta={alpha_beta:.3f}; " + f"margin={margin:.3f}" + ), + } + + def write_summary(rows: list[dict], path: Path) -> None: task_counts = Counter(row["task_label"] for row in rows) hypothesis_counts = Counter(row["brain_state_hypothesis"] for row in rows) diff --git a/tests/core/test_eegbci.py b/tests/core/test_eegbci.py index bc1dc0d64..eac5c398e 100644 --- a/tests/core/test_eegbci.py +++ b/tests/core/test_eegbci.py @@ -476,6 +476,103 @@ def test_build_rest_baselines_handles_no_rest_rows(self): self.assertEqual(baselines["same_subject_all_runs"], {}) self.assertIsNone(baselines["global_rest"]) + def test_annotate_rest_fallback_scopes(self): + from examples.eeg.eegbci.eegbci_pattern_discovery import ( + annotate_moment_rows, + build_rest_baselines, + ) + + rows = [ + self._moment_row(task_label="rest", subject_id=1, run=3, alpha_relative=0.50), + self._moment_row(task_label="rest", subject_id=1, run=4, alpha_relative=0.70), + self._moment_row( + task_label="execute_left_fist", + label_family="motor_execution", + subject_id=1, + run=3, + alpha_relative=0.80, + ), + self._moment_row( + task_label="execute_left_fist", + label_family="motor_execution", + subject_id=1, + run=5, + alpha_relative=0.80, + ), + self._moment_row( + task_label="execute_left_fist", + label_family="motor_execution", + subject_id=2, + run=8, + alpha_relative=0.80, + ), + ] + + annotated = annotate_moment_rows(rows, build_rest_baselines(rows)) + + self.assertEqual(annotated[2]["rest_reference_scope"], "same_subject_run") + self.assertEqual(annotated[3]["rest_reference_scope"], "same_subject_all_runs") + self.assertEqual(annotated[4]["rest_reference_scope"], "global_rest") + + def test_derive_state_hypothesis_detects_profiles(self): + from examples.eeg.eegbci.eegbci_pattern_discovery import derive_state_hypothesis + + cases = [ + ( + self._moment_row( + alpha_relative=0.60, + beta_relative=0.12, + gamma_relative=0.05, + alpha_beta_ratio=5.0, + ), + "idle_alpha_profile", + ), + ( + self._moment_row( + alpha_relative=0.12, + beta_relative=0.48, + gamma_relative=0.16, + alpha_beta_ratio=0.25, + ), + "sensorimotor_engagement_profile", + ), + ( + self._moment_row( + delta_relative=0.42, + theta_relative=0.36, + alpha_relative=0.08, + beta_relative=0.08, + ), + "slow_wave_dominant_pattern", + ), + ( + self._moment_row( + gamma_relative=0.48, alpha_relative=0.10, beta_relative=0.12 + ), + "possible_artifact_profile", + ), + ( + self._moment_row( + delta_relative=0.18, + theta_relative=0.20, + alpha_relative=0.22, + beta_relative=0.21, + gamma_relative=0.19, + alpha_beta_ratio=1.05, + ), + "mixed_ambiguous_profile", + ), + ] + + for row, expected in cases: + with self.subTest(expected=expected): + result = derive_state_hypothesis(row) + self.assertEqual(result["state_hypothesis"], expected) + self.assertIn(result["state_confidence"], {"low", "medium", "high"}) + self.assertGreaterEqual(result["evidence_score"], 0.0) + self.assertLessEqual(result["evidence_score"], 1.0) + self.assertIn("alpha=", result["evidence_summary"]) + @unittest.skipUnless( os.environ.get("PYHEALTH_RUN_REAL_EEGBCI") == "1", From d032d5acbd72977c60a6f9771eccf49d596d7565 Mon Sep 17 00:00:00 2001 From: Vihaan Agrawal <247244351+vihaan101@users.noreply.github.com> Date: Wed, 8 Jul 2026 12:21:46 +0530 Subject: [PATCH 12/21] Add EEGBCI task state quality helpers --- .../moment_report_continuation_plan.md | 4 + .../moment_report_implementation_plan.md | 8 +- .../eeg/eegbci/eegbci_pattern_discovery.py | 74 +++++++++++++++++++ tests/core/test_eegbci.py | 58 +++++++++++++++ 4 files changed, 140 insertions(+), 4 deletions(-) diff --git a/docs/eeg_pattern_discovery/moment_report_continuation_plan.md b/docs/eeg_pattern_discovery/moment_report_continuation_plan.md index 0e8aa1700..2a1f43f5d 100644 --- a/docs/eeg_pattern_discovery/moment_report_continuation_plan.md +++ b/docs/eeg_pattern_discovery/moment_report_continuation_plan.md @@ -444,3 +444,7 @@ graphify update . implemented `_baseline_for_row()`, `_clip01()`, and `derive_state_hypothesis()`, and verified the state-profile focused test. The fallback-scope test remains expected to pass in Task 5 when `annotate_moment_rows()` is added. +- 2026-07-08: Task 4 is complete: added deterministic task/state relation tests + and parseable quality-boolean tests, confirmed missing-helper failures, + implemented `derive_task_state_relation()` and `derive_quality_columns()`, and + verified the focused relation/quality tests. diff --git a/docs/eeg_pattern_discovery/moment_report_implementation_plan.md b/docs/eeg_pattern_discovery/moment_report_implementation_plan.md index f6b206381..8f9820464 100644 --- a/docs/eeg_pattern_discovery/moment_report_implementation_plan.md +++ b/docs/eeg_pattern_discovery/moment_report_implementation_plan.md @@ -468,7 +468,7 @@ Expected: pass. The fallback test still fails until `annotate_moment_rows()` exi - Produces: `derive_task_state_relation(row: dict) -> dict` - Produces: `derive_quality_columns(row: dict) -> dict` -- [ ] **Step 1: Write failing tests** +- [x] **Step 1: Write failing tests** Add: @@ -515,7 +515,7 @@ Add: self.assertFalse(flags["is_mixed_or_ambiguous"]) ``` -- [ ] **Step 2: Run tests to verify they fail** +- [x] **Step 2: Run tests to verify they fail** Run: @@ -525,7 +525,7 @@ Run: Expected: fail because helper functions are missing. -- [ ] **Step 3: Implement relation and quality helpers** +- [x] **Step 3: Implement relation and quality helpers** Add: @@ -586,7 +586,7 @@ def derive_quality_columns(row: dict) -> dict: } ``` -- [ ] **Step 4: Run tests to verify they pass** +- [x] **Step 4: Run tests to verify they pass** Run: diff --git a/examples/eeg/eegbci/eegbci_pattern_discovery.py b/examples/eeg/eegbci/eegbci_pattern_discovery.py index 159a641f7..6615b9d54 100644 --- a/examples/eeg/eegbci/eegbci_pattern_discovery.py +++ b/examples/eeg/eegbci/eegbci_pattern_discovery.py @@ -174,6 +174,80 @@ def derive_state_hypothesis(row: dict) -> dict: } +def derive_task_state_relation(row: dict) -> dict: + label_family = row.get("label_family", "") + task_label = row.get("task_label", "") + state = row.get("state_hypothesis", "") + + if state == "possible_artifact_profile": + relation = "not_applicable" + confidence = "medium" + rationale = ( + "Artifact-like frequency evidence is flagged for inspection instead of " + "task-label comparison." + ) + elif state == "mixed_ambiguous_profile": + relation = "ambiguous" + confidence = "low" + rationale = ( + "No frequency-profile state won clearly enough to compare strongly with " + "the task label." + ) + elif task_label == "rest" and state == "idle_alpha_profile": + relation = "supports_label" + confidence = "medium" + rationale = "The idle-like alpha profile is consistent with a rest-labeled EEGBCI window." + elif label_family == "motor_execution" and state == "sensorimotor_engagement_profile": + relation = "supports_label" + confidence = "medium" + rationale = ( + "The motor-engaged frequency profile is consistent with an " + "execution-labeled window." + ) + elif label_family == "motor_imagery" and state == "sensorimotor_engagement_profile": + relation = "adds_detail" + confidence = "medium" + rationale = ( + "The motor-engaged frequency profile adds signal detail to an " + "imagery-labeled window." + ) + elif label_family in {"motor_execution", "motor_imagery"} and state == "idle_alpha_profile": + relation = "disagrees" + confidence = "medium" + rationale = "The idle-like alpha profile does not align with a motor-labeled EEGBCI window." + elif state == "slow_wave_dominant_pattern": + relation = "adds_detail" + confidence = "low" + rationale = "The slow-wave dominant pattern adds frequency detail but is not a direct task match." + else: + relation = "ambiguous" + confidence = "low" + rationale = ( + "The task label and frequency-profile state do not have a stronger " + "deterministic mapping." + ) + + return { + "task_state_relation": relation, + "task_state_rationale": rationale, + "task_state_confidence": confidence, + } + + +def derive_quality_columns(row: dict) -> dict: + flags = str(row.get("quality_flags", "")) + state = row.get("state_hypothesis", "") + confidence = row.get("state_confidence", row.get("confidence", "")) + return { + "is_low_confidence": confidence == "low" or "low_confidence" in flags, + "is_possible_artifact": state == "possible_artifact_profile" + or "artifact" in flags + or "high_gamma" in flags, + "is_mixed_or_ambiguous": state == "mixed_ambiguous_profile" + or "ambiguous" in flags, + } + + def write_summary(rows: list[dict], path: Path) -> None: task_counts = Counter(row["task_label"] for row in rows) hypothesis_counts = Counter(row["brain_state_hypothesis"] for row in rows) diff --git a/tests/core/test_eegbci.py b/tests/core/test_eegbci.py index eac5c398e..ea58ce7bb 100644 --- a/tests/core/test_eegbci.py +++ b/tests/core/test_eegbci.py @@ -573,6 +573,64 @@ def test_derive_state_hypothesis_detects_profiles(self): self.assertLessEqual(result["evidence_score"], 1.0) self.assertIn("alpha=", result["evidence_summary"]) + def test_task_state_relation_table_is_deterministic(self): + from examples.eeg.eegbci.eegbci_pattern_discovery import ( + derive_task_state_relation, + ) + + cases = [ + ("rest", "rest", "idle_alpha_profile", "supports_label"), + ("rest", "rest", "mixed_ambiguous_profile", "ambiguous"), + ("rest", "rest", "possible_artifact_profile", "not_applicable"), + ( + "execute_left_fist", + "motor_execution", + "sensorimotor_engagement_profile", + "supports_label", + ), + ( + "imagine_left_fist", + "motor_imagery", + "sensorimotor_engagement_profile", + "adds_detail", + ), + ("execute_left_fist", "motor_execution", "idle_alpha_profile", "disagrees"), + ( + "imagine_left_fist", + "motor_imagery", + "slow_wave_dominant_pattern", + "adds_detail", + ), + ] + + for task_label, label_family, state, expected in cases: + with self.subTest(state=state, label_family=label_family): + result = derive_task_state_relation( + self._moment_row( + task_label=task_label, + label_family=label_family, + state_hypothesis=state, + ) + ) + self.assertEqual(result["task_state_relation"], expected) + self.assertIn(result["task_state_confidence"], {"low", "medium", "high"}) + self.assertGreater(len(result["task_state_rationale"]), 20) + + def test_quality_booleans_are_parseable(self): + from examples.eeg.eegbci.eegbci_pattern_discovery import derive_quality_columns + + flags = derive_quality_columns( + self._moment_row( + state_hypothesis="possible_artifact_profile", + state_confidence="low", + quality_flags="low_confidence; high_gamma", + ) + ) + + self.assertTrue(flags["is_low_confidence"]) + self.assertTrue(flags["is_possible_artifact"]) + self.assertFalse(flags["is_mixed_or_ambiguous"]) + @unittest.skipUnless( os.environ.get("PYHEALTH_RUN_REAL_EEGBCI") == "1", From 34c21bcda43c64d5f54642db74cf3d510e47f1b8 Mon Sep 17 00:00:00 2001 From: Vihaan Agrawal <247244351+vihaan101@users.noreply.github.com> Date: Wed, 8 Jul 2026 12:23:57 +0530 Subject: [PATCH 13/21] Add EEGBCI moment row annotation --- .../moment_report_continuation_plan.md | 5 + .../moment_report_implementation_plan.md | 8 +- .../eeg/eegbci/eegbci_pattern_discovery.py | 82 ++++++++++++ tests/core/test_eegbci.py | 126 ++++++++++++++++++ 4 files changed, 217 insertions(+), 4 deletions(-) diff --git a/docs/eeg_pattern_discovery/moment_report_continuation_plan.md b/docs/eeg_pattern_discovery/moment_report_continuation_plan.md index 2a1f43f5d..7d99b5159 100644 --- a/docs/eeg_pattern_discovery/moment_report_continuation_plan.md +++ b/docs/eeg_pattern_discovery/moment_report_continuation_plan.md @@ -448,3 +448,8 @@ graphify update . and parseable quality-boolean tests, confirmed missing-helper failures, implemented `derive_task_state_relation()` and `derive_quality_columns()`, and verified the focused relation/quality tests. +- 2026-07-08: Task 5 is complete: added annotation/schema tests plus + band-specific delta, legacy-field preservation, and non-mutation checks; + confirmed missing schema/annotator failures; implemented `BASE_OUTPUT_COLUMNS`, + `MOMENT_REPORT_COLUMNS`, `OUTPUT_COLUMNS`, and `annotate_moment_rows()`; and + verified the annotation/fallback and band-delta focused tests. diff --git a/docs/eeg_pattern_discovery/moment_report_implementation_plan.md b/docs/eeg_pattern_discovery/moment_report_implementation_plan.md index 8f9820464..cf6c3e402 100644 --- a/docs/eeg_pattern_discovery/moment_report_implementation_plan.md +++ b/docs/eeg_pattern_discovery/moment_report_implementation_plan.md @@ -609,7 +609,7 @@ Expected: pass. - Produces: `MOMENT_REPORT_COLUMNS: tuple[str, ...]` - Produces: `OUTPUT_COLUMNS: tuple[str, ...]` -- [ ] **Step 1: Write failing annotation tests** +- [x] **Step 1: Write failing annotation tests** Add: @@ -657,7 +657,7 @@ Add: self.assertEqual(annotated[0]["rest_alpha_relative_delta"], "") ``` -- [ ] **Step 2: Run tests to verify they fail** +- [x] **Step 2: Run tests to verify they fail** Run: @@ -667,7 +667,7 @@ Run: Expected: fail until `annotate_moment_rows()` exists. -- [ ] **Step 3: Implement annotation and schema** +- [x] **Step 3: Implement annotation and schema** Add below `derive_quality_columns()`: @@ -752,7 +752,7 @@ def annotate_moment_rows(rows: list[dict], baselines: dict) -> list[dict]: return annotated ``` -- [ ] **Step 4: Run tests to verify they pass** +- [x] **Step 4: Run tests to verify they pass** Run: diff --git a/examples/eeg/eegbci/eegbci_pattern_discovery.py b/examples/eeg/eegbci/eegbci_pattern_discovery.py index 6615b9d54..4fcf716ea 100644 --- a/examples/eeg/eegbci/eegbci_pattern_discovery.py +++ b/examples/eeg/eegbci/eegbci_pattern_discovery.py @@ -248,6 +248,88 @@ def derive_quality_columns(row: dict) -> dict: } +BASE_OUTPUT_COLUMNS = ( + "patient_id", + "record_id", + "subject_id", + "run", + "run_type", + "trial_id", + "event_code", + "task_label", + "label_family", + "label", + "eegbci_label", + "model_label", + "start_time", + "end_time", + "dominant_band", + "alpha_beta_ratio", + "theta_beta_ratio", + "brain_state_hypothesis", + "confidence", + "quality_flags", + "interpretation", + "delta_power", + "theta_power", + "alpha_power", + "beta_power", + "gamma_power", + "delta_relative", + "theta_relative", + "alpha_relative", + "beta_relative", + "gamma_relative", +) + +MOMENT_REPORT_COLUMNS = ( + "analysis_version", + "state_hypothesis", + "state_confidence", + "evidence_score", + "evidence_summary", + "rest_reference_scope", + "rest_delta_relative_delta", + "rest_theta_relative_delta", + "rest_alpha_relative_delta", + "rest_beta_relative_delta", + "rest_gamma_relative_delta", + "task_state_relation", + "task_state_rationale", + "task_state_confidence", + "is_low_confidence", + "is_possible_artifact", + "is_mixed_or_ambiguous", +) + +OUTPUT_COLUMNS = BASE_OUTPUT_COLUMNS + MOMENT_REPORT_COLUMNS + + +def annotate_moment_rows(rows: list[dict], baselines: dict) -> list[dict]: + annotated = [] + for row in rows: + next_row = dict(row) + scope, baseline = _baseline_for_row(next_row, baselines) + next_row["analysis_version"] = ANALYSIS_VERSION + next_row["rest_reference_scope"] = scope + + for band in REPORT_BANDS: + source_key = f"{band}_relative" + delta_key = f"rest_{band}_relative_delta" + if baseline and source_key in baseline and next_row.get(source_key) not in ("", None): + next_row[delta_key] = round( + float(next_row[source_key]) - float(baseline[source_key]), 6 + ) + else: + next_row[delta_key] = "" + + next_row.update(derive_state_hypothesis(next_row)) + next_row.update(derive_task_state_relation(next_row)) + next_row.update(derive_quality_columns(next_row)) + annotated.append(next_row) + return annotated + + def write_summary(rows: list[dict], path: Path) -> None: task_counts = Counter(row["task_label"] for row in rows) hypothesis_counts = Counter(row["brain_state_hypothesis"] for row in rows) diff --git a/tests/core/test_eegbci.py b/tests/core/test_eegbci.py index ea58ce7bb..7a5afb5a0 100644 --- a/tests/core/test_eegbci.py +++ b/tests/core/test_eegbci.py @@ -631,6 +631,132 @@ def test_quality_booleans_are_parseable(self): self.assertTrue(flags["is_possible_artifact"]) self.assertFalse(flags["is_mixed_or_ambiguous"]) + def test_annotate_moment_rows_adds_required_fields(self): + from examples.eeg.eegbci.eegbci_pattern_discovery import ( + ANALYSIS_VERSION, + MOMENT_REPORT_COLUMNS, + annotate_moment_rows, + build_rest_baselines, + ) + + rows = [ + self._moment_row(task_label="rest", alpha_relative=0.50, beta_relative=0.20), + self._moment_row( + task_label="execute_left_fist", + label_family="motor_execution", + alpha_relative=0.20, + beta_relative=0.45, + ), + ] + + annotated = annotate_moment_rows(rows, build_rest_baselines(rows)) + + for annotated_row in annotated: + for column in MOMENT_REPORT_COLUMNS: + self.assertIn(column, annotated_row) + row = annotated[1] + self.assertEqual(row["analysis_version"], ANALYSIS_VERSION) + self.assertIn( + row["state_hypothesis"], + { + "idle_alpha_profile", + "sensorimotor_engagement_profile", + "slow_wave_dominant_pattern", + "possible_artifact_profile", + "mixed_ambiguous_profile", + }, + ) + self.assertIn("rest_alpha_relative_delta", row) + self.assertAlmostEqual(row["rest_alpha_relative_delta"], -0.30) + self.assertIn("task_state_relation", row) + self.assertIn("task_state_rationale", row) + self.assertIn("is_low_confidence", row) + + def test_annotate_moment_rows_marks_unavailable_rest(self): + from examples.eeg.eegbci.eegbci_pattern_discovery import ( + annotate_moment_rows, + build_rest_baselines, + ) + + rows = [ + self._moment_row( + task_label="execute_left_fist", label_family="motor_execution" + ) + ] + + annotated = annotate_moment_rows(rows, build_rest_baselines(rows)) + + self.assertEqual(annotated[0]["rest_reference_scope"], "unavailable") + for band in ("delta", "theta", "alpha", "beta", "gamma"): + self.assertEqual(annotated[0][f"rest_{band}_relative_delta"], "") + + def test_rest_delta_values_are_band_specific(self): + from examples.eeg.eegbci.eegbci_pattern_discovery import ( + annotate_moment_rows, + build_rest_baselines, + ) + + rows = [ + self._moment_row( + task_label="rest", + delta_relative=0.10, + theta_relative=0.20, + alpha_relative=0.30, + beta_relative=0.25, + gamma_relative=0.15, + ), + self._moment_row( + task_label="execute_left_fist", + label_family="motor_execution", + delta_relative=0.15, + theta_relative=0.18, + alpha_relative=0.25, + beta_relative=0.35, + gamma_relative=0.07, + ), + ] + + annotated = annotate_moment_rows(rows, build_rest_baselines(rows)) + + self.assertAlmostEqual(annotated[1]["rest_delta_relative_delta"], 0.05) + self.assertAlmostEqual(annotated[1]["rest_theta_relative_delta"], -0.02) + self.assertAlmostEqual(annotated[1]["rest_alpha_relative_delta"], -0.05) + self.assertAlmostEqual(annotated[1]["rest_beta_relative_delta"], 0.10) + self.assertAlmostEqual(annotated[1]["rest_gamma_relative_delta"], -0.08) + + def test_annotate_moment_rows_preserves_legacy_fields(self): + from examples.eeg.eegbci.eegbci_pattern_discovery import ( + annotate_moment_rows, + build_rest_baselines, + ) + + legacy = self._moment_row( + brain_state_hypothesis="legacy_state", + confidence="medium", + quality_flags="legacy_flag", + interpretation="Legacy interpretation.", + ) + + annotated = annotate_moment_rows([legacy], build_rest_baselines([legacy])) + + self.assertEqual(annotated[0]["brain_state_hypothesis"], "legacy_state") + self.assertEqual(annotated[0]["confidence"], "medium") + self.assertEqual(annotated[0]["quality_flags"], "legacy_flag") + self.assertEqual(annotated[0]["interpretation"], "Legacy interpretation.") + + def test_annotate_moment_rows_does_not_mutate_input_rows(self): + from examples.eeg.eegbci.eegbci_pattern_discovery import ( + annotate_moment_rows, + build_rest_baselines, + ) + + rows = [self._moment_row()] + original = [dict(row) for row in rows] + + annotate_moment_rows(rows, build_rest_baselines(rows)) + + self.assertEqual(rows, original) + @unittest.skipUnless( os.environ.get("PYHEALTH_RUN_REAL_EEGBCI") == "1", From f36e9aae568d453da5250542a964948bfbd96d81 Mon Sep 17 00:00:00 2001 From: Vihaan Agrawal <247244351+vihaan101@users.noreply.github.com> Date: Wed, 8 Jul 2026 12:25:31 +0530 Subject: [PATCH 14/21] Add EEGBCI representative windows --- .../moment_report_continuation_plan.md | 4 + .../moment_report_implementation_plan.md | 8 +- .../eeg/eegbci/eegbci_pattern_discovery.py | 66 +++++++++ tests/core/test_eegbci.py | 125 ++++++++++++++++++ 4 files changed, 199 insertions(+), 4 deletions(-) diff --git a/docs/eeg_pattern_discovery/moment_report_continuation_plan.md b/docs/eeg_pattern_discovery/moment_report_continuation_plan.md index 7d99b5159..1e8060639 100644 --- a/docs/eeg_pattern_discovery/moment_report_continuation_plan.md +++ b/docs/eeg_pattern_discovery/moment_report_continuation_plan.md @@ -453,3 +453,7 @@ graphify update . confirmed missing schema/annotator failures; implemented `BASE_OUTPUT_COLUMNS`, `MOMENT_REPORT_COLUMNS`, `OUTPUT_COLUMNS`, and `annotate_moment_rows()`; and verified the annotation/fallback and band-delta focused tests. +- 2026-07-08: Task 6 is complete: added deterministic representative-window + tests plus ambiguous and disagreement edge checks, confirmed the missing + selector failure, implemented stable selection helpers, and verified the + representative-window focused tests. diff --git a/docs/eeg_pattern_discovery/moment_report_implementation_plan.md b/docs/eeg_pattern_discovery/moment_report_implementation_plan.md index cf6c3e402..3855a5a72 100644 --- a/docs/eeg_pattern_discovery/moment_report_implementation_plan.md +++ b/docs/eeg_pattern_discovery/moment_report_implementation_plan.md @@ -780,7 +780,7 @@ Return shape: } ``` -- [ ] **Step 1: Write failing representative selection tests** +- [x] **Step 1: Write failing representative selection tests** Add: @@ -805,7 +805,7 @@ Add: self.assertIn("strongest_artifact_like", selected["absent"]) ``` -- [ ] **Step 2: Run test to verify it fails** +- [x] **Step 2: Run test to verify it fails** Run: @@ -815,7 +815,7 @@ Run: Expected: fail because `select_representative_windows` is missing. -- [ ] **Step 3: Implement representative selection** +- [x] **Step 3: Implement representative selection** Add: @@ -882,7 +882,7 @@ def select_representative_windows(rows: list[dict]) -> dict: return {"cards": cards, "absent": absent} ``` -- [ ] **Step 4: Run test to verify it passes** +- [x] **Step 4: Run test to verify it passes** Run: diff --git a/examples/eeg/eegbci/eegbci_pattern_discovery.py b/examples/eeg/eegbci/eegbci_pattern_discovery.py index 4fcf716ea..490ae8a50 100644 --- a/examples/eeg/eegbci/eegbci_pattern_discovery.py +++ b/examples/eeg/eegbci/eegbci_pattern_discovery.py @@ -330,6 +330,72 @@ def annotate_moment_rows(rows: list[dict], baselines: dict) -> list[dict]: return annotated +def _stable_row_key(row: dict) -> tuple: + return ( + row.get("subject_id", 0), + row.get("run", 0), + float(row.get("start_time", 0.0) or 0.0), + ) + + +def _strongest_row(rows: list[dict]) -> dict | None: + if not rows: + return None + return sorted( + rows, + key=lambda row: ( + -float(row.get("evidence_score", 0.0) or 0.0), + -STATE_CONFIDENCE_RANK.get(row.get("state_confidence", "low"), 0), + *_stable_row_key(row), + ), + )[0] + + +def select_representative_windows(rows: list[dict]) -> dict: + definitions = { + "strongest_idle_like": "idle_alpha_profile", + "strongest_motor_engaged": "sensorimotor_engagement_profile", + "strongest_slow_wave": "slow_wave_dominant_pattern", + "strongest_artifact_like": "possible_artifact_profile", + } + cards = {} + absent = [] + + for card_name, state in definitions.items(): + candidate = _strongest_row( + [row for row in rows if row.get("state_hypothesis") == state] + ) + if candidate is None: + absent.append(card_name) + else: + cards[card_name] = candidate + + ambiguous = [ + row for row in rows if row.get("state_hypothesis") == "mixed_ambiguous_profile" + ] + if ambiguous: + cards["most_ambiguous"] = sorted( + ambiguous, + key=lambda row: ( + float(row.get("evidence_score", 0.0) or 0.0), + -STATE_CONFIDENCE_RANK.get(row.get("state_confidence", "low"), 0), + *_stable_row_key(row), + ), + )[0] + else: + absent.append("most_ambiguous") + + disagreement = _strongest_row( + [row for row in rows if row.get("task_state_relation") == "disagrees"] + ) + if disagreement is None: + absent.append("strongest_task_state_disagreement") + else: + cards["strongest_task_state_disagreement"] = disagreement + + return {"cards": cards, "absent": absent} + + def write_summary(rows: list[dict], path: Path) -> None: task_counts = Counter(row["task_label"] for row in rows) hypothesis_counts = Counter(row["brain_state_hypothesis"] for row in rows) diff --git a/tests/core/test_eegbci.py b/tests/core/test_eegbci.py index 7a5afb5a0..754782517 100644 --- a/tests/core/test_eegbci.py +++ b/tests/core/test_eegbci.py @@ -757,6 +757,131 @@ def test_annotate_moment_rows_does_not_mutate_input_rows(self): self.assertEqual(rows, original) + def test_select_representative_windows_is_deterministic(self): + from examples.eeg.eegbci.eegbci_pattern_discovery import ( + select_representative_windows, + ) + + rows = [ + self._moment_row( + subject_id=2, + run=4, + start_time=6.0, + state_hypothesis="idle_alpha_profile", + state_confidence="medium", + evidence_score=0.80, + ), + self._moment_row( + subject_id=1, + run=3, + start_time=4.0, + state_hypothesis="idle_alpha_profile", + state_confidence="medium", + evidence_score=0.80, + ), + self._moment_row( + subject_id=1, + run=3, + start_time=8.0, + state_hypothesis="sensorimotor_engagement_profile", + state_confidence="high", + evidence_score=0.90, + ), + self._moment_row( + subject_id=1, + run=3, + start_time=10.0, + state_hypothesis="mixed_ambiguous_profile", + state_confidence="low", + evidence_score=0.12, + ), + self._moment_row( + subject_id=1, + run=3, + start_time=12.0, + state_hypothesis="idle_alpha_profile", + task_state_relation="disagrees", + state_confidence="medium", + evidence_score=0.70, + ), + ] + + selected = select_representative_windows(rows) + + self.assertEqual(selected["cards"]["strongest_idle_like"]["subject_id"], 1) + self.assertEqual( + selected["cards"]["strongest_motor_engaged"]["state_hypothesis"], + "sensorimotor_engagement_profile", + ) + self.assertEqual(selected["cards"]["most_ambiguous"]["start_time"], 10.0) + self.assertEqual( + selected["cards"]["strongest_task_state_disagreement"][ + "task_state_relation" + ], + "disagrees", + ) + self.assertIn("strongest_artifact_like", selected["absent"]) + + def test_select_representative_windows_picks_lowest_evidence_ambiguous(self): + from examples.eeg.eegbci.eegbci_pattern_discovery import ( + select_representative_windows, + ) + + rows = [ + self._moment_row( + subject_id=2, + run=3, + start_time=4.0, + state_hypothesis="mixed_ambiguous_profile", + state_confidence="low", + evidence_score=0.20, + ), + self._moment_row( + subject_id=1, + run=3, + start_time=8.0, + state_hypothesis="mixed_ambiguous_profile", + state_confidence="low", + evidence_score=0.10, + ), + ] + + selected = select_representative_windows(rows) + + self.assertEqual(selected["cards"]["most_ambiguous"]["subject_id"], 1) + + def test_select_representative_windows_picks_strongest_disagreement(self): + from examples.eeg.eegbci.eegbci_pattern_discovery import ( + select_representative_windows, + ) + + rows = [ + self._moment_row( + subject_id=1, + run=3, + start_time=4.0, + state_hypothesis="idle_alpha_profile", + task_state_relation="disagrees", + state_confidence="medium", + evidence_score=0.50, + ), + self._moment_row( + subject_id=2, + run=3, + start_time=6.0, + state_hypothesis="idle_alpha_profile", + task_state_relation="disagrees", + state_confidence="medium", + evidence_score=0.80, + ), + ] + + selected = select_representative_windows(rows) + + self.assertEqual( + selected["cards"]["strongest_task_state_disagreement"]["subject_id"], 2 + ) + @unittest.skipUnless( os.environ.get("PYHEALTH_RUN_REAL_EEGBCI") == "1", From c6933caae9fe6eab62be78b8bbd23c8b91e117a8 Mon Sep 17 00:00:00 2001 From: Vihaan Agrawal <247244351+vihaan101@users.noreply.github.com> Date: Wed, 8 Jul 2026 12:27:59 +0530 Subject: [PATCH 15/21] Render EEGBCI moment summary --- .../moment_report_continuation_plan.md | 6 + .../moment_report_implementation_plan.md | 8 +- .../eeg/eegbci/eegbci_pattern_discovery.py | 193 +++++++++++++- tests/core/test_eegbci.py | 237 ++++++++++++++++++ 4 files changed, 427 insertions(+), 17 deletions(-) diff --git a/docs/eeg_pattern_discovery/moment_report_continuation_plan.md b/docs/eeg_pattern_discovery/moment_report_continuation_plan.md index 1e8060639..5de139628 100644 --- a/docs/eeg_pattern_discovery/moment_report_continuation_plan.md +++ b/docs/eeg_pattern_discovery/moment_report_continuation_plan.md @@ -457,3 +457,9 @@ graphify update . tests plus ambiguous and disagreement edge checks, confirmed the missing selector failure, implemented stable selection helpers, and verified the representative-window focused tests. +- 2026-07-08: Task 7 is complete: added Markdown renderer tests covering required + sections, empty output, all-low-confidence/all-same-state messaging, task/state + matrix output, representative card details, limitations placement, and removal + of the old row-level caveat; confirmed missing-renderer failures; implemented + `render_summary()` and config-aware `write_summary()`; and verified the focused + renderer tests plus the caveat regression. diff --git a/docs/eeg_pattern_discovery/moment_report_implementation_plan.md b/docs/eeg_pattern_discovery/moment_report_implementation_plan.md index 3855a5a72..c47686bd7 100644 --- a/docs/eeg_pattern_discovery/moment_report_implementation_plan.md +++ b/docs/eeg_pattern_discovery/moment_report_implementation_plan.md @@ -902,7 +902,7 @@ Expected: pass. - Produces: `render_summary(rows: list[dict], config: dict) -> str` - Updates: `write_summary(rows: list[dict], path: Path, config: dict) -> None` -- [ ] **Step 1: Write failing renderer tests** +- [x] **Step 1: Write failing renderer tests** Add: @@ -966,7 +966,7 @@ Add: self.assertIn("## Limitations", summary) ``` -- [ ] **Step 2: Run tests to verify they fail** +- [x] **Step 2: Run tests to verify they fail** Run: @@ -976,7 +976,7 @@ Run: Expected: fail because `render_summary` is missing. -- [ ] **Step 3: Implement renderer** +- [x] **Step 3: Implement renderer** Replace existing `write_summary()` with: @@ -1138,7 +1138,7 @@ def write_summary(rows: list[dict], path: Path, config: dict) -> None: path.write_text(render_summary(rows, config), encoding="utf-8") ``` -- [ ] **Step 4: Run tests to verify they pass** +- [x] **Step 4: Run tests to verify they pass** Run: diff --git a/examples/eeg/eegbci/eegbci_pattern_discovery.py b/examples/eeg/eegbci/eegbci_pattern_discovery.py index 490ae8a50..5b6ed0321 100644 --- a/examples/eeg/eegbci/eegbci_pattern_discovery.py +++ b/examples/eeg/eegbci/eegbci_pattern_discovery.py @@ -396,25 +396,192 @@ def select_representative_windows(rows: list[dict]) -> dict: return {"cards": cards, "absent": absent} -def write_summary(rows: list[dict], path: Path) -> None: - task_counts = Counter(row["task_label"] for row in rows) - hypothesis_counts = Counter(row["brain_state_hypothesis"] for row in rows) +def _format_count_lines(counter: Counter) -> list[str]: + if not counter: + return ["- None"] + return [f"- {label}: {count}" for label, count in counter.most_common()] + + +def _format_card(row: dict) -> list[str]: + bands = ", ".join( + f"{band}={float(row.get(f'{band}_relative', 0.0) or 0.0):.3f}" + for band in REPORT_BANDS + ) + deltas = ", ".join( + f"{band}={row.get(f'rest_{band}_relative_delta', '')}" + for band in REPORT_BANDS + ) + return [ + f"- Subject {row.get('subject_id')} run {row.get('run')} trial {row.get('trial_id')}", + f" - Task: {row.get('task_label')} from {row.get('start_time')}s to {row.get('end_time')}s", + ( + f" - State: {row.get('state_hypothesis')} " + f"({row.get('state_confidence')}, evidence {row.get('evidence_score')})" + ), + f" - Dominant band: {row.get('dominant_band')}; relative bands: {bands}", + f" - Rest deltas: {deltas}; scope: {row.get('rest_reference_scope')}", + ( + f" - Task relation: {row.get('task_state_relation')} " + f"({row.get('task_state_confidence')})" + ), + ( + f" - Flags: low_confidence={row.get('is_low_confidence')}, " + f"possible_artifact={row.get('is_possible_artifact')}, " + f"mixed_or_ambiguous={row.get('is_mixed_or_ambiguous')}" + ), + f" - Rationale: {row.get('task_state_rationale')}", + ] + + +def render_summary(rows: list[dict], config: dict) -> str: + state_counts = Counter(row.get("state_hypothesis", "missing") for row in rows) + task_counts = Counter(row.get("task_label", "missing") for row in rows) + confidence_counts = Counter(row.get("state_confidence", "missing") for row in rows) + relation_counts = Counter(row.get("task_state_relation", "missing") for row in rows) + unavailable_rest = sum( + row.get("rest_reference_scope") == "unavailable" for row in rows + ) + low_confidence = sum(bool(row.get("is_low_confidence")) for row in rows) + artifacts = sum(bool(row.get("is_possible_artifact")) for row in rows) + ambiguous = sum(bool(row.get("is_mixed_or_ambiguous")) for row in rows) + representatives = select_representative_windows(rows) + + executive = [] + if not rows: + executive.append("No windows were produced for the requested configuration.") + else: + top_state, top_state_count = state_counts.most_common(1)[0] + executive.append( + f"Processed {len(rows)} windows. Most common state: `{top_state}` " + f"({top_state_count}/{len(rows)})." + ) + if low_confidence == len(rows): + executive.append("Every window is low confidence.") + if len(state_counts) == 1: + executive.append( + "Every window maps to the same state; broaden coverage or review thresholds." + ) + if unavailable_rest == len(rows): + executive.append("No rest baseline was available for the emitted rows.") + if config.get("output_was_capped"): + executive.append("Output was capped by `--max-windows`.") + lines = [ - "# EEGBCI Pattern Discovery Summary", + "# EEGBCI Pattern Discovery Moment Report", + "", + f"Analysis version: `{ANALYSIS_VERSION}`", + "", + "## Executive Result", + "", + *[f"- {item}" for item in executive], + "", + "## Run Configuration", + "", + f"- Subjects: {config.get('subjects')}", + f"- Runs: {config.get('runs')}", + f"- Max windows: {config.get('max_windows')}", + f"- Baseline source rows: {config.get('baseline_row_count')}", + "", + "## Window Coverage", + "", + f"- Output windows: {len(rows)}", + f"- Task labels: {dict(task_counts)}", "", - "Brain-state hypotheses are exploratory signal metadata, not clinical diagnoses.", + "## Moment-State Summary", "", - f"Processed windows: {len(rows)}", + *_format_count_lines(state_counts), "", - "## Task Labels", + "## Task Label x State Matrix", "", ] - for label, count in task_counts.most_common(): - lines.append(f"- {label}: {count}") - lines.extend(["", "## Brain-State Hypotheses", ""]) - for label, count in hypothesis_counts.most_common(): - lines.append(f"- {label}: {count}") - path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + matrix = Counter( + (row.get("task_label", "missing"), row.get("state_hypothesis", "missing")) + for row in rows + ) + if matrix: + for (task_label, state), count in sorted(matrix.items()): + lines.append(f"- {task_label} x {state}: {count}") + else: + lines.append("- None") + + lines.extend( + [ + "", + "## Rest-Normalized Bandpower Summary", + "", + f"- Rows with unavailable rest baseline: {unavailable_rest}", + ] + ) + for band in REPORT_BANDS: + key = f"rest_{band}_relative_delta" + values = [float(row.get(key)) for row in rows if row.get(key) not in ("", None)] + if values: + lines.append(f"- {band}: mean delta {sum(values) / len(values):.3f}") + else: + lines.append(f"- {band}: unavailable") + + lines.extend( + [ + "", + "## Confidence and Quality Audit", + "", + f"- State confidence: {dict(confidence_counts)}", + f"- Task-state relations: {dict(relation_counts)}", + f"- Low-confidence rows: {low_confidence}", + f"- Possible artifact rows: {artifacts}", + f"- Mixed or ambiguous rows: {ambiguous}", + "", + "## Representative Windows", + "", + ] + ) + if representatives["cards"]: + for card_name, row in representatives["cards"].items(): + lines.append(f"### {card_name.replace('_', ' ').title()}") + lines.extend(_format_card(row)) + lines.append("") + else: + lines.append("- None") + if representatives["absent"]: + lines.append( + f"- Absent representative classes: {', '.join(representatives['absent'])}" + ) + + lines.extend( + [ + "", + "## Limitations", + "", + ( + "- These labels are signal-pattern summaries from short EEG windows. " + "They are not clinical findings and should not be read as evidence " + "of a subject's cognition." + ), + ] + ) + if unavailable_rest: + lines.append("- No rest baseline was available for at least one emitted row.") + if config.get("output_was_capped"): + lines.append( + "- The output was capped, so the artifact may not represent all requested windows." + ) + + lines.extend( + [ + "", + "## Next Checks", + "", + "- Run with broader subjects/runs to verify that state diversity improves.", + "- Inspect possible artifact rows before drawing conclusions from state counts.", + "- Compare rest-normalized deltas against the raw relative band shares.", + ] + ) + return "\n".join(lines).rstrip() + "\n" + + +def write_summary(rows: list[dict], path: Path, config: dict) -> None: + path.write_text(render_summary(rows, config), encoding="utf-8") def main() -> None: diff --git a/tests/core/test_eegbci.py b/tests/core/test_eegbci.py index 754782517..4c1a84370 100644 --- a/tests/core/test_eegbci.py +++ b/tests/core/test_eegbci.py @@ -882,6 +882,243 @@ def test_select_representative_windows_picks_strongest_disagreement(self): selected["cards"]["strongest_task_state_disagreement"]["subject_id"], 2 ) + def test_render_summary_contains_required_sections_and_limitations(self): + from examples.eeg.eegbci.eegbci_pattern_discovery import ( + ANALYSIS_VERSION, + annotate_moment_rows, + build_rest_baselines, + render_summary, + ) + + rows = [ + self._moment_row( + task_label="execute_left_fist", label_family="motor_execution" + ) + ] + annotated = annotate_moment_rows(rows, build_rest_baselines(rows)) + summary = render_summary( + annotated, + { + "subjects": [1], + "runs": [3], + "max_windows": 1, + "baseline_row_count": 1, + "output_was_capped": True, + }, + ) + + self.assertIn(ANALYSIS_VERSION, summary.splitlines()[2]) + for heading in [ + "## Executive Result", + "## Run Configuration", + "## Window Coverage", + "## Moment-State Summary", + "## Task Label x State Matrix", + "## Rest-Normalized Bandpower Summary", + "## Confidence and Quality Audit", + "## Representative Windows", + "## Limitations", + "## Next Checks", + ]: + self.assertIn(heading, summary) + self.assertIn("No rest baseline was available", summary) + self.assertIn("Output was capped by `--max-windows`", summary) + self.assertNotIn( + "Brain-state hypotheses are exploratory signal metadata", + summary.splitlines()[2], + ) + + def test_render_summary_handles_empty_rows(self): + from examples.eeg.eegbci.eegbci_pattern_discovery import render_summary + + summary = render_summary( + [], + { + "subjects": [1], + "runs": [3], + "max_windows": 0, + "baseline_row_count": 0, + "output_was_capped": True, + }, + ) + + self.assertIn("No windows were produced", summary) + self.assertIn("## Limitations", summary) + + def test_render_summary_reports_all_low_confidence_and_same_state(self): + from examples.eeg.eegbci.eegbci_pattern_discovery import render_summary + + rows = [ + self._moment_row( + state_hypothesis="mixed_ambiguous_profile", + state_confidence="low", + evidence_score=0.10, + task_state_relation="ambiguous", + task_state_confidence="low", + rest_reference_scope="unavailable", + is_low_confidence=True, + is_possible_artifact=False, + is_mixed_or_ambiguous=True, + ), + self._moment_row( + start_time=2.0, + state_hypothesis="mixed_ambiguous_profile", + state_confidence="low", + evidence_score=0.12, + task_state_relation="ambiguous", + task_state_confidence="low", + rest_reference_scope="unavailable", + is_low_confidence=True, + is_possible_artifact=False, + is_mixed_or_ambiguous=True, + ), + ] + + summary = render_summary( + rows, + { + "subjects": [1], + "runs": [3], + "max_windows": None, + "baseline_row_count": 2, + "output_was_capped": False, + }, + ) + + self.assertIn("Every window is low confidence", summary) + self.assertIn("Every window maps to the same state", summary) + + def test_render_summary_reports_task_state_matrix(self): + from examples.eeg.eegbci.eegbci_pattern_discovery import render_summary + + rows = [ + self._moment_row( + task_label="rest", + state_hypothesis="idle_alpha_profile", + state_confidence="medium", + evidence_score=0.60, + task_state_relation="supports_label", + task_state_confidence="medium", + rest_reference_scope="same_subject_run", + ), + self._moment_row( + task_label="execute_left_fist", + label_family="motor_execution", + state_hypothesis="sensorimotor_engagement_profile", + state_confidence="medium", + evidence_score=0.70, + task_state_relation="supports_label", + task_state_confidence="medium", + rest_reference_scope="same_subject_run", + ), + ] + + summary = render_summary( + rows, + { + "subjects": [1], + "runs": [3], + "max_windows": None, + "baseline_row_count": 2, + "output_was_capped": False, + }, + ) + + self.assertIn("rest x idle_alpha_profile: 1", summary) + self.assertIn( + "execute_left_fist x sensorimotor_engagement_profile: 1", summary + ) + + def test_render_summary_includes_representative_window_details(self): + from examples.eeg.eegbci.eegbci_pattern_discovery import render_summary + + row = self._moment_row( + state_hypothesis="idle_alpha_profile", + state_confidence="medium", + evidence_score=0.75, + task_state_relation="supports_label", + task_state_confidence="medium", + task_state_rationale="The idle-like alpha profile is consistent with rest.", + rest_reference_scope="same_subject_run", + rest_delta_relative_delta=0.01, + rest_theta_relative_delta=0.02, + rest_alpha_relative_delta=0.03, + rest_beta_relative_delta=-0.02, + rest_gamma_relative_delta=-0.01, + is_low_confidence=False, + is_possible_artifact=False, + is_mixed_or_ambiguous=False, + ) + + summary = render_summary( + [row], + { + "subjects": [1], + "runs": [3], + "max_windows": None, + "baseline_row_count": 1, + "output_was_capped": False, + }, + ) + + for text in [ + "Subject 1 run 3 trial S001_R03_T0_0", + "Task: rest from 0.0s to 2.0s", + "State: idle_alpha_profile", + "Dominant band: alpha", + "Rest deltas:", + "Task relation: supports_label", + "low_confidence=False", + "Rationale: The idle-like alpha profile", + ]: + self.assertIn(text, summary) + + def test_render_summary_moves_nonclinical_warning_to_limitations(self): + from examples.eeg.eegbci.eegbci_pattern_discovery import render_summary + + summary = render_summary( + [self._moment_row(state_hypothesis="idle_alpha_profile")], + { + "subjects": [1], + "runs": [3], + "max_windows": None, + "baseline_row_count": 1, + "output_was_capped": False, + }, + ) + + opening = "\n".join(summary.splitlines()[:6]) + limitations = summary.split("## Limitations", 1)[1] + self.assertNotIn("clinical findings", opening) + self.assertIn("clinical findings", limitations) + + def test_summary_text_does_not_repeat_old_row_level_caveat(self): + from examples.eeg.eegbci.eegbci_pattern_discovery import ( + annotate_moment_rows, + build_rest_baselines, + render_summary, + ) + + rows = [ + self._moment_row( + interpretation="This is exploratory signal metadata, not a diagnosis." + ) + ] + annotated = annotate_moment_rows(rows, build_rest_baselines(rows)) + + summary = render_summary( + annotated, + { + "subjects": [1], + "runs": [3], + "max_windows": None, + "baseline_row_count": 1, + "output_was_capped": False, + }, + ) + + self.assertNotIn("This is exploratory signal metadata", summary) + @unittest.skipUnless( os.environ.get("PYHEALTH_RUN_REAL_EEGBCI") == "1", From 57f8f8f208c103cd4904fa0b3743216957edaa5d Mon Sep 17 00:00:00 2001 From: Vihaan Agrawal <247244351+vihaan101@users.noreply.github.com> Date: Wed, 8 Jul 2026 12:30:15 +0530 Subject: [PATCH 16/21] Wire EEGBCI moment report main flow --- .../moment_report_continuation_plan.md | 5 + .../moment_report_implementation_plan.md | 8 +- .../eeg/eegbci/eegbci_pattern_discovery.py | 30 ++- tests/core/test_eegbci.py | 211 ++++++++++++++++++ 4 files changed, 243 insertions(+), 11 deletions(-) diff --git a/docs/eeg_pattern_discovery/moment_report_continuation_plan.md b/docs/eeg_pattern_discovery/moment_report_continuation_plan.md index 5de139628..ac63b45cd 100644 --- a/docs/eeg_pattern_discovery/moment_report_continuation_plan.md +++ b/docs/eeg_pattern_discovery/moment_report_continuation_plan.md @@ -463,3 +463,8 @@ graphify update . of the old row-level caveat; confirmed missing-renderer failures; implemented `render_summary()` and config-aware `write_summary()`; and verified the focused renderer tests plus the caveat regression. +- 2026-07-08: Task 8 is complete: added schema, empty CSV, patched-main + `--max-windows=0`, uncapped-baseline, analysis-version CSV, and invalid parser + tests; confirmed the old main-flow failures; updated `main()` to annotate all + rows before capping and to write stable `OUTPUT_COLUMNS`; verified the full + helper suite and full `tests/core/test_eegbci.py` file. diff --git a/docs/eeg_pattern_discovery/moment_report_implementation_plan.md b/docs/eeg_pattern_discovery/moment_report_implementation_plan.md index c47686bd7..74f9d16f6 100644 --- a/docs/eeg_pattern_discovery/moment_report_implementation_plan.md +++ b/docs/eeg_pattern_discovery/moment_report_implementation_plan.md @@ -1158,7 +1158,7 @@ Expected: pass. - Updates: `main()` so all requested rows are collected before truncation. - Produces: empty CSV with stable columns when `--max-windows=0`. -- [ ] **Step 1: Write failing schema test** +- [x] **Step 1: Write failing schema test** Add: @@ -1191,7 +1191,7 @@ Add: self.assertIn("analysis_version", MOMENT_REPORT_COLUMNS) ``` -- [ ] **Step 2: Run focused helper tests** +- [x] **Step 2: Run focused helper tests** Run: @@ -1201,7 +1201,7 @@ Run: Expected: pass after previous tasks and the schema declaration. -- [ ] **Step 3: Update main flow** +- [x] **Step 3: Update main flow** Replace the row collection and output block in `main()` with: @@ -1239,7 +1239,7 @@ Replace the row collection and output block in `main()` with: This deliberately uses `OUTPUT_COLUMNS` even when `output_rows` is empty, so `--max-windows=0` and no-window runs still produce a parseable CSV contract. -- [ ] **Step 4: Run full EEGBCI unit test file** +- [x] **Step 4: Run full EEGBCI unit test file** Run: diff --git a/examples/eeg/eegbci/eegbci_pattern_discovery.py b/examples/eeg/eegbci/eegbci_pattern_discovery.py index 5b6ed0321..8e4ce1887 100644 --- a/examples/eeg/eegbci/eegbci_pattern_discovery.py +++ b/examples/eeg/eegbci/eegbci_pattern_discovery.py @@ -605,16 +605,32 @@ def main() -> None: ) sample_dataset = dataset.set_task(EEGBCIPatternDiscovery(compute_stft=False)) - rows = [] - for idx, sample in enumerate(sample_dataset): - if args.max_windows is not None and idx >= args.max_windows: - break - rows.append(sample_to_row(sample)) + all_rows = [sample_to_row(sample) for sample in sample_dataset] + baselines = build_rest_baselines(all_rows) + annotated_rows = annotate_moment_rows(all_rows, baselines) + output_rows = ( + annotated_rows[: args.max_windows] + if args.max_windows is not None + else annotated_rows + ) + output_was_capped = ( + args.max_windows is not None and len(annotated_rows) > len(output_rows) + ) csv_path = output_dir / "eegbci_pattern_windows.csv" summary_path = output_dir / "eegbci_pattern_summary.md" - pd.DataFrame(rows).to_csv(csv_path, index=False) - write_summary(rows, summary_path) + pd.DataFrame(output_rows, columns=OUTPUT_COLUMNS).to_csv(csv_path, index=False) + write_summary( + output_rows, + summary_path, + { + "subjects": parse_int_list(args.subjects), + "runs": parse_int_list(args.runs), + "max_windows": args.max_windows, + "baseline_row_count": len(all_rows), + "output_was_capped": output_was_capped, + }, + ) print(f"Wrote {csv_path}") print(f"Wrote {summary_path}") diff --git a/tests/core/test_eegbci.py b/tests/core/test_eegbci.py index 4c1a84370..24f1cdeb4 100644 --- a/tests/core/test_eegbci.py +++ b/tests/core/test_eegbci.py @@ -1,4 +1,5 @@ import os +import sys import unittest import tempfile from dataclasses import dataclass @@ -435,6 +436,44 @@ def _moment_row(self, **overrides): row.update(overrides) return row + def _sample(self, **overrides): + sample = { + "patient_id": "S001", + "record_id": "R03", + "subject_id": 1, + "run": 3, + "run_type": "motor_execution_left_right", + "trial_id": "S001_R03_T0_0", + "event_code": "T0", + "task_label": "rest", + "label_family": "rest", + "label": 0, + "eegbci_label": 0, + "start_time": 0.0, + "end_time": 2.0, + "brain_state_hypothesis": "relaxed_or_idle", + "confidence": "medium", + "quality_flags": "", + "interpretation": "Alpha-dominant profile.", + "bandpower": { + "dominant_band": "alpha", + "alpha_beta_ratio": 2.75, + "theta_beta_ratio": 0.50, + "delta_power": 0.05, + "theta_power": 0.10, + "alpha_power": 0.55, + "beta_power": 0.20, + "gamma_power": 0.10, + "delta_relative": 0.05, + "theta_relative": 0.10, + "alpha_relative": 0.55, + "beta_relative": 0.20, + "gamma_relative": 0.10, + }, + } + sample.update(overrides) + return sample + def test_analysis_version_constant(self): from examples.eeg.eegbci.eegbci_pattern_discovery import ANALYSIS_VERSION @@ -1119,6 +1158,178 @@ def test_summary_text_does_not_repeat_old_row_level_caveat(self): self.assertNotIn("This is exploratory signal metadata", summary) + def test_moment_report_columns_are_declared(self): + from examples.eeg.eegbci.eegbci_pattern_discovery import ( + MOMENT_REPORT_COLUMNS, + OUTPUT_COLUMNS, + ) + + for column in [ + "patient_id", + "task_label", + "alpha_relative", + "analysis_version", + "state_hypothesis", + "state_confidence", + "evidence_score", + "evidence_summary", + "rest_reference_scope", + "rest_alpha_relative_delta", + "task_state_relation", + "task_state_rationale", + "task_state_confidence", + "is_low_confidence", + "is_possible_artifact", + "is_mixed_or_ambiguous", + ]: + self.assertIn(column, OUTPUT_COLUMNS) + self.assertIn("analysis_version", MOMENT_REPORT_COLUMNS) + + def test_empty_dataframe_uses_output_columns(self): + from examples.eeg.eegbci.eegbci_pattern_discovery import OUTPUT_COLUMNS + + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "empty.csv" + pd.DataFrame([], columns=OUTPUT_COLUMNS).to_csv(path, index=False) + + df = pd.read_csv(path) + + self.assertEqual(len(df), 0) + self.assertEqual(list(df.columns), list(OUTPUT_COLUMNS)) + + def test_main_max_windows_zero_writes_empty_artifacts(self): + from examples.eeg.eegbci import eegbci_pattern_discovery as example + + class FakeDataset: + def __init__(self, *args, **kwargs): + pass + + def set_task(self, task): + return [self_sample] + + self_sample = self._sample() + with tempfile.TemporaryDirectory() as tmp: + argv = [ + "eegbci_pattern_discovery.py", + "--subjects", + "1", + "--runs", + "3", + "--max-windows", + "0", + "--output-dir", + tmp, + ] + with patch.object(sys, "argv", argv), patch.object( + example, "EEGBCIDataset", FakeDataset + ): + example.main() + + csv_path = Path(tmp) / "eegbci_pattern_windows.csv" + summary_path = Path(tmp) / "eegbci_pattern_summary.md" + df = pd.read_csv(csv_path) + summary = summary_path.read_text(encoding="utf-8") + + self.assertEqual(len(df), 0) + self.assertEqual(list(df.columns), list(example.OUTPUT_COLUMNS)) + self.assertIn("No windows were produced", summary) + self.assertIn("Output was capped by `--max-windows`", summary) + + def test_main_baseline_uses_uncapped_rows(self): + from examples.eeg.eegbci import eegbci_pattern_discovery as example + + first = self._sample( + task_label="execute_left_fist", + label_family="motor_execution", + alpha_beta_ratio=0.5, + bandpower={ + **self._sample()["bandpower"], + "dominant_band": "beta", + "alpha_relative": 0.20, + "beta_relative": 0.45, + "alpha_beta_ratio": 0.5, + }, + ) + rest = self._sample( + task_label="rest", + start_time=2.0, + bandpower={ + **self._sample()["bandpower"], + "alpha_relative": 0.50, + "beta_relative": 0.20, + }, + ) + + class FakeDataset: + def __init__(self, *args, **kwargs): + pass + + def set_task(self, task): + return [first, rest] + + with tempfile.TemporaryDirectory() as tmp: + argv = [ + "eegbci_pattern_discovery.py", + "--subjects", + "1", + "--runs", + "3", + "--max-windows", + "1", + "--output-dir", + tmp, + ] + with patch.object(sys, "argv", argv), patch.object( + example, "EEGBCIDataset", FakeDataset + ): + example.main() + + df = pd.read_csv(Path(tmp) / "eegbci_pattern_windows.csv") + + self.assertEqual(len(df), 1) + self.assertEqual(df.loc[0, "rest_reference_scope"], "same_subject_run") + self.assertAlmostEqual(df.loc[0, "rest_alpha_relative_delta"], -0.30) + + def test_main_writes_analysis_version_to_every_csv_row(self): + from examples.eeg.eegbci import eegbci_pattern_discovery as example + + samples = [self._sample(), self._sample(start_time=2.0, trial_id="second")] + + class FakeDataset: + def __init__(self, *args, **kwargs): + pass + + def set_task(self, task): + return samples + + with tempfile.TemporaryDirectory() as tmp: + argv = [ + "eegbci_pattern_discovery.py", + "--subjects", + "1", + "--runs", + "3", + "--output-dir", + tmp, + ] + with patch.object(sys, "argv", argv), patch.object( + example, "EEGBCIDataset", FakeDataset + ): + example.main() + + df = pd.read_csv(Path(tmp) / "eegbci_pattern_windows.csv") + + self.assertEqual(len(df), 2) + self.assertTrue((df["analysis_version"] == example.ANALYSIS_VERSION).all()) + + def test_parse_int_list_rejects_invalid_input_loudly(self): + from examples.eeg.eegbci.eegbci_pattern_discovery import parse_int_list + + with self.assertRaises(ValueError): + parse_int_list("a") + with self.assertRaises(ValueError): + parse_int_list("3-a") + @unittest.skipUnless( os.environ.get("PYHEALTH_RUN_REAL_EEGBCI") == "1", From a0771112b8654647f88c4006fb599d79ab33fb8c Mon Sep 17 00:00:00 2001 From: Vihaan Agrawal <247244351+vihaan101@users.noreply.github.com> Date: Wed, 8 Jul 2026 12:31:39 +0530 Subject: [PATCH 17/21] Document EEGBCI moment report outputs --- .../moment_report_continuation_plan.md | 5 ++++ .../moment_report_implementation_plan.md | 6 ++-- examples/eeg/eegbci/README.md | 30 +++++++++++++++++++ 3 files changed, 38 insertions(+), 3 deletions(-) diff --git a/docs/eeg_pattern_discovery/moment_report_continuation_plan.md b/docs/eeg_pattern_discovery/moment_report_continuation_plan.md index ac63b45cd..01cf01af0 100644 --- a/docs/eeg_pattern_discovery/moment_report_continuation_plan.md +++ b/docs/eeg_pattern_discovery/moment_report_continuation_plan.md @@ -468,3 +468,8 @@ graphify update . tests; confirmed the old main-flow failures; updated `main()` to annotate all rows before capping and to write stable `OUTPUT_COLUMNS`; verified the full helper suite and full `tests/core/test_eegbci.py` file. +- 2026-07-08: Task 9 is complete: updated the EEGBCI example README with the + upgraded CSV and Markdown moment-report contract, retained the implementation + plan and GStack `/plan-eng-review` progress history, and verified documentation + references with `rg` using single quotes around the backtick-containing pattern + for zsh compatibility. diff --git a/docs/eeg_pattern_discovery/moment_report_implementation_plan.md b/docs/eeg_pattern_discovery/moment_report_implementation_plan.md index 74f9d16f6..63a952fdb 100644 --- a/docs/eeg_pattern_discovery/moment_report_implementation_plan.md +++ b/docs/eeg_pattern_discovery/moment_report_implementation_plan.md @@ -1259,7 +1259,7 @@ Expected: pass, with the real-data smoke test skipped unless `PYHEALTH_RUN_REAL_ - Produces: README section describing upgraded moment report fields and limitations. - Produces: continuation plan progress entry for implementation. -- [ ] **Step 1: Update README output description** +- [x] **Step 1: Update README output description** Replace the CSV paragraph in `examples/eeg/eegbci/README.md` with: @@ -1287,7 +1287,7 @@ summaries from short EEG windows, not clinical findings or evidence of a subject's cognition. ``` -- [ ] **Step 2: Update continuation plan progress** +- [x] **Step 2: Update continuation plan progress** Append to `docs/eeg_pattern_discovery/moment_report_continuation_plan.md`: @@ -1297,7 +1297,7 @@ Append to `docs/eeg_pattern_discovery/moment_report_continuation_plan.md`: GStack `/plan-eng-review` against the plan before code implementation. ``` -- [ ] **Step 3: Verify docs mention the plan** +- [x] **Step 3: Verify docs mention the plan** Run: diff --git a/examples/eeg/eegbci/README.md b/examples/eeg/eegbci/README.md index 225670eb6..1ef9f84e2 100644 --- a/examples/eeg/eegbci/README.md +++ b/examples/eeg/eegbci/README.md @@ -21,3 +21,33 @@ Outputs are written to `outputs/eegbci_pattern_discovery/` by default: - `eegbci_pattern_windows.csv` - `eegbci_pattern_summary.md` + +The CSV has one row per emitted 2-second window. Key columns include subject/run +metadata, `event_code`, decoded `task_label`, raw EEGBCI numeric label +(`eegbci_label` / `label`), PyHealth model-local label (`model_label`), +absolute window timing, band powers, relative band powers, `dominant_band`, +frequency ratios, legacy `brain_state_hypothesis`, `confidence`, +`quality_flags`, and `interpretation`. + +The moment-report columns add analysis-grade fields: + +- `analysis_version` +- `state_hypothesis`, `state_confidence`, and `evidence_score` +- `evidence_summary` +- `rest_reference_scope` and rest-normalized relative band deltas +- `task_state_relation`, `task_state_rationale`, and `task_state_confidence` +- `is_low_confidence`, `is_possible_artifact`, and `is_mixed_or_ambiguous` + +The Markdown report summarizes state counts, task-label/state agreement, +rest-normalized bandpower deltas, confidence and quality flags, representative +windows, limitations, and next checks. These labels are signal-pattern +summaries from short EEG windows, not clinical findings or evidence of a +subject's cognition. + +Implementation details are tracked in +`docs/eeg_pattern_discovery/moment_report_implementation_plan.md`. + +`--root` points to the local EEGBCI data directory. With `--download`, MNE +downloads any missing EDF files under that root. PyHealth task caches are stored +under the configured PyHealth cache directory and are keyed by the requested +subject/run selection. From aaf659a3ad140139d5b485dd39f0629d768fc1de Mon Sep 17 00:00:00 2001 From: Vihaan Agrawal <247244351+vihaan101@users.noreply.github.com> Date: Wed, 8 Jul 2026 12:42:23 +0530 Subject: [PATCH 18/21] Fix EEGBCI moment report review findings --- .../moment_report_continuation_plan.md | 6 + .../moment_report_implementation_plan.md | 10 +- .../moment_report_review.md | 161 ++++++++++++++++++ .../eeg/eegbci/eegbci_pattern_discovery.py | 2 +- tests/core/test_eegbci.py | 60 +++++++ 5 files changed, 233 insertions(+), 6 deletions(-) create mode 100644 docs/eeg_pattern_discovery/moment_report_review.md diff --git a/docs/eeg_pattern_discovery/moment_report_continuation_plan.md b/docs/eeg_pattern_discovery/moment_report_continuation_plan.md index 01cf01af0..ad5435dc2 100644 --- a/docs/eeg_pattern_discovery/moment_report_continuation_plan.md +++ b/docs/eeg_pattern_discovery/moment_report_continuation_plan.md @@ -473,3 +473,9 @@ graphify update . plan and GStack `/plan-eng-review` progress history, and verified documentation references with `rg` using single quotes around the backtick-containing pattern for zsh compatibility. +- 2026-07-08: Task 10 and post-review fixes are complete: full EEGBCI tests pass + with `56 passed, 1 skipped`, the real-data example generated 20 CSV rows and a + Markdown report with all required sections, artifact schema and confidence + consistency checks pass, `graphify update .` completed after code changes, and + the independent review findings were fixed or closed in + `docs/eeg_pattern_discovery/moment_report_review.md`. diff --git a/docs/eeg_pattern_discovery/moment_report_implementation_plan.md b/docs/eeg_pattern_discovery/moment_report_implementation_plan.md index 63a952fdb..3c013f3ec 100644 --- a/docs/eeg_pattern_discovery/moment_report_implementation_plan.md +++ b/docs/eeg_pattern_discovery/moment_report_implementation_plan.md @@ -1315,7 +1315,7 @@ Expected: matches in the continuation plan and README content. **Interfaces:** - Verifies: local synthetic/unit coverage and real-data example behavior. -- [ ] **Step 1: Run unit tests** +- [x] **Step 1: Run unit tests** Run: @@ -1325,7 +1325,7 @@ Run: Expected: pass, with real-data smoke skipped unless explicitly enabled. -- [ ] **Step 2: Run the example on a tiny real-data request** +- [x] **Step 2: Run the example on a tiny real-data request** Run: @@ -1345,7 +1345,7 @@ Expected: - Markdown includes all required sections. - Markdown does not start with the old generic exploratory caveat. -- [ ] **Step 3: Inspect artifact schema** +- [x] **Step 3: Inspect artifact schema** Run: @@ -1383,7 +1383,7 @@ rows 20 missing [] ``` -- [ ] **Step 4: Inspect Markdown contract** +- [x] **Step 4: Inspect Markdown contract** Run: @@ -1393,7 +1393,7 @@ rg "Executive Result|Run Configuration|Window Coverage|Moment-State Summary|Task Expected: all required headings match. -- [ ] **Step 5: Update Graphify** +- [x] **Step 5: Update Graphify** Run: diff --git a/docs/eeg_pattern_discovery/moment_report_review.md b/docs/eeg_pattern_discovery/moment_report_review.md new file mode 100644 index 000000000..fec265093 --- /dev/null +++ b/docs/eeg_pattern_discovery/moment_report_review.md @@ -0,0 +1,161 @@ +# EEGBCI Moment Report Implementation Review + +Date: 2026-07-08 +Branch: eegbci-pattern-discovery +Review path: GStack /review + +## Scope Reviewed + +Reviewed the committed branch at `a077111` against `origin/master`, with primary focus on the moment-report pass: + +- `examples/eeg/eegbci/eegbci_pattern_discovery.py` +- `tests/core/test_eegbci.py` +- `examples/eeg/eegbci/README.md` +- `docs/eeg_pattern_discovery/moment_report_implementation_plan.md` +- `docs/eeg_pattern_discovery/moment_report_continuation_plan.md` + +Also checked the existing generated artifacts under `outputs/eegbci_pattern_discovery/` because Task 10 requires CSV and Markdown contract validation. + +Public API boundary check: the report-only fields stay out of `pyhealth/tasks/eegbci.py` and `pyhealth/datasets/eegbci.py`. Only the legacy `brain_state_hypothesis` task field appears in reusable task code. + +## Verification Commands + +Commands run: + +```bash +graphify query "Review EEGBCI moment report implementation files, task contracts, artifact generation, and tests" --budget 2000 +git diff --stat origin/master...HEAD +git diff --check origin/master...HEAD +.venv/bin/python -m pytest tests/core/test_eegbci.py::TestEEGBCIMomentReportHelpers -v +.venv/bin/python -m pytest tests/core/test_eegbci.py -v +.venv/bin/python - <<'PY' +import pandas as pd +from pathlib import Path +csv = Path("outputs/eegbci_pattern_discovery/eegbci_pattern_windows.csv") +summary = Path("outputs/eegbci_pattern_discovery/eegbci_pattern_summary.md") +required = { + "analysis_version", "state_hypothesis", "state_confidence", "evidence_score", + "evidence_summary", "rest_reference_scope", "rest_alpha_relative_delta", + "task_state_relation", "task_state_rationale", "task_state_confidence", + "is_low_confidence", "is_possible_artifact", "is_mixed_or_ambiguous", +} +df = pd.read_csv(csv) +print("rows", len(df)) +print("missing", sorted(required - set(df.columns))) +print("analysis_version_all", bool((df["analysis_version"] == "eegbci_pattern_moment_report_v1").all())) +text = summary.read_text(encoding="utf-8") +headings = [ + "Executive Result", "Run Configuration", "Window Coverage", "Moment-State Summary", + "Task Label x State Matrix", "Rest-Normalized Bandpower Summary", + "Confidence and Quality Audit", "Representative Windows", "Limitations", "Next Checks", +] +print("missing_headings", [h for h in headings if h not in text]) +PY +rg "Executive Result|Run Configuration|Window Coverage|Moment-State Summary|Task Label x State Matrix|Rest-Normalized Bandpower Summary|Confidence and Quality Audit|Representative Windows|Limitations|Next Checks" outputs/eegbci_pattern_discovery/eegbci_pattern_summary.md +``` + +Results: + +- Helper suite: 28 passed, 12 subtests passed. +- Full EEGBCI test file: 52 passed, 1 skipped, 12 subtests passed. The skipped test is the opt-in real-data smoke test. +- `git diff --check`: clean. +- Existing artifact schema check: 20 rows, no missing required moment-report columns, all rows use `eegbci_pattern_moment_report_v1`. +- Existing Markdown artifact includes all required headings and representative windows. + +Not run: + +- I did not rerun the real-data example command because this reviewer was instructed that the only write target is this review document. The existing generated artifacts were inspected read-only. +- I did not run `graphify update .` because it writes graph files and this reviewer is read-only except for this file. + +## Findings + +1. [P1] (confidence: 9/10) `examples/eeg/eegbci/eegbci_pattern_discovery.py:237`, `examples/eeg/eegbci/eegbci_pattern_discovery.py:444`, `outputs/eegbci_pattern_discovery/eegbci_pattern_summary.md:45` - The report conflates legacy low-confidence flags with the new moment-report state confidence. In the generated artifact, `state_confidence` is `medium` for 16 of 20 rows, but `is_low_confidence` is `True` for all 20 rows because every row inherits legacy `quality_flags=low_confidence`. The Markdown then says "Every window is low confidence" and reports `Low-confidence rows: 20`, contradicting `State confidence: {'low': 4, 'medium': 16}`. This weakens the main artifact the pass is supposed to improve. + +2. [P2] (confidence: 8/10) `docs/eeg_pattern_discovery/moment_report_implementation_plan.md:1406`, `tests/core/test_eegbci.py:556`, `tests/core/test_eegbci.py:658`, `tests/core/test_eegbci.py:1325` - The extensive correctness matrix is not fully satisfied as written. Missing named coverage includes `test_state_confidence_requires_margin`, `test_quality_booleans_do_not_depend_on_string_parsing_only`, and `test_parse_int_list_accepts_ranges_and_singletons`. Existing tests cover broad state detection, string-based quality flags, and invalid parser input, but they do not prove these specific required edge cases. + +3. [P2] (confidence: 9/10) `docs/eeg_pattern_discovery/moment_report_implementation_plan.md:1318`, `docs/eeg_pattern_discovery/moment_report_implementation_plan.md:1396`, `docs/eeg_pattern_discovery/moment_report_continuation_plan.md:471` - Task 10 remains unchecked and the continuation progress log stops at Task 9. The implementation contract required Task 10 verification, artifact inspection, and graph update tracking before completion. The artifacts exist and several checks pass, but the plan state does not show that the implementation session completed those required steps. + +No SQL, shell-injection, LLM trust-boundary, race-condition, CI/CD, or public API boundary issues found in the moment-report pass. + +## Required Fixes + +1. Separate legacy quality flags from moment-report confidence in the CSV/Markdown contract. Either make `is_low_confidence` mean `state_confidence == "low"` and add a separate legacy flag column/count, or keep the boolean as a legacy-quality indicator and change the Markdown wording so it does not claim every moment-report state is low confidence when most `state_confidence` values are medium. Add a regression test using rows with `quality_flags="low_confidence"` and `state_confidence="medium"`. + +2. Add the missing correctness-matrix tests or update the matrix with explicit rationale for merged coverage. Minimum expected tests: margin lowers `state_confidence`, ambiguous state sets `is_mixed_or_ambiguous` without relying on flag text, and `parse_int_list("1,3-5") == [1, 3, 4, 5]`. + +3. Complete Task 10 bookkeeping in the plan documents after the main implementation chat reruns the allowed final verification commands and `graphify update .` after any code changes. Mark the Task 10 checkboxes accurately and append a continuation-plan progress entry. + +## Fix Implementation Log + +- Accepted and fixed Finding 1. `derive_quality_columns()` now makes + `is_low_confidence` depend on moment-report `state_confidence == "low"` rather + than legacy `quality_flags=low_confidence`. This keeps the CSV boolean and + Markdown low-confidence count consistent with the state-confidence audit. + Added regression coverage for a medium-confidence state with legacy + `quality_flags="low_confidence"`. +- Accepted and fixed Finding 2. Added matrix coverage for + `test_state_confidence_requires_margin`, + `test_quality_booleans_do_not_depend_on_string_parsing_only`, and + `test_parse_int_list_accepts_ranges_and_singletons`. +- Accepted and fixed Finding 3. Marked Task 10 checkboxes complete in the + implementation plan and appended final Task 10/post-review progress to the + continuation plan. + +## Post-Fix Verification + +Commands run after fixes: + +```bash +.venv/bin/python -m pytest tests/core/test_eegbci.py::TestEEGBCIMomentReportHelpers -k "state_confidence_requires_margin or quality_booleans_do_not or parse_int_list_accepts" -v +.venv/bin/python -m pytest tests/core/test_eegbci.py::TestEEGBCIMomentReportHelpers -v +.venv/bin/python -m pytest tests/core/test_eegbci.py -v +.venv/bin/python examples/eeg/eegbci/eegbci_pattern_discovery.py \ + --subjects 1 \ + --runs 3 \ + --max-windows 20 \ + --download +.venv/bin/python - <<'PY' +import pandas as pd +from pathlib import Path +df = pd.read_csv("outputs/eegbci_pattern_discovery/eegbci_pattern_windows.csv") +required = { + "analysis_version", "state_hypothesis", "state_confidence", "evidence_score", + "evidence_summary", "rest_reference_scope", "rest_alpha_relative_delta", + "task_state_relation", "task_state_rationale", "task_state_confidence", + "is_low_confidence", "is_possible_artifact", "is_mixed_or_ambiguous", +} +missing = sorted(required - set(df.columns)) +summary = Path("outputs/eegbci_pattern_discovery/eegbci_pattern_summary.md").read_text(encoding="utf-8") +assert len(df) == 20 +assert not missing +assert (df["analysis_version"] == "eegbci_pattern_moment_report_v1").all() +assert int(df["is_low_confidence"].sum()) == int((df["state_confidence"] == "low").sum()) +assert not summary.splitlines()[2].startswith("Brain-state hypotheses are exploratory signal metadata") +assert summary.count("### ") >= 1 +PY +rg "Executive Result|Run Configuration|Window Coverage|Moment-State Summary|Task Label x State Matrix|Rest-Normalized Bandpower Summary|Confidence and Quality Audit|Representative Windows|Limitations|Next Checks" outputs/eegbci_pattern_discovery/eegbci_pattern_summary.md +graphify update . +``` + +Results: + +- Focused review-fix tests: 4 passed. +- Full helper suite: 32 passed, 12 subtests passed. +- Full EEGBCI test file: 56 passed, 1 skipped, 12 subtests passed. The skipped + test is the opt-in real-data smoke test. +- Real-data example regenerated + `outputs/eegbci_pattern_discovery/eegbci_pattern_windows.csv` and + `outputs/eegbci_pattern_discovery/eegbci_pattern_summary.md`. +- Refreshed CSV: 20 rows, no missing required moment-report columns, + `analysis_version` is correct for every row, `state_confidence` counts are + `{'medium': 16, 'low': 4}`, and `is_low_confidence` count is 4. +- Refreshed Markdown: all required headings are present, the old generic caveat + is not the opening body text, and at least one representative window card is + present. +- `graphify update .` completed after code changes. + +## Final Verdict + +Approved after fixes. The confidence contradiction is resolved, the missing +correctness-matrix tests were added, Task 10 bookkeeping is complete, artifacts +were regenerated and validated, full EEGBCI tests pass, and graphify was updated. diff --git a/examples/eeg/eegbci/eegbci_pattern_discovery.py b/examples/eeg/eegbci/eegbci_pattern_discovery.py index 8e4ce1887..95994ea35 100644 --- a/examples/eeg/eegbci/eegbci_pattern_discovery.py +++ b/examples/eeg/eegbci/eegbci_pattern_discovery.py @@ -239,7 +239,7 @@ def derive_quality_columns(row: dict) -> dict: state = row.get("state_hypothesis", "") confidence = row.get("state_confidence", row.get("confidence", "")) return { - "is_low_confidence": confidence == "low" or "low_confidence" in flags, + "is_low_confidence": confidence == "low", "is_possible_artifact": state == "possible_artifact_profile" or "artifact" in flags or "high_gamma" in flags, diff --git a/tests/core/test_eegbci.py b/tests/core/test_eegbci.py index 24f1cdeb4..23dfa5732 100644 --- a/tests/core/test_eegbci.py +++ b/tests/core/test_eegbci.py @@ -612,6 +612,35 @@ def test_derive_state_hypothesis_detects_profiles(self): self.assertLessEqual(result["evidence_score"], 1.0) self.assertIn("alpha=", result["evidence_summary"]) + def test_state_confidence_requires_margin(self): + from examples.eeg.eegbci.eegbci_pattern_discovery import ( + STATE_CONFIDENCE_RANK, + derive_state_hypothesis, + ) + + clear = derive_state_hypothesis( + self._moment_row( + alpha_relative=0.70, + beta_relative=0.10, + gamma_relative=0.04, + alpha_beta_ratio=6.0, + ) + ) + weaker = derive_state_hypothesis( + self._moment_row( + alpha_relative=0.40, + beta_relative=0.22, + gamma_relative=0.10, + alpha_beta_ratio=2.0, + ) + ) + + self.assertEqual(clear["state_hypothesis"], weaker["state_hypothesis"]) + self.assertGreater( + STATE_CONFIDENCE_RANK[clear["state_confidence"]], + STATE_CONFIDENCE_RANK[weaker["state_confidence"]], + ) + def test_task_state_relation_table_is_deterministic(self): from examples.eeg.eegbci.eegbci_pattern_discovery import ( derive_task_state_relation, @@ -670,6 +699,32 @@ def test_quality_booleans_are_parseable(self): self.assertTrue(flags["is_possible_artifact"]) self.assertFalse(flags["is_mixed_or_ambiguous"]) + def test_quality_booleans_do_not_depend_on_string_parsing_only(self): + from examples.eeg.eegbci.eegbci_pattern_discovery import derive_quality_columns + + flags = derive_quality_columns( + self._moment_row( + state_hypothesis="mixed_ambiguous_profile", + state_confidence="medium", + quality_flags="", + ) + ) + + self.assertTrue(flags["is_mixed_or_ambiguous"]) + + def test_quality_booleans_do_not_conflate_legacy_low_confidence(self): + from examples.eeg.eegbci.eegbci_pattern_discovery import derive_quality_columns + + flags = derive_quality_columns( + self._moment_row( + state_hypothesis="idle_alpha_profile", + state_confidence="medium", + quality_flags="low_confidence", + ) + ) + + self.assertFalse(flags["is_low_confidence"]) + def test_annotate_moment_rows_adds_required_fields(self): from examples.eeg.eegbci.eegbci_pattern_discovery import ( ANALYSIS_VERSION, @@ -1330,6 +1385,11 @@ def test_parse_int_list_rejects_invalid_input_loudly(self): with self.assertRaises(ValueError): parse_int_list("3-a") + def test_parse_int_list_accepts_ranges_and_singletons(self): + from examples.eeg.eegbci.eegbci_pattern_discovery import parse_int_list + + self.assertEqual(parse_int_list("1,3-5"), [1, 3, 4, 5]) + @unittest.skipUnless( os.environ.get("PYHEALTH_RUN_REAL_EEGBCI") == "1", From 8f11f8d1cd0d02ea2c33aef3b028543a6a47e053 Mon Sep 17 00:00:00 2001 From: Vihaan Agrawal <247244351+vihaan101@users.noreply.github.com> Date: Wed, 8 Jul 2026 19:11:38 +0530 Subject: [PATCH 19/21] Polish EEGBCI report artifact --- docs/eeg_pattern_discovery/brainstorm.md | 453 ++++++++++++++++++ docs/eeg_pattern_discovery/design.md | 280 +++++++++++ .../eeg_pipeline_guide.md | 185 +++++++ .../implementation_plan.md | 50 +- .../pattern_analysis_redesign.md | 420 ++++++++++++++++ .../test_verification_plan.md | 280 +++++++++++ examples/eeg/eegbci/README.md | 7 +- .../eeg/eegbci/eegbci_pattern_discovery.py | 24 +- pyhealth/datasets/eegbci.py | 51 +- pyhealth/tasks/eegbci.py | 12 +- tests/core/test_eegbci.py | 41 +- 11 files changed, 1763 insertions(+), 40 deletions(-) create mode 100644 docs/eeg_pattern_discovery/brainstorm.md create mode 100644 docs/eeg_pattern_discovery/design.md create mode 100644 docs/eeg_pattern_discovery/eeg_pipeline_guide.md create mode 100644 docs/eeg_pattern_discovery/pattern_analysis_redesign.md create mode 100644 docs/eeg_pattern_discovery/test_verification_plan.md diff --git a/docs/eeg_pattern_discovery/brainstorm.md b/docs/eeg_pattern_discovery/brainstorm.md new file mode 100644 index 000000000..56042a288 --- /dev/null +++ b/docs/eeg_pattern_discovery/brainstorm.md @@ -0,0 +1,453 @@ +# EEG Pattern Discovery Migration Brainstorm + +Date: 2026-07-07 +Status: living brainstorm and design scratchpad +Mode: Builder / research + +## Source Context + +The original CELM artifact lives at `/Users/vihaanagrawal/Research/CELM/eeg_pattern_discovery`. + +The prior design note is `/Users/vihaanagrawal/.gstack/projects/office-hours-users-vihaanagrawal-gstack-repos/vihaanagrawal-unknown-design-20260629-143914.md`. + +The target project is `/Users/vihaanagrawal/Research/PyHealth`. + +Useful external references: + +- PhysioNet EEG Motor Movement/Imagery Dataset v1.0.0: https://physionet.org/content/eegmmidb/ +- MNE EEGBCI loader: https://mne.tools/stable/generated/mne.datasets.eegbci.load_data.html + +## Original CELM Idea + +The CELM project asks whether frequency-based EEG patterns reveal moment-level brain-state hypotheses that clinical or experimental labels do not fully capture. It uses MNE's PhysioNet EEG Motor Movement/Imagery loader, processes subjects `1`, `2`, and `3`, runs `3-14`, creates 2-second labeled windows, computes Welch band powers, and assigns cautious deterministic interpretations. + +The core output is a row-per-window table with: + +- subject, run, event code, task label, and label family +- delta, theta, alpha, beta, and gamma absolute power +- relative band powers +- dominant band +- alpha/beta and theta/beta ratios +- moment-level hypothesis +- confidence and quality flags +- plain-English interpretation + +The interpretation layer is explicitly exploratory. It must say that a signal pattern is consistent with a state, not that it proves the subject's cognition or a clinical diagnosis. + +CELM code shape: + +- `run_analysis.py`: orchestrates subject/run processing, writes CSV and Markdown summary. +- `src/data.py`: downloads PhysioNet EEGBCI with MNE, reads EDF, standardizes channels, filters, and yields labeled 2-second windows. +- `src/labels.py`: run-aware mapping for `T0`, `T1`, and `T2`. +- `src/features.py`: Welch PSD bandpower extraction. +- `src/interpretation.py`: deterministic frequency-profile hypothesis engine. +- `src/report.py`: summary grouped by task label and inferred hypothesis. +- `tests/test_labels.py` and `tests/test_interpretation.py`: fast unit tests for pure logic. + +Important correction to the phrase "same pretrained models": the CELM artifact does not appear to use pretrained EEG models. It uses a real pretrained ecosystem only in the loose sense that MNE downloads and parses an established public dataset. The PyHealth migration can expand the idea by adding pretrained PyHealth EEG embeddings from BIOT, ContraWR, SparcNet, and TFMTokenizer on top of the CELM bandpower baseline. + +## Real Dataset Answer + +Yes, there is a clean way to stop relying only on mocked filesystem and signal-processing tests. + +Use a two-tier test strategy: + +1. Fast unit tests stay synthetic and mocked. They should cover label decoding, event-window slicing, bandpower output shape, interpretation rules, metadata CSV generation, and schema contracts. These run in normal CI. +2. Real-data smoke tests run behind an explicit opt-in flag, for example `PYHEALTH_RUN_REAL_EEGBCI=1`. They download a tiny EEGBCI subset with MNE, for example subject `1`, run `3`, create real windows, compute real bandpowers, and verify at least one sample has a real signal tensor and interpretation metadata. + +Why this split works: + +- Normal CI should not hit PhysioNet or download EDF files. +- The actual example should use real EEGBCI data end-to-end. +- The optional smoke test catches the exact class of bugs mocks miss: MNE annotation names, channel names, EDF loading, sampling frequency, and label boundaries. + +Existing PyHealth EEG datasets include `TUABDataset`, `TUEVDataset`, `SleepEDFDataset`, `SHHSDataset`, and `ISRUCDataset`. Those are real EEG datasets, but they do not match the CELM project as closely as EEGBCI. TUAB/TUEV are better for clinical abnormal/event detection; SleepEDF is better for sleep staging; EEGBCI is the clean fit for motor execution/imagery and the CELM label semantics. + +## PyHealth Fit + +PyHealth already has EEG datasets, tasks, examples, and models. The migration should use PyHealth's current `BaseDataset -> BaseTask -> SampleDataset -> Trainer` flow, not a standalone script folder. + +Relevant PyHealth conventions: + +- dataset classes live in `pyhealth/datasets/` +- dataset metadata is represented as `*-pyhealth.csv` files +- dataset config YAML files live in `pyhealth/datasets/configs/` +- tasks live in `pyhealth/tasks/` +- examples live under `examples/eeg/` or `examples/conformal_eeg/` +- unit tests live under `tests/core/` +- exports are added in `pyhealth/datasets/__init__.py` and `pyhealth/tasks/__init__.py` + +Existing EEG patterns to follow: + +- `pyhealth/datasets/tuab.py` +- `pyhealth/datasets/tuev.py` +- `pyhealth/datasets/sleepedf.py` +- `pyhealth/tasks/temple_university_EEG_tasks.py` +- `examples/eeg/eeg_models/` +- `examples/conformal_eeg/` + +Important local gotchas: + +- `TUEVDataset` and `TUABDataset` build `*-pyhealth.csv` metadata files from EDF paths, then `BaseDataset` reads those tables. +- `EEGEventsTUEV` and `EEGAbnormalTUAB` read EDF inside the task. That is the pattern to copy for EEGBCI. +- Current EEG tests mock raw EDF reading and signal conversion heavily. That is acceptable for CI, but not enough for this feature's confidence. +- TUEV/TUAB tasks normalize to 16 channels for model compatibility. EEGBCI starts as 64 channels, so channel adaptation is a first-class design decision, not a small detail. + +## Likely Migration Shape + +Add a first-class EEGBCI dataset and tasks: + +- `pyhealth/datasets/eegbci.py` +- `pyhealth/datasets/configs/eegbci.yaml` +- `pyhealth/tasks/eegbci.py` +- `pyhealth/tasks/eeg_pattern_discovery.py` or helper module under `pyhealth/tasks/eegbci.py` +- `tests/core/test_eegbci.py` +- `tests/core/test_eeg_pattern_discovery.py` +- `examples/eeg/eegbci/` + +The dataset should represent each downloaded subject/run EDF as metadata: + +- `patient_id` +- `record_id` +- `subject_id` +- `run` +- `run_type` +- `signal_file` +- `source` +- `sfreq` if cheaply known, otherwise computed by the task + +The task should parse annotations and emit window-level samples: + +- `patient_id` +- `record_id` +- `signal_file` +- `run` +- `run_type` +- `trial_id` +- `event_code` +- `label` +- `task_label` +- `label_family` +- `signal` +- `start_time` +- `end_time` +- optional `stft` +- optional `bandpower` +- optional frequency interpretation metadata + +### Proposed Dataset Contract + +`EEGBCIDataset` should be the dataset wrapper for the PhysioNet EEG Motor Movement/Imagery Dataset. + +Recommended constructor: + +```python +dataset = EEGBCIDataset( + root="~/.cache/pyhealth/eegbci", + subjects=[1, 2, 3], + runs=list(range(3, 15)), + download=True, +) +``` + +Recommended metadata rows: + +| Column | Meaning | +| --- | --- | +| `patient_id` | stable subject key, for example `S001` | +| `record_id` | run key, for example `R03` | +| `subject_id` | integer EEGBCI subject id | +| `run` | integer EEGBCI run id | +| `run_type` | baseline, motor execution left/right, motor imagery left/right, motor execution fists/feet, motor imagery fists/feet | +| `signal_file` | local EDF path downloaded by MNE | +| `source` | `physionet_eegbci` | + +`download=False` should require files to already exist and should fail clearly if metadata cannot be built. `download=True` can call `mne.datasets.eegbci.load_data(...)`. + +### Proposed Task Contracts + +1. `EEGMotorImageryEEGBCI` + - Purpose: supervised classification task using real EEGBCI event labels. + - Input: raw `signal` tensor, optional `stft`. + - Output: `label` as multiclass task label. + - Windowing: fixed windows inside each annotation, default 2 seconds for parity with CELM. + +2. `EEGPatternDiscoveryEEGBCI` + - Purpose: exploratory moment-level pattern discovery. + - Input: raw `signal` tensor and computed `bandpower` tensor/dict. + - Output: `brain_state_hypothesis` as metadata or optional multiclass label. + - Extra fields: `dominant_band`, `alpha_beta_ratio`, `theta_beta_ratio`, `confidence`, `quality_flags`, `interpretation`. + +Keep these separate. Supervised task-label prediction and exploratory frequency interpretation are different jobs. Mixing them into one task will make the API confusing. + +## Model Reuse + +Existing EEG-capable models: + +- `BIOT`: consumes raw `signal`, has `get_embeddings()` and `load_pretrained_weights()`. +- `ContraWR`: consumes one signal tensor, computes STFT internally, supports `embed=True`. +- `SparcNet`: consumes one signal tensor, supports `embed=True`. +- `TFMTokenizer`: uses `signal` and `stft`, supports pretrained/token workflows. + +Important model mismatch: + +EEGBCI is commonly 64-channel EEG, while several PyHealth EEG examples and pretrained checkpoints expect 16 or 18 channels. The design must choose a channel adaptation strategy: + +- select a stable 16-channel 10-20 subset +- regional average/pool EEGBCI channels into a 16-channel montage +- add a small channel adapter model +- run 64-channel-compatible models only in the first version + +The conservative PyHealth-aligned first version is likely 16-channel selection, because it keeps existing pretrained EEG models usable. + +Recommended first channel strategy: + +1. Preserve the full 64-channel signal in metadata or a configurable task mode. +2. Default the model-facing task output to a stable 16-channel 10-20 subset or montage compatible with existing PyHealth EEG models. +3. Document the selected channels and make the adapter function pure and testable. + +Do not hide this inside model code. Channel mapping belongs near EEGBCI task preprocessing so every model sees the same input contract. + +## Bigger Ideas + +### 1. EEGBCI Moment Discovery Benchmark + +Create a PyHealth benchmark where the same EEGBCI windows can be used for supervised task-label prediction and CELM-style frequency-pattern discovery. Compare bandpower-only features, BIOT embeddings, ContraWR embeddings, SparcNet embeddings, and TFM token features. + +### 2. Pretrained Embedding Atlas + +Use pretrained EEG model embeddings to cluster windows into learned neural motifs. Summarize clusters by subject, run family, task label, CELM hypothesis, and quality flags. This turns the project from rules-only interpretation into representation discovery. + +### 3. TFM Token Report Cards + +Use TFMTokenizer to extract discrete token patterns from EEG windows. Report which token motifs are enriched in rest, execution, imagery, artifact-like gamma, and slow-wave-heavy windows. + +### 4. EEG Model Cards + +For each evaluated window or cluster, generate a compact model card with prediction, confidence, band profile, model embedding neighborhood, salient channels/time regions where available, and caution flags. + +### 5. Subject-Shift Reliability Suite + +Use PyHealth's calibration/conformal examples to measure how well models and hypotheses transfer across subjects and run families. Report uncertain windows, artifact-heavy subjects, and subject-specific drift. + +### 6. Brain-State Atlas Explorer + +Generate a static Markdown/CSV/HTML artifact that treats each cluster as a "neural motif." Each motif gets: + +- cluster size +- dominant CELM hypothesis +- top task labels +- subject/run enrichment +- representative windows +- model confidence spread +- quality flags + +This is the most demoable bigger idea. It turns "we computed band powers" into "we discovered recurring motifs and can inspect where they appear." + +### 7. Label Disagreement Mining + +Rank windows where the experimental task label and model/frequency evidence disagree: + +- task says rest but embedding neighbors look like motor execution +- task says imagery but bandpower looks artifact-heavy +- model predicts left/right confidently while bandpower hypothesis is low confidence +- cluster is subject-specific rather than task-specific + +This is useful because the real research question is not just classification accuracy. It is whether moment-level signal structure contains information labels miss. + +### 8. Real-Data Regression Fixture + +Add a tiny recorded fixture generated from one real EEGBCI run, not raw private data: + +- a small `.npz` containing one or two already-extracted 2-second windows +- expected label metadata +- expected bandpower keys and rough numerical ranges + +This gives CI a real-signal-ish path without downloading PhysioNet. The full real-data smoke test remains opt-in. + +## Open Design Decisions + +1. Minimum viable contribution versus larger research module. +2. Channel adaptation strategy for EEGBCI to pretrained EEG models. +3. Whether bandpower features are part of `input_schema` or extra metadata. +4. Whether the default task predicts task labels, moment-state hypotheses, or both. +5. How much generated analysis output should be included in the repo versus created by examples. +6. Whether `EEGPatternDiscoveryEEGBCI` should be a PyHealth task, a task helper, or an example-only analysis layer. +7. Whether MNE should become a required dependency for this dataset or stay behind an EEG extra. + +## Approaches Considered + +### Approach A: Minimal Example-Only Port + +Summary: Add `examples/eeg/eegbci/eeg_pattern_discovery.py` that imports CELM logic adapted to PyHealth imports, downloads EEGBCI through MNE, and writes CSV/Markdown outputs. + +Effort: S +Risk: Low + +Pros: + +- Fastest way to get real EEGBCI data flowing. +- Small surface area. +- Good for proving that the CELM idea still works inside the PyHealth repo. + +Cons: + +- Not really "in PyHealth format." +- Harder for users to reuse in PyHealth training/calibration workflows. +- Tests still mostly sit around the example, not the library. + +### Approach B: First-Class EEGBCI Dataset + Pattern Task + +Summary: Add `EEGBCIDataset`, `EEGMotorImageryEEGBCI`, and `EEGPatternDiscoveryEEGBCI`, then add examples that use both the supervised labels and the exploratory frequency hypotheses. + +Effort: M +Risk: Medium + +Pros: + +- Matches PyHealth architecture. +- Lets EEGBCI become reusable for future EEG work. +- Cleanly supports real-data examples and optional smoke tests. + +Cons: + +- Requires careful metadata generation and MNE dependency handling. +- Needs a clear channel adapter story. +- Slightly bigger API commitment. + +### Approach C: EEG Representation Atlas + +Summary: Build Approach B, then add an atlas example that extracts embeddings from BIOT, ContraWR, SparcNet, or TFMTokenizer, clusters windows, and reports neural motifs alongside CELM bandpower hypotheses. + +Effort: L +Risk: Medium-High + +Pros: + +- The most creative version. +- Uses PyHealth's pretrained EEG model story. +- Produces a research artifact that is more interesting than a CSV. + +Cons: + +- Pretrained checkpoint availability and channel mismatch can slow this down. +- Clustering can become arbitrary if success criteria are vague. +- Needs careful caveats so it does not overclaim cognition. + +## Current Recommendation + +Use a two-layer design: + +1. Core PyHealth integration: `EEGBCIDataset`, `EEGMotorImagery`, and `EEGBCIPatternDiscovery` with CELM bandpower and interpretation helpers. +2. Small creative layer: one optional example that compares CELM bandpower hypotheses with embeddings from one compatible PyHealth EEG model. + +This keeps the core contribution maintainable while expanding the idea only a little beyond the original deterministic CSV/report artifact. + +More concrete recommendation: choose Approach B as the implementation baseline. Do not build the full Approach C atlas unless the core migration feels too small after it works. + +## Implementation Plan Sketch + +### Task 1: Extract Pure EEGBCI Utilities + +Create run-aware label mapping, event-window slicing, channel selection, and bandpower functions as testable pure helpers. These should be adapted from CELM, not copied blindly. + +Likely files: + +- `pyhealth/tasks/eegbci.py` +- `tests/core/test_eegbci.py` + +Verification: + +- unit tests for all EEGBCI run/event mappings +- unit tests that windows do not cross annotation boundaries +- unit tests for bandpower keys and quality flags + +### Task 2: Add `EEGBCIDataset` + +Implement dataset metadata generation around MNE's EEGBCI downloader. + +Likely files: + +- `pyhealth/datasets/eegbci.py` +- `pyhealth/datasets/configs/eegbci.yaml` +- `pyhealth/datasets/__init__.py` +- `docs/api/datasets.rst` +- `docs/api/datasets/pyhealth.datasets.EEGBCIDataset.rst` + +Verification: + +- synthetic metadata tests with mocked `mne.datasets.eegbci.load_data` +- no network calls in default CI + +### Task 3: Add Supervised and Discovery Tasks + +Add a supervised EEGBCI motor task and an exploratory pattern discovery task. + +Likely files: + +- `pyhealth/tasks/eegbci.py` +- `pyhealth/tasks/__init__.py` +- `docs/api/tasks/pyhealth.tasks.eegbci.rst` + +Verification: + +- mocked EDF tests for task sample schema +- pure helper tests for interpretation logic +- optional real-data smoke test behind `PYHEALTH_RUN_REAL_EEGBCI=1` + +### Task 4: Add Real Examples + +Create examples that a user can run locally. + +Likely files: + +- `examples/eeg/eegbci/eegbci_pattern_discovery.py` +- optional: `examples/eeg/eegbci/eegbci_embedding_comparison.py` +- `examples/eeg/eegbci/README.md` + +Verification: + +- example supports `--subjects 1 --runs 3 --max-windows 20` +- output CSV and Markdown summary are generated outside the source tree by default + +### Task 5: Add Optional Tiny Pretrained Embedding Comparison + +Add one small embedding comparison where channel shape permits. This should not become a multi-model benchmark, dashboard, or large clustering framework. + +Likely files: + +- `examples/eeg/eegbci/eegbci_embedding_comparison.py` + +Verification: + +- embedding extraction works with a toy model or one compatible PyHealth EEG model +- real pretrained checkpoint paths are CLI arguments, not hardcoded local paths + +## Success Criteria + +- PyHealth exposes `EEGBCIDataset`. +- PyHealth exposes at least one EEGBCI task that returns real 2-second windows from EDF annotations. +- The CELM label-mapping mistake risk is handled: `T1`/`T2` are decoded using run number. +- Bandpower and interpretation metadata can be produced for every window. +- Unit tests do not require network access. +- An opt-in smoke test runs against real MNE-downloaded EEGBCI data. +- A runnable example produces a CSV and Markdown summary similar to CELM. +- The optional expanded example can compare CELM bandpower hypotheses with pretrained or learned embeddings without becoming a full research platform. + +## Premises To Validate Before Coding + +1. EEGBCI should be added as a first-class PyHealth dataset, not just an example script. +2. The default EEGBCI task should emit 2-second windows because preserving the CELM question matters more than matching TUAB/TUEV window lengths. +3. Channel adaptation should be explicit and testable, with a conservative 16-channel default for pretrained PyHealth EEG models. +4. Real-data confidence should come from an opt-in smoke test plus runnable examples, not from making normal CI download PhysioNet data. +5. The discovery interpretation must remain cautious and non-clinical. + +## Next Question + +The main fork in the road is scope: + +- If the goal is a quick migration, implement Approach A first. +- If the goal is "get it into PyHealth format," implement Approach B first. +- If the goal is a small but memorable research demo, implement Approach B and one tiny embedding comparison. + +Recommendation: Approach B first. Add only the smallest embedding comparison if it clearly improves the story. diff --git a/docs/eeg_pattern_discovery/design.md b/docs/eeg_pattern_discovery/design.md new file mode 100644 index 000000000..7094a8314 --- /dev/null +++ b/docs/eeg_pattern_discovery/design.md @@ -0,0 +1,280 @@ +# Design: EEG Pattern Discovery in PyHealth + +Generated: 2026-07-07 +Branch: master +Repo: PyHealth +Status: APPROVED FOR IMPLEMENTATION +Mode: Builder / research + +## Problem Statement + +Migrate the CELM EEG pattern discovery idea into PyHealth's architecture. + +The original question is still the right one: + +> Can frequency-based EEG patterns reveal moment-level brain-state hypotheses that task or clinical labels do not fully capture? + +In CELM, this was a standalone research artifact. In PyHealth, it should become a reusable EEG dataset/task path, with examples that can train, embed, cluster, and summarize real EEG windows. + +High-level summary: We are turning the standalone CELM EEG pattern-discovery pipeline into a reusable PyHealth dataset, task, and example for real PhysioNet EEGBCI motor movement/imagery data. The research question is whether simple frequency profiles in short EEG windows can surface moment-level brain-state hypotheses that task labels alone miss. The practical problem is that PyHealth has EEG models and task infrastructure, but no first-class EEGBCI path that produces labeled windows, bandpower features, cautious interpretation metadata, and real-data validation without forcing normal CI to download raw EDF files. + +## Source Artifact + +The CELM artifact lives at `/Users/vihaanagrawal/Research/CELM/eeg_pattern_discovery`. + +It is a real EEG pipeline, not just mocked signal processing: + +- Dataset: PhysioNet EEG Motor Movement/Imagery through MNE EEGBCI. +- Subjects: `1`, `2`, `3`. +- Runs: `3-14`. +- Windowing: full 2-second windows inside MNE annotations. +- Signal processing: MNE EDF loading, standardization, EEG picking, `0.5-45 Hz` filtering, Welch PSD. +- Output: checked-in CSV with 2,160 processed segments. +- Models: no pretrained model or checkpoint. The original is classical signal processing plus deterministic interpretation rules. + +That last point matters. The PyHealth expansion should not pretend CELM already used pretrained models. It should add them as the bigger creative layer. + +## Why PyHealth Is A Good Fit + +PyHealth already has the right abstractions: + +- EEG datasets: `TUABDataset`, `TUEVDataset`, `SleepEDFDataset`, `SHHSDataset`, `ISRUCDataset`. +- EEG tasks: `EEGEventsTUEV`, `EEGAbnormalTUAB`, `SleepStagingSleepEDF`. +- EEG models: `BIOT`, `ContraWR`, `SparcNet`, `TFMTokenizer`. +- Calibration examples under `examples/conformal_eeg/`. + +The migration should follow the existing pattern: + +1. Dataset class builds metadata rows pointing at raw signal files. +2. Task class reads EDF, preprocesses signals, windows annotations, and returns PyHealth samples. +3. Examples run actual research workflows. +4. Tests mock network/EDF boundaries by default, with optional real-data smoke tests. + +## Premises + +1. EEGBCI should be a first-class PyHealth dataset, not only a script in `examples/`. +2. The default pattern-discovery window should stay 2 seconds to preserve the CELM research question. +3. `T1` and `T2` labels must be decoded using the run number. They do not mean the same thing in every EEGBCI run. +4. Brain-state hypotheses are heuristic metadata, not clinical labels. +5. Normal CI should not download PhysioNet data. Real-data coverage should be opt-in. +6. Channel adaptation is part of the API design. EEGBCI is 64-channel/160 Hz; several PyHealth EEG model examples assume 16 channels and often 200 Hz. + +## Recommended Approach + +Build the core PyHealth integration first, then add the creative atlas. + +### Layer 1: Core PyHealth Integration + +Add: + +- `pyhealth/datasets/eegbci.py` +- `pyhealth/datasets/configs/eegbci.yaml` +- `pyhealth/tasks/eegbci.py` +- exports in `pyhealth/datasets/__init__.py` and `pyhealth/tasks/__init__.py` +- tests under `tests/core/` +- documentation under `docs/api/` + +Core classes: + +- `EEGBCIDataset` +- `EEGMotorImageryEEGBCI` +- `EEGBCIPatternDiscovery` + +The dataset should produce metadata. The task should do EDF reading, annotation parsing, windowing, channel handling, optional STFT, bandpower extraction, and interpretation metadata. + +### Layer 2: Small Optional Research Layer + +Add examples under `examples/eeg/eegbci/`: + +- `eegbci_pattern_discovery.py`: reproduce the CELM CSV/Markdown artifact using PyHealth objects. +- optional `eegbci_embedding_comparison.py`: extract embeddings from one compatible EEG model and compare them with CELM bandpower hypotheses. + +Keep this small. The goal is not to create a large EEG research platform. The optional comparison should answer one question: + +> Do learned EEG embeddings group windows in a way that agrees with, sharpens, or contradicts the simple bandpower hypotheses? + +It can compare: + +- CELM bandpower hypotheses. +- Supervised task labels. +- One pretrained or learned model embedding. + +## Model Boundary + +The first EEGBCI pattern-discovery pipeline does not require a neural model. It uses real EEGBCI data, MNE preprocessing, Welch bandpower features, and deterministic interpretation rules. The supervised `EEGMotorImageryEEGBCI` task should make model training possible through normal PyHealth models, and the dataset/task contract should be compatible with BIOT, ContraWR, SparcNet, and TFMTokenizer where channel and sampling assumptions fit. Pretrained model embeddings are a second-stage research layer, not a dependency for the first implementation. + +## Analysis Stage + +The first analysis stage should be a CELM-equivalent sample-level and aggregate report. It should convert `EEGBCIPatternDiscovery` samples into a CSV with task labels, bandpower features, ratios, hypotheses, confidence, and quality flags, then write a Markdown summary grouped by task label and inferred hypothesis. This answers whether frequency-profile hypotheses line up with, sharpen, or disagree with the experimental labels. The later atlas stage can add model embeddings and clustering, but the first pass should prove the dataset/task/analysis path end to end with real EEGBCI data. + +## Dataset Contract + +Recommended constructor: + +```python +dataset = EEGBCIDataset( + root="~/.cache/pyhealth/eegbci", + subjects=[1, 2, 3], + runs=list(range(3, 15)), + download=True, +) +``` + +Recommended metadata columns: + +| Column | Meaning | +| --- | --- | +| `patient_id` | subject key, for example `S001` | +| `record_id` | run key, for example `R03` | +| `subject_id` | integer EEGBCI subject id | +| `run` | integer EEGBCI run id | +| `run_type` | baseline, motor execution, or motor imagery subtype | +| `signal_file` | local EDF path downloaded by MNE | +| `source` | `physionet_eegbci` | + +`download=True` may call `mne.datasets.eegbci.load_data`. `download=False` should require local files/metadata and fail clearly if they are missing. + +## Task Contracts + +### `EEGMotorImageryEEGBCI` + +Purpose: supervised task-label prediction. + +Sample shape: + +```python +{ + "patient_id": "S001", + "signal_file": ".../S001R03.edf", + "run": 3, + "trial_id": "S001_R03_0001", + "event_code": "T1", + "task_label": "execute_left_fist", + "label_family": "motor_execution", + "label": 1, + "signal": Tensor[C, T], + "stft": Tensor[C, F, TT], # if compute_stft=True +} +``` + +### `EEGBCIPatternDiscovery` + +Purpose: exploratory moment-level signal interpretation. + +Sample shape extends the supervised sample with: + +```python +{ + "bandpower": { + "delta_power": ..., + "theta_power": ..., + "alpha_power": ..., + "beta_power": ..., + "gamma_power": ..., + "delta_relative": ..., + "theta_relative": ..., + "alpha_relative": ..., + "beta_relative": ..., + "gamma_relative": ..., + "dominant_band": "alpha", + "alpha_beta_ratio": ..., + "theta_beta_ratio": ..., + }, + "brain_state_hypothesis": "relaxed_or_idle", + "confidence": "medium", + "quality_flags": "low_confidence", + "interpretation": "The segment is alpha-dominant...", +} +``` + +The discovery task should keep `task_label` and `label` available so PyHealth training, evaluation, and embedding extraction remain usable. + +## Channel And Sampling Strategy + +Default recommendation: + +- Preserve `original_sample_rate=160` as metadata. +- Resample to `200 Hz` by default for compatibility with existing PyHealth EEG models. +- Offer `resample_rate=None` to keep the original signal. +- Default to a stable 16-channel adapter for pretrained-model compatibility. +- Offer `channel_mode="all"` for 64-channel experiments with compatible models. + +Reasoning: + +- BIOT can be configured for different channel counts, but pretrained 18-channel weights are not directly compatible with raw 64-channel EEGBCI. +- ContraWR and SparcNet are structurally friendlier to raw tensors, but still need sane window length and channel expectations. +- TFMTokenizer has stronger assumptions: it expects `signal` and `stft`, has 200 Hz-ish temporal assumptions, and the classifier currently uses a 16-channel embedding table. + +Do not reuse TUAB/TUEV bipolar montage functions. They hard-code TUH channel names. + +## Real Dataset Testing + +Use three levels: + +1. Unit tests, always on: + - run-aware `T0`/`T1`/`T2` label mapping + - fixed-window segmentation + - bandpower feature keys and rough values on synthetic sinusoids + - interpretation rules + - dataset metadata generation with mocked MNE download + - task sample schema with mocked EDF/Raw object + +2. Fixture test, always on if fixture is accepted: + - a tiny `.npz` with one or two extracted windows from EEGBCI + - verifies real-shaped signal arrays without downloading data + +3. Real-data smoke test, opt-in: + - gated by `PYHEALTH_RUN_REAL_EEGBCI=1` + - downloads subject `1`, run `3` + - verifies at least one real window, signal tensor shape, decoded task label, and bandpower metadata + +This is the right answer to the current testing concern. Mocking is fine for normal CI, but there should be one real-data path for the thing mocks cannot prove. + +## Approaches Considered + +### Approach A: Example-Only Port + +Fastest. Add only an example script that wraps the CELM pipeline in PyHealth imports. + +This proves the idea quickly, but it is not really PyHealth-format. It is a demo living inside the repo. + +### Approach B: Dataset + Task + Example + +Add EEGBCI as a dataset, add supervised and discovery tasks, then add a runnable example. + +This is the recommended path. It fits PyHealth and creates reusable substrate for later EEG research. + +### Approach C: EEG Representation Atlas + +Build Approach B, then add a clustering/embedding report that compares bandpower hypotheses with BIOT, ContraWR, SparcNet, or TFMTokenizer embeddings. + +This is the most interesting research artifact, but it is larger than the desired scope right now. Treat it as a later idea, not the current implementation target. + +## Success Criteria + +- `EEGBCIDataset` can build metadata for selected subjects/runs. +- `EEGMotorImageryEEGBCI` returns real labeled EEG windows. +- `EEGBCIPatternDiscovery` reproduces the CELM-style bandpower and interpretation schema. +- Default tests do not require network access. +- Optional real-data smoke test validates MNE/PhysioNet behavior. +- Example writes a CSV and Markdown summary from real EEGBCI data. +- Optional embedding comparison can run with at least one compatible model path. +- Documentation states that brain-state hypotheses are exploratory and non-clinical. + +## Resolved Decisions + +1. Stop the first implementation at dataset, tasks, tests, docs, and CELM-equivalent example. Defer the embedding comparison. +2. Use 16-channel compatibility as the default channel mode. Offer `channel_mode="all"` for 64-channel experiments. +3. Keep using `mne` as a normal project dependency. `pyproject.toml` already declares `mne~=1.10.0`, and existing EEG tasks import it directly. +4. Do not commit a tiny real-signal `.npz` fixture in the first pass. Use offline mocked tests plus the opt-in `PYHEALTH_RUN_REAL_EEGBCI=1` smoke test. +5. Treat the CELM-equivalent CSV and Markdown generator as the first analysis stage. Do not require a neural model for that stage. + +## Recommendation + +Implement Approach B first. + +Stop there unless the implementation feels too small. If adding a creative layer, add only a tiny one-model embedding comparison, not a full atlas. + +## Planning Update + +2026-07-07: The approved design has been converted into a concrete implementation plan at `docs/eeg_pattern_discovery/implementation_plan.md`. GStack `/plan-eng-review` reviewed the plan and locked the first implementation scope to Approach B: dataset, tasks, tests, docs, and CELM-equivalent example. The embedding comparison remains deferred. diff --git a/docs/eeg_pattern_discovery/eeg_pipeline_guide.md b/docs/eeg_pattern_discovery/eeg_pipeline_guide.md new file mode 100644 index 000000000..fa7dfa759 --- /dev/null +++ b/docs/eeg_pattern_discovery/eeg_pipeline_guide.md @@ -0,0 +1,185 @@ +# EEGBCI Pipeline Guide + +This guide explains the EEGBCI files added for the motor movement/imagery +pipeline, the order to call them, what each file does, and how to interpret the +current report output. + +## Runtime Call Order + +For the moment-report artifact, run only the example script: + +```bash +.venv/bin/python examples/eeg/eegbci/eegbci_pattern_discovery.py \ + --subjects 1 \ + --runs 3 \ + --max-windows 20 \ + --download +``` + +Internally, that calls the pipeline in this order: + +1. `examples/eeg/eegbci/eegbci_pattern_discovery.py` +2. `EEGBCIDataset(...)` from `pyhealth/datasets/eegbci.py` +3. `pyhealth/datasets/configs/eegbci.yaml` +4. `dataset.set_task(EEGBCIPatternDiscovery(compute_stft=False))` +5. `EEGBCIPatternDiscovery.__call__()` in `pyhealth/tasks/eegbci.py` +6. `EEGMotorImageryEEGBCI._base_samples_from_patient()` in + `pyhealth/tasks/eegbci.py` +7. `iter_annotation_windows()`, channel selection, normalization, optional STFT, + and Welch bandpower computation in `pyhealth/tasks/eegbci.py` +8. Example-owned report helpers in + `examples/eeg/eegbci/eegbci_pattern_discovery.py` +9. Output files under `outputs/eegbci_pattern_discovery/` + +The reusable PyHealth data path stops at step 5 or 6 with a `SampleDataset`. +Steps 8 and 9 are report-only and are not the normal model-training interface. + +## Files And Responsibilities + +### `pyhealth/datasets/eegbci.py` + +Defines `EEGBCIDataset`, the dataset entry point. It selects requested subjects +and runs, optionally downloads PhysioNet EEGBCI EDF files through MNE, finds local +EDF files when download is disabled, and writes the metadata table +`eegbci-pyhealth.csv`. + +The metadata table has one record per subject/run EDF file. It does not create +2-second windows itself. Windowing happens in the task layer. + +### `pyhealth/datasets/configs/eegbci.yaml` + +Tells `BaseDataset` how to read `eegbci-pyhealth.csv` as the `records` table. +It maps `patient_id`, `record_id`, `subject_id`, `run`, `run_type`, +`signal_file`, and `source` into PyHealth dataset events. + +### `pyhealth/tasks/eegbci.py` + +Contains the reusable EEGBCI task logic. + +Key pieces: + +- `run_type_for_run()`, `task_label_for_event()`, and + `numeric_label_for_task()` decode EEGBCI run/event labels. +- `select_eegbci_channels()` selects either the 16-channel compatibility montage + or all channels. +- `normalize_signal()` applies task-level signal normalization. +- `iter_annotation_windows()` converts MNE annotations into full 2-second + windows. +- `EEGMotorImageryEEGBCI` produces model-ready samples with `signal`, optional + `stft`, and multiclass `label`. +- `EEGBCIPatternDiscovery` extends the motor-imagery task by adding Welch + bandpower metadata and cautious legacy frequency-profile interpretation. + +This file is the correct reusable task layer for downstream PyHealth models. + +### `examples/eeg/eegbci/eegbci_pattern_discovery.py` + +This is the report generator. It is not a training script. + +It creates an `EEGBCIDataset`, applies `EEGBCIPatternDiscovery`, converts +samples to rows, computes rest baselines across all requested rows before +`--max-windows` truncation, annotates each moment with report-level state +hypotheses, and writes: + +- `outputs/eegbci_pattern_discovery/eegbci_pattern_windows.csv` +- `outputs/eegbci_pattern_discovery/eegbci_pattern_summary.md` + +The report-level fields are intentionally example-owned because they depend on +cross-window context such as rest baselines and task/state comparison. + +### `examples/eeg/eegbci/README.md` + +Short user-facing instructions for running the example and reading the generated +CSV and Markdown files. + +### `tests/core/test_eegbci.py` + +Unit coverage for EEGBCI dataset metadata, task windowing, bandpower behavior, +and the report helpers. Normal tests use synthetic data and do not download +EEGBCI. The real-data smoke test is opt-in through `PYHEALTH_RUN_REAL_EEGBCI=1`. + +### API Documentation Files + +These expose the reusable dataset and task APIs in generated docs: + +- `docs/api/datasets/pyhealth.datasets.EEGBCIDataset.rst` +- `docs/api/tasks/pyhealth.tasks.eegbci.rst` +- `docs/api/datasets.rst` +- `docs/api/tasks.rst` + +## Output Interpretation + +The CSV is a moment-by-moment ledger. Each row is one emitted 2-second EEG +window. The most important columns are: + +- `task_label`: what the experiment instructed in that window. +- `dominant_band` and `{band}_relative`: the raw frequency profile. +- `state_hypothesis`: report-level frequency-pattern state. +- `state_confidence` and `evidence_score`: strength of the deterministic + frequency-pattern evidence. +- `rest_reference_scope`: which rest baseline was used. +- `rest_{band}_relative_delta`: how the row differs from the selected rest + baseline for that band. +- `task_state_relation`: whether the frequency-pattern state supports, adds + detail to, disagrees with, or is ambiguous relative to the task label. +- `task_state_confidence`: confidence in that deterministic task/state relation. +- `interpretation`: report-level text summarizing the row in terms of the + moment-report state, raw dominant band, rest reference, and task/state + relation. +- `is_low_confidence`, `is_possible_artifact`, and `is_mixed_or_ambiguous`: + parseable quality flags for filtering. + +The CSV intentionally omits legacy task-level columns such as +`brain_state_hypothesis`, `confidence`, and `quality_flags`. Those fields still +exist inside `EEGBCIPatternDiscovery` samples for reusable task compatibility, +but they were too redundant and confusing for the final report artifact. + +The Markdown report summarizes those rows. It should be read as signal-pattern +metadata, not as a clinical or cognitive conclusion. For example, a +`slow_wave_dominant_pattern` means the short window's delta/theta evidence was +strong under the current heuristic. It does not diagnose a subject state. + +The report is useful for asking: + +- Which frequency-pattern states dominate this run? +- Are the states diverse or collapsed into one profile? +- Did rest normalization produce usable deltas? +- Which windows are representative enough to inspect manually? +- Which rows are low confidence, artifact-like, or ambiguous? + +## Next PyHealth Stage + +The next normal PyHealth stage after dataset/task construction is model +training or evaluation on a `SampleDataset`. + +Use this path for model work: + +```python +dataset = EEGBCIDataset(root=..., subjects=[...], runs=[...], download=True) +sample_dataset = dataset.set_task(EEGMotorImageryEEGBCI()) +``` + +That produces samples shaped for PyHealth models: + +- `signal`: EEG tensor +- optional `stft`: time-frequency tensor when `compute_stft=True` +- `label`: multiclass target +- metadata fields such as subject, run, event, and timing + +The current CSV/Markdown report is not the format expected by the next PyHealth +model-training stage. It is an analysis artifact for researchers. If the next +stage is a PyHealth model, pass the `SampleDataset` returned by `dataset.set_task` +to the trainer/model stack rather than reading +`eegbci_pattern_windows.csv`. + +If the next stage is offline analysis, dashboarding, or manual review, then the +CSV and Markdown are the right outputs. + +## Which Output Should Feed What? + +| Output | Intended consumer | Suitable for PyHealth model training? | +| --- | --- | --- | +| `EEGMotorImageryEEGBCI` `SampleDataset` | PyHealth models and trainers | Yes | +| `EEGBCIPatternDiscovery` `SampleDataset` | Signal inspection plus reusable task metadata | Partly, but primarily exploratory | +| `eegbci_pattern_windows.csv` | Analysis, filtering, audit, dashboards | No | +| `eegbci_pattern_summary.md` | Human-readable report | No | diff --git a/docs/eeg_pattern_discovery/implementation_plan.md b/docs/eeg_pattern_discovery/implementation_plan.md index 8c6cb008d..02cff1ceb 100644 --- a/docs/eeg_pattern_discovery/implementation_plan.md +++ b/docs/eeg_pattern_discovery/implementation_plan.md @@ -7,10 +7,11 @@ Date: 2026-07-07 Source docs: - `docs/eeg_pattern_discovery/brainstorm.md` - `docs/eeg_pattern_discovery/design.md` +- `docs/eeg_pattern_discovery/pattern_analysis_redesign.md` **Goal:** Add a first-class EEGBCI dataset and two EEGBCI tasks to PyHealth so real PhysioNet motor movement/imagery windows can be used for supervised classification and CELM-style exploratory pattern discovery. -**High-Level Summary:** We are turning the standalone CELM EEG pattern-discovery pipeline into a reusable PyHealth dataset, task, and example for real PhysioNet EEGBCI motor movement/imagery data. The research question is whether simple frequency profiles in short EEG windows can surface moment-level brain-state hypotheses that task labels alone miss. The practical problem is that PyHealth has EEG models and task infrastructure, but no first-class EEGBCI path that produces labeled windows, bandpower features, cautious interpretation metadata, and real-data validation without forcing normal CI to download raw EDF files. +**High-Level Summary:** We are turning the standalone CELM EEG pattern-discovery pipeline into a reusable PyHealth dataset, task, and example for real PhysioNet EEGBCI motor movement/imagery data. The research question is: what is the brain doing in each moment, according to its frequency patterns? More precisely, for each 2-second EEG segment, infer the most likely functional brain-state hypothesis from its frequency-band profile, then compare that hypothesis to the experimental task label. The practical problem is that PyHealth has EEG models and task infrastructure, but no first-class EEGBCI path that produces labeled windows, bandpower features, cautious moment-level interpretation metadata, and real-data validation without forcing normal CI to download raw EDF files. **Architecture:** `EEGBCIDataset` builds one metadata row per subject/run EDF, following the existing `TUABDataset` and `TUEVDataset` CSV pattern. `EEGMotorImageryEEGBCI` reads EDF annotations and emits fixed windows for task-label prediction; `EEGBCIPatternDiscovery` extends the same windows with Welch bandpower features and cautious interpretation metadata. Offline unit tests mock MNE and EDF reads; an opt-in smoke test downloads one real EEGBCI run. @@ -61,7 +62,7 @@ Resolved decisions: - Dependency model: use existing project-level `mne~=1.10.0`; do not add an optional EEG extra in this pass. - Real-data validation: no committed `.npz` fixture in the first pass; use mocked offline tests plus the opt-in real-data smoke test. - Model boundary: the first pattern-discovery pipeline uses signal processing and deterministic interpretation, not a neural model. PyHealth model training and pretrained embeddings are enabled by the task outputs but deferred from the first deliverable. -- Analysis stage: the first analysis stage is the CELM-equivalent CSV and Markdown report produced by `examples/eeg/eegbci/eegbci_pattern_discovery.py`. +- Analysis stage: the first analysis stage is the CELM-equivalent CSV and Markdown report produced by `examples/eeg/eegbci/eegbci_pattern_discovery.py`, upgraded into a moment-state report that compares inferred frequency-pattern states against experimental task labels. ## Recommended Execution Path @@ -116,11 +117,15 @@ An implementation is complete only when these checks pass: - `EEGBCIPatternDiscovery(compute_stft=False)` returns every supervised field plus `bandpower`, `brain_state_hypothesis`, `confidence`, `quality_flags`, and `interpretation`. - Default `pytest tests/core/test_eegbci.py -v` passes without network access and skips the real-data smoke test. - `PYHEALTH_RUN_REAL_EEGBCI=1 pytest tests/core/test_eegbci.py::TestEEGBCIRealDataSmoke -v` passes against subject `1`, run `3`. -- The example command writes `eegbci_pattern_windows.csv` and `eegbci_pattern_summary.md`, with at least one row containing task label, dominant band, hypothesis, confidence, and non-clinical interpretation text. +- The example command writes `eegbci_pattern_windows.csv` and `eegbci_pattern_summary.md`, with rows containing task label, dominant band, state hypothesis, evidence summary, confidence, quality flags, task-label comparison, and non-clinical interpretation text. ## Analysis Stage Contract -The first analysis stage lives in `examples/eeg/eegbci/eegbci_pattern_discovery.py`. It is not just a demo script. It is the artifact generator that proves the pipeline can answer the research question on real windows. +The first analysis stage lives in `examples/eeg/eegbci/eegbci_pattern_discovery.py`. It is not just a demo script. It is the artifact generator that proves the pipeline can answer the research question on real windows: + +> What is the brain doing in each moment, according to its frequency patterns? + +For each 2-second EEG segment, the artifact should infer the most likely functional brain-state hypothesis from the frequency-band profile, then compare that hypothesis to the experimental task label. Inputs: @@ -130,12 +135,22 @@ Inputs: Outputs: -- `eegbci_pattern_windows.csv`: one row per window, including subject/run metadata, event code, decoded task label, bandpower values, relative powers, dominant band, ratios, hypothesis, confidence, quality flags, and interpretation. -- `eegbci_pattern_summary.md`: aggregate counts by task label and brain-state hypothesis, plus the non-clinical caveat. +- `eegbci_pattern_windows.csv`: one row per window, including subject/run metadata, event code, decoded task label, bandpower values, relative powers, dominant band, ratios, state hypothesis, evidence score or evidence summary, confidence, quality flags, task-label comparison, and interpretation. +- `eegbci_pattern_summary.md`: a compact research report with run configuration, window coverage, moment-state ledger, task-label x hypothesis matrix, dominant bands by task, confidence and quality audit, bandpower summaries, notable windows, limitations, and next checks. Question answered: -- Do the frequency-profile hypotheses agree with, sharpen, or flag possible disagreement with the experimental EEGBCI labels? +- At this moment, does the EEG look relaxed, engaged, drowsy, motor-active, noisy, mixed, or ambiguous? +- Do the frequency-profile hypotheses agree with, add detail to, or flag possible disagreement with the experimental EEGBCI labels? +- If all windows collapse to low-confidence or mixed states, what exactly caused that outcome? + +Required output-quality behavior: + +- Do not start the summary with the generic exploratory caveat. +- Move the non-clinical safety boundary into a clear methods or limitations section. +- Do not repeat "This is exploratory signal metadata..." in every row. +- If every window has low confidence, say that plainly and explain the distributional reason. +- Surface top windows by alpha/beta, theta/beta, beta-relative, and gamma-relative power so the user can inspect moments that might represent idle, slow-wave, motor-active, or artifact-like patterns. Deferred analysis: @@ -1686,3 +1701,24 @@ Do not include the optional embedding comparison in the initial implementation. - 2026-07-07: Task 5 complete; `.venv/bin/python examples/eeg/eegbci/eegbci_pattern_discovery.py --subjects 1 --runs 3 --max-windows 20 --download` writes a 20-row CSV and Markdown summary. - 2026-07-07: Task 6 complete; docs import smoke prints `EEGBCIDataset`, `EEGMotorImageryEEGBCI`, and `EEGBCIPatternDiscovery`. - 2026-07-07: Final verification complete; default EEGBCI tests pass with 21 passed/1 skipped, import smoke prints `imports ok`, opt-in real-data smoke passes, the example writes verified 20-row artifacts, and `graphify update .` refreshed the code graph. +- 2026-07-08: Ran GStack `/office-hours` and `/plan-ceo-review` after inspecting weak generated outputs. The new product target is an analysis-grade moment report: for each 2-second EEG segment, infer the likely frequency-pattern state, expose evidence, compare it to the experimental task label, and make low-confidence collapse explicit. + +## GSTACK REVIEW REPORT + +| Review | Trigger | Why | Runs | Status | Findings | +|--------|---------|-----|------|--------|----------| +| CEO Review | `/plan-ceo-review` | Scope & strategy | 1 | CLEAR | Selective expansion: 5 proposals, 4 accepted, 1 deferred. Accepted: rest-normalized evidence, parseable quality flags, representative window cards, analysis versioning. Deferred: HTML report. | +| Codex Review | `/codex review` | Independent 2nd opinion | 0 | — | — | +| Eng Review | `/plan-eng-review` | Architecture & tests (required) | 1 | STALE | Prior engineering review cleared the original dataset/task/example implementation, but it predates the new moment-report output contract. | +| Design Review | `/plan-design-review` | UI/UX gaps | 0 | — | No UI scope. | +| DX Review | `/plan-devex-review` | Developer experience gaps | 0 | — | — | + +**CEO:** Scope baseline is Approach B with Approach D's north star: build the analysis-grade moment report now, not the full atlas. + +**ACCEPTED SCOPE:** Add rest-normalized evidence, parseable quality booleans, representative window cards, and `analysis_version`. + +**DEFERRED:** HTML report belongs to the later atlas phase. + +**BOUNDARY DECISION:** Keep `state_hypothesis`, `evidence_score`, rest-normalized deltas, `task_state_relation`, and related moment-report fields example-only in `examples/eeg/eegbci/eegbci_pattern_discovery.py`. Do not add them to the reusable `EEGBCIPatternDiscovery` task API in this PR. + +**VERDICT:** CEO REVIEW CLEAR. Fresh engineering review is recommended after the output contract is implemented because the prior `/plan-eng-review` predates the new analysis scope. diff --git a/docs/eeg_pattern_discovery/pattern_analysis_redesign.md b/docs/eeg_pattern_discovery/pattern_analysis_redesign.md new file mode 100644 index 000000000..b6431cc81 --- /dev/null +++ b/docs/eeg_pattern_discovery/pattern_analysis_redesign.md @@ -0,0 +1,420 @@ +# EEGBCI Pattern Discovery Output Redesign + +Date: 2026-07-08 +Status: draft office-hours design note +Branch: eegbci-pattern-discovery +Mode: Builder / open source research + +## Problem Statement + +The EEGBCI pattern-discovery implementation exists, but the generated artifact does +not yet answer the research question it was designed to answer. + +The stronger question is: + +> What is the brain doing in each moment, according to its frequency patterns? + +More precisely: + +> For each 2-second EEG segment, infer the most likely functional brain-state +> hypothesis from its frequency-band profile, then compare that hypothesis to the +> experimental task label. + +That moves the project from "does the label match the data?" to: + +> At this moment, does the EEG look relaxed, engaged, drowsy, motor-active, +> noisy, mixed, or ambiguous? + +The current output at `outputs/eegbci_pattern_discovery/` is mechanically valid and +analytically weak: + +- `eegbci_pattern_summary.md` reports only counts. +- All 20 inspected windows are `mixed_frequency_profile`. +- All 20 inspected windows have `confidence=low`. +- All 20 inspected windows have `quality_flags=low_confidence`. +- The Markdown summary does not compare task labels with frequency profiles. +- The interpretation sentence repeats the same caveat per row, which makes the + artifact feel defensive instead of informative. + +This is not a copy problem only. The current artifact does not expose enough +analysis for a researcher to tell whether the pattern-discovery layer found +anything, failed to find anything, or needs better thresholds. + +## Evidence From Current Outputs + +Source files inspected: + +- `outputs/eegbci_pattern_discovery/eegbci_pattern_summary.md` +- `outputs/eegbci_pattern_discovery/eegbci_pattern_windows.csv` +- `examples/eeg/eegbci/eegbci_pattern_discovery.py` +- `pyhealth/tasks/eegbci.py` +- `docs/eeg_pattern_discovery/brainstorm.md` +- `docs/eeg_pattern_discovery/design.md` +- `docs/eeg_pattern_discovery/implementation_plan.md` + +Observed current sample: + +| Metric | Value | +| --- | --- | +| Windows | 20 | +| Subjects / runs | S001 / R03 only in current inspected output | +| Task labels | rest: 10, execute_right_fist: 6, execute_left_fist: 4 | +| Hypotheses | mixed_frequency_profile: 20 | +| Confidence | low: 20 | +| Quality flags | low_confidence: 20 | + +Current task-label medians: + +| Task label | Delta rel. | Theta rel. | Alpha rel. | Beta rel. | Gamma rel. | Alpha/beta | Theta/beta | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| execute_left_fist | 0.585 | 0.159 | 0.103 | 0.116 | 0.036 | 0.857 | 1.485 | +| execute_right_fist | 0.629 | 0.144 | 0.078 | 0.122 | 0.030 | 0.638 | 1.195 | +| rest | 0.559 | 0.157 | 0.083 | 0.123 | 0.027 | 0.692 | 0.929 | + +The data does contain variation. The output just fails to turn it into a useful +analysis. For example, `execute_left_fist` has higher median theta/beta than rest, +and the top alpha/beta window is a rest window. Those are the kinds of signals the +summary should surface. + +## Premises + +1. The output artifact should answer the analysis question, not merely prove the + pipeline runs. +2. A weak result is acceptable if the artifact explains why it is weak using data. +3. The non-clinical warning belongs in one clear methods/limitations section, not + repeated inside every window interpretation. +4. `mixed_frequency_profile` should be treated as an analysis outcome that needs + explanation, not as useful interpretation by itself. +5. The first pass can remain rules-only. It does not need pretrained embeddings to + produce a much stronger report. +6. The artifact should be organized around moment-level state hypotheses, not + around defensive caveats. + +## Product Thesis + +The useful artifact is not "a CSV with bandpower columns." The useful artifact is a +moment-by-moment brain-state ledger. + +Each 2-second segment should tell a researcher: + +- What the subject was instructed to do. +- What the EEG frequency profile looked like. +- Which functional state hypothesis best fits that profile. +- How strong or weak the evidence is. +- Whether the frequency pattern agrees with the experimental label, adds detail + the label does not contain, or looks ambiguous/noisy. + +This should feel like a field guide for EEG moments: + +| Experimental label | Frequency-pattern question | +| --- | --- | +| rest | Does this window look idle/alpha-heavy, drowsy/slow-wave, noisy, or mixed? | +| motor execution | Does this window show motor-active beta/sensorimotor evidence, or does it still look idle/mixed? | +| motor imagery | Does this window show engagement without movement, or does it collapse into ambiguous/rest-like activity? | +| any label | Is this window clean enough to interpret, or should it be treated as noise/low confidence? | + +The current artifact cannot do this because it collapses all windows to the same +state and gives no ranked evidence. A better artifact can still say "ambiguous," +but it must say why. + +## What Strong Output Should Look Like + +The Markdown summary should read like a compact research result: + +1. Executive result. + - What was processed. + - Whether the run produced separable moment-level state hypotheses. + - Whether confidence was useful or collapsed. + - The strongest observed signal in one or two sentences. + +2. Dataset/run coverage. + - Subjects, runs, run families, task labels, windows per label. + - Window size, sample rate, channel mode, preprocessing choices. + - A warning if the output is capped by `--max-windows`. + +3. Moment-state ledger. + - One row per representative or notable window. + - Experimental label, inferred state, evidence score, confidence, and note. + - This is the human-readable answer to "what is the brain doing right now?" + +4. Label vs. hypothesis matrix. + - Crosstab of `task_label` by `brain_state_hypothesis`. + - Percentages by task label. + - Explicit statement when all labels collapse to one hypothesis. + +5. Bandpower profile by task label. + - Median relative delta/theta/alpha/beta/gamma by task label. + - Median alpha/beta and theta/beta ratios. + - Simple deltas from rest, for example execution beta minus rest beta. + +6. Confidence and quality audit. + - Confidence distribution. + - Quality flag distribution. + - Explanation of why low confidence fired. + - A threshold diagnostic: which rule each window almost matched, if any. + +7. Notable windows. + - Top alpha/beta windows. + - Top theta/beta windows. + - Top beta-relative windows. + - Highest gamma-relative windows as possible artifact candidates. + - Include trial id, task label, time range, ratios, and a short reason. + +8. Interpretation. + - Replace repeated generic row text with concise, evidence-specific language. + - Example: `Delta-heavy mixed profile; no rule-specific hypothesis met. Keep as + low-confidence baseline evidence, not a brain-state claim.` + - For the summary, state what the artifact can and cannot conclude. + +9. Next analysis recommendations. + - Whether to run more subjects/runs. + - Whether thresholds are too strict for EEGBCI after normalization. + - Whether a rest-normalized or subject-normalized report should be generated. + +## Functional State Vocabulary + +The current vocabulary has the right caution but too little information. Use a +small set of functional hypotheses that describe what the frequency profile looks +like, not what the person definitely experienced: + +| State hypothesis | Frequency evidence | Confidence rule | Good wording | +| --- | --- | --- | --- | +| `idle_alpha_profile` | Alpha is elevated and alpha/beta is high | Medium when alpha clearly dominates; low when only mildly elevated | `Idle-like alpha profile` | +| `sensorimotor_engagement_profile` | Beta or low-gamma is elevated without artifact flags | Medium when beta is meaningfully above rest/task baseline | `Motor-engaged frequency profile` | +| `slow_wave_drowsy_profile` | Theta or delta/theta dominates and theta/beta is high | Medium only when slow-wave power is not universal across all labels | `Slow-wave/drowsy-like profile` | +| `possible_artifact_profile` | Gamma spike, extreme power, or noisy band mix | Low until inspected | `Possible artifact or muscle activity` | +| `mixed_ambiguous_profile` | No rule wins, or several weak rules conflict | Low | `Mixed or ambiguous frequency profile` | + +This vocabulary is intentionally not clinical. It is still much better than +`mixed_frequency_profile` for every row because it gives the reader a mental model +for what the algorithm is trying to see. + +## Evidence Scoring Contract + +Each row should expose why the state was chosen. At minimum: + +- `state_hypothesis`: one of the functional state names above. +- `state_confidence`: `low`, `medium`, or `high`. +- `evidence_score`: numeric 0.0 to 1.0, even if simple at first. +- `evidence_summary`: compact text, for example + `delta_rel=0.66; alpha_beta=2.35; beta_rel=0.08`. +- `agreement_with_task`: `supports_label`, `adds_detail`, `disagrees`, + `ambiguous`, or `not_applicable`. +- `task_comparison_note`: one sentence comparing the inferred state with the + experimental label. + +The first implementation can compute `evidence_score` from heuristic margins: + +- Alpha score: alpha relative share plus alpha/beta margin. +- Motor score: beta relative share plus beta-above-rest margin when rest baseline + is available. +- Slow-wave score: theta/beta and delta/theta dominance. +- Artifact score: gamma relative share and extreme-power flags. +- Mixed score: inverse of the winning margin. + +Do not overfit this yet. The point is to make uncertainty inspectable. + +## Interpretation Language Contract + +Remove this repeated sentence from per-window output: + +> This is exploratory signal metadata, not evidence of cognition or a clinical diagnosis. + +Keep the safety boundary, but move it into the summary methods section: + +> These labels are signal-pattern summaries from short EEG windows. They are not +> clinical findings and should not be read as evidence of a subject's cognition. + +Per-window interpretation should be short and data-specific: + +| Case | Better interpretation | +| --- | --- | +| No rule match | `Mixed frequency profile; no band-specific rule met. Low confidence.` | +| Alpha-ish rest | `Alpha/beta is elevated versus beta, consistent with an idle-like profile.` | +| Beta-heavy movement | `Beta-relative power is elevated, consistent with active sensorimotor processing.` | +| Gamma-heavy | `Gamma-relative power is elevated; inspect for muscle or movement artifact.` | +| Delta-heavy | `Delta dominates this short window. Treat as low-specificity unless this repeats across runs.` | + +## Approaches Considered + +### Approach A: Summary-Only Repair + +Summary: Keep the current task schema and interpretation rules. Rewrite only the +example summary generator so it computes stronger aggregate tables and removes the +repeated caveat from Markdown/CSV interpretation text. + +Effort: S + +Risk: Low + +Pros: + +- Fastest path to useful outputs. +- Smallest source diff. +- Does not change task behavior or public task schema. + +Cons: + +- Per-row `confidence` still collapses to low on the inspected run. +- Does not explain why thresholds fail unless extra diagnostics are derived in the + example. +- Leaves weak interpretation rules in `pyhealth/tasks/eegbci.py`. + +Reuses: + +- Existing `sample_to_row()`. +- Existing CSV fields. +- Pandas aggregation in `examples/eeg/eegbci/eegbci_pattern_discovery.py`. + +### Approach B: Analysis-Grade Example Contract + +Summary: Upgrade the example into a real artifact generator. Add moment-state +ledger rows, summary tables, near-miss diagnostics, task-vs-rest comparisons, +notable windows, and cleaner interpretation language while keeping the core task +rules conservative. + +Effort: M + +Risk: Low to medium + +Pros: + +- Directly fixes the weak output the user sees. +- Preserves the rules-only first implementation boundary. +- Makes low-confidence collapse informative instead of embarrassing. +- Gives researchers enough evidence to decide the next experiment. + +Cons: + +- More code in the example script. +- Requires focused tests for report sections and edge cases. +- Still depends on simple heuristic thresholds. + +Reuses: + +- Existing bandpower columns. +- Current task labels and quality flags. +- The original design's "answer whether hypotheses line up, sharpen, or disagree" + contract. + +### Approach C: Rule Engine Redesign + +Summary: Redesign `interpret_band_profile()` so it uses richer confidence scoring, +rest-normalized deltas, and graded hypotheses instead of hard thresholds. + +Effort: L + +Risk: Medium + +Pros: + +- Fixes the root cause of all windows becoming low-confidence mixed profiles. +- Produces better per-window interpretation. +- Can make confidence meaningful across subjects and runs. + +Cons: + +- More likely to overclaim from simple bandpower features. +- Needs real-data validation across multiple subjects/runs. +- Changes behavior in the task API, not just the example artifact. + +Reuses: + +- Existing bandpower computation. +- Existing tests around interpretation helper, with expanded cases. + +### Approach D: Moment Atlas + +Summary: Generate a richer static "brain-state atlas" artifact in Markdown plus +CSV. Each state gets representative windows, task-label enrichment, evidence +distributions, and next-inspection recommendations. + +Effort: L + +Risk: Medium + +Pros: + +- Best match for the bigger question: what the brain appears to be doing moment by + moment. +- Produces a compelling research artifact, not just a report. +- Sets up the later embedding/cluster atlas without requiring neural models now. + +Cons: + +- Bigger than a repair pass. +- Needs careful wording to avoid overclaiming. +- More tests and fixture data needed to keep the report deterministic. + +Reuses: + +- The "Brain-State Atlas Explorer" and "Label Disagreement Mining" ideas from + `docs/eeg_pattern_discovery/brainstorm.md`. +- Existing CSV fields, plus evidence scoring and task comparison notes. + +## Recommendation + +Choose Approach B now, with the north-star question from Approach D. + +The current artifact fails at the analysis/reporting layer first. Fixing that layer +turns even an all-low-confidence run into a useful result: "the current thresholds +do not produce separable hypotheses on this run, but these bandpower differences +are visible and these windows are worth inspecting." That is a real research +artifact. + +But do not keep Approach B small in spirit. The report should be designed as the +first version of a moment atlas: + +- "What does this 2-second segment look like?" +- "How confident are we?" +- "Does that fit the task label?" +- "Which windows deserve human inspection?" + +Then Approach C can be justified with evidence instead of taste. + +## Success Criteria + +The output redesign is successful when: + +- The summary no longer starts with the generic exploratory caveat. +- The summary has a one-paragraph result that names the strongest finding and the + biggest limitation. +- The summary directly answers: "What is the brain doing in each moment, according + to its frequency patterns?" +- The CSV has moment-state fields: `state_hypothesis`, `evidence_score`, + `evidence_summary`, `agreement_with_task`, and `task_comparison_note`, or an + explicitly documented equivalent. +- The summary includes a moment-state ledger for representative or notable + windows. +- The summary includes label-vs-hypothesis percentages. +- The summary includes bandpower medians by task label. +- The summary includes confidence and quality-flag audits. +- The summary includes notable windows ranked by alpha/beta, theta/beta, beta, and + gamma. +- The summary explicitly explains an all-low-confidence outcome when it happens. +- Per-window interpretation text is short, evidence-specific, and non-clinical + without repeating legalistic boilerplate. +- Tests cover the report generator on a tiny synthetic row set that includes mixed, + medium-confidence, and artifact-like windows. + +## Open Questions + +- Should the CSV keep `interpretation`, or should row-level explanations move to a + separate `pattern_note` field while `interpretation` remains backward-compatible? +- Should the example default `--max-windows` remain uncapped in docs, or should the + README encourage larger multi-run output for meaningful summaries? +- Should the next iteration add `analysis_version` to the CSV/summary so future + rule changes are traceable? +- Should threshold diagnostics live in the example only, or become fields returned + by `EEGBCIPatternDiscovery`? + +## Next Steps + +1. Implement Approach B in `examples/eeg/eegbci/eegbci_pattern_discovery.py`. +2. Update `tests/core/test_eegbci.py` with focused tests for summary sections and + interpretation text. +3. Regenerate `outputs/eegbci_pattern_discovery/`. +4. Update `docs/eeg_pattern_discovery/implementation_plan.md` with the output + quality correction and verification evidence. +5. Run the existing EEGBCI unit tests plus the example command. + diff --git a/docs/eeg_pattern_discovery/test_verification_plan.md b/docs/eeg_pattern_discovery/test_verification_plan.md new file mode 100644 index 000000000..0a2ff6686 --- /dev/null +++ b/docs/eeg_pattern_discovery/test_verification_plan.md @@ -0,0 +1,280 @@ +# EEGBCI Pattern Discovery Test Verification Plan + +Date: 2026-07-07 +Branch: `eegbci-pattern-discovery` +Base branch: `master` +Python: `.venv/bin/python` + +This plan verifies the EEGBCI pattern-discovery implementation against the +Correctness Oracle in `docs/eeg_pattern_discovery/implementation_plan.md`. +Normal/offline checks must not download PhysioNet data. Real EEGBCI checks are +explicitly opt-in and use `/tmp/pyhealth-eegbci-verification` as the data/output +root. + +## Checklist and Commands + +### 1. Offline Unit Tests + +- [ ] Run the default EEGBCI test file without network access. +- [ ] Confirm the real-data smoke test is skipped by default. +- [ ] Confirm run-aware labels, channel selection, normalization, annotation + windowing, task sample generation, bandpower, dataset metadata, and exports + pass together. + +```bash +PYHEALTH_RUN_REAL_EEGBCI=0 .venv/bin/python -m pytest tests/core/test_eegbci.py -v +``` + +### 2. Task and Schema Tests + +- [ ] Verify `EEGMotorImageryEEGBCI` exposes the expected task name, input + schema, and multiclass output schema. +- [ ] Verify `compute_stft=False` removes `stft` from `input_schema`. +- [ ] Verify `EEGBCIPatternDiscovery(compute_stft=False)` keeps the pattern task + name and tensor signal input. +- [ ] Verify STFT generation receives the actual task sample rate. +- [ ] Verify generated supervised samples include fixed-shape `signal`, + decoded `task_label`, numeric model `label`, raw `eegbci_label`, `trial_id`, + timing fields, and `sample_rate`. +- [ ] Verify pattern-discovery samples add `bandpower`, + `brain_state_hypothesis`, `confidence`, `quality_flags`, and + `interpretation`. + +```bash +.venv/bin/python -m pytest \ + tests/core/test_eegbci.py::TestEEGBCITasks::test_task_schema_attributes \ + tests/core/test_eegbci.py::TestEEGBCITasks::test_task_schema_without_stft \ + tests/core/test_eegbci.py::TestEEGBCITasks::test_pattern_discovery_schema_attributes \ + tests/core/test_eegbci.py::TestEEGBCITasks::test_motor_imagery_task_returns_samples_from_raw \ + tests/core/test_eegbci.py::TestEEGBCITasks::test_pattern_discovery_adds_bandpower_metadata \ + tests/core/test_eegbci.py::TestEEGBCITasks::test_stft_uses_current_sample_rate \ + -v +``` + +### 3. Synthetic Bandpower Tests + +- [ ] Verify a synthetic 10 Hz signal produces `dominant_band == "alpha"` and + high `alpha_relative`. +- [ ] Verify a synthetic 20 Hz signal produces `dominant_band == "beta"` and + high `beta_relative`. +- [ ] Verify deterministic interpretation metadata remains cautious and + non-clinical. + +```bash +.venv/bin/python -m pytest \ + tests/core/test_eegbci.py::TestEEGBCIHelpers::test_compute_band_powers_detects_alpha_sinusoid \ + tests/core/test_eegbci.py::TestEEGBCIHelpers::test_compute_band_powers_detects_beta_sinusoid \ + tests/core/test_eegbci.py::TestEEGBCIHelpers::test_interpret_band_profile_returns_cautious_metadata \ + -v +``` + +### 4. Dataset Metadata Tests + +- [ ] Verify `EEGBCIDataset.prepare_metadata()` writes one row per requested + subject/run. +- [ ] Verify required metadata columns are present: + `patient_id`, `record_id`, `subject_id`, `run`, `run_type`, `signal_file`, + and `source`. +- [ ] Verify local-file discovery works without network access. +- [ ] Verify metadata is rebuilt when the same root is reused with a different + subject/run selection. +- [ ] Verify an offline `EEGBCIDataset(...).set_task(...)` integration path + works through `BaseDataset`. +- [ ] Verify `download=True` delegates to `mne.datasets.eegbci.load_data`. +- [ ] Verify missing local files fail clearly when `download=False`. +- [ ] Verify the default task is `EEGBCIPatternDiscovery`. + +```bash +.venv/bin/python -m pytest tests/core/test_eegbci.py::TestEEGBCIDataset -v +``` + +### 5. Import/Export Smoke Tests + +- [ ] Verify public imports work from `pyhealth.datasets`, `pyhealth.tasks`, and + direct modules. +- [ ] Verify run-aware EEGBCI label semantics from the Correctness Oracle. +- [ ] Verify synthetic alpha bandpower from the Correctness Oracle. + +```bash +.venv/bin/python - <<'PY' +import numpy as np + +from pyhealth.datasets import EEGBCIDataset +from pyhealth.datasets.eegbci import EEGBCIDataset as DirectDataset +from pyhealth.tasks import EEGBCIPatternDiscovery, EEGMotorImageryEEGBCI +from pyhealth.tasks.eegbci import ( + compute_band_powers, + task_label_for_event, +) + +assert EEGBCIDataset is DirectDataset +assert EEGMotorImageryEEGBCI.task_name == "EEGBCI_motor_imagery" +assert EEGBCIPatternDiscovery.task_name == "EEGBCI_pattern_discovery" +assert task_label_for_event(3, "T1") == "execute_left_fist" +assert task_label_for_event(4, "T1") == "imagine_left_fist" + +sfreq = 200.0 +times = np.arange(0, 2, 1 / sfreq) +alpha = np.sin(2 * np.pi * 10 * times) +features = compute_band_powers(np.stack([alpha, alpha]), sfreq) +assert features["dominant_band"] == "alpha" +assert features["alpha_relative"] > 0.5 +print("imports and oracle smoke checks ok") +PY +``` + +### 6. Example-Output Validation + +- [ ] Run the example on subject `1`, run `3`, with a small window cap. +- [ ] Verify `eegbci_pattern_windows.csv` exists and has at least one row. +- [ ] Verify `eegbci_pattern_summary.md` exists. +- [ ] Verify at least one CSV row contains task label, dominant band, + hypothesis, confidence, and non-clinical interpretation text. + +```bash +.venv/bin/python examples/eeg/eegbci/eegbci_pattern_discovery.py \ + --root /tmp/pyhealth-eegbci-verification/data \ + --subjects 1 \ + --runs 3 \ + --output-dir /tmp/pyhealth-eegbci-verification/example-output \ + --max-windows 20 \ + --download +.venv/bin/python - <<'PY' +from pathlib import Path + +import pandas as pd + +out = Path("/tmp/pyhealth-eegbci-verification/example-output") +csv_path = out / "eegbci_pattern_windows.csv" +summary_path = out / "eegbci_pattern_summary.md" +assert csv_path.exists(), csv_path +assert summary_path.exists(), summary_path +df = pd.read_csv(csv_path) +assert len(df) >= 1 +required = { + "task_label", + "label", + "eegbci_label", + "model_label", + "dominant_band", + "brain_state_hypothesis", + "confidence", + "interpretation", +} +assert required.issubset(df.columns), sorted(set(required) - set(df.columns)) +assert df["task_label"].notna().any() +assert not df["label"].astype(str).str.contains("tensor").any() +assert df["eegbci_label"].notna().any() +assert df["model_label"].notna().any() +assert df["dominant_band"].notna().any() +assert df["brain_state_hypothesis"].notna().any() +assert df["confidence"].notna().any() +assert df["interpretation"].str.contains("not evidence of cognition|not clinical", case=False, regex=True).any() +print(f"validated {len(df)} example rows") +PY +``` + +### 7. Opt-In Real EEGBCI Smoke Test + +- [ ] Run the skipped-by-default real-data smoke test explicitly. +- [ ] Verify it downloads or reuses subject `1`, run `3`. +- [ ] Verify it produces at least one 16-channel pattern-discovery sample. + +```bash +PYHEALTH_RUN_REAL_EEGBCI=1 .venv/bin/python -m pytest \ + tests/core/test_eegbci.py::TestEEGBCIRealDataSmoke \ + -v +``` + +### 8. Docs/API Smoke Checks + +- [ ] Verify API RST files exist. +- [ ] Verify dataset/task toctrees include EEGBCI pages. +- [ ] Verify Sphinx targets can import the referenced objects/modules. + +```bash +.venv/bin/python - <<'PY' +from pathlib import Path + +from pyhealth.datasets import EEGBCIDataset +from pyhealth.tasks import EEGBCIPatternDiscovery, EEGMotorImageryEEGBCI +import pyhealth.tasks.eegbci as eegbci_module + +dataset_page = Path("docs/api/datasets/pyhealth.datasets.EEGBCIDataset.rst") +task_page = Path("docs/api/tasks/pyhealth.tasks.eegbci.rst") +assert dataset_page.exists() +assert task_page.exists() +assert "datasets/pyhealth.datasets.EEGBCIDataset" in Path("docs/api/datasets.rst").read_text() +assert "tasks/pyhealth.tasks.eegbci" in Path("docs/api/tasks.rst").read_text() +assert EEGBCIDataset.__name__ == "EEGBCIDataset" +assert EEGMotorImageryEEGBCI.task_name == "EEGBCI_motor_imagery" +assert EEGBCIPatternDiscovery.task_name == "EEGBCI_pattern_discovery" +assert hasattr(eegbci_module, "compute_band_powers") +print("docs/api smoke checks ok") +PY +``` + +## Results + +Status after execution: all required checks passed on 2026-07-07. + +| Area | Command | Result | Notes | +| --- | --- | --- | --- | +| Offline unit tests | `PYHEALTH_RUN_REAL_EEGBCI=0 .venv/bin/python -m pytest tests/core/test_eegbci.py -v` | Passed | Final run: 24 passed, 1 skipped in 9.36s. Real-data smoke skipped by default. | +| Task/schema tests | Targeted `TestEEGBCITasks` command | Passed | 6 passed in 8.58s, including STFT sample-rate forwarding. | +| Synthetic bandpower tests | Targeted `TestEEGBCIHelpers` command | Passed | 3 passed in 5.45s. 10 Hz alpha and 20 Hz beta checks passed. | +| Dataset metadata tests | `.venv/bin/python -m pytest tests/core/test_eegbci.py::TestEEGBCIDataset -v` | Passed | 6 passed in 8.60s, including stale selection rebuild and offline `set_task()` integration. | +| Import/export smoke tests | Inline Python smoke script | Passed | Printed `imports and oracle smoke checks ok`. | +| Example-output validation | Example command plus CSV/Markdown validator | Passed | Downloaded/reused subject 1 run 3, wrote CSV and Markdown, validated 20 rows. | +| Opt-in real EEGBCI smoke test | `PYHEALTH_RUN_REAL_EEGBCI=1 ... TestEEGBCIRealDataSmoke` | Passed | 1 passed in 33.61s against subject 1 run 3. | +| Docs/API smoke checks | Inline Python docs/API smoke script | Passed | Printed `docs/api smoke checks ok`. | +| Syntax smoke | `.venv/bin/python -m py_compile ...` | Passed | Dataset, task, example, and test modules compile. | + +## Failures and Fixes Needed + +Independent code review found one critical issue and several important issues +before final status was marked complete. They were fixed and covered by +regression tests: + +- Fixed stale metadata reuse when the same EEGBCI root is instantiated with a + different subject/run selection. Existing `eegbci-pyhealth.csv` is reused only + when it exactly matches the requested pairs. +- Fixed PyHealth cache identity by including the subject/run selection in the + default dataset name passed to `BaseDataset`. +- Added raw `eegbci_label` to task samples and changed the example CSV so + `label`/`eegbci_label` preserve EEGBCI semantics while `model_label` records + the processed PyHealth label. +- Fixed STFT generation to pass the actual `sample_rate` into + `get_stft_torch()`. +- Changed annotation onset conversion to use MNE `raw.time_as_index(..., + use_rounding=True)`. +- Added offline `BaseDataset.set_task()` integration coverage. +- Expanded the example README with output schema, label caveat, root/download, + and cache behavior. + +No remaining failing checks. + +## Correctness Oracle Status + +Satisfied: + +- Run-aware decoding: import/export smoke asserts + `task_label_for_event(3, "T1") == "execute_left_fist"` and + `task_label_for_event(4, "T1") == "imagine_left_fist"`. +- Synthetic 10 Hz alpha: synthetic bandpower and import/export smoke checks pass. +- Metadata rows and columns: dataset metadata tests pass, including changed + subject/run selection rebuild. +- `EEGMotorImageryEEGBCI(compute_stft=False)` schema and sample fields: task + tests and offline integration pass. +- `EEGBCIPatternDiscovery(compute_stft=False)` supervised fields plus + bandpower/hypothesis/confidence/flags/interpretation: task tests pass. +- Default offline test command: passes with the real-data smoke skipped. +- Opt-in real EEGBCI smoke: passes against subject 1, run 3. +- Example artifacts: CSV and Markdown are written and validated with 20 rows, + including task label, dominant band, hypothesis, confidence, and non-clinical + interpretation text. + +## Final Status + +Complete. The Correctness Oracle is satisfied, the independent code-review +findings were addressed, and every command in this plan passed. diff --git a/examples/eeg/eegbci/README.md b/examples/eeg/eegbci/README.md index 1ef9f84e2..fadb008c6 100644 --- a/examples/eeg/eegbci/README.md +++ b/examples/eeg/eegbci/README.md @@ -26,8 +26,7 @@ The CSV has one row per emitted 2-second window. Key columns include subject/run metadata, `event_code`, decoded `task_label`, raw EEGBCI numeric label (`eegbci_label` / `label`), PyHealth model-local label (`model_label`), absolute window timing, band powers, relative band powers, `dominant_band`, -frequency ratios, legacy `brain_state_hypothesis`, `confidence`, -`quality_flags`, and `interpretation`. +frequency ratios, and `interpretation`. The moment-report columns add analysis-grade fields: @@ -38,6 +37,10 @@ The moment-report columns add analysis-grade fields: - `task_state_relation`, `task_state_rationale`, and `task_state_confidence` - `is_low_confidence`, `is_possible_artifact`, and `is_mixed_or_ambiguous` +The `interpretation` column is report-level text derived from these moment-report +fields. Legacy task-level fields such as `brain_state_hypothesis`, `confidence`, +and `quality_flags` are intentionally not written to the CSV. + The Markdown report summarizes state counts, task-label/state agreement, rest-normalized bandpower deltas, confidence and quality flags, representative windows, limitations, and next checks. These labels are signal-pattern diff --git a/examples/eeg/eegbci/eegbci_pattern_discovery.py b/examples/eeg/eegbci/eegbci_pattern_discovery.py index 95994ea35..522a9592e 100644 --- a/examples/eeg/eegbci/eegbci_pattern_discovery.py +++ b/examples/eeg/eegbci/eegbci_pattern_discovery.py @@ -59,10 +59,6 @@ def sample_to_row(sample: dict) -> dict: "dominant_band": bandpower["dominant_band"], "alpha_beta_ratio": bandpower["alpha_beta_ratio"], "theta_beta_ratio": bandpower["theta_beta_ratio"], - "brain_state_hypothesis": sample["brain_state_hypothesis"], - "confidence": sample["confidence"], - "quality_flags": sample["quality_flags"], - "interpretation": sample["interpretation"], **{key: value for key, value in bandpower.items() if key.endswith("_power")}, **{key: value for key, value in bandpower.items() if key.endswith("_relative")}, } @@ -248,6 +244,22 @@ def derive_quality_columns(row: dict) -> dict: } +def derive_moment_interpretation(row: dict) -> str: + state = row.get("state_hypothesis", "missing") + confidence = row.get("state_confidence", "missing") + evidence = row.get("evidence_score", "") + relation = row.get("task_state_relation", "missing") + task = row.get("task_label", "missing") + dominant = row.get("dominant_band", "missing") + scope = row.get("rest_reference_scope", "missing") + return ( + f"The segment is consistent with `{state}` based on a `{dominant}`-dominant " + f"frequency profile ({confidence} confidence, evidence {evidence}). " + f"The task label is `{task}`, the task/state relation is `{relation}`, " + f"and the rest reference is `{scope}`." + ) + + BASE_OUTPUT_COLUMNS = ( "patient_id", "record_id", @@ -266,9 +278,6 @@ def derive_quality_columns(row: dict) -> dict: "dominant_band", "alpha_beta_ratio", "theta_beta_ratio", - "brain_state_hypothesis", - "confidence", - "quality_flags", "interpretation", "delta_power", "theta_power", @@ -325,6 +334,7 @@ def annotate_moment_rows(rows: list[dict], baselines: dict) -> list[dict]: next_row.update(derive_state_hypothesis(next_row)) next_row.update(derive_task_state_relation(next_row)) + next_row["interpretation"] = derive_moment_interpretation(next_row) next_row.update(derive_quality_columns(next_row)) annotated.append(next_row) return annotated diff --git a/pyhealth/datasets/eegbci.py b/pyhealth/datasets/eegbci.py index 6a69b2aa8..92ef113af 100644 --- a/pyhealth/datasets/eegbci.py +++ b/pyhealth/datasets/eegbci.py @@ -1,5 +1,7 @@ from __future__ import annotations +import hashlib +import json import logging from pathlib import Path from typing import Optional @@ -12,6 +14,16 @@ logger = logging.getLogger(__name__) +EEGBCI_METADATA_COLUMNS = { + "patient_id", + "record_id", + "subject_id", + "run", + "run_type", + "signal_file", + "source", +} + class EEGBCIDataset(BaseDataset): """PhysioNet EEG Motor Movement/Imagery metadata dataset.""" @@ -29,28 +41,59 @@ def __init__( if config_path is None: config_path = Path(__file__).parent / "configs" / "eegbci.yaml" self.root = root - self.subjects = subjects or [1, 2, 3] - self.runs = runs or list(range(3, 15)) + self.subjects = list(subjects) if subjects is not None else [1, 2, 3] + self.runs = list(runs) if runs is not None else list(range(3, 15)) self.download = download + self.selection_key = self._build_selection_key() self.prepare_metadata() + dataset_name = dataset_name or "eegbci" super().__init__( root=root, tables=["records"], - dataset_name=dataset_name or "eegbci", + dataset_name=f"{dataset_name}_{self.selection_key}", config_path=config_path, **kwargs, ) + def _build_selection_key(self) -> str: + payload = { + "subjects": [int(subject) for subject in self.subjects], + "runs": [int(run) for run in self.runs], + } + digest = hashlib.sha1( + json.dumps(payload, sort_keys=True).encode("utf-8") + ).hexdigest()[:10] + subject_part = "-".join(f"{int(subject):03d}" for subject in self.subjects) + run_part = "-".join(f"{int(run):02d}" for run in self.runs) + return f"s{subject_part}_r{run_part}_{digest}" + def _find_local_edf(self, subject: int, run: int) -> Path | None: root = Path(self.root) pattern = f"S{subject:03d}R{run:02d}.edf" matches = sorted(root.rglob(pattern)) return matches[0] if matches else None + def _requested_pairs(self) -> list[tuple[int, int]]: + return sorted( + (int(subject), int(run)) + for subject in self.subjects + for run in self.runs + ) + + def _metadata_matches_request(self, csv_path: Path) -> bool: + try: + df = pd.read_csv(csv_path) + except Exception: + return False + if not EEGBCI_METADATA_COLUMNS.issubset(df.columns): + return False + pairs = sorted((int(row.subject_id), int(row.run)) for row in df.itertuples()) + return pairs == self._requested_pairs() + def prepare_metadata(self) -> None: root = Path(self.root) csv_path = root / "eegbci-pyhealth.csv" - if csv_path.exists(): + if csv_path.exists() and self._metadata_matches_request(csv_path): return rows: list[dict] = [] diff --git a/pyhealth/tasks/eegbci.py b/pyhealth/tasks/eegbci.py index f24ce13c7..b834e0ce7 100644 --- a/pyhealth/tasks/eegbci.py +++ b/pyhealth/tasks/eegbci.py @@ -228,8 +228,7 @@ def interpret_band_profile(features: Dict[str, float | str]) -> Dict[str, str]: "quality_flags": ";".join(quality_flags) if quality_flags else "none", "interpretation": ( f"The segment is consistent with {hypothesis} based on a " - f"{dominant}-dominant frequency profile. This is exploratory signal " - "metadata, not evidence of cognition or a clinical diagnosis." + f"{dominant}-dominant frequency profile." ), } @@ -246,7 +245,9 @@ def iter_annotation_windows( event_code = str(annotation["description"]) if event_code not in {"T0", "T1", "T2"}: continue - start_sample = int(round(float(annotation["onset"]) * sfreq)) + start_sample = int( + raw.time_as_index([float(annotation["onset"])], use_rounding=True)[0] + ) duration_samples = int(round(float(annotation["duration"]) * sfreq)) n_full_windows = duration_samples // window_samples for window_idx in range(n_full_windows): @@ -338,6 +339,7 @@ def _base_samples_from_patient(self, patient: Any) -> List[Dict[str, Any]]: "task_label": window["task_label"], "label_family": window["label_family"], "label": int(window["label"]), + "eegbci_label": int(window["label"]), "signal": signal, "channel_names": selected_names, "start_time": window["start_time"], @@ -347,7 +349,9 @@ def _base_samples_from_patient(self, patient: Any) -> List[Dict[str, Any]]: if self.compute_stft: from pyhealth.models.tfm_tokenizer import get_stft_torch - sample["stft"] = get_stft_torch(signal.unsqueeze(0)).squeeze(0) + sample["stft"] = get_stft_torch( + signal.unsqueeze(0), resampling_rate=int(round(sfreq)) + ).squeeze(0) samples.append(sample) raw.close() return samples diff --git a/tests/core/test_eegbci.py b/tests/core/test_eegbci.py index 23dfa5732..549f4d0af 100644 --- a/tests/core/test_eegbci.py +++ b/tests/core/test_eegbci.py @@ -125,6 +125,10 @@ def test_interpret_band_profile_returns_cautious_metadata(self): self.assertEqual(interpretation["brain_state_hypothesis"], "relaxed_or_idle") self.assertIn(interpretation["confidence"], {"low", "medium", "high"}) self.assertIn("consistent with", interpretation["interpretation"]) + self.assertNotIn( + "This is exploratory signal metadata", interpretation["interpretation"] + ) + self.assertNotIn("clinical diagnosis", interpretation["interpretation"]) from pyhealth.datasets.eegbci import EEGBCIDataset @@ -428,10 +432,6 @@ def _moment_row(self, **overrides): "gamma_relative": 0.10, "alpha_beta_ratio": 2.75, "theta_beta_ratio": 0.50, - "brain_state_hypothesis": "relaxed_or_idle", - "confidence": "medium", - "quality_flags": "", - "interpretation": "Alpha-dominant profile.", } row.update(overrides) return row @@ -818,25 +818,19 @@ def test_rest_delta_values_are_band_specific(self): self.assertAlmostEqual(annotated[1]["rest_beta_relative_delta"], 0.10) self.assertAlmostEqual(annotated[1]["rest_gamma_relative_delta"], -0.08) - def test_annotate_moment_rows_preserves_legacy_fields(self): + def test_annotate_moment_rows_adds_report_interpretation(self): from examples.eeg.eegbci.eegbci_pattern_discovery import ( annotate_moment_rows, build_rest_baselines, ) - legacy = self._moment_row( - brain_state_hypothesis="legacy_state", - confidence="medium", - quality_flags="legacy_flag", - interpretation="Legacy interpretation.", - ) + row = self._moment_row() - annotated = annotate_moment_rows([legacy], build_rest_baselines([legacy])) + annotated = annotate_moment_rows([row], build_rest_baselines([row])) - self.assertEqual(annotated[0]["brain_state_hypothesis"], "legacy_state") - self.assertEqual(annotated[0]["confidence"], "medium") - self.assertEqual(annotated[0]["quality_flags"], "legacy_flag") - self.assertEqual(annotated[0]["interpretation"], "Legacy interpretation.") + self.assertIn("consistent with", annotated[0]["interpretation"]) + self.assertIn("task label", annotated[0]["interpretation"]) + self.assertIn(annotated[0]["state_hypothesis"], annotated[0]["interpretation"]) def test_annotate_moment_rows_does_not_mutate_input_rows(self): from examples.eeg.eegbci.eegbci_pattern_discovery import ( @@ -1233,6 +1227,7 @@ def test_moment_report_columns_are_declared(self): "task_state_relation", "task_state_rationale", "task_state_confidence", + "interpretation", "is_low_confidence", "is_possible_artifact", "is_mixed_or_ambiguous", @@ -1240,6 +1235,20 @@ def test_moment_report_columns_are_declared(self): self.assertIn(column, OUTPUT_COLUMNS) self.assertIn("analysis_version", MOMENT_REPORT_COLUMNS) + def test_output_columns_remove_legacy_task_fields(self): + from examples.eeg.eegbci.eegbci_pattern_discovery import OUTPUT_COLUMNS + + for legacy_column in [ + "brain_state_hypothesis", + "confidence", + "quality_flags", + "legacy_brain_state_hypothesis", + "legacy_confidence", + "legacy_quality_flags", + "legacy_interpretation", + ]: + self.assertNotIn(legacy_column, OUTPUT_COLUMNS) + def test_empty_dataframe_uses_output_columns(self): from examples.eeg.eegbci.eegbci_pattern_discovery import OUTPUT_COLUMNS From cbe113e7eac17d67046ad78024feb3be83bd55d3 Mon Sep 17 00:00:00 2001 From: Vihaan Agrawal <247244351+vihaan101@users.noreply.github.com> Date: Wed, 8 Jul 2026 19:36:59 +0530 Subject: [PATCH 20/21] Exclude EEG pattern discovery notes --- docs/eeg_pattern_discovery/brainstorm.md | 453 ----- docs/eeg_pattern_discovery/design.md | 280 --- .../eeg_pipeline_guide.md | 185 -- .../implementation_plan.md | 1724 ----------------- .../moment_report_continuation_plan.md | 481 ----- .../moment_report_implementation_plan.md | 1702 ---------------- .../moment_report_refined_design.md | 323 --- .../moment_report_review.md | 161 -- .../pattern_analysis_redesign.md | 420 ---- .../test_verification_plan.md | 280 --- 10 files changed, 6009 deletions(-) delete mode 100644 docs/eeg_pattern_discovery/brainstorm.md delete mode 100644 docs/eeg_pattern_discovery/design.md delete mode 100644 docs/eeg_pattern_discovery/eeg_pipeline_guide.md delete mode 100644 docs/eeg_pattern_discovery/implementation_plan.md delete mode 100644 docs/eeg_pattern_discovery/moment_report_continuation_plan.md delete mode 100644 docs/eeg_pattern_discovery/moment_report_implementation_plan.md delete mode 100644 docs/eeg_pattern_discovery/moment_report_refined_design.md delete mode 100644 docs/eeg_pattern_discovery/moment_report_review.md delete mode 100644 docs/eeg_pattern_discovery/pattern_analysis_redesign.md delete mode 100644 docs/eeg_pattern_discovery/test_verification_plan.md diff --git a/docs/eeg_pattern_discovery/brainstorm.md b/docs/eeg_pattern_discovery/brainstorm.md deleted file mode 100644 index 56042a288..000000000 --- a/docs/eeg_pattern_discovery/brainstorm.md +++ /dev/null @@ -1,453 +0,0 @@ -# EEG Pattern Discovery Migration Brainstorm - -Date: 2026-07-07 -Status: living brainstorm and design scratchpad -Mode: Builder / research - -## Source Context - -The original CELM artifact lives at `/Users/vihaanagrawal/Research/CELM/eeg_pattern_discovery`. - -The prior design note is `/Users/vihaanagrawal/.gstack/projects/office-hours-users-vihaanagrawal-gstack-repos/vihaanagrawal-unknown-design-20260629-143914.md`. - -The target project is `/Users/vihaanagrawal/Research/PyHealth`. - -Useful external references: - -- PhysioNet EEG Motor Movement/Imagery Dataset v1.0.0: https://physionet.org/content/eegmmidb/ -- MNE EEGBCI loader: https://mne.tools/stable/generated/mne.datasets.eegbci.load_data.html - -## Original CELM Idea - -The CELM project asks whether frequency-based EEG patterns reveal moment-level brain-state hypotheses that clinical or experimental labels do not fully capture. It uses MNE's PhysioNet EEG Motor Movement/Imagery loader, processes subjects `1`, `2`, and `3`, runs `3-14`, creates 2-second labeled windows, computes Welch band powers, and assigns cautious deterministic interpretations. - -The core output is a row-per-window table with: - -- subject, run, event code, task label, and label family -- delta, theta, alpha, beta, and gamma absolute power -- relative band powers -- dominant band -- alpha/beta and theta/beta ratios -- moment-level hypothesis -- confidence and quality flags -- plain-English interpretation - -The interpretation layer is explicitly exploratory. It must say that a signal pattern is consistent with a state, not that it proves the subject's cognition or a clinical diagnosis. - -CELM code shape: - -- `run_analysis.py`: orchestrates subject/run processing, writes CSV and Markdown summary. -- `src/data.py`: downloads PhysioNet EEGBCI with MNE, reads EDF, standardizes channels, filters, and yields labeled 2-second windows. -- `src/labels.py`: run-aware mapping for `T0`, `T1`, and `T2`. -- `src/features.py`: Welch PSD bandpower extraction. -- `src/interpretation.py`: deterministic frequency-profile hypothesis engine. -- `src/report.py`: summary grouped by task label and inferred hypothesis. -- `tests/test_labels.py` and `tests/test_interpretation.py`: fast unit tests for pure logic. - -Important correction to the phrase "same pretrained models": the CELM artifact does not appear to use pretrained EEG models. It uses a real pretrained ecosystem only in the loose sense that MNE downloads and parses an established public dataset. The PyHealth migration can expand the idea by adding pretrained PyHealth EEG embeddings from BIOT, ContraWR, SparcNet, and TFMTokenizer on top of the CELM bandpower baseline. - -## Real Dataset Answer - -Yes, there is a clean way to stop relying only on mocked filesystem and signal-processing tests. - -Use a two-tier test strategy: - -1. Fast unit tests stay synthetic and mocked. They should cover label decoding, event-window slicing, bandpower output shape, interpretation rules, metadata CSV generation, and schema contracts. These run in normal CI. -2. Real-data smoke tests run behind an explicit opt-in flag, for example `PYHEALTH_RUN_REAL_EEGBCI=1`. They download a tiny EEGBCI subset with MNE, for example subject `1`, run `3`, create real windows, compute real bandpowers, and verify at least one sample has a real signal tensor and interpretation metadata. - -Why this split works: - -- Normal CI should not hit PhysioNet or download EDF files. -- The actual example should use real EEGBCI data end-to-end. -- The optional smoke test catches the exact class of bugs mocks miss: MNE annotation names, channel names, EDF loading, sampling frequency, and label boundaries. - -Existing PyHealth EEG datasets include `TUABDataset`, `TUEVDataset`, `SleepEDFDataset`, `SHHSDataset`, and `ISRUCDataset`. Those are real EEG datasets, but they do not match the CELM project as closely as EEGBCI. TUAB/TUEV are better for clinical abnormal/event detection; SleepEDF is better for sleep staging; EEGBCI is the clean fit for motor execution/imagery and the CELM label semantics. - -## PyHealth Fit - -PyHealth already has EEG datasets, tasks, examples, and models. The migration should use PyHealth's current `BaseDataset -> BaseTask -> SampleDataset -> Trainer` flow, not a standalone script folder. - -Relevant PyHealth conventions: - -- dataset classes live in `pyhealth/datasets/` -- dataset metadata is represented as `*-pyhealth.csv` files -- dataset config YAML files live in `pyhealth/datasets/configs/` -- tasks live in `pyhealth/tasks/` -- examples live under `examples/eeg/` or `examples/conformal_eeg/` -- unit tests live under `tests/core/` -- exports are added in `pyhealth/datasets/__init__.py` and `pyhealth/tasks/__init__.py` - -Existing EEG patterns to follow: - -- `pyhealth/datasets/tuab.py` -- `pyhealth/datasets/tuev.py` -- `pyhealth/datasets/sleepedf.py` -- `pyhealth/tasks/temple_university_EEG_tasks.py` -- `examples/eeg/eeg_models/` -- `examples/conformal_eeg/` - -Important local gotchas: - -- `TUEVDataset` and `TUABDataset` build `*-pyhealth.csv` metadata files from EDF paths, then `BaseDataset` reads those tables. -- `EEGEventsTUEV` and `EEGAbnormalTUAB` read EDF inside the task. That is the pattern to copy for EEGBCI. -- Current EEG tests mock raw EDF reading and signal conversion heavily. That is acceptable for CI, but not enough for this feature's confidence. -- TUEV/TUAB tasks normalize to 16 channels for model compatibility. EEGBCI starts as 64 channels, so channel adaptation is a first-class design decision, not a small detail. - -## Likely Migration Shape - -Add a first-class EEGBCI dataset and tasks: - -- `pyhealth/datasets/eegbci.py` -- `pyhealth/datasets/configs/eegbci.yaml` -- `pyhealth/tasks/eegbci.py` -- `pyhealth/tasks/eeg_pattern_discovery.py` or helper module under `pyhealth/tasks/eegbci.py` -- `tests/core/test_eegbci.py` -- `tests/core/test_eeg_pattern_discovery.py` -- `examples/eeg/eegbci/` - -The dataset should represent each downloaded subject/run EDF as metadata: - -- `patient_id` -- `record_id` -- `subject_id` -- `run` -- `run_type` -- `signal_file` -- `source` -- `sfreq` if cheaply known, otherwise computed by the task - -The task should parse annotations and emit window-level samples: - -- `patient_id` -- `record_id` -- `signal_file` -- `run` -- `run_type` -- `trial_id` -- `event_code` -- `label` -- `task_label` -- `label_family` -- `signal` -- `start_time` -- `end_time` -- optional `stft` -- optional `bandpower` -- optional frequency interpretation metadata - -### Proposed Dataset Contract - -`EEGBCIDataset` should be the dataset wrapper for the PhysioNet EEG Motor Movement/Imagery Dataset. - -Recommended constructor: - -```python -dataset = EEGBCIDataset( - root="~/.cache/pyhealth/eegbci", - subjects=[1, 2, 3], - runs=list(range(3, 15)), - download=True, -) -``` - -Recommended metadata rows: - -| Column | Meaning | -| --- | --- | -| `patient_id` | stable subject key, for example `S001` | -| `record_id` | run key, for example `R03` | -| `subject_id` | integer EEGBCI subject id | -| `run` | integer EEGBCI run id | -| `run_type` | baseline, motor execution left/right, motor imagery left/right, motor execution fists/feet, motor imagery fists/feet | -| `signal_file` | local EDF path downloaded by MNE | -| `source` | `physionet_eegbci` | - -`download=False` should require files to already exist and should fail clearly if metadata cannot be built. `download=True` can call `mne.datasets.eegbci.load_data(...)`. - -### Proposed Task Contracts - -1. `EEGMotorImageryEEGBCI` - - Purpose: supervised classification task using real EEGBCI event labels. - - Input: raw `signal` tensor, optional `stft`. - - Output: `label` as multiclass task label. - - Windowing: fixed windows inside each annotation, default 2 seconds for parity with CELM. - -2. `EEGPatternDiscoveryEEGBCI` - - Purpose: exploratory moment-level pattern discovery. - - Input: raw `signal` tensor and computed `bandpower` tensor/dict. - - Output: `brain_state_hypothesis` as metadata or optional multiclass label. - - Extra fields: `dominant_band`, `alpha_beta_ratio`, `theta_beta_ratio`, `confidence`, `quality_flags`, `interpretation`. - -Keep these separate. Supervised task-label prediction and exploratory frequency interpretation are different jobs. Mixing them into one task will make the API confusing. - -## Model Reuse - -Existing EEG-capable models: - -- `BIOT`: consumes raw `signal`, has `get_embeddings()` and `load_pretrained_weights()`. -- `ContraWR`: consumes one signal tensor, computes STFT internally, supports `embed=True`. -- `SparcNet`: consumes one signal tensor, supports `embed=True`. -- `TFMTokenizer`: uses `signal` and `stft`, supports pretrained/token workflows. - -Important model mismatch: - -EEGBCI is commonly 64-channel EEG, while several PyHealth EEG examples and pretrained checkpoints expect 16 or 18 channels. The design must choose a channel adaptation strategy: - -- select a stable 16-channel 10-20 subset -- regional average/pool EEGBCI channels into a 16-channel montage -- add a small channel adapter model -- run 64-channel-compatible models only in the first version - -The conservative PyHealth-aligned first version is likely 16-channel selection, because it keeps existing pretrained EEG models usable. - -Recommended first channel strategy: - -1. Preserve the full 64-channel signal in metadata or a configurable task mode. -2. Default the model-facing task output to a stable 16-channel 10-20 subset or montage compatible with existing PyHealth EEG models. -3. Document the selected channels and make the adapter function pure and testable. - -Do not hide this inside model code. Channel mapping belongs near EEGBCI task preprocessing so every model sees the same input contract. - -## Bigger Ideas - -### 1. EEGBCI Moment Discovery Benchmark - -Create a PyHealth benchmark where the same EEGBCI windows can be used for supervised task-label prediction and CELM-style frequency-pattern discovery. Compare bandpower-only features, BIOT embeddings, ContraWR embeddings, SparcNet embeddings, and TFM token features. - -### 2. Pretrained Embedding Atlas - -Use pretrained EEG model embeddings to cluster windows into learned neural motifs. Summarize clusters by subject, run family, task label, CELM hypothesis, and quality flags. This turns the project from rules-only interpretation into representation discovery. - -### 3. TFM Token Report Cards - -Use TFMTokenizer to extract discrete token patterns from EEG windows. Report which token motifs are enriched in rest, execution, imagery, artifact-like gamma, and slow-wave-heavy windows. - -### 4. EEG Model Cards - -For each evaluated window or cluster, generate a compact model card with prediction, confidence, band profile, model embedding neighborhood, salient channels/time regions where available, and caution flags. - -### 5. Subject-Shift Reliability Suite - -Use PyHealth's calibration/conformal examples to measure how well models and hypotheses transfer across subjects and run families. Report uncertain windows, artifact-heavy subjects, and subject-specific drift. - -### 6. Brain-State Atlas Explorer - -Generate a static Markdown/CSV/HTML artifact that treats each cluster as a "neural motif." Each motif gets: - -- cluster size -- dominant CELM hypothesis -- top task labels -- subject/run enrichment -- representative windows -- model confidence spread -- quality flags - -This is the most demoable bigger idea. It turns "we computed band powers" into "we discovered recurring motifs and can inspect where they appear." - -### 7. Label Disagreement Mining - -Rank windows where the experimental task label and model/frequency evidence disagree: - -- task says rest but embedding neighbors look like motor execution -- task says imagery but bandpower looks artifact-heavy -- model predicts left/right confidently while bandpower hypothesis is low confidence -- cluster is subject-specific rather than task-specific - -This is useful because the real research question is not just classification accuracy. It is whether moment-level signal structure contains information labels miss. - -### 8. Real-Data Regression Fixture - -Add a tiny recorded fixture generated from one real EEGBCI run, not raw private data: - -- a small `.npz` containing one or two already-extracted 2-second windows -- expected label metadata -- expected bandpower keys and rough numerical ranges - -This gives CI a real-signal-ish path without downloading PhysioNet. The full real-data smoke test remains opt-in. - -## Open Design Decisions - -1. Minimum viable contribution versus larger research module. -2. Channel adaptation strategy for EEGBCI to pretrained EEG models. -3. Whether bandpower features are part of `input_schema` or extra metadata. -4. Whether the default task predicts task labels, moment-state hypotheses, or both. -5. How much generated analysis output should be included in the repo versus created by examples. -6. Whether `EEGPatternDiscoveryEEGBCI` should be a PyHealth task, a task helper, or an example-only analysis layer. -7. Whether MNE should become a required dependency for this dataset or stay behind an EEG extra. - -## Approaches Considered - -### Approach A: Minimal Example-Only Port - -Summary: Add `examples/eeg/eegbci/eeg_pattern_discovery.py` that imports CELM logic adapted to PyHealth imports, downloads EEGBCI through MNE, and writes CSV/Markdown outputs. - -Effort: S -Risk: Low - -Pros: - -- Fastest way to get real EEGBCI data flowing. -- Small surface area. -- Good for proving that the CELM idea still works inside the PyHealth repo. - -Cons: - -- Not really "in PyHealth format." -- Harder for users to reuse in PyHealth training/calibration workflows. -- Tests still mostly sit around the example, not the library. - -### Approach B: First-Class EEGBCI Dataset + Pattern Task - -Summary: Add `EEGBCIDataset`, `EEGMotorImageryEEGBCI`, and `EEGPatternDiscoveryEEGBCI`, then add examples that use both the supervised labels and the exploratory frequency hypotheses. - -Effort: M -Risk: Medium - -Pros: - -- Matches PyHealth architecture. -- Lets EEGBCI become reusable for future EEG work. -- Cleanly supports real-data examples and optional smoke tests. - -Cons: - -- Requires careful metadata generation and MNE dependency handling. -- Needs a clear channel adapter story. -- Slightly bigger API commitment. - -### Approach C: EEG Representation Atlas - -Summary: Build Approach B, then add an atlas example that extracts embeddings from BIOT, ContraWR, SparcNet, or TFMTokenizer, clusters windows, and reports neural motifs alongside CELM bandpower hypotheses. - -Effort: L -Risk: Medium-High - -Pros: - -- The most creative version. -- Uses PyHealth's pretrained EEG model story. -- Produces a research artifact that is more interesting than a CSV. - -Cons: - -- Pretrained checkpoint availability and channel mismatch can slow this down. -- Clustering can become arbitrary if success criteria are vague. -- Needs careful caveats so it does not overclaim cognition. - -## Current Recommendation - -Use a two-layer design: - -1. Core PyHealth integration: `EEGBCIDataset`, `EEGMotorImagery`, and `EEGBCIPatternDiscovery` with CELM bandpower and interpretation helpers. -2. Small creative layer: one optional example that compares CELM bandpower hypotheses with embeddings from one compatible PyHealth EEG model. - -This keeps the core contribution maintainable while expanding the idea only a little beyond the original deterministic CSV/report artifact. - -More concrete recommendation: choose Approach B as the implementation baseline. Do not build the full Approach C atlas unless the core migration feels too small after it works. - -## Implementation Plan Sketch - -### Task 1: Extract Pure EEGBCI Utilities - -Create run-aware label mapping, event-window slicing, channel selection, and bandpower functions as testable pure helpers. These should be adapted from CELM, not copied blindly. - -Likely files: - -- `pyhealth/tasks/eegbci.py` -- `tests/core/test_eegbci.py` - -Verification: - -- unit tests for all EEGBCI run/event mappings -- unit tests that windows do not cross annotation boundaries -- unit tests for bandpower keys and quality flags - -### Task 2: Add `EEGBCIDataset` - -Implement dataset metadata generation around MNE's EEGBCI downloader. - -Likely files: - -- `pyhealth/datasets/eegbci.py` -- `pyhealth/datasets/configs/eegbci.yaml` -- `pyhealth/datasets/__init__.py` -- `docs/api/datasets.rst` -- `docs/api/datasets/pyhealth.datasets.EEGBCIDataset.rst` - -Verification: - -- synthetic metadata tests with mocked `mne.datasets.eegbci.load_data` -- no network calls in default CI - -### Task 3: Add Supervised and Discovery Tasks - -Add a supervised EEGBCI motor task and an exploratory pattern discovery task. - -Likely files: - -- `pyhealth/tasks/eegbci.py` -- `pyhealth/tasks/__init__.py` -- `docs/api/tasks/pyhealth.tasks.eegbci.rst` - -Verification: - -- mocked EDF tests for task sample schema -- pure helper tests for interpretation logic -- optional real-data smoke test behind `PYHEALTH_RUN_REAL_EEGBCI=1` - -### Task 4: Add Real Examples - -Create examples that a user can run locally. - -Likely files: - -- `examples/eeg/eegbci/eegbci_pattern_discovery.py` -- optional: `examples/eeg/eegbci/eegbci_embedding_comparison.py` -- `examples/eeg/eegbci/README.md` - -Verification: - -- example supports `--subjects 1 --runs 3 --max-windows 20` -- output CSV and Markdown summary are generated outside the source tree by default - -### Task 5: Add Optional Tiny Pretrained Embedding Comparison - -Add one small embedding comparison where channel shape permits. This should not become a multi-model benchmark, dashboard, or large clustering framework. - -Likely files: - -- `examples/eeg/eegbci/eegbci_embedding_comparison.py` - -Verification: - -- embedding extraction works with a toy model or one compatible PyHealth EEG model -- real pretrained checkpoint paths are CLI arguments, not hardcoded local paths - -## Success Criteria - -- PyHealth exposes `EEGBCIDataset`. -- PyHealth exposes at least one EEGBCI task that returns real 2-second windows from EDF annotations. -- The CELM label-mapping mistake risk is handled: `T1`/`T2` are decoded using run number. -- Bandpower and interpretation metadata can be produced for every window. -- Unit tests do not require network access. -- An opt-in smoke test runs against real MNE-downloaded EEGBCI data. -- A runnable example produces a CSV and Markdown summary similar to CELM. -- The optional expanded example can compare CELM bandpower hypotheses with pretrained or learned embeddings without becoming a full research platform. - -## Premises To Validate Before Coding - -1. EEGBCI should be added as a first-class PyHealth dataset, not just an example script. -2. The default EEGBCI task should emit 2-second windows because preserving the CELM question matters more than matching TUAB/TUEV window lengths. -3. Channel adaptation should be explicit and testable, with a conservative 16-channel default for pretrained PyHealth EEG models. -4. Real-data confidence should come from an opt-in smoke test plus runnable examples, not from making normal CI download PhysioNet data. -5. The discovery interpretation must remain cautious and non-clinical. - -## Next Question - -The main fork in the road is scope: - -- If the goal is a quick migration, implement Approach A first. -- If the goal is "get it into PyHealth format," implement Approach B first. -- If the goal is a small but memorable research demo, implement Approach B and one tiny embedding comparison. - -Recommendation: Approach B first. Add only the smallest embedding comparison if it clearly improves the story. diff --git a/docs/eeg_pattern_discovery/design.md b/docs/eeg_pattern_discovery/design.md deleted file mode 100644 index 7094a8314..000000000 --- a/docs/eeg_pattern_discovery/design.md +++ /dev/null @@ -1,280 +0,0 @@ -# Design: EEG Pattern Discovery in PyHealth - -Generated: 2026-07-07 -Branch: master -Repo: PyHealth -Status: APPROVED FOR IMPLEMENTATION -Mode: Builder / research - -## Problem Statement - -Migrate the CELM EEG pattern discovery idea into PyHealth's architecture. - -The original question is still the right one: - -> Can frequency-based EEG patterns reveal moment-level brain-state hypotheses that task or clinical labels do not fully capture? - -In CELM, this was a standalone research artifact. In PyHealth, it should become a reusable EEG dataset/task path, with examples that can train, embed, cluster, and summarize real EEG windows. - -High-level summary: We are turning the standalone CELM EEG pattern-discovery pipeline into a reusable PyHealth dataset, task, and example for real PhysioNet EEGBCI motor movement/imagery data. The research question is whether simple frequency profiles in short EEG windows can surface moment-level brain-state hypotheses that task labels alone miss. The practical problem is that PyHealth has EEG models and task infrastructure, but no first-class EEGBCI path that produces labeled windows, bandpower features, cautious interpretation metadata, and real-data validation without forcing normal CI to download raw EDF files. - -## Source Artifact - -The CELM artifact lives at `/Users/vihaanagrawal/Research/CELM/eeg_pattern_discovery`. - -It is a real EEG pipeline, not just mocked signal processing: - -- Dataset: PhysioNet EEG Motor Movement/Imagery through MNE EEGBCI. -- Subjects: `1`, `2`, `3`. -- Runs: `3-14`. -- Windowing: full 2-second windows inside MNE annotations. -- Signal processing: MNE EDF loading, standardization, EEG picking, `0.5-45 Hz` filtering, Welch PSD. -- Output: checked-in CSV with 2,160 processed segments. -- Models: no pretrained model or checkpoint. The original is classical signal processing plus deterministic interpretation rules. - -That last point matters. The PyHealth expansion should not pretend CELM already used pretrained models. It should add them as the bigger creative layer. - -## Why PyHealth Is A Good Fit - -PyHealth already has the right abstractions: - -- EEG datasets: `TUABDataset`, `TUEVDataset`, `SleepEDFDataset`, `SHHSDataset`, `ISRUCDataset`. -- EEG tasks: `EEGEventsTUEV`, `EEGAbnormalTUAB`, `SleepStagingSleepEDF`. -- EEG models: `BIOT`, `ContraWR`, `SparcNet`, `TFMTokenizer`. -- Calibration examples under `examples/conformal_eeg/`. - -The migration should follow the existing pattern: - -1. Dataset class builds metadata rows pointing at raw signal files. -2. Task class reads EDF, preprocesses signals, windows annotations, and returns PyHealth samples. -3. Examples run actual research workflows. -4. Tests mock network/EDF boundaries by default, with optional real-data smoke tests. - -## Premises - -1. EEGBCI should be a first-class PyHealth dataset, not only a script in `examples/`. -2. The default pattern-discovery window should stay 2 seconds to preserve the CELM research question. -3. `T1` and `T2` labels must be decoded using the run number. They do not mean the same thing in every EEGBCI run. -4. Brain-state hypotheses are heuristic metadata, not clinical labels. -5. Normal CI should not download PhysioNet data. Real-data coverage should be opt-in. -6. Channel adaptation is part of the API design. EEGBCI is 64-channel/160 Hz; several PyHealth EEG model examples assume 16 channels and often 200 Hz. - -## Recommended Approach - -Build the core PyHealth integration first, then add the creative atlas. - -### Layer 1: Core PyHealth Integration - -Add: - -- `pyhealth/datasets/eegbci.py` -- `pyhealth/datasets/configs/eegbci.yaml` -- `pyhealth/tasks/eegbci.py` -- exports in `pyhealth/datasets/__init__.py` and `pyhealth/tasks/__init__.py` -- tests under `tests/core/` -- documentation under `docs/api/` - -Core classes: - -- `EEGBCIDataset` -- `EEGMotorImageryEEGBCI` -- `EEGBCIPatternDiscovery` - -The dataset should produce metadata. The task should do EDF reading, annotation parsing, windowing, channel handling, optional STFT, bandpower extraction, and interpretation metadata. - -### Layer 2: Small Optional Research Layer - -Add examples under `examples/eeg/eegbci/`: - -- `eegbci_pattern_discovery.py`: reproduce the CELM CSV/Markdown artifact using PyHealth objects. -- optional `eegbci_embedding_comparison.py`: extract embeddings from one compatible EEG model and compare them with CELM bandpower hypotheses. - -Keep this small. The goal is not to create a large EEG research platform. The optional comparison should answer one question: - -> Do learned EEG embeddings group windows in a way that agrees with, sharpens, or contradicts the simple bandpower hypotheses? - -It can compare: - -- CELM bandpower hypotheses. -- Supervised task labels. -- One pretrained or learned model embedding. - -## Model Boundary - -The first EEGBCI pattern-discovery pipeline does not require a neural model. It uses real EEGBCI data, MNE preprocessing, Welch bandpower features, and deterministic interpretation rules. The supervised `EEGMotorImageryEEGBCI` task should make model training possible through normal PyHealth models, and the dataset/task contract should be compatible with BIOT, ContraWR, SparcNet, and TFMTokenizer where channel and sampling assumptions fit. Pretrained model embeddings are a second-stage research layer, not a dependency for the first implementation. - -## Analysis Stage - -The first analysis stage should be a CELM-equivalent sample-level and aggregate report. It should convert `EEGBCIPatternDiscovery` samples into a CSV with task labels, bandpower features, ratios, hypotheses, confidence, and quality flags, then write a Markdown summary grouped by task label and inferred hypothesis. This answers whether frequency-profile hypotheses line up with, sharpen, or disagree with the experimental labels. The later atlas stage can add model embeddings and clustering, but the first pass should prove the dataset/task/analysis path end to end with real EEGBCI data. - -## Dataset Contract - -Recommended constructor: - -```python -dataset = EEGBCIDataset( - root="~/.cache/pyhealth/eegbci", - subjects=[1, 2, 3], - runs=list(range(3, 15)), - download=True, -) -``` - -Recommended metadata columns: - -| Column | Meaning | -| --- | --- | -| `patient_id` | subject key, for example `S001` | -| `record_id` | run key, for example `R03` | -| `subject_id` | integer EEGBCI subject id | -| `run` | integer EEGBCI run id | -| `run_type` | baseline, motor execution, or motor imagery subtype | -| `signal_file` | local EDF path downloaded by MNE | -| `source` | `physionet_eegbci` | - -`download=True` may call `mne.datasets.eegbci.load_data`. `download=False` should require local files/metadata and fail clearly if they are missing. - -## Task Contracts - -### `EEGMotorImageryEEGBCI` - -Purpose: supervised task-label prediction. - -Sample shape: - -```python -{ - "patient_id": "S001", - "signal_file": ".../S001R03.edf", - "run": 3, - "trial_id": "S001_R03_0001", - "event_code": "T1", - "task_label": "execute_left_fist", - "label_family": "motor_execution", - "label": 1, - "signal": Tensor[C, T], - "stft": Tensor[C, F, TT], # if compute_stft=True -} -``` - -### `EEGBCIPatternDiscovery` - -Purpose: exploratory moment-level signal interpretation. - -Sample shape extends the supervised sample with: - -```python -{ - "bandpower": { - "delta_power": ..., - "theta_power": ..., - "alpha_power": ..., - "beta_power": ..., - "gamma_power": ..., - "delta_relative": ..., - "theta_relative": ..., - "alpha_relative": ..., - "beta_relative": ..., - "gamma_relative": ..., - "dominant_band": "alpha", - "alpha_beta_ratio": ..., - "theta_beta_ratio": ..., - }, - "brain_state_hypothesis": "relaxed_or_idle", - "confidence": "medium", - "quality_flags": "low_confidence", - "interpretation": "The segment is alpha-dominant...", -} -``` - -The discovery task should keep `task_label` and `label` available so PyHealth training, evaluation, and embedding extraction remain usable. - -## Channel And Sampling Strategy - -Default recommendation: - -- Preserve `original_sample_rate=160` as metadata. -- Resample to `200 Hz` by default for compatibility with existing PyHealth EEG models. -- Offer `resample_rate=None` to keep the original signal. -- Default to a stable 16-channel adapter for pretrained-model compatibility. -- Offer `channel_mode="all"` for 64-channel experiments with compatible models. - -Reasoning: - -- BIOT can be configured for different channel counts, but pretrained 18-channel weights are not directly compatible with raw 64-channel EEGBCI. -- ContraWR and SparcNet are structurally friendlier to raw tensors, but still need sane window length and channel expectations. -- TFMTokenizer has stronger assumptions: it expects `signal` and `stft`, has 200 Hz-ish temporal assumptions, and the classifier currently uses a 16-channel embedding table. - -Do not reuse TUAB/TUEV bipolar montage functions. They hard-code TUH channel names. - -## Real Dataset Testing - -Use three levels: - -1. Unit tests, always on: - - run-aware `T0`/`T1`/`T2` label mapping - - fixed-window segmentation - - bandpower feature keys and rough values on synthetic sinusoids - - interpretation rules - - dataset metadata generation with mocked MNE download - - task sample schema with mocked EDF/Raw object - -2. Fixture test, always on if fixture is accepted: - - a tiny `.npz` with one or two extracted windows from EEGBCI - - verifies real-shaped signal arrays without downloading data - -3. Real-data smoke test, opt-in: - - gated by `PYHEALTH_RUN_REAL_EEGBCI=1` - - downloads subject `1`, run `3` - - verifies at least one real window, signal tensor shape, decoded task label, and bandpower metadata - -This is the right answer to the current testing concern. Mocking is fine for normal CI, but there should be one real-data path for the thing mocks cannot prove. - -## Approaches Considered - -### Approach A: Example-Only Port - -Fastest. Add only an example script that wraps the CELM pipeline in PyHealth imports. - -This proves the idea quickly, but it is not really PyHealth-format. It is a demo living inside the repo. - -### Approach B: Dataset + Task + Example - -Add EEGBCI as a dataset, add supervised and discovery tasks, then add a runnable example. - -This is the recommended path. It fits PyHealth and creates reusable substrate for later EEG research. - -### Approach C: EEG Representation Atlas - -Build Approach B, then add a clustering/embedding report that compares bandpower hypotheses with BIOT, ContraWR, SparcNet, or TFMTokenizer embeddings. - -This is the most interesting research artifact, but it is larger than the desired scope right now. Treat it as a later idea, not the current implementation target. - -## Success Criteria - -- `EEGBCIDataset` can build metadata for selected subjects/runs. -- `EEGMotorImageryEEGBCI` returns real labeled EEG windows. -- `EEGBCIPatternDiscovery` reproduces the CELM-style bandpower and interpretation schema. -- Default tests do not require network access. -- Optional real-data smoke test validates MNE/PhysioNet behavior. -- Example writes a CSV and Markdown summary from real EEGBCI data. -- Optional embedding comparison can run with at least one compatible model path. -- Documentation states that brain-state hypotheses are exploratory and non-clinical. - -## Resolved Decisions - -1. Stop the first implementation at dataset, tasks, tests, docs, and CELM-equivalent example. Defer the embedding comparison. -2. Use 16-channel compatibility as the default channel mode. Offer `channel_mode="all"` for 64-channel experiments. -3. Keep using `mne` as a normal project dependency. `pyproject.toml` already declares `mne~=1.10.0`, and existing EEG tasks import it directly. -4. Do not commit a tiny real-signal `.npz` fixture in the first pass. Use offline mocked tests plus the opt-in `PYHEALTH_RUN_REAL_EEGBCI=1` smoke test. -5. Treat the CELM-equivalent CSV and Markdown generator as the first analysis stage. Do not require a neural model for that stage. - -## Recommendation - -Implement Approach B first. - -Stop there unless the implementation feels too small. If adding a creative layer, add only a tiny one-model embedding comparison, not a full atlas. - -## Planning Update - -2026-07-07: The approved design has been converted into a concrete implementation plan at `docs/eeg_pattern_discovery/implementation_plan.md`. GStack `/plan-eng-review` reviewed the plan and locked the first implementation scope to Approach B: dataset, tasks, tests, docs, and CELM-equivalent example. The embedding comparison remains deferred. diff --git a/docs/eeg_pattern_discovery/eeg_pipeline_guide.md b/docs/eeg_pattern_discovery/eeg_pipeline_guide.md deleted file mode 100644 index fa7dfa759..000000000 --- a/docs/eeg_pattern_discovery/eeg_pipeline_guide.md +++ /dev/null @@ -1,185 +0,0 @@ -# EEGBCI Pipeline Guide - -This guide explains the EEGBCI files added for the motor movement/imagery -pipeline, the order to call them, what each file does, and how to interpret the -current report output. - -## Runtime Call Order - -For the moment-report artifact, run only the example script: - -```bash -.venv/bin/python examples/eeg/eegbci/eegbci_pattern_discovery.py \ - --subjects 1 \ - --runs 3 \ - --max-windows 20 \ - --download -``` - -Internally, that calls the pipeline in this order: - -1. `examples/eeg/eegbci/eegbci_pattern_discovery.py` -2. `EEGBCIDataset(...)` from `pyhealth/datasets/eegbci.py` -3. `pyhealth/datasets/configs/eegbci.yaml` -4. `dataset.set_task(EEGBCIPatternDiscovery(compute_stft=False))` -5. `EEGBCIPatternDiscovery.__call__()` in `pyhealth/tasks/eegbci.py` -6. `EEGMotorImageryEEGBCI._base_samples_from_patient()` in - `pyhealth/tasks/eegbci.py` -7. `iter_annotation_windows()`, channel selection, normalization, optional STFT, - and Welch bandpower computation in `pyhealth/tasks/eegbci.py` -8. Example-owned report helpers in - `examples/eeg/eegbci/eegbci_pattern_discovery.py` -9. Output files under `outputs/eegbci_pattern_discovery/` - -The reusable PyHealth data path stops at step 5 or 6 with a `SampleDataset`. -Steps 8 and 9 are report-only and are not the normal model-training interface. - -## Files And Responsibilities - -### `pyhealth/datasets/eegbci.py` - -Defines `EEGBCIDataset`, the dataset entry point. It selects requested subjects -and runs, optionally downloads PhysioNet EEGBCI EDF files through MNE, finds local -EDF files when download is disabled, and writes the metadata table -`eegbci-pyhealth.csv`. - -The metadata table has one record per subject/run EDF file. It does not create -2-second windows itself. Windowing happens in the task layer. - -### `pyhealth/datasets/configs/eegbci.yaml` - -Tells `BaseDataset` how to read `eegbci-pyhealth.csv` as the `records` table. -It maps `patient_id`, `record_id`, `subject_id`, `run`, `run_type`, -`signal_file`, and `source` into PyHealth dataset events. - -### `pyhealth/tasks/eegbci.py` - -Contains the reusable EEGBCI task logic. - -Key pieces: - -- `run_type_for_run()`, `task_label_for_event()`, and - `numeric_label_for_task()` decode EEGBCI run/event labels. -- `select_eegbci_channels()` selects either the 16-channel compatibility montage - or all channels. -- `normalize_signal()` applies task-level signal normalization. -- `iter_annotation_windows()` converts MNE annotations into full 2-second - windows. -- `EEGMotorImageryEEGBCI` produces model-ready samples with `signal`, optional - `stft`, and multiclass `label`. -- `EEGBCIPatternDiscovery` extends the motor-imagery task by adding Welch - bandpower metadata and cautious legacy frequency-profile interpretation. - -This file is the correct reusable task layer for downstream PyHealth models. - -### `examples/eeg/eegbci/eegbci_pattern_discovery.py` - -This is the report generator. It is not a training script. - -It creates an `EEGBCIDataset`, applies `EEGBCIPatternDiscovery`, converts -samples to rows, computes rest baselines across all requested rows before -`--max-windows` truncation, annotates each moment with report-level state -hypotheses, and writes: - -- `outputs/eegbci_pattern_discovery/eegbci_pattern_windows.csv` -- `outputs/eegbci_pattern_discovery/eegbci_pattern_summary.md` - -The report-level fields are intentionally example-owned because they depend on -cross-window context such as rest baselines and task/state comparison. - -### `examples/eeg/eegbci/README.md` - -Short user-facing instructions for running the example and reading the generated -CSV and Markdown files. - -### `tests/core/test_eegbci.py` - -Unit coverage for EEGBCI dataset metadata, task windowing, bandpower behavior, -and the report helpers. Normal tests use synthetic data and do not download -EEGBCI. The real-data smoke test is opt-in through `PYHEALTH_RUN_REAL_EEGBCI=1`. - -### API Documentation Files - -These expose the reusable dataset and task APIs in generated docs: - -- `docs/api/datasets/pyhealth.datasets.EEGBCIDataset.rst` -- `docs/api/tasks/pyhealth.tasks.eegbci.rst` -- `docs/api/datasets.rst` -- `docs/api/tasks.rst` - -## Output Interpretation - -The CSV is a moment-by-moment ledger. Each row is one emitted 2-second EEG -window. The most important columns are: - -- `task_label`: what the experiment instructed in that window. -- `dominant_band` and `{band}_relative`: the raw frequency profile. -- `state_hypothesis`: report-level frequency-pattern state. -- `state_confidence` and `evidence_score`: strength of the deterministic - frequency-pattern evidence. -- `rest_reference_scope`: which rest baseline was used. -- `rest_{band}_relative_delta`: how the row differs from the selected rest - baseline for that band. -- `task_state_relation`: whether the frequency-pattern state supports, adds - detail to, disagrees with, or is ambiguous relative to the task label. -- `task_state_confidence`: confidence in that deterministic task/state relation. -- `interpretation`: report-level text summarizing the row in terms of the - moment-report state, raw dominant band, rest reference, and task/state - relation. -- `is_low_confidence`, `is_possible_artifact`, and `is_mixed_or_ambiguous`: - parseable quality flags for filtering. - -The CSV intentionally omits legacy task-level columns such as -`brain_state_hypothesis`, `confidence`, and `quality_flags`. Those fields still -exist inside `EEGBCIPatternDiscovery` samples for reusable task compatibility, -but they were too redundant and confusing for the final report artifact. - -The Markdown report summarizes those rows. It should be read as signal-pattern -metadata, not as a clinical or cognitive conclusion. For example, a -`slow_wave_dominant_pattern` means the short window's delta/theta evidence was -strong under the current heuristic. It does not diagnose a subject state. - -The report is useful for asking: - -- Which frequency-pattern states dominate this run? -- Are the states diverse or collapsed into one profile? -- Did rest normalization produce usable deltas? -- Which windows are representative enough to inspect manually? -- Which rows are low confidence, artifact-like, or ambiguous? - -## Next PyHealth Stage - -The next normal PyHealth stage after dataset/task construction is model -training or evaluation on a `SampleDataset`. - -Use this path for model work: - -```python -dataset = EEGBCIDataset(root=..., subjects=[...], runs=[...], download=True) -sample_dataset = dataset.set_task(EEGMotorImageryEEGBCI()) -``` - -That produces samples shaped for PyHealth models: - -- `signal`: EEG tensor -- optional `stft`: time-frequency tensor when `compute_stft=True` -- `label`: multiclass target -- metadata fields such as subject, run, event, and timing - -The current CSV/Markdown report is not the format expected by the next PyHealth -model-training stage. It is an analysis artifact for researchers. If the next -stage is a PyHealth model, pass the `SampleDataset` returned by `dataset.set_task` -to the trainer/model stack rather than reading -`eegbci_pattern_windows.csv`. - -If the next stage is offline analysis, dashboarding, or manual review, then the -CSV and Markdown are the right outputs. - -## Which Output Should Feed What? - -| Output | Intended consumer | Suitable for PyHealth model training? | -| --- | --- | --- | -| `EEGMotorImageryEEGBCI` `SampleDataset` | PyHealth models and trainers | Yes | -| `EEGBCIPatternDiscovery` `SampleDataset` | Signal inspection plus reusable task metadata | Partly, but primarily exploratory | -| `eegbci_pattern_windows.csv` | Analysis, filtering, audit, dashboards | No | -| `eegbci_pattern_summary.md` | Human-readable report | No | diff --git a/docs/eeg_pattern_discovery/implementation_plan.md b/docs/eeg_pattern_discovery/implementation_plan.md deleted file mode 100644 index 02cff1ceb..000000000 --- a/docs/eeg_pattern_discovery/implementation_plan.md +++ /dev/null @@ -1,1724 +0,0 @@ -# EEG Pattern Discovery Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -Status: ENGINEERING REVIEWED, ready for implementation -Date: 2026-07-07 -Source docs: -- `docs/eeg_pattern_discovery/brainstorm.md` -- `docs/eeg_pattern_discovery/design.md` -- `docs/eeg_pattern_discovery/pattern_analysis_redesign.md` - -**Goal:** Add a first-class EEGBCI dataset and two EEGBCI tasks to PyHealth so real PhysioNet motor movement/imagery windows can be used for supervised classification and CELM-style exploratory pattern discovery. - -**High-Level Summary:** We are turning the standalone CELM EEG pattern-discovery pipeline into a reusable PyHealth dataset, task, and example for real PhysioNet EEGBCI motor movement/imagery data. The research question is: what is the brain doing in each moment, according to its frequency patterns? More precisely, for each 2-second EEG segment, infer the most likely functional brain-state hypothesis from its frequency-band profile, then compare that hypothesis to the experimental task label. The practical problem is that PyHealth has EEG models and task infrastructure, but no first-class EEGBCI path that produces labeled windows, bandpower features, cautious moment-level interpretation metadata, and real-data validation without forcing normal CI to download raw EDF files. - -**Architecture:** `EEGBCIDataset` builds one metadata row per subject/run EDF, following the existing `TUABDataset` and `TUEVDataset` CSV pattern. `EEGMotorImageryEEGBCI` reads EDF annotations and emits fixed windows for task-label prediction; `EEGBCIPatternDiscovery` extends the same windows with Welch bandpower features and cautious interpretation metadata. Offline unit tests mock MNE and EDF reads; an opt-in smoke test downloads one real EEGBCI run. - -**Tech Stack:** PyHealth `BaseDataset` and `BaseTask`, MNE EEGBCI loader, NumPy, SciPy Welch PSD, pandas, torch, pytest/unittest, Sphinx RST docs. - -## Global Constraints - -- Do not claim brain-state hypotheses are clinical diagnoses. -- Decode EEGBCI `T1` and `T2` using run number. -- Normal CI must not download PhysioNet data. -- Keep raw EEG downloads outside the repo. -- Preserve compatibility with PyHealth models by making channel and sampling strategy explicit. -- Use real EEGBCI data in examples and opt-in smoke tests. -- Default pattern-discovery windows are 2 seconds. -- Default model-facing channel mode is a stable 16-channel subset; `channel_mode="all"` keeps all EEG channels. -- Default resampling is `200 Hz`; `resample_rate=None` keeps the EDF sample rate. - ---- - -## File Structure - -Create: - -- `pyhealth/datasets/eegbci.py`: EEGBCI metadata dataset. Calls `mne.datasets.eegbci.load_data` only when `download=True`, otherwise discovers existing `SxxxRxx.edf` files under `root`. -- `pyhealth/datasets/configs/eegbci.yaml`: one-table metadata config for `eegbci-pyhealth.csv`. -- `pyhealth/tasks/eegbci.py`: run-aware labels, channel selection, annotation windowing, bandpower extraction, interpretation rules, and task classes. -- `tests/core/test_eegbci.py`: offline unit tests plus skipped-by-default real-data smoke test. -- `examples/eeg/eegbci/README.md`: runnable example instructions, output schema, caveats. -- `examples/eeg/eegbci/eegbci_pattern_discovery.py`: PyHealth example that writes CELM-equivalent CSV and Markdown summary. -- `docs/api/datasets/pyhealth.datasets.EEGBCIDataset.rst`: dataset API page. -- `docs/api/tasks/pyhealth.tasks.eegbci.rst`: EEGBCI task API page. - -Modify: - -- `pyhealth/datasets/__init__.py`: export `EEGBCIDataset`. -- `pyhealth/tasks/__init__.py`: export `EEGMotorImageryEEGBCI` and `EEGBCIPatternDiscovery`. -- `docs/api/datasets.rst`: include the EEGBCI dataset page. -- `docs/api/tasks.rst`: include the EEGBCI task page. - -Defer: - -- `examples/eeg/eegbci/eegbci_embedding_comparison.py`: useful later, but out of scope for the first implementation. The first pass should ship dataset, tasks, tests, docs, and the CELM-equivalent example. - -Resolved decisions: - -- First implementation scope: dataset, tasks, tests, docs, and CELM-equivalent example only. -- Default channel mode: 16-channel compatibility, with `channel_mode="all"` available for 64-channel experiments. -- Dependency model: use existing project-level `mne~=1.10.0`; do not add an optional EEG extra in this pass. -- Real-data validation: no committed `.npz` fixture in the first pass; use mocked offline tests plus the opt-in real-data smoke test. -- Model boundary: the first pattern-discovery pipeline uses signal processing and deterministic interpretation, not a neural model. PyHealth model training and pretrained embeddings are enabled by the task outputs but deferred from the first deliverable. -- Analysis stage: the first analysis stage is the CELM-equivalent CSV and Markdown report produced by `examples/eeg/eegbci/eegbci_pattern_discovery.py`, upgraded into a moment-state report that compares inferred frequency-pattern states against experimental task labels. - -## Recommended Execution Path - -Start from a feature branch, not `main`: - -```bash -git checkout -b codex/eegbci-pattern-discovery -``` - -If the implementation agent uses an isolated worktree, create or switch to that branch inside the worktree before editing. Keep each task boundary commit on this branch so review and rollback stay clean. - -Use one main implementation session to own the feature end to end. Do not split concurrent coding across multiple sub-agents because `pyhealth/tasks/eegbci.py` and `tests/core/test_eegbci.py` are shared by most tasks, and parallel edits would create interface drift. - -Execute the plan sequentially: - -1. Task 1: pure EEGBCI helpers and tests. -2. Task 2: `EEGBCIDataset`, metadata config, dataset export, and tests. -3. Task 3: EEGBCI task classes, annotation windowing, sample schema, task export, and tests. -4. Task 4: skipped-by-default real-data smoke test. -5. Task 5: CELM-equivalent analysis example. -6. Task 6: API docs and import smoke test. -7. Final verification: run the default test command, import smoke, optional real-data smoke test, optional example command, and `graphify update .`. - -Use sub-agents only for bounded review or investigation after a section is implemented. Good sub-agent assignments: - -- Review `pyhealth/tasks/eegbci.py` for label/window/channel contract drift. -- Inspect existing PyHealth docs/export patterns before Task 6. -- Investigate real EEGBCI channel names if the opt-in smoke test fails. -- Review the example output schema against the correctness oracle. - -Do not give different sections to independent sessions unless each session starts from the previous section's committed result. If separate sessions are used, hand off after Tasks 1, 2, 3, 4, and 5 only, with tests passing and commits made at each boundary. - -## Data Flow - -1. User instantiates `EEGBCIDataset(root, subjects, runs, download)`. -2. Dataset writes or reuses `/eegbci-pyhealth.csv` with one row per EDF run. -3. `BaseDataset` loads rows into patient events from `pyhealth/datasets/configs/eegbci.yaml`. -4. User calls `dataset.set_task(EEGMotorImageryEEGBCI(...))` or `dataset.set_task(EEGBCIPatternDiscovery(...))`. -5. Task reads each `signal_file` with MNE, picks EEG channels, filters, optionally resamples, decodes annotations, and slices full fixed-length windows. -6. Motor-imagery task returns supervised samples with `label`. -7. Pattern-discovery task returns the same samples plus `bandpower`, `brain_state_hypothesis`, `confidence`, `quality_flags`, and `interpretation`. -8. Example script converts samples to CSV rows and writes a Markdown summary grouped by task label and hypothesis. - -## Correctness Oracle - -An implementation is complete only when these checks pass: - -- `task_label_for_event(3, "T1") == "execute_left_fist"` and `task_label_for_event(4, "T1") == "imagine_left_fist"`, proving run-aware EEGBCI decoding. -- Synthetic 10 Hz input produces `dominant_band == "alpha"` and high `alpha_relative`, proving bandpower extraction works. -- `EEGBCIDataset.prepare_metadata()` writes one row per requested subject/run with `patient_id`, `record_id`, `subject_id`, `run`, `run_type`, `signal_file`, and `source`. -- `EEGMotorImageryEEGBCI(compute_stft=False)` returns fixed-shape `signal` tensors, decoded `task_label`, numeric `label`, `trial_id`, timing fields, and `sample_rate`. -- `EEGBCIPatternDiscovery(compute_stft=False)` returns every supervised field plus `bandpower`, `brain_state_hypothesis`, `confidence`, `quality_flags`, and `interpretation`. -- Default `pytest tests/core/test_eegbci.py -v` passes without network access and skips the real-data smoke test. -- `PYHEALTH_RUN_REAL_EEGBCI=1 pytest tests/core/test_eegbci.py::TestEEGBCIRealDataSmoke -v` passes against subject `1`, run `3`. -- The example command writes `eegbci_pattern_windows.csv` and `eegbci_pattern_summary.md`, with rows containing task label, dominant band, state hypothesis, evidence summary, confidence, quality flags, task-label comparison, and non-clinical interpretation text. - -## Analysis Stage Contract - -The first analysis stage lives in `examples/eeg/eegbci/eegbci_pattern_discovery.py`. It is not just a demo script. It is the artifact generator that proves the pipeline can answer the research question on real windows: - -> What is the brain doing in each moment, according to its frequency patterns? - -For each 2-second EEG segment, the artifact should infer the most likely functional brain-state hypothesis from the frequency-band profile, then compare that hypothesis to the experimental task label. - -Inputs: - -- `EEGBCIDataset` -- `EEGBCIPatternDiscovery` -- CLI flags: `--root`, `--subjects`, `--runs`, `--output-dir`, `--max-windows`, `--download` - -Outputs: - -- `eegbci_pattern_windows.csv`: one row per window, including subject/run metadata, event code, decoded task label, bandpower values, relative powers, dominant band, ratios, state hypothesis, evidence score or evidence summary, confidence, quality flags, task-label comparison, and interpretation. -- `eegbci_pattern_summary.md`: a compact research report with run configuration, window coverage, moment-state ledger, task-label x hypothesis matrix, dominant bands by task, confidence and quality audit, bandpower summaries, notable windows, limitations, and next checks. - -Question answered: - -- At this moment, does the EEG look relaxed, engaged, drowsy, motor-active, noisy, mixed, or ambiguous? -- Do the frequency-profile hypotheses agree with, add detail to, or flag possible disagreement with the experimental EEGBCI labels? -- If all windows collapse to low-confidence or mixed states, what exactly caused that outcome? - -Required output-quality behavior: - -- Do not start the summary with the generic exploratory caveat. -- Move the non-clinical safety boundary into a clear methods or limitations section. -- Do not repeat "This is exploratory signal metadata..." in every row. -- If every window has low confidence, say that plainly and explain the distributional reason. -- Surface top windows by alpha/beta, theta/beta, beta-relative, and gamma-relative power so the user can inspect moments that might represent idle, slow-wave, motor-active, or artifact-like patterns. - -Deferred analysis: - -- Neural embedding comparison. -- Clustering or atlas generation. -- TFM token motif reports. -- Subject-shift reliability reports. - -## Shared Interfaces - -These names are fixed across tasks: - -```python -EEGBCI_RUN_TYPES: dict[int, str] -EEGBCI_COMPAT_CHANNELS: tuple[str, ...] -EEGBCI_LABELS: dict[str, int] - -def normalize_eegbci_channel_name(name: str) -> str: ... -def run_type_for_run(run: int) -> str: ... -def label_family_for_run(run: int) -> str: ... -def task_label_for_event(run: int, event_code: str) -> str: ... -def numeric_label_for_task(task_label: str) -> int: ... -def select_eegbci_channels(data: np.ndarray, ch_names: list[str], channel_mode: str = "compat16") -> tuple[np.ndarray, list[str]]: ... -def iter_annotation_windows(raw: mne.io.BaseRaw, run: int, window_size: float = 2.0) -> list[dict[str, Any]]: ... -def compute_band_powers(data: np.ndarray, sfreq: float) -> dict[str, float | str]: ... -def interpret_band_profile(features: dict[str, float | str]) -> dict[str, str]: ... -def normalize_signal(signal: np.ndarray, mode: str | None) -> np.ndarray: ... -``` - -Task classes: - -```python -class EEGMotorImageryEEGBCI(BaseTask): - task_name = "EEGBCI_motor_imagery" - input_schema = {"signal": "tensor", "stft": "tensor"} - output_schema = {"label": "multiclass"} - -class EEGBCIPatternDiscovery(EEGMotorImageryEEGBCI): - task_name = "EEGBCI_pattern_discovery" -``` - -## Task 1: Pure EEGBCI Helpers - -**Files:** - -- Create: `pyhealth/tasks/eegbci.py` -- Create: `tests/core/test_eegbci.py` - -**Interfaces:** - -- Consumes: NumPy and SciPy signal processing. -- Produces: `run_type_for_run`, `label_family_for_run`, `task_label_for_event`, `numeric_label_for_task`, `select_eegbci_channels`, `compute_band_powers`, `interpret_band_profile`, `normalize_signal`. - -- [x] **Step 1: Write failing tests for run-aware labels** - -Add these tests to `tests/core/test_eegbci.py`: - -```python -import unittest - -from pyhealth.tasks.eegbci import ( - label_family_for_run, - numeric_label_for_task, - run_type_for_run, - task_label_for_event, -) - - -class TestEEGBCIHelpers(unittest.TestCase): - def test_run_type_for_run(self): - self.assertEqual(run_type_for_run(3), "motor_execution_left_right") - self.assertEqual(run_type_for_run(4), "motor_imagery_left_right") - self.assertEqual(run_type_for_run(5), "motor_execution_fists_feet") - self.assertEqual(run_type_for_run(6), "motor_imagery_fists_feet") - self.assertEqual(run_type_for_run(14), "motor_imagery_fists_feet") - - def test_task_label_for_event_is_run_aware(self): - self.assertEqual(task_label_for_event(3, "T0"), "rest") - self.assertEqual(task_label_for_event(3, "T1"), "execute_left_fist") - self.assertEqual(task_label_for_event(3, "T2"), "execute_right_fist") - self.assertEqual(task_label_for_event(4, "T1"), "imagine_left_fist") - self.assertEqual(task_label_for_event(4, "T2"), "imagine_right_fist") - self.assertEqual(task_label_for_event(5, "T1"), "execute_both_fists") - self.assertEqual(task_label_for_event(5, "T2"), "execute_both_feet") - self.assertEqual(task_label_for_event(6, "T1"), "imagine_both_fists") - self.assertEqual(task_label_for_event(6, "T2"), "imagine_both_feet") - - def test_label_family_and_numeric_labels(self): - self.assertEqual(label_family_for_run(3), "motor_execution") - self.assertEqual(label_family_for_run(4), "motor_imagery") - self.assertEqual(numeric_label_for_task("rest"), 0) - self.assertEqual(numeric_label_for_task("execute_left_fist"), 1) - self.assertEqual(numeric_label_for_task("imagine_both_feet"), 8) - - def test_invalid_run_and_event_raise_clear_errors(self): - with self.assertRaisesRegex(ValueError, "Unsupported EEGBCI run"): - run_type_for_run(2) - with self.assertRaisesRegex(ValueError, "Unsupported EEGBCI event"): - task_label_for_event(3, "BAD") -``` - -- [x] **Step 2: Run tests to verify import failure** - -Run: - -```bash -pytest tests/core/test_eegbci.py::TestEEGBCIHelpers -v -``` - -Expected: FAIL with `ModuleNotFoundError: No module named 'pyhealth.tasks.eegbci'`. - -- [x] **Step 3: Implement label helpers** - -Add to `pyhealth/tasks/eegbci.py`: - -```python -from __future__ import annotations - -from typing import Any, Dict, List, Tuple - -import numpy as np - - -EEGBCI_RUN_TYPES = { - 3: "motor_execution_left_right", - 4: "motor_imagery_left_right", - 5: "motor_execution_fists_feet", - 6: "motor_imagery_fists_feet", - 7: "motor_execution_left_right", - 8: "motor_imagery_left_right", - 9: "motor_execution_fists_feet", - 10: "motor_imagery_fists_feet", - 11: "motor_execution_left_right", - 12: "motor_imagery_left_right", - 13: "motor_execution_fists_feet", - 14: "motor_imagery_fists_feet", -} - -EEGBCI_LABELS = { - "rest": 0, - "execute_left_fist": 1, - "execute_right_fist": 2, - "imagine_left_fist": 3, - "imagine_right_fist": 4, - "execute_both_fists": 5, - "execute_both_feet": 6, - "imagine_both_fists": 7, - "imagine_both_feet": 8, -} - - -def run_type_for_run(run: int) -> str: - try: - return EEGBCI_RUN_TYPES[int(run)] - except KeyError as exc: - raise ValueError(f"Unsupported EEGBCI run: {run}") from exc - - -def label_family_for_run(run: int) -> str: - run_type = run_type_for_run(run) - if "execution" in run_type: - return "motor_execution" - if "imagery" in run_type: - return "motor_imagery" - return "baseline" - - -def task_label_for_event(run: int, event_code: str) -> str: - code = str(event_code).strip() - if code == "T0": - return "rest" - run_type = run_type_for_run(run) - mapping = { - "motor_execution_left_right": { - "T1": "execute_left_fist", - "T2": "execute_right_fist", - }, - "motor_imagery_left_right": { - "T1": "imagine_left_fist", - "T2": "imagine_right_fist", - }, - "motor_execution_fists_feet": { - "T1": "execute_both_fists", - "T2": "execute_both_feet", - }, - "motor_imagery_fists_feet": { - "T1": "imagine_both_fists", - "T2": "imagine_both_feet", - }, - } - try: - return mapping[run_type][code] - except KeyError as exc: - raise ValueError(f"Unsupported EEGBCI event {event_code!r} for run {run}") from exc - - -def numeric_label_for_task(task_label: str) -> int: - try: - return EEGBCI_LABELS[task_label] - except KeyError as exc: - raise ValueError(f"Unsupported EEGBCI task label: {task_label}") from exc -``` - -- [x] **Step 4: Run label tests** - -Run: - -```bash -pytest tests/core/test_eegbci.py::TestEEGBCIHelpers -v -``` - -Expected: PASS for the four label tests. - -- [x] **Step 5: Add failing tests for channel selection and normalization** - -Append to `TestEEGBCIHelpers`: - -```python - def test_select_eegbci_channels_compat16(self): - from pyhealth.tasks.eegbci import EEGBCI_COMPAT_CHANNELS, select_eegbci_channels - - ch_names = list(EEGBCI_COMPAT_CHANNELS) + ["EXTRA"] - data = np.arange(len(ch_names) * 100, dtype=float).reshape(len(ch_names), 100) - selected, selected_names = select_eegbci_channels(data, ch_names, "compat16") - self.assertEqual(selected.shape, (16, 100)) - self.assertEqual(selected_names, list(EEGBCI_COMPAT_CHANNELS)) - np.testing.assert_allclose(selected[0], data[0]) - - def test_select_eegbci_channels_all(self): - from pyhealth.tasks.eegbci import select_eegbci_channels - - data = np.ones((64, 50)) - ch_names = [f"CH{i}" for i in range(64)] - selected, selected_names = select_eegbci_channels(data, ch_names, "all") - self.assertEqual(selected.shape, (64, 50)) - self.assertEqual(selected_names, ch_names) - - def test_select_eegbci_channels_missing_channel_raises(self): - from pyhealth.tasks.eegbci import select_eegbci_channels - - with self.assertRaisesRegex(ValueError, "Missing EEGBCI channels"): - select_eegbci_channels(np.ones((2, 20)), ["C3", "C4"], "compat16") - - def test_normalize_signal_95th_percentile(self): - from pyhealth.tasks.eegbci import normalize_signal - - signal = np.array([[0.0, 1.0, 2.0, 100.0], [0.0, -2.0, 2.0, 4.0]]) - normalized = normalize_signal(signal, "95th_percentile") - self.assertEqual(normalized.shape, signal.shape) - self.assertLess(np.max(np.abs(normalized[0])), 2.0) -``` - -- [x] **Step 6: Implement channel and normalization helpers** - -Add below the label helpers: - -```python -EEGBCI_COMPAT_CHANNELS = ( - "FC5", - "FC3", - "FC1", - "FC2", - "FC4", - "FC6", - "C5", - "C3", - "C1", - "C2", - "C4", - "C6", - "CP5", - "CP3", - "CP4", - "CP6", -) - - -def normalize_eegbci_channel_name(name: str) -> str: - clean = name.upper().replace(".", "").replace("EEG ", "").replace("-REF", "") - aliases = { - "T9": "FT9", - "T10": "FT10", - } - return aliases.get(clean, clean) - - -def select_eegbci_channels( - data: np.ndarray, - ch_names: List[str], - channel_mode: str = "compat16", -) -> Tuple[np.ndarray, List[str]]: - if channel_mode == "all": - return data, list(ch_names) - if channel_mode != "compat16": - raise ValueError("channel_mode must be one of {'compat16', 'all'}") - - normalized_to_index = { - normalize_eegbci_channel_name(name): idx for idx, name in enumerate(ch_names) - } - missing = [ch for ch in EEGBCI_COMPAT_CHANNELS if ch not in normalized_to_index] - if missing: - raise ValueError(f"Missing EEGBCI channels for compat16 mode: {missing}") - indices = [normalized_to_index[ch] for ch in EEGBCI_COMPAT_CHANNELS] - return data[indices], list(EEGBCI_COMPAT_CHANNELS) - - -def normalize_signal(signal: np.ndarray, mode: str | None) -> np.ndarray: - if mode is None: - return signal - if mode == "95th_percentile": - scale = np.quantile( - np.abs(signal), q=0.95, axis=-1, method="linear", keepdims=True - ) - return signal / (scale + 1e-8) - if mode == "div_by_100": - return signal / 100.0 - raise ValueError("normalization must be one of {None, '95th_percentile', 'div_by_100'}") -``` - -- [x] **Step 7: Run helper tests** - -Run: - -```bash -pytest tests/core/test_eegbci.py::TestEEGBCIHelpers -v -``` - -Expected: PASS. - -- [x] **Step 8: Add failing tests for bandpower and interpretation** - -Append to `TestEEGBCIHelpers`: - -```python - def test_compute_band_powers_detects_alpha_sinusoid(self): - from pyhealth.tasks.eegbci import compute_band_powers - - sfreq = 200.0 - times = np.arange(0, 2, 1 / sfreq) - alpha = np.sin(2 * np.pi * 10 * times) - data = np.stack([alpha, alpha]) - features = compute_band_powers(data, sfreq) - self.assertEqual(features["dominant_band"], "alpha") - self.assertGreater(features["alpha_relative"], 0.5) - self.assertGreater(features["alpha_beta_ratio"], 1.0) - - def test_compute_band_powers_detects_beta_sinusoid(self): - from pyhealth.tasks.eegbci import compute_band_powers - - sfreq = 200.0 - times = np.arange(0, 2, 1 / sfreq) - beta = np.sin(2 * np.pi * 20 * times) - data = np.stack([beta, beta]) - features = compute_band_powers(data, sfreq) - self.assertEqual(features["dominant_band"], "beta") - self.assertGreater(features["beta_relative"], 0.5) - - def test_interpret_band_profile_returns_cautious_metadata(self): - from pyhealth.tasks.eegbci import interpret_band_profile - - interpretation = interpret_band_profile( - { - "dominant_band": "alpha", - "alpha_relative": 0.65, - "beta_relative": 0.10, - "theta_relative": 0.10, - "gamma_relative": 0.05, - "alpha_beta_ratio": 6.5, - "theta_beta_ratio": 1.0, - } - ) - self.assertEqual(interpretation["brain_state_hypothesis"], "relaxed_or_idle") - self.assertIn(interpretation["confidence"], {"low", "medium", "high"}) - self.assertIn("consistent with", interpretation["interpretation"]) -``` - -- [x] **Step 9: Implement bandpower and interpretation helpers** - -Add below the channel helpers: - -```python -BANDS = { - "delta": (0.5, 4.0), - "theta": (4.0, 8.0), - "alpha": (8.0, 13.0), - "beta": (13.0, 30.0), - "gamma": (30.0, 45.0), -} - - -def compute_band_powers(data: np.ndarray, sfreq: float) -> Dict[str, float | str]: - from scipy.signal import welch - - if data.ndim != 2: - raise ValueError("data must have shape (channels, time)") - nperseg = min(data.shape[-1], int(sfreq * 2)) - freqs, psd = welch(data, fs=sfreq, nperseg=nperseg, axis=-1) - mean_psd = psd.mean(axis=0) - - features: Dict[str, float | str] = {} - total_power = 0.0 - band_values: Dict[str, float] = {} - for band, (low, high) in BANDS.items(): - mask = (freqs >= low) & (freqs < high) - value = float(np.trapz(mean_psd[mask], freqs[mask])) if np.any(mask) else 0.0 - features[f"{band}_power"] = value - band_values[band] = value - total_power += value - - denom = total_power + 1e-12 - for band, value in band_values.items(): - features[f"{band}_relative"] = float(value / denom) - - features["dominant_band"] = max(band_values, key=band_values.get) - features["alpha_beta_ratio"] = float( - band_values["alpha"] / (band_values["beta"] + 1e-12) - ) - features["theta_beta_ratio"] = float( - band_values["theta"] / (band_values["beta"] + 1e-12) - ) - return features - - -def interpret_band_profile(features: Dict[str, float | str]) -> Dict[str, str]: - dominant = str(features["dominant_band"]) - alpha_rel = float(features.get("alpha_relative", 0.0)) - beta_rel = float(features.get("beta_relative", 0.0)) - theta_rel = float(features.get("theta_relative", 0.0)) - gamma_rel = float(features.get("gamma_relative", 0.0)) - alpha_beta = float(features.get("alpha_beta_ratio", 0.0)) - theta_beta = float(features.get("theta_beta_ratio", 0.0)) - - quality_flags: List[str] = [] - hypothesis = "mixed_frequency_profile" - confidence = "low" - - if dominant == "alpha" and alpha_rel >= 0.45 and alpha_beta >= 2.0: - hypothesis = "relaxed_or_idle" - confidence = "medium" - elif dominant == "beta" and beta_rel >= 0.35: - hypothesis = "active_sensorimotor_processing" - confidence = "medium" - elif dominant == "theta" and theta_rel >= 0.35 and theta_beta >= 1.5: - hypothesis = "slow_wave_or_drowsy_pattern" - confidence = "medium" - elif dominant == "gamma" and gamma_rel >= 0.30: - hypothesis = "high_frequency_or_artifact_pattern" - confidence = "low" - quality_flags.append("possible_muscle_artifact") - - if confidence == "low": - quality_flags.append("low_confidence") - - return { - "brain_state_hypothesis": hypothesis, - "confidence": confidence, - "quality_flags": ";".join(quality_flags) if quality_flags else "none", - "interpretation": ( - f"The segment is consistent with {hypothesis} based on a " - f"{dominant}-dominant frequency profile. This is exploratory signal " - "metadata, not evidence of cognition or a clinical diagnosis." - ), - } -``` - -- [x] **Step 10: Run helper tests** - -Run: - -```bash -pytest tests/core/test_eegbci.py::TestEEGBCIHelpers -v -``` - -Expected: PASS. - -- [x] **Step 11: Commit task 1** - -Run: - -```bash -git add pyhealth/tasks/eegbci.py tests/core/test_eegbci.py -git commit -m "feat: add EEGBCI helper functions" -``` - -## Task 2: EEGBCI Dataset - -**Files:** - -- Create: `pyhealth/datasets/eegbci.py` -- Create: `pyhealth/datasets/configs/eegbci.yaml` -- Modify: `pyhealth/datasets/__init__.py` -- Modify: `tests/core/test_eegbci.py` - -**Interfaces:** - -- Consumes: helper `run_type_for_run` from task 1, MNE EEGBCI loader when `download=True`. -- Produces: `EEGBCIDataset(root, dataset_name=None, config_path=None, subjects=None, runs=None, download=False, **kwargs)`. -- Produces metadata file: `/eegbci-pyhealth.csv`. - -- [x] **Step 1: Add failing dataset metadata tests** - -Append to `tests/core/test_eegbci.py`: - -```python -import tempfile -from pathlib import Path -from unittest.mock import patch - -import pandas as pd - -from pyhealth.datasets.eegbci import EEGBCIDataset - - -class TestEEGBCIDataset(unittest.TestCase): - def test_prepare_metadata_with_existing_files(self): - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - edf = root / "files" / "eegmmidb" / "1.0.0" / "S001" / "S001R03.edf" - edf.parent.mkdir(parents=True) - edf.write_bytes(b"") - - ds = EEGBCIDataset.__new__(EEGBCIDataset) - ds.root = str(root) - ds.subjects = [1] - ds.runs = [3] - ds.download = False - ds.prepare_metadata() - - csv_path = root / "eegbci-pyhealth.csv" - self.assertTrue(csv_path.exists()) - df = pd.read_csv(csv_path) - self.assertEqual(len(df), 1) - self.assertEqual(df.loc[0, "patient_id"], "S001") - self.assertEqual(df.loc[0, "record_id"], "R03") - self.assertEqual(df.loc[0, "subject_id"], 1) - self.assertEqual(df.loc[0, "run"], 3) - self.assertEqual(df.loc[0, "run_type"], "motor_execution_left_right") - self.assertEqual(df.loc[0, "source"], "physionet_eegbci") - - def test_prepare_metadata_download_uses_mne_loader(self): - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - fake_path = root / "S001R04.edf" - fake_path.write_bytes(b"") - ds = EEGBCIDataset.__new__(EEGBCIDataset) - ds.root = str(root) - ds.subjects = [1] - ds.runs = [4] - ds.download = True - - with patch( - "pyhealth.datasets.eegbci.mne.datasets.eegbci.load_data", - return_value=[str(fake_path)], - ) as load_data: - ds.prepare_metadata() - - load_data.assert_called_once_with(1, [4], path=str(root)) - df = pd.read_csv(root / "eegbci-pyhealth.csv") - self.assertEqual(df.loc[0, "record_id"], "R04") - self.assertEqual(df.loc[0, "run_type"], "motor_imagery_left_right") - - def test_prepare_metadata_missing_local_file_raises(self): - with tempfile.TemporaryDirectory() as tmp: - ds = EEGBCIDataset.__new__(EEGBCIDataset) - ds.root = tmp - ds.subjects = [1] - ds.runs = [3] - ds.download = False - with self.assertRaisesRegex(FileNotFoundError, "download=True"): - ds.prepare_metadata() - - def test_default_task_returns_pattern_discovery(self): - from pyhealth.tasks.eegbci import EEGBCIPatternDiscovery - - ds = EEGBCIDataset.__new__(EEGBCIDataset) - self.assertIsInstance(ds.default_task, EEGBCIPatternDiscovery) -``` - -- [x] **Step 2: Run dataset tests to verify failure** - -Run: - -```bash -pytest tests/core/test_eegbci.py::TestEEGBCIDataset -v -``` - -Expected: FAIL with `ModuleNotFoundError: No module named 'pyhealth.datasets.eegbci'`. - -- [x] **Step 3: Implement `EEGBCIDataset`** - -Create `pyhealth/datasets/eegbci.py`: - -```python -from __future__ import annotations - -import logging -from pathlib import Path -from typing import Optional - -import mne -import pandas as pd - -from .base_dataset import BaseDataset -from pyhealth.tasks.eegbci import EEGBCIPatternDiscovery, run_type_for_run - -logger = logging.getLogger(__name__) - - -class EEGBCIDataset(BaseDataset): - """PhysioNet EEG Motor Movement/Imagery metadata dataset.""" - - def __init__( - self, - root: str, - dataset_name: Optional[str] = None, - config_path: Optional[str] = None, - subjects: Optional[list[int]] = None, - runs: Optional[list[int]] = None, - download: bool = False, - **kwargs, - ) -> None: - if config_path is None: - config_path = Path(__file__).parent / "configs" / "eegbci.yaml" - self.root = root - self.subjects = subjects or [1, 2, 3] - self.runs = runs or list(range(3, 15)) - self.download = download - self.prepare_metadata() - super().__init__( - root=root, - tables=["records"], - dataset_name=dataset_name or "eegbci", - config_path=config_path, - **kwargs, - ) - - def _find_local_edf(self, subject: int, run: int) -> Path | None: - root = Path(self.root) - pattern = f"S{subject:03d}R{run:02d}.edf" - matches = sorted(root.rglob(pattern)) - return matches[0] if matches else None - - def prepare_metadata(self) -> None: - root = Path(self.root) - csv_path = root / "eegbci-pyhealth.csv" - if csv_path.exists(): - return - - rows: list[dict] = [] - for subject in self.subjects: - paths_by_run: dict[int, Path] = {} - if self.download: - downloaded = mne.datasets.eegbci.load_data( - subject, self.runs, path=str(root) - ) - for path in downloaded: - p = Path(path) - for run in self.runs: - if p.name == f"S{subject:03d}R{run:02d}.edf": - paths_by_run[run] = p - for run in self.runs: - signal_file = paths_by_run.get(run) or self._find_local_edf(subject, run) - if signal_file is None: - raise FileNotFoundError( - f"Missing EEGBCI EDF for subject {subject}, run {run}. " - "Pass download=True to fetch it with MNE." - ) - rows.append( - { - "patient_id": f"S{subject:03d}", - "record_id": f"R{run:02d}", - "subject_id": int(subject), - "run": int(run), - "run_type": run_type_for_run(run), - "signal_file": str(signal_file), - "source": "physionet_eegbci", - } - ) - - df = pd.DataFrame(rows) - df.sort_values(["subject_id", "run"], inplace=True) - df.reset_index(drop=True, inplace=True) - csv_path.parent.mkdir(parents=True, exist_ok=True) - df.to_csv(csv_path, index=False) - logger.info("Wrote EEGBCI metadata to %s", csv_path) - - @property - def default_task(self) -> EEGBCIPatternDiscovery: - return EEGBCIPatternDiscovery() -``` - -- [x] **Step 4: Add dataset config** - -Create `pyhealth/datasets/configs/eegbci.yaml`: - -```yaml -version: "1.0.0" -tables: - records: - file_path: "eegbci-pyhealth.csv" - patient_id: "patient_id" - timestamp: null - attributes: - - "record_id" - - "subject_id" - - "run" - - "run_type" - - "signal_file" - - "source" -``` - -- [x] **Step 5: Export dataset** - -Modify `pyhealth/datasets/__init__.py`: - -```python -from pyhealth.datasets.eegbci import EEGBCIDataset -``` - -Place the import beside other EEG dataset exports. - -- [x] **Step 6: Run dataset tests** - -Run: - -```bash -pytest tests/core/test_eegbci.py::TestEEGBCIDataset -v -``` - -Expected: PASS. - -- [x] **Step 7: Commit task 2** - -Run: - -```bash -git add pyhealth/datasets/eegbci.py pyhealth/datasets/configs/eegbci.yaml pyhealth/datasets/__init__.py tests/core/test_eegbci.py -git commit -m "feat: add EEGBCI dataset" -``` - -## Task 3: EEGBCI Task Classes - -**Files:** - -- Modify: `pyhealth/tasks/eegbci.py` -- Modify: `pyhealth/tasks/__init__.py` -- Modify: `tests/core/test_eegbci.py` - -**Interfaces:** - -- Consumes: dataset metadata events with `signal_file`, `run`, `record_id`, `run_type`. -- Produces: `EEGMotorImageryEEGBCI` and `EEGBCIPatternDiscovery` samples. - -- [x] **Step 1: Add task schema tests** - -Append to `tests/core/test_eegbci.py`: - -```python -from dataclasses import dataclass -from typing import List - -import numpy as np - -from pyhealth.tasks.eegbci import EEGBCIPatternDiscovery, EEGMotorImageryEEGBCI - - -@dataclass -class _EEGBCIEvent: - signal_file: str - record_id: str = "R03" - subject_id: int = 1 - run: int = 3 - run_type: str = "motor_execution_left_right" - source: str = "physionet_eegbci" - - -class _EEGBCIPatient: - def __init__(self, patient_id: str, events: List[_EEGBCIEvent]): - self.patient_id = patient_id - self._events = events - - def get_events(self, event_type=None) -> List[_EEGBCIEvent]: - if event_type not in (None, "records"): - return [] - return self._events - - -class TestEEGBCITasks(unittest.TestCase): - def test_task_schema_attributes(self): - task = EEGMotorImageryEEGBCI() - self.assertEqual(task.task_name, "EEGBCI_motor_imagery") - self.assertEqual(task.input_schema, {"signal": "tensor", "stft": "tensor"}) - self.assertEqual(task.output_schema, {"label": "multiclass"}) - - def test_task_schema_without_stft(self): - task = EEGMotorImageryEEGBCI(compute_stft=False) - self.assertEqual(task.input_schema, {"signal": "tensor"}) - - def test_pattern_discovery_schema_attributes(self): - task = EEGBCIPatternDiscovery(compute_stft=False) - self.assertEqual(task.task_name, "EEGBCI_pattern_discovery") - self.assertEqual(task.input_schema, {"signal": "tensor"}) -``` - -- [x] **Step 2: Run schema tests to verify failure** - -Run: - -```bash -pytest tests/core/test_eegbci.py::TestEEGBCITasks -v -``` - -Expected: FAIL because classes do not exist. - -- [x] **Step 3: Implement task constructors** - -Add to `pyhealth/tasks/eegbci.py`: - -```python -import torch -import mne - -from pyhealth.tasks import BaseTask - - -class EEGMotorImageryEEGBCI(BaseTask): - task_name: str = "EEGBCI_motor_imagery" - input_schema: Dict[str, str] = {"signal": "tensor", "stft": "tensor"} - output_schema: Dict[str, str] = {"label": "multiclass"} - - def __init__( - self, - window_size: float = 2.0, - resample_rate: float | None = 200, - bandpass_filter: Tuple[float, float] | None = (0.5, 45.0), - channel_mode: str = "compat16", - normalization: str | None = "95th_percentile", - compute_stft: bool = True, - ) -> None: - super().__init__() - self.window_size = window_size - self.resample_rate = resample_rate - self.bandpass_filter = bandpass_filter - self.channel_mode = channel_mode - self.normalization = normalization - self.compute_stft = compute_stft - if not compute_stft: - self.input_schema = {"signal": "tensor"} - - -class EEGBCIPatternDiscovery(EEGMotorImageryEEGBCI): - task_name: str = "EEGBCI_pattern_discovery" -``` - -- [x] **Step 4: Run schema tests** - -Run: - -```bash -pytest tests/core/test_eegbci.py::TestEEGBCITasks -v -``` - -Expected: PASS for schema tests. - -- [x] **Step 5: Add failing annotation window tests** - -Append to `TestEEGBCITasks`: - -```python - def test_iter_annotation_windows_uses_full_2s_windows(self): - import mne - from pyhealth.tasks.eegbci import iter_annotation_windows - - sfreq = 200.0 - raw = mne.io.RawArray( - np.zeros((2, int(sfreq * 6))), - mne.create_info(["C3", "C4"], sfreq=sfreq, ch_types=["eeg", "eeg"]), - verbose="error", - ) - raw.set_annotations( - mne.Annotations(onset=[0.5, 2.0], duration=[1.0, 3.0], description=["T0", "T1"]) - ) - windows = iter_annotation_windows(raw, run=3, window_size=2.0) - self.assertEqual(len(windows), 1) - self.assertEqual(windows[0]["event_code"], "T1") - self.assertEqual(windows[0]["task_label"], "execute_left_fist") - self.assertEqual(windows[0]["start_sample"], 400) - self.assertEqual(windows[0]["end_sample"], 800) -``` - -- [x] **Step 6: Implement annotation windowing** - -Add before the task classes: - -```python -def iter_annotation_windows( - raw: mne.io.BaseRaw, - run: int, - window_size: float = 2.0, -) -> List[Dict[str, Any]]: - sfreq = float(raw.info["sfreq"]) - window_samples = int(round(window_size * sfreq)) - windows: List[Dict[str, Any]] = [] - for idx, annotation in enumerate(raw.annotations): - event_code = str(annotation["description"]) - if event_code not in {"T0", "T1", "T2"}: - continue - start_sample = int(round(float(annotation["onset"]) * sfreq)) - duration_samples = int(round(float(annotation["duration"]) * sfreq)) - n_full_windows = duration_samples // window_samples - for window_idx in range(n_full_windows): - s0 = start_sample + window_idx * window_samples - s1 = s0 + window_samples - windows.append( - { - "trial_id": f"ann{idx:04d}_win{window_idx:03d}", - "event_code": event_code, - "task_label": task_label_for_event(run, event_code), - "label_family": label_family_for_run(run), - "label": numeric_label_for_task(task_label_for_event(run, event_code)), - "start_time": s0 / sfreq, - "end_time": s1 / sfreq, - "start_sample": s0, - "end_sample": s1, - } - ) - return windows -``` - -- [x] **Step 7: Run annotation tests** - -Run: - -```bash -pytest tests/core/test_eegbci.py::TestEEGBCITasks::test_iter_annotation_windows_uses_full_2s_windows -v -``` - -Expected: PASS. - -- [x] **Step 8: Add failing sample-generation tests** - -Append to `TestEEGBCITasks`: - -```python - def test_motor_imagery_task_returns_samples_from_raw(self): - import mne - - sfreq = 200.0 - raw = mne.io.RawArray( - np.ones((16, int(sfreq * 5))), - mne.create_info( - list(__import__("pyhealth.tasks.eegbci", fromlist=["EEGBCI_COMPAT_CHANNELS"]).EEGBCI_COMPAT_CHANNELS), - sfreq=sfreq, - ch_types=["eeg"] * 16, - ), - verbose="error", - ) - raw.set_annotations(mne.Annotations(onset=[0.0], duration=[2.0], description=["T1"])) - patient = _EEGBCIPatient("S001", [_EEGBCIEvent(signal_file="dummy.edf")]) - task = EEGMotorImageryEEGBCI(compute_stft=False, resample_rate=None, bandpass_filter=None) - - with patch("pyhealth.tasks.eegbci.mne.io.read_raw_edf", return_value=raw): - samples = task(patient) - - self.assertEqual(len(samples), 1) - sample = samples[0] - self.assertEqual(sample["patient_id"], "S001") - self.assertEqual(sample["record_id"], "R03") - self.assertEqual(sample["event_code"], "T1") - self.assertEqual(sample["task_label"], "execute_left_fist") - self.assertEqual(sample["label"], 1) - self.assertEqual(tuple(sample["signal"].shape), (16, 400)) - - def test_pattern_discovery_adds_bandpower_metadata(self): - import mne - from pyhealth.tasks.eegbci import EEGBCI_COMPAT_CHANNELS - - sfreq = 200.0 - times = np.arange(0, 2, 1 / sfreq) - alpha = np.sin(2 * np.pi * 10 * times) - raw = mne.io.RawArray( - np.tile(alpha, (16, 1)), - mne.create_info(list(EEGBCI_COMPAT_CHANNELS), sfreq=sfreq, ch_types=["eeg"] * 16), - verbose="error", - ) - raw.set_annotations(mne.Annotations(onset=[0.0], duration=[2.0], description=["T0"])) - patient = _EEGBCIPatient("S001", [_EEGBCIEvent(signal_file="dummy.edf")]) - task = EEGBCIPatternDiscovery(compute_stft=False, resample_rate=None, bandpass_filter=None) - - with patch("pyhealth.tasks.eegbci.mne.io.read_raw_edf", return_value=raw): - samples = task(patient) - - self.assertEqual(len(samples), 1) - sample = samples[0] - self.assertEqual(sample["bandpower"]["dominant_band"], "alpha") - self.assertEqual(sample["brain_state_hypothesis"], "relaxed_or_idle") - self.assertIn("interpretation", sample) -``` - -- [x] **Step 9: Implement EDF reading and sample generation** - -Add methods inside `EEGMotorImageryEEGBCI`: - -```python - def read_raw(self, signal_file: str) -> mne.io.BaseRaw: - raw = mne.io.read_raw_edf(signal_file, preload=True, verbose="error") - raw.pick_types(eeg=True, stim=False, exclude=[]) - if self.bandpass_filter is not None: - raw.filter( - l_freq=self.bandpass_filter[0], - h_freq=self.bandpass_filter[1], - verbose="error", - ) - if self.resample_rate is not None: - raw.resample(self.resample_rate, n_jobs=1, verbose="error") - return raw - - def _base_samples_from_patient(self, patient: Any) -> List[Dict[str, Any]]: - samples: List[Dict[str, Any]] = [] - for event in patient.get_events("records"): - raw = self.read_raw(event.signal_file) - data = raw.get_data(units="uV") - selected, selected_names = select_eegbci_channels( - data, raw.ch_names, self.channel_mode - ) - selected = normalize_signal(selected, self.normalization) - sfreq = float(raw.info["sfreq"]) - for idx, window in enumerate( - iter_annotation_windows(raw, int(event.run), self.window_size) - ): - signal_np = selected[:, window["start_sample"] : window["end_sample"]] - if signal_np.shape[-1] != int(round(self.window_size * sfreq)): - continue - signal = torch.FloatTensor(signal_np) - sample = { - "patient_id": patient.patient_id, - "record_id": event.record_id, - "subject_id": int(event.subject_id), - "run": int(event.run), - "run_type": event.run_type, - "signal_file": event.signal_file, - "trial_id": f"{patient.patient_id}_{event.record_id}_{idx:04d}", - "event_code": window["event_code"], - "task_label": window["task_label"], - "label_family": window["label_family"], - "label": int(window["label"]), - "signal": signal, - "channel_names": selected_names, - "start_time": window["start_time"], - "end_time": window["end_time"], - "sample_rate": sfreq, - } - if self.compute_stft: - from pyhealth.models.tfm_tokenizer import get_stft_torch - - sample["stft"] = get_stft_torch(signal.unsqueeze(0)).squeeze(0) - samples.append(sample) - raw.close() - return samples - - def __call__(self, patient: Any) -> List[Dict[str, Any]]: - return self._base_samples_from_patient(patient) -``` - -Override `__call__` in `EEGBCIPatternDiscovery`: - -```python - def __call__(self, patient: Any) -> List[Dict[str, Any]]: - samples = self._base_samples_from_patient(patient) - for sample in samples: - features = compute_band_powers( - sample["signal"].detach().cpu().numpy(), - float(sample["sample_rate"]), - ) - interpretation = interpret_band_profile(features) - sample["bandpower"] = features - sample.update(interpretation) - return samples -``` - -- [x] **Step 10: Export tasks** - -Modify `pyhealth/tasks/__init__.py`: - -```python -from pyhealth.tasks.eegbci import EEGBCIPatternDiscovery, EEGMotorImageryEEGBCI -``` - -Place it near other EEG task exports. - -- [x] **Step 11: Run task tests** - -Run: - -```bash -pytest tests/core/test_eegbci.py::TestEEGBCITasks -v -``` - -Expected: PASS. - -- [x] **Step 12: Commit task 3** - -Run: - -```bash -git add pyhealth/tasks/eegbci.py pyhealth/tasks/__init__.py tests/core/test_eegbci.py -git commit -m "feat: add EEGBCI tasks" -``` - -## Task 4: Real-Data Smoke Test - -**Files:** - -- Modify: `tests/core/test_eegbci.py` - -**Interfaces:** - -- Consumes: `PYHEALTH_RUN_REAL_EEGBCI=1`. -- Produces: skipped-by-default network/data smoke test. - -- [x] **Step 1: Add skipped-by-default smoke test** - -Append to `tests/core/test_eegbci.py`: - -```python -import os - - -@unittest.skipUnless( - os.environ.get("PYHEALTH_RUN_REAL_EEGBCI") == "1", - "Set PYHEALTH_RUN_REAL_EEGBCI=1 to download and test real EEGBCI data.", -) -class TestEEGBCIRealDataSmoke(unittest.TestCase): - def test_real_eegbci_subject_1_run_3_pattern_discovery(self): - with tempfile.TemporaryDirectory() as tmp: - dataset = EEGBCIDataset(root=tmp, subjects=[1], runs=[3], download=True) - sample_dataset = dataset.set_task( - EEGBCIPatternDiscovery(compute_stft=False, window_size=2.0) - ) - self.assertGreater(len(sample_dataset), 0) - sample = sample_dataset[0] - self.assertIn("signal", sample) - self.assertEqual(sample["signal"].shape[0], 16) - self.assertIn(sample["task_label"], set(EEGBCI_LABELS)) - self.assertIn("bandpower", sample) - self.assertIn("brain_state_hypothesis", sample) -``` - -- [x] **Step 2: Run default tests and confirm skip** - -Run: - -```bash -pytest tests/core/test_eegbci.py -v -``` - -Expected: PASS with `TestEEGBCIRealDataSmoke` skipped. - -- [x] **Step 3: Run opt-in smoke test when network access is acceptable** - -Run: - -```bash -PYHEALTH_RUN_REAL_EEGBCI=1 pytest tests/core/test_eegbci.py::TestEEGBCIRealDataSmoke -v -``` - -Expected: PASS after MNE downloads subject `1`, run `3`. - -- [x] **Step 4: Commit task 4** - -Run: - -```bash -git add tests/core/test_eegbci.py -git commit -m "test: add opt-in EEGBCI real-data smoke test" -``` - -## Task 5: CELM-Equivalent Example - -**Files:** - -- Create: `examples/eeg/eegbci/README.md` -- Create: `examples/eeg/eegbci/eegbci_pattern_discovery.py` - -**Interfaces:** - -- Consumes: `EEGBCIDataset` and `EEGBCIPatternDiscovery`. -- CLI flags: `--root`, `--subjects`, `--runs`, `--output-dir`, `--max-windows`, `--download`. -- Produces: `eegbci_pattern_windows.csv` and `eegbci_pattern_summary.md`. - -- [x] **Step 1: Create example script** - -Create `examples/eeg/eegbci/eegbci_pattern_discovery.py`: - -```python -from __future__ import annotations - -import argparse -from collections import Counter -from pathlib import Path - -import pandas as pd - -from pyhealth.datasets import EEGBCIDataset -from pyhealth.tasks import EEGBCIPatternDiscovery - - -def parse_int_list(value: str) -> list[int]: - items: list[int] = [] - for part in value.split(","): - if "-" in part: - start, end = part.split("-", 1) - items.extend(range(int(start), int(end) + 1)) - else: - items.append(int(part)) - return items - - -def sample_to_row(sample: dict) -> dict: - bandpower = sample["bandpower"] - return { - "patient_id": sample["patient_id"], - "record_id": sample["record_id"], - "subject_id": sample["subject_id"], - "run": sample["run"], - "run_type": sample["run_type"], - "trial_id": sample["trial_id"], - "event_code": sample["event_code"], - "task_label": sample["task_label"], - "label_family": sample["label_family"], - "label": sample["label"], - "start_time": sample["start_time"], - "end_time": sample["end_time"], - "dominant_band": bandpower["dominant_band"], - "alpha_beta_ratio": bandpower["alpha_beta_ratio"], - "theta_beta_ratio": bandpower["theta_beta_ratio"], - "brain_state_hypothesis": sample["brain_state_hypothesis"], - "confidence": sample["confidence"], - "quality_flags": sample["quality_flags"], - "interpretation": sample["interpretation"], - **{key: value for key, value in bandpower.items() if key.endswith("_power")}, - **{key: value for key, value in bandpower.items() if key.endswith("_relative")}, - } - - -def write_summary(rows: list[dict], path: Path) -> None: - task_counts = Counter(row["task_label"] for row in rows) - hypothesis_counts = Counter(row["brain_state_hypothesis"] for row in rows) - lines = [ - "# EEGBCI Pattern Discovery Summary", - "", - "Brain-state hypotheses are exploratory signal metadata, not clinical diagnoses.", - "", - f"Processed windows: {len(rows)}", - "", - "## Task Labels", - "", - ] - for label, count in task_counts.most_common(): - lines.append(f"- {label}: {count}") - lines.extend(["", "## Brain-State Hypotheses", ""]) - for label, count in hypothesis_counts.most_common(): - lines.append(f"- {label}: {count}") - path.write_text("\n".join(lines) + "\n", encoding="utf-8") - - -def main() -> None: - parser = argparse.ArgumentParser() - parser.add_argument("--root", default="~/.cache/pyhealth/eegbci") - parser.add_argument("--subjects", default="1,2,3") - parser.add_argument("--runs", default="3-14") - parser.add_argument("--output-dir", default="outputs/eegbci_pattern_discovery") - parser.add_argument("--max-windows", type=int, default=None) - parser.add_argument("--download", action="store_true") - args = parser.parse_args() - - output_dir = Path(args.output_dir).expanduser() - output_dir.mkdir(parents=True, exist_ok=True) - - dataset = EEGBCIDataset( - root=str(Path(args.root).expanduser()), - subjects=parse_int_list(args.subjects), - runs=parse_int_list(args.runs), - download=args.download, - ) - sample_dataset = dataset.set_task(EEGBCIPatternDiscovery(compute_stft=False)) - - rows = [] - for idx, sample in enumerate(sample_dataset): - if args.max_windows is not None and idx >= args.max_windows: - break - rows.append(sample_to_row(sample)) - - csv_path = output_dir / "eegbci_pattern_windows.csv" - summary_path = output_dir / "eegbci_pattern_summary.md" - pd.DataFrame(rows).to_csv(csv_path, index=False) - write_summary(rows, summary_path) - print(f"Wrote {csv_path}") - print(f"Wrote {summary_path}") - - -if __name__ == "__main__": - main() -``` - -- [x] **Step 2: Create README** - -Create `examples/eeg/eegbci/README.md`: - -````markdown -# EEGBCI Pattern Discovery - -This example uses `EEGBCIDataset` and `EEGBCIPatternDiscovery` to create -2-second EEGBCI windows with task labels, Welch bandpower features, and cautious -frequency-profile interpretations. - -The interpretations are exploratory signal metadata. They are not clinical -diagnoses and do not prove a subject's cognition. - -Run a tiny real-data example: - -```bash -python examples/eeg/eegbci/eegbci_pattern_discovery.py \ - --subjects 1 \ - --runs 3 \ - --max-windows 20 \ - --download -``` - -Outputs are written to `outputs/eegbci_pattern_discovery/` by default: - -- `eegbci_pattern_windows.csv` -- `eegbci_pattern_summary.md` -```` - -- [x] **Step 3: Run example on a tiny subset** - -Run: - -```bash -python examples/eeg/eegbci/eegbci_pattern_discovery.py --subjects 1 --runs 3 --max-windows 20 --download -``` - -Expected: - -```text -Wrote outputs/eegbci_pattern_discovery/eegbci_pattern_windows.csv -Wrote outputs/eegbci_pattern_discovery/eegbci_pattern_summary.md -``` - -- [x] **Step 4: Commit task 5** - -Run: - -```bash -git add examples/eeg/eegbci/README.md examples/eeg/eegbci/eegbci_pattern_discovery.py -git commit -m "docs: add EEGBCI pattern discovery example" -``` - -## Task 6: API Documentation - -**Files:** - -- Create: `docs/api/datasets/pyhealth.datasets.EEGBCIDataset.rst` -- Create: `docs/api/tasks/pyhealth.tasks.eegbci.rst` -- Modify: `docs/api/datasets.rst` -- Modify: `docs/api/tasks.rst` - -**Interfaces:** - -- Consumes: public classes and helpers from tasks 2 and 3. -- Produces: Sphinx API pages. - -- [x] **Step 1: Add dataset API page** - -Create `docs/api/datasets/pyhealth.datasets.EEGBCIDataset.rst`: - -```rst -pyhealth.datasets.EEGBCIDataset -================================ - -.. autoclass:: pyhealth.datasets.EEGBCIDataset - :members: - :undoc-members: - :show-inheritance: -``` - -- [x] **Step 2: Add task API page** - -Create `docs/api/tasks/pyhealth.tasks.eegbci.rst`: - -```rst -pyhealth.tasks.eegbci -===================== - -.. automodule:: pyhealth.tasks.eegbci - :members: - :undoc-members: - :show-inheritance: -``` - -- [x] **Step 3: Include pages in API indexes** - -Add the dataset page to the relevant `.. toctree::` in `docs/api/datasets.rst`: - -```rst - datasets/pyhealth.datasets.EEGBCIDataset -``` - -Add the task page to the relevant `.. toctree::` in `docs/api/tasks.rst`: - -```rst - tasks/pyhealth.tasks.eegbci -``` - -- [x] **Step 4: Run docs import smoke** - -Run: - -```bash -python - <<'PY' -from pyhealth.datasets import EEGBCIDataset -from pyhealth.tasks import EEGBCIPatternDiscovery, EEGMotorImageryEEGBCI -print(EEGBCIDataset.__name__) -print(EEGMotorImageryEEGBCI.__name__) -print(EEGBCIPatternDiscovery.__name__) -PY -``` - -Expected: - -```text -EEGBCIDataset -EEGMotorImageryEEGBCI -EEGBCIPatternDiscovery -``` - -- [x] **Step 5: Commit task 6** - -Run: - -```bash -git add docs/api/datasets/pyhealth.datasets.EEGBCIDataset.rst docs/api/tasks/pyhealth.tasks.eegbci.rst docs/api/datasets.rst docs/api/tasks.rst -git commit -m "docs: add EEGBCI API docs" -``` - -## Final Verification - -Run after all tasks: - -```bash -pytest tests/core/test_eegbci.py -v -python - <<'PY' -from pyhealth.datasets import EEGBCIDataset -from pyhealth.tasks import EEGBCIPatternDiscovery, EEGMotorImageryEEGBCI -print("imports ok") -PY -``` - -Optional network/data verification: - -```bash -PYHEALTH_RUN_REAL_EEGBCI=1 pytest tests/core/test_eegbci.py::TestEEGBCIRealDataSmoke -v -python examples/eeg/eegbci/eegbci_pattern_discovery.py --subjects 1 --runs 3 --max-windows 20 --download -``` - -Graph update after code changes: - -```bash -graphify update . -``` - -## Engineering Review - -Review mode: GStack `/plan-eng-review` -Review date: 2026-07-07 -Review inputs: `brainstorm.md`, `design.md`, existing PyHealth EEG dataset/task files, current plan. - -### Architecture - -Decision: keep the dataset as metadata-only and put EDF reading/windowing in tasks. - -Reason: this matches `pyhealth/datasets/tuab.py`, `pyhealth/datasets/tuev.py`, and `pyhealth/tasks/temple_university_EEG_tasks.py`. It also keeps MNE Raw objects out of cached dataset metadata. - -### Data Flow - -Risk found: the previous draft did not specify whether annotations are windowed before or after resampling. - -Resolution: read/filter/resample first, then window from `raw.annotations` using the post-resample `raw.info["sfreq"]`. MNE keeps annotation onsets in seconds, so sample indices must be computed only after the final sample rate is known. - -### Channel Strategy - -Risk found: defaulting to all 64 EEGBCI channels would break many existing EEG model assumptions. - -Resolution: default `channel_mode="compat16"` with named central motor channels. Allow `channel_mode="all"` for research use. Do not reuse TUAB/TUEV bipolar montage code because EEGBCI channel names and montage semantics differ. - -### Label Semantics - -Risk found: `T1` and `T2` are easy to decode incorrectly because their meaning depends on run number. - -Resolution: keep `task_label_for_event(run, event_code)` as a pure helper with direct unit tests. Do not inline this mapping in task code. - -### Edge Cases - -- Missing local EDF with `download=False`: raise `FileNotFoundError` that explicitly suggests `download=True`. -- Unsupported run outside `3-14`: raise `ValueError`. -- Non-`T0`/`T1`/`T2` annotations: skip. -- Annotation shorter than `window_size`: emit no sample. -- Last partial window inside an annotation: skip to preserve fixed tensor shapes. -- Missing compatibility channels: raise `ValueError` listing missing channel names. -- `resample_rate=None`: preserve original sample rate and record it in each sample. -- `compute_stft=False`: remove `stft` from `input_schema`. -- Normal CI: never runs the real-data smoke test unless `PYHEALTH_RUN_REAL_EEGBCI=1`. - -### Test Coverage - -The plan covers: - -- Run-aware labels. -- Channel selection. -- Bandpower on synthetic alpha and beta sinusoids. -- Interpretation metadata and non-clinical wording. -- Dataset metadata generation with local files and mocked MNE download. -- Task sample schema with MNE `RawArray`. -- Skipped-by-default real data test. -- Example smoke path. - -Remaining risk: MNE's real EEGBCI channel labels may vary slightly from the expected names. The opt-in real-data smoke test is the guardrail for that. If it fails, adjust `normalize_eegbci_channel_name` rather than weakening the default channel contract. - -### Performance - -The first implementation reads EDF files inside task execution, matching current PyHealth EEG tasks. This is acceptable for the small example and keeps the first pass simple. If users process many subjects/runs repeatedly, add a later cache for preprocessed windows rather than doing it in this first feature. - -### Scope Decision - -Approved first scope: Approach B only. - -Do not include the optional embedding comparison in the initial implementation. It depends on model/checkpoint/channel compatibility decisions that should be made after the core dataset and task path works on real data. - -## Progress Log - -- 2026-07-07: Converted draft requirements/design into a concrete TDD implementation plan using `superpowers:writing-plans`. -- 2026-07-07: Ran automatic engineering review and incorporated decisions on dataset/task boundaries, channel strategy, annotation timing, edge cases, and test coverage. -- 2026-07-07: Task 1 complete on branch `eegbci-pattern-discovery`; helper tests pass with `.venv/bin/python -m pytest tests/core/test_eegbci.py::TestEEGBCIHelpers -v`. -- 2026-07-07: Task 2 complete; dataset metadata tests pass with `.venv/bin/python -m pytest tests/core/test_eegbci.py::TestEEGBCIDataset -v`. -- 2026-07-07: Task 3 complete; `.venv/bin/python -m pytest tests/core/test_eegbci.py -v` passes with 21 tests. -- 2026-07-07: Task 4 complete; normal EEGBCI tests pass with 21 passed/1 skipped, and `PYHEALTH_RUN_REAL_EEGBCI=1 .venv/bin/python -m pytest tests/core/test_eegbci.py::TestEEGBCIRealDataSmoke -v` passes against subject 1 run 3. -- 2026-07-07: Task 5 complete; `.venv/bin/python examples/eeg/eegbci/eegbci_pattern_discovery.py --subjects 1 --runs 3 --max-windows 20 --download` writes a 20-row CSV and Markdown summary. -- 2026-07-07: Task 6 complete; docs import smoke prints `EEGBCIDataset`, `EEGMotorImageryEEGBCI`, and `EEGBCIPatternDiscovery`. -- 2026-07-07: Final verification complete; default EEGBCI tests pass with 21 passed/1 skipped, import smoke prints `imports ok`, opt-in real-data smoke passes, the example writes verified 20-row artifacts, and `graphify update .` refreshed the code graph. -- 2026-07-08: Ran GStack `/office-hours` and `/plan-ceo-review` after inspecting weak generated outputs. The new product target is an analysis-grade moment report: for each 2-second EEG segment, infer the likely frequency-pattern state, expose evidence, compare it to the experimental task label, and make low-confidence collapse explicit. - -## GSTACK REVIEW REPORT - -| Review | Trigger | Why | Runs | Status | Findings | -|--------|---------|-----|------|--------|----------| -| CEO Review | `/plan-ceo-review` | Scope & strategy | 1 | CLEAR | Selective expansion: 5 proposals, 4 accepted, 1 deferred. Accepted: rest-normalized evidence, parseable quality flags, representative window cards, analysis versioning. Deferred: HTML report. | -| Codex Review | `/codex review` | Independent 2nd opinion | 0 | — | — | -| Eng Review | `/plan-eng-review` | Architecture & tests (required) | 1 | STALE | Prior engineering review cleared the original dataset/task/example implementation, but it predates the new moment-report output contract. | -| Design Review | `/plan-design-review` | UI/UX gaps | 0 | — | No UI scope. | -| DX Review | `/plan-devex-review` | Developer experience gaps | 0 | — | — | - -**CEO:** Scope baseline is Approach B with Approach D's north star: build the analysis-grade moment report now, not the full atlas. - -**ACCEPTED SCOPE:** Add rest-normalized evidence, parseable quality booleans, representative window cards, and `analysis_version`. - -**DEFERRED:** HTML report belongs to the later atlas phase. - -**BOUNDARY DECISION:** Keep `state_hypothesis`, `evidence_score`, rest-normalized deltas, `task_state_relation`, and related moment-report fields example-only in `examples/eeg/eegbci/eegbci_pattern_discovery.py`. Do not add them to the reusable `EEGBCIPatternDiscovery` task API in this PR. - -**VERDICT:** CEO REVIEW CLEAR. Fresh engineering review is recommended after the output contract is implemented because the prior `/plan-eng-review` predates the new analysis scope. diff --git a/docs/eeg_pattern_discovery/moment_report_continuation_plan.md b/docs/eeg_pattern_discovery/moment_report_continuation_plan.md deleted file mode 100644 index ad5435dc2..000000000 --- a/docs/eeg_pattern_discovery/moment_report_continuation_plan.md +++ /dev/null @@ -1,481 +0,0 @@ -# EEGBCI Moment Report Continuation Plan - -Date: 2026-07-08 -Status: ready for implementation -Branch: eegbci-pattern-discovery - -## Context - -The previous EEGBCI implementation plan has already been implemented. - -Do not restart from scratch. - -The existing implementation already added: - -- `EEGBCIDataset` -- `EEGMotorImageryEEGBCI` -- `EEGBCIPatternDiscovery` -- EEGBCI dataset/task exports -- EEGBCI docs/API pages -- offline unit tests -- skipped-by-default real-data smoke test -- `examples/eeg/eegbci/eegbci_pattern_discovery.py` -- `examples/eeg/eegbci/README.md` - -The problem is not missing core implementation. The problem is output quality. - -The current generated artifacts in `outputs/eegbci_pattern_discovery/` are weak: - -- Markdown summary reports only counts. -- All inspected windows collapse to `mixed_frequency_profile`. -- All inspected windows have `confidence=low`. -- All inspected windows have `quality_flags=low_confidence`. -- The summary does not compare task labels with inferred frequency-pattern states. -- The repeated row text `"This is exploratory signal metadata..."` is not useful. - -## New North Star - -Answer this question: - -> What is the brain doing in each moment, according to its frequency patterns? - -More precisely: - -> For each 2-second EEG segment, infer the most likely functional brain-state -> hypothesis from its frequency-band profile, then compare that hypothesis to the -> experimental task label. - -This moves the output from: - -> "Did the code produce a CSV?" - -to: - -> "At this moment, does the EEG look idle, motor-engaged, slow-wave dominant, -> artifact-like, mixed, or ambiguous, and does that match the task?" - -## Source Documents - -- `docs/eeg_pattern_discovery/brainstorm.md` -- `docs/eeg_pattern_discovery/design.md` -- `docs/eeg_pattern_discovery/pattern_analysis_redesign.md` -- `docs/eeg_pattern_discovery/implementation_plan.md` -- local CEO plan: - `/Users/vihaanagrawal/.gstack/projects/sunlabuiuc-PyHealth/ceo-plans/2026-07-08-eegbci-moment-report.md` - -## Scope - -Implement only the moment-report upgrade. - -Primary files: - -- `examples/eeg/eegbci/eegbci_pattern_discovery.py` -- `tests/core/test_eegbci.py` -- `examples/eeg/eegbci/README.md` -- this continuation plan, as progress is made - -Avoid touching: - -- `pyhealth/datasets/eegbci.py` -- `pyhealth/tasks/eegbci.py` -- dataset/task exports -- API RST docs - -Only touch `pyhealth/tasks/eegbci.py` if implementation proves a field must become -part of the reusable task API. Current decision: it should not. - -## Explicit Non-Goals - -Do not implement: - -- the full static brain-state atlas -- HTML report -- pretrained embedding comparison -- clustering or motif discovery -- subject-shift reliability suite -- clinical or cognitive claims -- new package dependencies -- new PyHealth model-training APIs -- a rewrite of the existing EEGBCI dataset/task work - -## CEO Review Decisions - -Mode: Selective Expansion. - -Baseline approach: analysis-grade moment report now, with the future atlas as the -north star. - -Accepted additions: - -1. Rest-normalized evidence. -2. Parseable quality flags. -3. Representative window cards. -4. Analysis versioning. - -Deferred: - -- HTML report. It belongs to the later atlas phase after CSV/Markdown prove useful. - -Boundary decision: - -- Keep moment-report fields example-only. -- Do not add `state_hypothesis`, `evidence_score`, rest-normalized deltas, - `task_state_relation`, or related fields to `EEGBCIPatternDiscovery` samples in - this PR. - -Reason: - -- These fields depend on cross-window context such as rest baselines and task-label - comparisons. They belong in the artifact generator, not the reusable per-sample - PyHealth task. - -## Product Thesis - -The useful artifact is not "a CSV with bandpower columns." - -The useful artifact is a moment-by-moment EEG state ledger. - -Each 2-second segment should tell a researcher: - -- what the subject was instructed to do -- what the EEG frequency profile looked like -- which functional state hypothesis best fits that profile -- how strong or weak the evidence is -- whether the frequency pattern supports, adds detail to, disagrees with, or is - ambiguous relative to the experimental task label - -## Required CSV Fields - -Keep existing useful fields: - -- `patient_id` -- `record_id` -- `subject_id` -- `run` -- `run_type` -- `trial_id` -- `event_code` -- `task_label` -- `label_family` -- `label` -- `eegbci_label` -- `model_label` -- `start_time` -- `end_time` -- bandpower absolute and relative fields -- `dominant_band` -- `alpha_beta_ratio` -- `theta_beta_ratio` -- `brain_state_hypothesis` -- `confidence` -- `quality_flags` -- `interpretation` - -Add: - -- `analysis_version` -- `state_hypothesis` -- `state_confidence` -- `evidence_score` -- `evidence_summary` -- `rest_reference_scope` -- `rest_delta_relative_delta` -- `rest_theta_relative_delta` -- `rest_alpha_relative_delta` -- `rest_beta_relative_delta` -- `rest_gamma_relative_delta` -- `task_state_relation` -- `task_state_rationale` -- `task_state_confidence` -- `is_low_confidence` -- `is_possible_artifact` -- `is_mixed_or_ambiguous` - -Use: - -```python -ANALYSIS_VERSION = "eegbci_pattern_moment_report_v1" -``` - -## Functional State Vocabulary - -Use these report-level state names: - -| State | Meaning | -| --- | --- | -| `idle_alpha_profile` | Alpha is elevated and alpha/beta is high enough to look idle-like. | -| `sensorimotor_engagement_profile` | Beta or low-gamma evidence is elevated enough to look motor-engaged. | -| `slow_wave_dominant_pattern` | Delta/theta evidence dominates, without implying cognition or diagnosis. | -| `possible_artifact_profile` | Gamma spike, extreme power, or noisy profile suggests inspection. | -| `mixed_ambiguous_profile` | No state wins cleanly or several weak signals conflict. | - -Do not use `slow_wave_drowsy_profile`. It sounds too cognitive/clinical. - -## Rest Baseline Rules - -Rest-normalized evidence is required. - -Compute baselines before applying `--max-windows` whenever possible. - -Baseline fallback order: - -1. Same subject and same run rest windows. -2. Same subject, all requested runs, rest windows. -3. All requested subjects/runs, rest windows. -4. `unavailable`. - -If no baseline exists: - -- set `rest_reference_scope = "unavailable"` -- set rest-normalized deltas to `NaN` or blank -- state the limitation in Markdown - -Do not silently use a missing baseline. - -## Task-State Relation Rules - -Use this deterministic first-pass table: - -| Task family | State hypothesis | Relation | -| --- | --- | --- | -| rest | `idle_alpha_profile` | `supports_label` | -| rest | `mixed_ambiguous_profile` | `ambiguous` | -| rest | `possible_artifact_profile` | `not_applicable` | -| motor execution | `sensorimotor_engagement_profile` | `supports_label` | -| motor imagery | `sensorimotor_engagement_profile` | `adds_detail` | -| any motor task | `idle_alpha_profile` | `disagrees` | -| any task | `slow_wave_dominant_pattern` | `adds_detail` | -| any task | `possible_artifact_profile` | `not_applicable` | -| any task | `mixed_ambiguous_profile` | `ambiguous` | - -Add one sentence of rationale in `task_state_rationale`. - -## Representative Window Cards - -Markdown summary must include deterministic representative windows. - -Cards to include when present: - -- strongest idle-like window -- strongest motor-engaged window -- strongest slow-wave dominant window -- strongest artifact-like window -- most ambiguous window -- strongest task/state disagreement - -Selection rules: - -1. Pick highest `evidence_score` for each state class. -2. Tie-break by higher `state_confidence`. -3. Tie-break by earliest `subject_id`, then `run`, then `start_time`. -4. If a state class is absent, omit it and list it as absent. -5. Add one disagreement card using the highest-evidence row where - `task_state_relation == "disagrees"`. - -Each card should include: - -- subject -- run -- trial id -- task label -- time range -- state -- evidence score -- dominant band -- relative band values -- rest-normalized deltas -- task-state relation -- confidence -- quality flags -- one-line rationale - -## Markdown Summary Contract - -The summary must not start with the generic exploratory caveat. - -Required sections: - -1. Executive result. -2. Run configuration. -3. Window coverage. -4. Moment-state summary. -5. Task label x state matrix. -6. Rest-normalized bandpower summary. -7. Confidence and quality audit. -8. Representative windows. -9. Limitations. -10. Next checks. - -The non-clinical warning should live in `Limitations`, for example: - -> These labels are signal-pattern summaries from short EEG windows. They are not -> clinical findings and should not be read as evidence of a subject's cognition. - -The summary must explicitly say when: - -- every window is low confidence -- every window maps to the same state -- no rest baseline is available -- output was capped by `--max-windows` - -## Implementation Shape - -Keep helper functions pure and testable. - -Recommended shape inside `examples/eeg/eegbci/eegbci_pattern_discovery.py`: - -```python -ANALYSIS_VERSION = "eegbci_pattern_moment_report_v1" - -def build_rest_baselines(rows): ... -def annotate_moment_rows(rows, baselines): ... -def derive_state_hypothesis(row): ... -def derive_quality_columns(row): ... -def task_state_relation(row): ... -def select_representative_windows(rows): ... -def render_summary(rows, config): ... -``` - -Avoid turning `write_summary()` into a giant function. - -Do not create new modules unless this file becomes genuinely hard to read. - -## Test Plan - -Add focused tests around report helpers using synthetic rows. - -Required tests: - -- rest baseline fallback: - - same-run rest - - same-subject all-run rest - - global rest - - unavailable baseline -- parseable quality flags: - - booleans match `quality_flags` - - ambiguous state sets `is_mixed_or_ambiguous` -- task-state comparison: - - deterministic relation table - - rationale is non-empty -- representative windows: - - deterministic selection - - tie-breaks are stable - - absent state classes are handled -- analysis version: - - appears in every CSV row - - appears near the top of Markdown -- interpretation language: - - row-level interpretation does not contain - `"This is exploratory signal metadata"` -- empty/edge outputs: - - no rest windows - - all low-confidence rows - - all same-state rows - - `--max-windows=0` - -Keep existing tests for dataset/task behavior. - -## Verification Commands - -Use the project venv. Plain `python` may not exist in this workspace. - -```bash -.venv/bin/python -m pytest tests/core/test_eegbci.py -v - -.venv/bin/python examples/eeg/eegbci/eegbci_pattern_discovery.py \ - --subjects 1 \ - --runs 3 \ - --max-windows 20 \ - --download -``` - -Then validate: - -- CSV contains all required moment-report columns. -- Markdown contains all required sections. -- Markdown does not start with the generic exploratory caveat. -- Markdown includes representative windows. -- Markdown explains all-low-confidence or all-ambiguous outcomes when they happen. - -After code changes: - -```bash -graphify update . -``` - -## Progress Log - -- 2026-07-08: Created continuation plan after `/office-hours` and - `/plan-ceo-review`. This plan explicitly continues from the already-implemented - EEGBCI dataset/task/example work and scopes only the moment-report upgrade. -- 2026-07-08: Refined and challenged the continuation plan through - `superpowers:brainstorming`. Wrote the approved design to - `docs/eeg_pattern_discovery/moment_report_refined_design.md`. -- 2026-07-08: Converted the approved refined design into - `docs/eeg_pattern_discovery/moment_report_implementation_plan.md` using - `superpowers:writing-plans`. Ran GStack `/plan-eng-review` against that plan - before code implementation; the review approved the example-owned architecture - and tightened empty CSV handling with stable `OUTPUT_COLUMNS`. -- 2026-07-08: Added an extensive correctness test matrix to the implementation - plan, covering rest fallback, state scoring, task-state relation, quality - booleans, representative windows, Markdown rendering, empty CSV behavior, - `--max-windows=0`, uncapped baselines, and end-to-end artifact checks. -- 2026-07-08: Added a post-implementation review gate to the implementation - plan. The recommended workflow is same-chat orchestration, independent - sub-agent or fresh review context for GStack `/review`, Markdown review output - at `docs/eeg_pattern_discovery/moment_report_review.md`, then main-chat fix - implementation and re-verification. -- 2026-07-08: Tightened the post-implementation review gate: final GStack - `/review` must run in an independent sub-agent or separate session, not inline - in the same reasoning thread that implemented the feature. -- 2026-07-08: Added an autonomous execution contract to the implementation plan. - It now explicitly requires Tasks 1-10, focused and full verification, artifact - checks, `graphify update .`, independent GStack `/review`, review Markdown, - accepted-fix implementation, and post-fix re-verification before completion. -- 2026-07-08: Started moment-report implementation. Task 1 is complete: - added the synthetic moment-row fixture test, added `ANALYSIS_VERSION`, - `REPORT_BANDS`, and `STATE_CONFIDENCE_RANK`, confirmed the focused test failed - before implementation and passed after implementation. -- 2026-07-08: Task 2 is complete: added synthetic rest-baseline tests, confirmed - the missing-helper failure, implemented `build_rest_baselines()` and - `_mean_band_values()`, and verified rest-only averaging plus no-rest fallback. -- 2026-07-08: Task 3 is complete for the planned partial checkpoint: added rest - fallback and state-profile tests, confirmed missing-helper failures, - implemented `_baseline_for_row()`, `_clip01()`, and `derive_state_hypothesis()`, - and verified the state-profile focused test. The fallback-scope test remains - expected to pass in Task 5 when `annotate_moment_rows()` is added. -- 2026-07-08: Task 4 is complete: added deterministic task/state relation tests - and parseable quality-boolean tests, confirmed missing-helper failures, - implemented `derive_task_state_relation()` and `derive_quality_columns()`, and - verified the focused relation/quality tests. -- 2026-07-08: Task 5 is complete: added annotation/schema tests plus - band-specific delta, legacy-field preservation, and non-mutation checks; - confirmed missing schema/annotator failures; implemented `BASE_OUTPUT_COLUMNS`, - `MOMENT_REPORT_COLUMNS`, `OUTPUT_COLUMNS`, and `annotate_moment_rows()`; and - verified the annotation/fallback and band-delta focused tests. -- 2026-07-08: Task 6 is complete: added deterministic representative-window - tests plus ambiguous and disagreement edge checks, confirmed the missing - selector failure, implemented stable selection helpers, and verified the - representative-window focused tests. -- 2026-07-08: Task 7 is complete: added Markdown renderer tests covering required - sections, empty output, all-low-confidence/all-same-state messaging, task/state - matrix output, representative card details, limitations placement, and removal - of the old row-level caveat; confirmed missing-renderer failures; implemented - `render_summary()` and config-aware `write_summary()`; and verified the focused - renderer tests plus the caveat regression. -- 2026-07-08: Task 8 is complete: added schema, empty CSV, patched-main - `--max-windows=0`, uncapped-baseline, analysis-version CSV, and invalid parser - tests; confirmed the old main-flow failures; updated `main()` to annotate all - rows before capping and to write stable `OUTPUT_COLUMNS`; verified the full - helper suite and full `tests/core/test_eegbci.py` file. -- 2026-07-08: Task 9 is complete: updated the EEGBCI example README with the - upgraded CSV and Markdown moment-report contract, retained the implementation - plan and GStack `/plan-eng-review` progress history, and verified documentation - references with `rg` using single quotes around the backtick-containing pattern - for zsh compatibility. -- 2026-07-08: Task 10 and post-review fixes are complete: full EEGBCI tests pass - with `56 passed, 1 skipped`, the real-data example generated 20 CSV rows and a - Markdown report with all required sections, artifact schema and confidence - consistency checks pass, `graphify update .` completed after code changes, and - the independent review findings were fixed or closed in - `docs/eeg_pattern_discovery/moment_report_review.md`. diff --git a/docs/eeg_pattern_discovery/moment_report_implementation_plan.md b/docs/eeg_pattern_discovery/moment_report_implementation_plan.md deleted file mode 100644 index 3c013f3ec..000000000 --- a/docs/eeg_pattern_discovery/moment_report_implementation_plan.md +++ /dev/null @@ -1,1702 +0,0 @@ -# EEGBCI Moment Report Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Upgrade the EEGBCI example artifact from a run receipt into a moment-by-moment frequency-pattern report with rest-normalized evidence, state hypotheses, task-state comparison, representative windows, and explicit limitations. - -**Architecture:** Keep `EEGBCIDataset`, `EEGMotorImageryEEGBCI`, and `EEGBCIPatternDiscovery` stable. Add an example-owned analysis layer in `examples/eeg/eegbci/eegbci_pattern_discovery.py` that converts per-window task samples into rows, computes rest baselines across the requested rows, annotates rows, writes CSV, and renders a Markdown analysis report. - -**Tech Stack:** Python, pandas, standard library `collections.Counter`, existing PyHealth EEGBCI dataset/task APIs, `unittest` tests in `tests/core/test_eegbci.py`. - -## Global Constraints - -- Do not add new package dependencies. -- Do not change dataset/task exports or API RST pages for this moment-report pass. -- Keep report-only fields example-level unless implementation proves they are intrinsically per-window and reusable outside the report. -- Do not use clinical or cognitive claims. -- Use `ANALYSIS_VERSION = "eegbci_pattern_moment_report_v1"`. -- Normal tests must use synthetic rows and must not download EEGBCI data. -- `--max-windows` caps the final artifact rows, not the baseline source rows. -- After code changes, run `graphify update .`. - ---- - -## File Structure - -- Modify `examples/eeg/eegbci/eegbci_pattern_discovery.py`: add pure report helper functions, update `main()` data flow, write enriched CSV, render Markdown report. -- Modify `tests/core/test_eegbci.py`: add tests for report helpers using synthetic rows. -- Modify `examples/eeg/eegbci/README.md`: document the upgraded CSV and Markdown report. -- Modify `docs/eeg_pattern_discovery/moment_report_continuation_plan.md`: keep the progress log current. - -Do not modify: - -- `pyhealth/datasets/eegbci.py` -- `pyhealth/tasks/eegbci.py` -- `pyhealth/datasets/__init__.py` -- `pyhealth/tasks/__init__.py` -- `docs/api/datasets/pyhealth.datasets.EEGBCIDataset.rst` -- `docs/api/tasks/pyhealth.tasks.eegbci.rst` -- `docs/api/datasets.rst` -- `docs/api/tasks.rst` - -## Data Flow - -```text -EEGBCIDataset - -> dataset.set_task(EEGBCIPatternDiscovery(compute_stft=False)) - -> collect all requested sample_to_row(sample) rows - -> build_rest_baselines(all_rows) - -> annotate_moment_rows(all_rows, baselines) - -> apply --max-windows cap to annotated rows - -> write eegbci_pattern_windows.csv - -> render_summary(capped_rows, config) - -> write eegbci_pattern_summary.md -``` - -## Autonomous Execution Contract - -This plan is intended to be executed end to end by an implementation agent. -It is not a shell script, but it is a complete execution contract. - -Required execution flow: - -```text -1. Execute Tasks 1-10 in order. -2. For each task: - - write the failing tests first - - run the named focused test command and confirm failure - - implement the minimal code/doc change - - run the named focused test command and confirm pass - - update `docs/eeg_pattern_discovery/moment_report_continuation_plan.md` - when progress meaningfully changes -3. After Task 10: - - run `.venv/bin/python -m pytest tests/core/test_eegbci.py -v` - - run the real-data example command when network/data access is available - - validate generated CSV and Markdown artifacts - - run `graphify update .` -4. Dispatch an independent sub-agent or separate session to run GStack `/review`. -5. Require the independent reviewer to write - `docs/eeg_pattern_discovery/moment_report_review.md`. -6. Main implementation chat reads the review document, applies accepted fixes, - and records deferred or rejected findings with rationale. -7. Rerun focused tests for every fix, then full EEGBCI tests and artifact checks. -8. Update `moment_report_review.md` with fix log, post-fix verification, and - final verdict. -9. Report completion only after code, docs, tests, artifacts, graph update, and - review fixes are complete. -``` - -Use `superpowers:subagent-driven-development` when available for Task 1-10 -implementation. Use one independent sub-agent per task or small task group when -the task can be reviewed independently. Keep the main chat responsible for -reviewing sub-agent output, applying final patches, and running verification. - -Do not stop for user input unless: - -- a required dependency or data source is unavailable -- tests fail in a way that contradicts the approved design -- the independent review finds a scope change that would modify the public - PyHealth dataset/task APIs -- a destructive or externally visible action would be required - -## Task 1: Report Constants And Synthetic Test Fixture - -**Files:** -- Modify: `examples/eeg/eegbci/eegbci_pattern_discovery.py` -- Modify: `tests/core/test_eegbci.py` - -**Interfaces:** -- Produces: `ANALYSIS_VERSION: str` -- Produces: `REPORT_BANDS: tuple[str, ...]` -- Produces: test helper `_moment_row(**overrides) -> dict` - -- [x] **Step 1: Write failing import and fixture test** - -Add this test class near the end of `tests/core/test_eegbci.py`, before `TestEEGBCIRealDataSmoke`: - -```python -class TestEEGBCIMomentReportHelpers(unittest.TestCase): - def _moment_row(self, **overrides): - row = { - "patient_id": "S001", - "record_id": "R03", - "subject_id": 1, - "run": 3, - "run_type": "motor_execution_left_right", - "trial_id": "S001_R03_T0_0", - "event_code": "T0", - "task_label": "rest", - "label_family": "rest", - "label": 0, - "eegbci_label": 0, - "model_label": 0, - "start_time": 0.0, - "end_time": 2.0, - "dominant_band": "alpha", - "delta_relative": 0.05, - "theta_relative": 0.10, - "alpha_relative": 0.55, - "beta_relative": 0.20, - "gamma_relative": 0.10, - "alpha_beta_ratio": 2.75, - "theta_beta_ratio": 0.50, - "brain_state_hypothesis": "relaxed_or_idle", - "confidence": "medium", - "quality_flags": "", - "interpretation": "Alpha-dominant profile.", - } - row.update(overrides) - return row - - def test_analysis_version_constant(self): - from examples.eeg.eegbci.eegbci_pattern_discovery import ANALYSIS_VERSION - - self.assertEqual(ANALYSIS_VERSION, "eegbci_pattern_moment_report_v1") -``` - -- [x] **Step 2: Run test to verify it fails** - -Run: - -```bash -.venv/bin/python -m pytest tests/core/test_eegbci.py::TestEEGBCIMomentReportHelpers::test_analysis_version_constant -v -``` - -Expected: fail with `ImportError` or `AttributeError` for missing `ANALYSIS_VERSION`. - -- [x] **Step 3: Add constants** - -Add below the PyHealth imports in `examples/eeg/eegbci/eegbci_pattern_discovery.py`: - -```python -ANALYSIS_VERSION = "eegbci_pattern_moment_report_v1" -REPORT_BANDS = ("delta", "theta", "alpha", "beta", "gamma") -STATE_CONFIDENCE_RANK = {"low": 0, "medium": 1, "high": 2} -``` - -- [x] **Step 4: Run test to verify it passes** - -Run: - -```bash -.venv/bin/python -m pytest tests/core/test_eegbci.py::TestEEGBCIMomentReportHelpers::test_analysis_version_constant -v -``` - -Expected: pass. - -## Task 2: Rest Baseline Builder - -**Files:** -- Modify: `examples/eeg/eegbci/eegbci_pattern_discovery.py` -- Modify: `tests/core/test_eegbci.py` - -**Interfaces:** -- Consumes: row dicts with `subject_id`, `run`, `task_label`, and `{band}_relative` -- Produces: `build_rest_baselines(rows: list[dict]) -> dict` - -The returned dict must include: - -```python -{ - "same_subject_run": {(1, 3): {"delta_relative": 0.05, ...}}, - "same_subject_all_runs": {1: {"delta_relative": 0.06, ...}}, - "global_rest": {"delta_relative": 0.07, ...}, -} -``` - -- [x] **Step 1: Write failing baseline tests** - -Add these tests to `TestEEGBCIMomentReportHelpers`: - -```python - def test_build_rest_baselines_uses_rest_rows_only(self): - from examples.eeg.eegbci.eegbci_pattern_discovery import build_rest_baselines - - rows = [ - self._moment_row(task_label="rest", subject_id=1, run=3, alpha_relative=0.50), - self._moment_row(task_label="execute_left_fist", subject_id=1, run=3, alpha_relative=0.90), - self._moment_row(task_label="rest", subject_id=1, run=4, alpha_relative=0.70), - ] - - baselines = build_rest_baselines(rows) - - self.assertAlmostEqual( - baselines["same_subject_run"][(1, 3)]["alpha_relative"], 0.50 - ) - self.assertAlmostEqual( - baselines["same_subject_all_runs"][1]["alpha_relative"], 0.60 - ) - self.assertAlmostEqual(baselines["global_rest"]["alpha_relative"], 0.60) - - def test_build_rest_baselines_handles_no_rest_rows(self): - from examples.eeg.eegbci.eegbci_pattern_discovery import build_rest_baselines - - rows = [self._moment_row(task_label="execute_left_fist", label_family="motor_execution")] - - baselines = build_rest_baselines(rows) - - self.assertEqual(baselines["same_subject_run"], {}) - self.assertEqual(baselines["same_subject_all_runs"], {}) - self.assertIsNone(baselines["global_rest"]) -``` - -- [x] **Step 2: Run tests to verify they fail** - -Run: - -```bash -.venv/bin/python -m pytest \ - tests/core/test_eegbci.py::TestEEGBCIMomentReportHelpers::test_build_rest_baselines_uses_rest_rows_only \ - tests/core/test_eegbci.py::TestEEGBCIMomentReportHelpers::test_build_rest_baselines_handles_no_rest_rows \ - -v -``` - -Expected: fail because `build_rest_baselines` is missing. - -- [x] **Step 3: Implement baseline helpers** - -Add below `sample_to_row()`: - -```python -def _mean_band_values(rows: list[dict]) -> dict: - means = {} - for band in REPORT_BANDS: - key = f"{band}_relative" - values = [float(row[key]) for row in rows if row.get(key) not in ("", None)] - if values: - means[key] = sum(values) / len(values) - return means - - -def build_rest_baselines(rows: list[dict]) -> dict: - rest_rows = [row for row in rows if row.get("task_label") == "rest"] - same_subject_run = {} - same_subject_all_runs = {} - - subject_run_keys = sorted({(row["subject_id"], row["run"]) for row in rest_rows}) - for key in subject_run_keys: - subject_id, run = key - grouped = [ - row for row in rest_rows - if row["subject_id"] == subject_id and row["run"] == run - ] - same_subject_run[key] = _mean_band_values(grouped) - - subject_keys = sorted({row["subject_id"] for row in rest_rows}) - for subject_id in subject_keys: - grouped = [row for row in rest_rows if row["subject_id"] == subject_id] - same_subject_all_runs[subject_id] = _mean_band_values(grouped) - - return { - "same_subject_run": same_subject_run, - "same_subject_all_runs": same_subject_all_runs, - "global_rest": _mean_band_values(rest_rows) if rest_rows else None, - } -``` - -- [x] **Step 4: Run tests to verify they pass** - -Run: - -```bash -.venv/bin/python -m pytest tests/core/test_eegbci.py::TestEEGBCIMomentReportHelpers -k "baseline or analysis_version" -v -``` - -Expected: pass. - -## Task 3: Rest Fallback And State Scoring - -**Files:** -- Modify: `examples/eeg/eegbci/eegbci_pattern_discovery.py` -- Modify: `tests/core/test_eegbci.py` - -**Interfaces:** -- Consumes: `build_rest_baselines()` output -- Produces: `_baseline_for_row(row: dict, baselines: dict) -> tuple[str, dict | None]` -- Produces: `derive_state_hypothesis(row: dict) -> dict` - -`derive_state_hypothesis()` returns: - -```python -{ - "state_hypothesis": "idle_alpha_profile", - "state_confidence": "medium", - "evidence_score": 0.72, - "evidence_summary": "alpha=0.55; beta=0.20; gamma=0.10; alpha_beta=2.75; margin=0.21", -} -``` - -- [x] **Step 1: Write failing rest fallback and state tests** - -Add these tests: - -```python - def test_annotate_rest_fallback_scopes(self): - from examples.eeg.eegbci.eegbci_pattern_discovery import ( - annotate_moment_rows, - build_rest_baselines, - ) - - rows = [ - self._moment_row(task_label="rest", subject_id=1, run=3, alpha_relative=0.50), - self._moment_row(task_label="rest", subject_id=1, run=4, alpha_relative=0.70), - self._moment_row(task_label="execute_left_fist", label_family="motor_execution", subject_id=1, run=3, alpha_relative=0.80), - self._moment_row(task_label="execute_left_fist", label_family="motor_execution", subject_id=1, run=5, alpha_relative=0.80), - self._moment_row(task_label="execute_left_fist", label_family="motor_execution", subject_id=2, run=8, alpha_relative=0.80), - ] - - annotated = annotate_moment_rows(rows, build_rest_baselines(rows)) - - self.assertEqual(annotated[2]["rest_reference_scope"], "same_subject_run") - self.assertEqual(annotated[3]["rest_reference_scope"], "same_subject_all_runs") - self.assertEqual(annotated[4]["rest_reference_scope"], "global_rest") - - def test_derive_state_hypothesis_detects_profiles(self): - from examples.eeg.eegbci.eegbci_pattern_discovery import derive_state_hypothesis - - cases = [ - (self._moment_row(alpha_relative=0.60, beta_relative=0.12, gamma_relative=0.05, alpha_beta_ratio=5.0), "idle_alpha_profile"), - (self._moment_row(alpha_relative=0.12, beta_relative=0.48, gamma_relative=0.16, alpha_beta_ratio=0.25), "sensorimotor_engagement_profile"), - (self._moment_row(delta_relative=0.42, theta_relative=0.36, alpha_relative=0.08, beta_relative=0.08), "slow_wave_dominant_pattern"), - (self._moment_row(gamma_relative=0.48, alpha_relative=0.10, beta_relative=0.12), "possible_artifact_profile"), - (self._moment_row(delta_relative=0.18, theta_relative=0.20, alpha_relative=0.22, beta_relative=0.21, gamma_relative=0.19, alpha_beta_ratio=1.05), "mixed_ambiguous_profile"), - ] - - for row, expected in cases: - with self.subTest(expected=expected): - result = derive_state_hypothesis(row) - self.assertEqual(result["state_hypothesis"], expected) - self.assertIn(result["state_confidence"], {"low", "medium", "high"}) - self.assertGreaterEqual(result["evidence_score"], 0.0) - self.assertLessEqual(result["evidence_score"], 1.0) - self.assertIn("alpha=", result["evidence_summary"]) -``` - -- [x] **Step 2: Run tests to verify they fail** - -Run: - -```bash -.venv/bin/python -m pytest tests/core/test_eegbci.py::TestEEGBCIMomentReportHelpers -k "fallback or profiles" -v -``` - -Expected: fail because `annotate_moment_rows` and `derive_state_hypothesis` are missing. - -- [x] **Step 3: Implement fallback and scoring** - -Add below `build_rest_baselines()`: - -```python -def _baseline_for_row(row: dict, baselines: dict) -> tuple[str, dict | None]: - subject_run_key = (row["subject_id"], row["run"]) - if subject_run_key in baselines["same_subject_run"]: - return "same_subject_run", baselines["same_subject_run"][subject_run_key] - if row["subject_id"] in baselines["same_subject_all_runs"]: - return "same_subject_all_runs", baselines["same_subject_all_runs"][row["subject_id"]] - if baselines["global_rest"]: - return "global_rest", baselines["global_rest"] - return "unavailable", None - - -def _clip01(value: float) -> float: - return max(0.0, min(1.0, value)) - - -def derive_state_hypothesis(row: dict) -> dict: - delta = float(row.get("delta_relative", 0.0) or 0.0) - theta = float(row.get("theta_relative", 0.0) or 0.0) - alpha = float(row.get("alpha_relative", 0.0) or 0.0) - beta = float(row.get("beta_relative", 0.0) or 0.0) - gamma = float(row.get("gamma_relative", 0.0) or 0.0) - alpha_beta = float(row.get("alpha_beta_ratio", 0.0) or 0.0) - theta_beta = float(row.get("theta_beta_ratio", 0.0) or 0.0) - - scores = { - "idle_alpha_profile": _clip01((alpha - 0.25) + min(alpha_beta / 8.0, 0.40)), - "sensorimotor_engagement_profile": _clip01((beta - 0.20) + max(gamma - 0.12, 0.0) + max(0.0, 1.5 - alpha_beta) / 6.0), - "slow_wave_dominant_pattern": _clip01((delta + theta) - 0.45 + min(theta_beta / 8.0, 0.20)), - "possible_artifact_profile": _clip01((gamma - 0.22) * 2.0 + max(delta - 0.50, 0.0)), - } - ordered = sorted(scores.items(), key=lambda item: item[1], reverse=True) - winner, winning_score = ordered[0] - runner_up = ordered[1][1] - margin = winning_score - runner_up - - if winning_score < 0.20 or margin < 0.08: - state = "mixed_ambiguous_profile" - evidence_score = round(max(winning_score, 0.10), 3) - confidence = "low" - else: - state = winner - evidence_score = round(winning_score, 3) - if winning_score >= 0.65 and margin >= 0.20: - confidence = "high" - elif winning_score >= 0.35 and margin >= 0.12: - confidence = "medium" - else: - confidence = "low" - - return { - "state_hypothesis": state, - "state_confidence": confidence, - "evidence_score": evidence_score, - "evidence_summary": ( - f"delta={delta:.3f}; theta={theta:.3f}; alpha={alpha:.3f}; " - f"beta={beta:.3f}; gamma={gamma:.3f}; alpha_beta={alpha_beta:.3f}; " - f"margin={margin:.3f}" - ), - } -``` - -- [x] **Step 4: Run tests to verify current expected partial failure** - -Run: - -```bash -.venv/bin/python -m pytest tests/core/test_eegbci.py::TestEEGBCIMomentReportHelpers -k "profiles" -v -``` - -Expected: pass. The fallback test still fails until `annotate_moment_rows()` exists in Task 5. - -## Task 4: Task-State Relation And Quality Booleans - -**Files:** -- Modify: `examples/eeg/eegbci/eegbci_pattern_discovery.py` -- Modify: `tests/core/test_eegbci.py` - -**Interfaces:** -- Produces: `derive_task_state_relation(row: dict) -> dict` -- Produces: `derive_quality_columns(row: dict) -> dict` - -- [x] **Step 1: Write failing tests** - -Add: - -```python - def test_task_state_relation_table_is_deterministic(self): - from examples.eeg.eegbci.eegbci_pattern_discovery import derive_task_state_relation - - cases = [ - ("rest", "rest", "idle_alpha_profile", "supports_label"), - ("rest", "rest", "mixed_ambiguous_profile", "ambiguous"), - ("rest", "rest", "possible_artifact_profile", "not_applicable"), - ("execute_left_fist", "motor_execution", "sensorimotor_engagement_profile", "supports_label"), - ("imagine_left_fist", "motor_imagery", "sensorimotor_engagement_profile", "adds_detail"), - ("execute_left_fist", "motor_execution", "idle_alpha_profile", "disagrees"), - ("imagine_left_fist", "motor_imagery", "slow_wave_dominant_pattern", "adds_detail"), - ] - - for task_label, label_family, state, expected in cases: - with self.subTest(state=state, label_family=label_family): - result = derive_task_state_relation( - self._moment_row( - task_label=task_label, - label_family=label_family, - state_hypothesis=state, - ) - ) - self.assertEqual(result["task_state_relation"], expected) - self.assertIn(result["task_state_confidence"], {"low", "medium", "high"}) - self.assertGreater(len(result["task_state_rationale"]), 20) - - def test_quality_booleans_are_parseable(self): - from examples.eeg.eegbci.eegbci_pattern_discovery import derive_quality_columns - - flags = derive_quality_columns( - self._moment_row( - state_hypothesis="possible_artifact_profile", - state_confidence="low", - quality_flags="low_confidence; high_gamma", - ) - ) - - self.assertTrue(flags["is_low_confidence"]) - self.assertTrue(flags["is_possible_artifact"]) - self.assertFalse(flags["is_mixed_or_ambiguous"]) -``` - -- [x] **Step 2: Run tests to verify they fail** - -Run: - -```bash -.venv/bin/python -m pytest tests/core/test_eegbci.py::TestEEGBCIMomentReportHelpers -k "relation_table or quality_booleans" -v -``` - -Expected: fail because helper functions are missing. - -- [x] **Step 3: Implement relation and quality helpers** - -Add: - -```python -def derive_task_state_relation(row: dict) -> dict: - label_family = row.get("label_family", "") - task_label = row.get("task_label", "") - state = row.get("state_hypothesis", "") - - if state == "possible_artifact_profile": - relation = "not_applicable" - confidence = "medium" - rationale = "Artifact-like frequency evidence is flagged for inspection instead of task-label comparison." - elif state == "mixed_ambiguous_profile": - relation = "ambiguous" - confidence = "low" - rationale = "No frequency-profile state won clearly enough to compare strongly with the task label." - elif task_label == "rest" and state == "idle_alpha_profile": - relation = "supports_label" - confidence = "medium" - rationale = "The idle-like alpha profile is consistent with a rest-labeled EEGBCI window." - elif label_family == "motor_execution" and state == "sensorimotor_engagement_profile": - relation = "supports_label" - confidence = "medium" - rationale = "The motor-engaged frequency profile is consistent with an execution-labeled window." - elif label_family == "motor_imagery" and state == "sensorimotor_engagement_profile": - relation = "adds_detail" - confidence = "medium" - rationale = "The motor-engaged frequency profile adds signal detail to an imagery-labeled window." - elif label_family in {"motor_execution", "motor_imagery"} and state == "idle_alpha_profile": - relation = "disagrees" - confidence = "medium" - rationale = "The idle-like alpha profile does not align with a motor-labeled EEGBCI window." - elif state == "slow_wave_dominant_pattern": - relation = "adds_detail" - confidence = "low" - rationale = "The slow-wave dominant pattern adds frequency detail but is not a direct task match." - else: - relation = "ambiguous" - confidence = "low" - rationale = "The task label and frequency-profile state do not have a stronger deterministic mapping." - - return { - "task_state_relation": relation, - "task_state_rationale": rationale, - "task_state_confidence": confidence, - } - - -def derive_quality_columns(row: dict) -> dict: - flags = str(row.get("quality_flags", "")) - state = row.get("state_hypothesis", "") - confidence = row.get("state_confidence", row.get("confidence", "")) - return { - "is_low_confidence": confidence == "low" or "low_confidence" in flags, - "is_possible_artifact": state == "possible_artifact_profile" or "artifact" in flags or "high_gamma" in flags, - "is_mixed_or_ambiguous": state == "mixed_ambiguous_profile" or "ambiguous" in flags, - } -``` - -- [x] **Step 4: Run tests to verify they pass** - -Run: - -```bash -.venv/bin/python -m pytest tests/core/test_eegbci.py::TestEEGBCIMomentReportHelpers -k "relation_table or quality_booleans" -v -``` - -Expected: pass. - -## Task 5: Row Annotation And CSV Schema - -**Files:** -- Modify: `examples/eeg/eegbci/eegbci_pattern_discovery.py` -- Modify: `tests/core/test_eegbci.py` - -**Interfaces:** -- Consumes: `derive_state_hypothesis()`, `derive_task_state_relation()`, `derive_quality_columns()`, `_baseline_for_row()` -- Produces: `annotate_moment_rows(rows: list[dict], baselines: dict) -> list[dict]` -- Produces: `BASE_OUTPUT_COLUMNS: tuple[str, ...]` -- Produces: `MOMENT_REPORT_COLUMNS: tuple[str, ...]` -- Produces: `OUTPUT_COLUMNS: tuple[str, ...]` - -- [x] **Step 1: Write failing annotation tests** - -Add: - -```python - def test_annotate_moment_rows_adds_required_fields(self): - from examples.eeg.eegbci.eegbci_pattern_discovery import ( - ANALYSIS_VERSION, - annotate_moment_rows, - build_rest_baselines, - ) - - rows = [ - self._moment_row(task_label="rest", alpha_relative=0.50, beta_relative=0.20), - self._moment_row(task_label="execute_left_fist", label_family="motor_execution", alpha_relative=0.20, beta_relative=0.45), - ] - - annotated = annotate_moment_rows(rows, build_rest_baselines(rows)) - - row = annotated[1] - self.assertEqual(row["analysis_version"], ANALYSIS_VERSION) - self.assertIn(row["state_hypothesis"], { - "idle_alpha_profile", - "sensorimotor_engagement_profile", - "slow_wave_dominant_pattern", - "possible_artifact_profile", - "mixed_ambiguous_profile", - }) - self.assertIn("rest_alpha_relative_delta", row) - self.assertAlmostEqual(row["rest_alpha_relative_delta"], -0.30) - self.assertIn("task_state_relation", row) - self.assertIn("task_state_rationale", row) - self.assertIn("is_low_confidence", row) - - def test_annotate_moment_rows_marks_unavailable_rest(self): - from examples.eeg.eegbci.eegbci_pattern_discovery import ( - annotate_moment_rows, - build_rest_baselines, - ) - - rows = [self._moment_row(task_label="execute_left_fist", label_family="motor_execution")] - - annotated = annotate_moment_rows(rows, build_rest_baselines(rows)) - - self.assertEqual(annotated[0]["rest_reference_scope"], "unavailable") - self.assertEqual(annotated[0]["rest_alpha_relative_delta"], "") -``` - -- [x] **Step 2: Run tests to verify they fail** - -Run: - -```bash -.venv/bin/python -m pytest tests/core/test_eegbci.py::TestEEGBCIMomentReportHelpers -k "annotate_moment_rows or fallback_scopes" -v -``` - -Expected: fail until `annotate_moment_rows()` exists. - -- [x] **Step 3: Implement annotation and schema** - -Add below `derive_quality_columns()`: - -```python -BASE_OUTPUT_COLUMNS = ( - "patient_id", - "record_id", - "subject_id", - "run", - "run_type", - "trial_id", - "event_code", - "task_label", - "label_family", - "label", - "eegbci_label", - "model_label", - "start_time", - "end_time", - "dominant_band", - "alpha_beta_ratio", - "theta_beta_ratio", - "brain_state_hypothesis", - "confidence", - "quality_flags", - "interpretation", - "delta_power", - "theta_power", - "alpha_power", - "beta_power", - "gamma_power", - "delta_relative", - "theta_relative", - "alpha_relative", - "beta_relative", - "gamma_relative", -) - -MOMENT_REPORT_COLUMNS = ( - "analysis_version", - "state_hypothesis", - "state_confidence", - "evidence_score", - "evidence_summary", - "rest_reference_scope", - "rest_delta_relative_delta", - "rest_theta_relative_delta", - "rest_alpha_relative_delta", - "rest_beta_relative_delta", - "rest_gamma_relative_delta", - "task_state_relation", - "task_state_rationale", - "task_state_confidence", - "is_low_confidence", - "is_possible_artifact", - "is_mixed_or_ambiguous", -) - -OUTPUT_COLUMNS = BASE_OUTPUT_COLUMNS + MOMENT_REPORT_COLUMNS - - -def annotate_moment_rows(rows: list[dict], baselines: dict) -> list[dict]: - annotated = [] - for row in rows: - next_row = dict(row) - scope, baseline = _baseline_for_row(next_row, baselines) - next_row["analysis_version"] = ANALYSIS_VERSION - next_row["rest_reference_scope"] = scope - - for band in REPORT_BANDS: - source_key = f"{band}_relative" - delta_key = f"rest_{band}_relative_delta" - if baseline and source_key in baseline and next_row.get(source_key) not in ("", None): - next_row[delta_key] = round(float(next_row[source_key]) - float(baseline[source_key]), 6) - else: - next_row[delta_key] = "" - - next_row.update(derive_state_hypothesis(next_row)) - next_row.update(derive_task_state_relation(next_row)) - next_row.update(derive_quality_columns(next_row)) - annotated.append(next_row) - return annotated -``` - -- [x] **Step 4: Run tests to verify they pass** - -Run: - -```bash -.venv/bin/python -m pytest tests/core/test_eegbci.py::TestEEGBCIMomentReportHelpers -k "annotate_moment_rows or fallback_scopes" -v -``` - -Expected: pass. - -## Task 6: Representative Window Selection - -**Files:** -- Modify: `examples/eeg/eegbci/eegbci_pattern_discovery.py` -- Modify: `tests/core/test_eegbci.py` - -**Interfaces:** -- Produces: `select_representative_windows(rows: list[dict]) -> dict` - -Return shape: - -```python -{ - "cards": {"strongest_idle_like": row, "most_ambiguous": row, ...}, - "absent": ["strongest_artifact_like", ...], -} -``` - -- [x] **Step 1: Write failing representative selection tests** - -Add: - -```python - def test_select_representative_windows_is_deterministic(self): - from examples.eeg.eegbci.eegbci_pattern_discovery import select_representative_windows - - rows = [ - self._moment_row(subject_id=2, run=4, start_time=6.0, state_hypothesis="idle_alpha_profile", state_confidence="medium", evidence_score=0.80), - self._moment_row(subject_id=1, run=3, start_time=4.0, state_hypothesis="idle_alpha_profile", state_confidence="medium", evidence_score=0.80), - self._moment_row(subject_id=1, run=3, start_time=8.0, state_hypothesis="sensorimotor_engagement_profile", state_confidence="high", evidence_score=0.90), - self._moment_row(subject_id=1, run=3, start_time=10.0, state_hypothesis="mixed_ambiguous_profile", state_confidence="low", evidence_score=0.12), - self._moment_row(subject_id=1, run=3, start_time=12.0, state_hypothesis="idle_alpha_profile", task_state_relation="disagrees", state_confidence="medium", evidence_score=0.70), - ] - - selected = select_representative_windows(rows) - - self.assertEqual(selected["cards"]["strongest_idle_like"]["subject_id"], 1) - self.assertEqual(selected["cards"]["strongest_motor_engaged"]["state_hypothesis"], "sensorimotor_engagement_profile") - self.assertEqual(selected["cards"]["most_ambiguous"]["start_time"], 10.0) - self.assertEqual(selected["cards"]["strongest_task_state_disagreement"]["task_state_relation"], "disagrees") - self.assertIn("strongest_artifact_like", selected["absent"]) -``` - -- [x] **Step 2: Run test to verify it fails** - -Run: - -```bash -.venv/bin/python -m pytest tests/core/test_eegbci.py::TestEEGBCIMomentReportHelpers::test_select_representative_windows_is_deterministic -v -``` - -Expected: fail because `select_representative_windows` is missing. - -- [x] **Step 3: Implement representative selection** - -Add: - -```python -def _stable_row_key(row: dict) -> tuple: - return ( - row.get("subject_id", 0), - row.get("run", 0), - float(row.get("start_time", 0.0) or 0.0), - ) - - -def _strongest_row(rows: list[dict]) -> dict | None: - if not rows: - return None - return sorted( - rows, - key=lambda row: ( - -float(row.get("evidence_score", 0.0) or 0.0), - -STATE_CONFIDENCE_RANK.get(row.get("state_confidence", "low"), 0), - *_stable_row_key(row), - ), - )[0] - - -def select_representative_windows(rows: list[dict]) -> dict: - definitions = { - "strongest_idle_like": "idle_alpha_profile", - "strongest_motor_engaged": "sensorimotor_engagement_profile", - "strongest_slow_wave": "slow_wave_dominant_pattern", - "strongest_artifact_like": "possible_artifact_profile", - } - cards = {} - absent = [] - - for card_name, state in definitions.items(): - candidate = _strongest_row([row for row in rows if row.get("state_hypothesis") == state]) - if candidate is None: - absent.append(card_name) - else: - cards[card_name] = candidate - - ambiguous = [row for row in rows if row.get("state_hypothesis") == "mixed_ambiguous_profile"] - if ambiguous: - cards["most_ambiguous"] = sorted( - ambiguous, - key=lambda row: ( - float(row.get("evidence_score", 0.0) or 0.0), - -STATE_CONFIDENCE_RANK.get(row.get("state_confidence", "low"), 0), - *_stable_row_key(row), - ), - )[0] - else: - absent.append("most_ambiguous") - - disagreement = _strongest_row( - [row for row in rows if row.get("task_state_relation") == "disagrees"] - ) - if disagreement is None: - absent.append("strongest_task_state_disagreement") - else: - cards["strongest_task_state_disagreement"] = disagreement - - return {"cards": cards, "absent": absent} -``` - -- [x] **Step 4: Run test to verify it passes** - -Run: - -```bash -.venv/bin/python -m pytest tests/core/test_eegbci.py::TestEEGBCIMomentReportHelpers::test_select_representative_windows_is_deterministic -v -``` - -Expected: pass. - -## Task 7: Markdown Summary Renderer - -**Files:** -- Modify: `examples/eeg/eegbci/eegbci_pattern_discovery.py` -- Modify: `tests/core/test_eegbci.py` - -**Interfaces:** -- Produces: `render_summary(rows: list[dict], config: dict) -> str` -- Updates: `write_summary(rows: list[dict], path: Path, config: dict) -> None` - -- [x] **Step 1: Write failing renderer tests** - -Add: - -```python - def test_render_summary_contains_required_sections_and_limitations(self): - from examples.eeg.eegbci.eegbci_pattern_discovery import ( - ANALYSIS_VERSION, - annotate_moment_rows, - build_rest_baselines, - render_summary, - ) - - rows = [ - self._moment_row(task_label="execute_left_fist", label_family="motor_execution") - ] - annotated = annotate_moment_rows(rows, build_rest_baselines(rows)) - summary = render_summary( - annotated, - { - "subjects": [1], - "runs": [3], - "max_windows": 1, - "baseline_row_count": 1, - "output_was_capped": True, - }, - ) - - self.assertIn(ANALYSIS_VERSION, summary.splitlines()[2]) - for heading in [ - "## Executive Result", - "## Run Configuration", - "## Window Coverage", - "## Moment-State Summary", - "## Task Label x State Matrix", - "## Rest-Normalized Bandpower Summary", - "## Confidence and Quality Audit", - "## Representative Windows", - "## Limitations", - "## Next Checks", - ]: - self.assertIn(heading, summary) - self.assertIn("No rest baseline was available", summary) - self.assertIn("Output was capped by `--max-windows`", summary) - self.assertNotIn("Brain-state hypotheses are exploratory signal metadata", summary.splitlines()[2]) - - def test_render_summary_handles_empty_rows(self): - from examples.eeg.eegbci.eegbci_pattern_discovery import render_summary - - summary = render_summary( - [], - { - "subjects": [1], - "runs": [3], - "max_windows": 0, - "baseline_row_count": 0, - "output_was_capped": True, - }, - ) - - self.assertIn("No windows were produced", summary) - self.assertIn("## Limitations", summary) -``` - -- [x] **Step 2: Run tests to verify they fail** - -Run: - -```bash -.venv/bin/python -m pytest tests/core/test_eegbci.py::TestEEGBCIMomentReportHelpers -k "render_summary" -v -``` - -Expected: fail because `render_summary` is missing. - -- [x] **Step 3: Implement renderer** - -Replace existing `write_summary()` with: - -```python -def _format_count_lines(counter: Counter) -> list[str]: - if not counter: - return ["- None"] - return [f"- {label}: {count}" for label, count in counter.most_common()] - - -def _format_card(row: dict) -> list[str]: - bands = ", ".join( - f"{band}={float(row.get(f'{band}_relative', 0.0) or 0.0):.3f}" - for band in REPORT_BANDS - ) - deltas = ", ".join( - f"{band}={row.get(f'rest_{band}_relative_delta', '')}" - for band in REPORT_BANDS - ) - return [ - f"- Subject {row.get('subject_id')} run {row.get('run')} trial {row.get('trial_id')}", - f" - Task: {row.get('task_label')} from {row.get('start_time')}s to {row.get('end_time')}s", - f" - State: {row.get('state_hypothesis')} ({row.get('state_confidence')}, evidence {row.get('evidence_score')})", - f" - Dominant band: {row.get('dominant_band')}; relative bands: {bands}", - f" - Rest deltas: {deltas}; scope: {row.get('rest_reference_scope')}", - f" - Task relation: {row.get('task_state_relation')} ({row.get('task_state_confidence')})", - f" - Flags: low_confidence={row.get('is_low_confidence')}, possible_artifact={row.get('is_possible_artifact')}, mixed_or_ambiguous={row.get('is_mixed_or_ambiguous')}", - f" - Rationale: {row.get('task_state_rationale')}", - ] - - -def render_summary(rows: list[dict], config: dict) -> str: - state_counts = Counter(row.get("state_hypothesis", "missing") for row in rows) - task_counts = Counter(row.get("task_label", "missing") for row in rows) - confidence_counts = Counter(row.get("state_confidence", "missing") for row in rows) - relation_counts = Counter(row.get("task_state_relation", "missing") for row in rows) - unavailable_rest = sum(row.get("rest_reference_scope") == "unavailable" for row in rows) - low_confidence = sum(bool(row.get("is_low_confidence")) for row in rows) - artifacts = sum(bool(row.get("is_possible_artifact")) for row in rows) - ambiguous = sum(bool(row.get("is_mixed_or_ambiguous")) for row in rows) - representatives = select_representative_windows(rows) - - executive = [] - if not rows: - executive.append("No windows were produced for the requested configuration.") - else: - top_state, top_state_count = state_counts.most_common(1)[0] - executive.append( - f"Processed {len(rows)} windows. Most common state: `{top_state}` ({top_state_count}/{len(rows)})." - ) - if low_confidence == len(rows): - executive.append("Every window is low confidence.") - if len(state_counts) == 1: - executive.append("Every window maps to the same state; broaden coverage or review thresholds.") - if unavailable_rest == len(rows): - executive.append("No rest baseline was available for the emitted rows.") - if config.get("output_was_capped"): - executive.append("Output was capped by `--max-windows`.") - - lines = [ - "# EEGBCI Pattern Discovery Moment Report", - "", - f"Analysis version: `{ANALYSIS_VERSION}`", - "", - "## Executive Result", - "", - *[f"- {item}" for item in executive], - "", - "## Run Configuration", - "", - f"- Subjects: {config.get('subjects')}", - f"- Runs: {config.get('runs')}", - f"- Max windows: {config.get('max_windows')}", - f"- Baseline source rows: {config.get('baseline_row_count')}", - "", - "## Window Coverage", - "", - f"- Output windows: {len(rows)}", - f"- Task labels: {dict(task_counts)}", - "", - "## Moment-State Summary", - "", - *_format_count_lines(state_counts), - "", - "## Task Label x State Matrix", - "", - ] - - matrix = Counter( - (row.get("task_label", "missing"), row.get("state_hypothesis", "missing")) - for row in rows - ) - if matrix: - for (task_label, state), count in sorted(matrix.items()): - lines.append(f"- {task_label} x {state}: {count}") - else: - lines.append("- None") - - lines.extend([ - "", - "## Rest-Normalized Bandpower Summary", - "", - f"- Rows with unavailable rest baseline: {unavailable_rest}", - ]) - for band in REPORT_BANDS: - key = f"rest_{band}_relative_delta" - values = [float(row[key]) for row in rows if row.get(key) not in ("", None)] - if values: - lines.append(f"- {band}: mean delta {sum(values) / len(values):.3f}") - else: - lines.append(f"- {band}: unavailable") - - lines.extend([ - "", - "## Confidence and Quality Audit", - "", - f"- State confidence: {dict(confidence_counts)}", - f"- Task-state relations: {dict(relation_counts)}", - f"- Low-confidence rows: {low_confidence}", - f"- Possible artifact rows: {artifacts}", - f"- Mixed or ambiguous rows: {ambiguous}", - "", - "## Representative Windows", - "", - ]) - if representatives["cards"]: - for card_name, row in representatives["cards"].items(): - lines.append(f"### {card_name.replace('_', ' ').title()}") - lines.extend(_format_card(row)) - lines.append("") - else: - lines.append("- None") - if representatives["absent"]: - lines.append(f"- Absent representative classes: {', '.join(representatives['absent'])}") - - lines.extend([ - "", - "## Limitations", - "", - "- These labels are signal-pattern summaries from short EEG windows. They are not clinical findings and should not be read as evidence of a subject's cognition.", - ]) - if unavailable_rest: - lines.append("- No rest baseline was available for at least one emitted row.") - if config.get("output_was_capped"): - lines.append("- The output was capped, so the artifact may not represent all requested windows.") - - lines.extend([ - "", - "## Next Checks", - "", - "- Run with broader subjects/runs to verify that state diversity improves.", - "- Inspect possible artifact rows before drawing conclusions from state counts.", - "- Compare rest-normalized deltas against the raw relative band shares.", - ]) - return "\n".join(lines).rstrip() + "\n" - - -def write_summary(rows: list[dict], path: Path, config: dict) -> None: - path.write_text(render_summary(rows, config), encoding="utf-8") -``` - -- [x] **Step 4: Run tests to verify they pass** - -Run: - -```bash -.venv/bin/python -m pytest tests/core/test_eegbci.py::TestEEGBCIMomentReportHelpers -k "render_summary" -v -``` - -Expected: pass. - -## Task 8: Main Flow, Empty CSV, And Max-Windows Semantics - -**Files:** -- Modify: `examples/eeg/eegbci/eegbci_pattern_discovery.py` -- Modify: `tests/core/test_eegbci.py` - -**Interfaces:** -- Updates: `main()` so all requested rows are collected before truncation. -- Produces: empty CSV with stable columns when `--max-windows=0`. - -- [x] **Step 1: Write failing schema test** - -Add: - -```python - def test_moment_report_columns_are_declared(self): - from examples.eeg.eegbci.eegbci_pattern_discovery import ( - MOMENT_REPORT_COLUMNS, - OUTPUT_COLUMNS, - ) - - for column in [ - "patient_id", - "task_label", - "alpha_relative", - "analysis_version", - "state_hypothesis", - "state_confidence", - "evidence_score", - "evidence_summary", - "rest_reference_scope", - "rest_alpha_relative_delta", - "task_state_relation", - "task_state_rationale", - "task_state_confidence", - "is_low_confidence", - "is_possible_artifact", - "is_mixed_or_ambiguous", - ]: - self.assertIn(column, OUTPUT_COLUMNS) - self.assertIn("analysis_version", MOMENT_REPORT_COLUMNS) -``` - -- [x] **Step 2: Run focused helper tests** - -Run: - -```bash -.venv/bin/python -m pytest tests/core/test_eegbci.py::TestEEGBCIMomentReportHelpers -v -``` - -Expected: pass after previous tasks and the schema declaration. - -- [x] **Step 3: Update main flow** - -Replace the row collection and output block in `main()` with: - -```python - all_rows = [sample_to_row(sample) for sample in sample_dataset] - baselines = build_rest_baselines(all_rows) - annotated_rows = annotate_moment_rows(all_rows, baselines) - output_rows = ( - annotated_rows[: args.max_windows] - if args.max_windows is not None - else annotated_rows - ) - output_was_capped = ( - args.max_windows is not None and len(annotated_rows) > len(output_rows) - ) - - csv_path = output_dir / "eegbci_pattern_windows.csv" - summary_path = output_dir / "eegbci_pattern_summary.md" - pd.DataFrame(output_rows, columns=OUTPUT_COLUMNS).to_csv(csv_path, index=False) - write_summary( - output_rows, - summary_path, - { - "subjects": parse_int_list(args.subjects), - "runs": parse_int_list(args.runs), - "max_windows": args.max_windows, - "baseline_row_count": len(all_rows), - "output_was_capped": output_was_capped, - }, - ) - print(f"Wrote {csv_path}") - print(f"Wrote {summary_path}") -``` - -This deliberately uses `OUTPUT_COLUMNS` even when `output_rows` is empty, so -`--max-windows=0` and no-window runs still produce a parseable CSV contract. - -- [x] **Step 4: Run full EEGBCI unit test file** - -Run: - -```bash -.venv/bin/python -m pytest tests/core/test_eegbci.py -v -``` - -Expected: pass, with the real-data smoke test skipped unless `PYHEALTH_RUN_REAL_EEGBCI=1`. - -## Task 9: README And Progress Documentation - -**Files:** -- Modify: `examples/eeg/eegbci/README.md` -- Modify: `docs/eeg_pattern_discovery/moment_report_continuation_plan.md` - -**Interfaces:** -- Produces: README section describing upgraded moment report fields and limitations. -- Produces: continuation plan progress entry for implementation. - -- [x] **Step 1: Update README output description** - -Replace the CSV paragraph in `examples/eeg/eegbci/README.md` with: - -```markdown -The CSV has one row per emitted 2-second window. Key columns include subject/run -metadata, `event_code`, decoded `task_label`, raw EEGBCI numeric label -(`eegbci_label` / `label`), PyHealth model-local label (`model_label`), -absolute window timing, band powers, relative band powers, `dominant_band`, -frequency ratios, legacy `brain_state_hypothesis`, `confidence`, -`quality_flags`, and `interpretation`. - -The moment-report columns add analysis-grade fields: - -- `analysis_version` -- `state_hypothesis`, `state_confidence`, and `evidence_score` -- `evidence_summary` -- `rest_reference_scope` and rest-normalized relative band deltas -- `task_state_relation`, `task_state_rationale`, and `task_state_confidence` -- `is_low_confidence`, `is_possible_artifact`, and `is_mixed_or_ambiguous` - -The Markdown report summarizes state counts, task-label/state agreement, -rest-normalized bandpower deltas, confidence and quality flags, representative -windows, limitations, and next checks. These labels are signal-pattern -summaries from short EEG windows, not clinical findings or evidence of a -subject's cognition. -``` - -- [x] **Step 2: Update continuation plan progress** - -Append to `docs/eeg_pattern_discovery/moment_report_continuation_plan.md`: - -```markdown -- 2026-07-08: Converted the refined design into - `docs/eeg_pattern_discovery/moment_report_implementation_plan.md` and ran - GStack `/plan-eng-review` against the plan before code implementation. -``` - -- [x] **Step 3: Verify docs mention the plan** - -Run: - -```bash -rg "moment_report_implementation_plan|GStack `/plan-eng-review`" docs/eeg_pattern_discovery examples/eeg/eegbci/README.md -``` - -Expected: matches in the continuation plan and README content. - -## Task 10: Manual Artifact Verification - -**Files:** -- No planned code changes unless verification exposes a bug. - -**Interfaces:** -- Verifies: local synthetic/unit coverage and real-data example behavior. - -- [x] **Step 1: Run unit tests** - -Run: - -```bash -.venv/bin/python -m pytest tests/core/test_eegbci.py -v -``` - -Expected: pass, with real-data smoke skipped unless explicitly enabled. - -- [x] **Step 2: Run the example on a tiny real-data request** - -Run: - -```bash -.venv/bin/python examples/eeg/eegbci/eegbci_pattern_discovery.py \ - --subjects 1 \ - --runs 3 \ - --max-windows 20 \ - --download -``` - -Expected: - -- `outputs/eegbci_pattern_discovery/eegbci_pattern_windows.csv` is written. -- `outputs/eegbci_pattern_discovery/eegbci_pattern_summary.md` is written. -- CSV includes all `MOMENT_REPORT_COLUMNS`. -- Markdown includes all required sections. -- Markdown does not start with the old generic exploratory caveat. - -- [x] **Step 3: Inspect artifact schema** - -Run: - -```bash -.venv/bin/python - <<'PY' -import pandas as pd -df = pd.read_csv("outputs/eegbci_pattern_discovery/eegbci_pattern_windows.csv") -required = { - "analysis_version", - "state_hypothesis", - "state_confidence", - "evidence_score", - "evidence_summary", - "rest_reference_scope", - "rest_alpha_relative_delta", - "task_state_relation", - "task_state_rationale", - "task_state_confidence", - "is_low_confidence", - "is_possible_artifact", - "is_mixed_or_ambiguous", -} -missing = sorted(required - set(df.columns)) -print("rows", len(df)) -print("missing", missing) -assert not missing -assert (df["analysis_version"] == "eegbci_pattern_moment_report_v1").all() -PY -``` - -Expected: - -```text -rows 20 -missing [] -``` - -- [x] **Step 4: Inspect Markdown contract** - -Run: - -```bash -rg "Executive Result|Run Configuration|Window Coverage|Moment-State Summary|Task Label x State Matrix|Rest-Normalized Bandpower Summary|Confidence and Quality Audit|Representative Windows|Limitations|Next Checks" outputs/eegbci_pattern_discovery/eegbci_pattern_summary.md -``` - -Expected: all required headings match. - -- [x] **Step 5: Update Graphify** - -Run: - -```bash -graphify update . -``` - -Expected: graph update completes. Dirty `graphify-out/` files are expected. - -## Extensive Correctness Test Matrix - -Add these tests to `TestEEGBCIMomentReportHelpers` while implementing Tasks 1-8. -The goal is to prove the report helpers are correct under realistic edge cases, -not just that the happy path produces rows. - -| Test name | Fixture | Assertions | -| --- | --- | --- | -| `test_analysis_version_constant` | Import `ANALYSIS_VERSION`. | Exact value is `eegbci_pattern_moment_report_v1`. | -| `test_moment_report_columns_are_declared` | Import `OUTPUT_COLUMNS` and `MOMENT_REPORT_COLUMNS`. | Base row columns and report columns are present; `analysis_version` is in `MOMENT_REPORT_COLUMNS`. | -| `test_build_rest_baselines_uses_rest_rows_only` | Rest and non-rest rows for one subject across two runs. | Non-rest rows do not affect rest averages; same-run, same-subject, and global means are correct. | -| `test_build_rest_baselines_handles_no_rest_rows` | Only motor rows. | Same-run and same-subject baseline maps are empty; global baseline is `None`. | -| `test_annotate_rest_fallback_scopes` | One same-run rest candidate, one same-subject fallback, and one global fallback. | Rows receive `same_subject_run`, `same_subject_all_runs`, and `global_rest` in that order. | -| `test_annotate_moment_rows_marks_unavailable_rest` | Motor-only rows. | `rest_reference_scope == "unavailable"` and all rest delta columns are blank strings. | -| `test_rest_delta_values_are_band_specific` | Rest row with different values for each band plus one motor row. | `rest_delta_relative_delta`, `rest_theta_relative_delta`, `rest_alpha_relative_delta`, `rest_beta_relative_delta`, and `rest_gamma_relative_delta` equal motor minus rest for the matching band. | -| `test_derive_state_hypothesis_detects_idle_alpha_profile` | High alpha, low beta/gamma, high alpha/beta. | State is `idle_alpha_profile`; confidence is not outside `low`, `medium`, `high`; score is between 0 and 1. | -| `test_derive_state_hypothesis_detects_sensorimotor_engagement_profile` | High beta or low-gamma, low alpha/beta. | State is `sensorimotor_engagement_profile`; evidence summary includes beta and alpha/beta values. | -| `test_derive_state_hypothesis_detects_slow_wave_dominant_pattern` | Delta plus theta dominates. | State is `slow_wave_dominant_pattern`; no cognitive or clinical wording appears in evidence summary. | -| `test_derive_state_hypothesis_detects_possible_artifact_profile` | Gamma spike or extreme high-frequency share. | State is `possible_artifact_profile`; `derive_quality_columns()` marks `is_possible_artifact`. | -| `test_derive_state_hypothesis_marks_weak_margin_ambiguous` | Balanced band shares with no clear winner. | State is `mixed_ambiguous_profile`; confidence is `low`. | -| `test_state_confidence_requires_margin` | Two rows with same winning state, one clear margin and one narrow margin. | Narrow-margin row has lower confidence than clear-margin row. | -| `test_task_state_relation_table_is_deterministic` | Rows covering rest, motor execution, motor imagery, slow-wave, artifact, and ambiguous states. | Relation values match the approved decision table; every rationale is non-empty. | -| `test_task_state_relation_idle_motor_disagrees` | Motor task row with `idle_alpha_profile`. | Relation is `disagrees`; confidence is parseable; rationale mentions motor-labeled mismatch without clinical claims. | -| `test_task_state_relation_artifact_not_applicable` | Any task row with `possible_artifact_profile`. | Relation is `not_applicable`; rationale says inspection rather than task comparison. | -| `test_quality_booleans_are_parseable` | Low-confidence artifact row with text flags. | `is_low_confidence` and `is_possible_artifact` are true; ambiguous flag follows state/flag input. | -| `test_quality_booleans_do_not_depend_on_string_parsing_only` | Row with `state_hypothesis="mixed_ambiguous_profile"` and empty `quality_flags`. | `is_mixed_or_ambiguous` is true because the state is ambiguous. | -| `test_annotate_moment_rows_adds_required_fields` | One rest row and one motor row. | Every `MOMENT_REPORT_COLUMNS` entry exists in every annotated row. | -| `test_annotate_moment_rows_preserves_legacy_fields` | Row with existing `brain_state_hypothesis`, `confidence`, `quality_flags`, and `interpretation`. | Legacy fields remain unchanged after annotation. | -| `test_annotate_moment_rows_does_not_mutate_input_rows` | Keep a copy of input rows before annotation. | Original rows do not gain report fields after `annotate_moment_rows()`. | -| `test_select_representative_windows_is_deterministic` | Multiple candidate rows with tied evidence/confidence and different subject/run/start time. | Stable tie-break chooses earliest subject, run, and start time. | -| `test_select_representative_windows_lists_absent_classes` | Rows missing artifact and slow-wave classes. | Missing representative card names appear in `absent`. | -| `test_select_representative_windows_picks_lowest_evidence_ambiguous` | Multiple ambiguous rows with different evidence scores. | `most_ambiguous` chooses the lowest evidence score, then stable tie-breaks. | -| `test_select_representative_windows_picks_strongest_disagreement` | Multiple disagreement rows. | Highest evidence disagreement is selected; ties use confidence and stable ordering. | -| `test_render_summary_contains_required_sections_and_limitations` | Annotated row with unavailable rest and capped config. | All required headings appear; missing rest and cap limitations appear. | -| `test_render_summary_handles_empty_rows` | Empty row list with `max_windows=0`. | Summary says no windows were produced and still includes Limitations and Next Checks. | -| `test_render_summary_reports_all_low_confidence` | Rows where every `is_low_confidence` is true. | Executive Result states every window is low confidence. | -| `test_render_summary_reports_all_same_state` | Rows all mapped to one state. | Executive Result states every window maps to the same state. | -| `test_render_summary_reports_task_state_matrix` | Rows spanning at least two task labels and two states. | Matrix contains each task/state count deterministically. | -| `test_render_summary_includes_representative_window_details` | Annotated rows with at least one representative card. | Card includes subject, run, trial id, time range, state, evidence, dominant band, rest deltas, relation, confidence, flags, and rationale. | -| `test_render_summary_moves_nonclinical_warning_to_limitations` | Any non-empty summary. | Warning appears under `## Limitations` and not as the opening body text. | -| `test_summary_text_does_not_repeat_old_row_level_caveat` | Annotated rows with legacy interpretation text. | `"This is exploratory signal metadata"` is absent from generated summary and new row-level interpretation. | -| `test_empty_dataframe_uses_output_columns` | Build `pd.DataFrame([], columns=OUTPUT_COLUMNS)`. | Empty CSV header contains base and report columns. | -| `test_main_max_windows_zero_writes_empty_artifacts` | Patch dataset/task iteration to produce rows, invoke `main()` with `--max-windows 0` and temp output dir. | CSV has zero rows with `OUTPUT_COLUMNS`; Markdown says no windows were produced and output was capped. | -| `test_main_baseline_uses_uncapped_rows` | Patch task iteration so rest row appears after the first emitted row; run with `--max-windows 1`. | Emitted row can still receive non-`unavailable` rest baseline from rows beyond the cap. | -| `test_main_writes_analysis_version_to_every_csv_row` | Patch task iteration to produce two rows. | CSV `analysis_version` column exists and every value equals `ANALYSIS_VERSION`. | -| `test_parse_int_list_accepts_ranges_and_singletons` | Existing parser with `"1,3-5"`. | Result is `[1, 3, 4, 5]`. | -| `test_parse_int_list_rejects_invalid_input_loudly` | Existing parser with `"a"` or malformed range. | Raises `ValueError`; no silent fallback. | - -Run the complete helper suite with: - -```bash -.venv/bin/python -m pytest tests/core/test_eegbci.py::TestEEGBCIMomentReportHelpers -v -``` - -Run the full EEGBCI test file with: - -```bash -.venv/bin/python -m pytest tests/core/test_eegbci.py -v -``` - -Manual real-data artifact checks still belong in Task 10 because they validate -the end-to-end generated CSV and Markdown, not just pure helper behavior. - -## Post-Implementation Review Gate - -After implementation and verification finish, run a fresh review before calling -the work complete. - -Recommended workflow: - -```text -main implementation chat - -> complete Tasks 1-10 - -> run unit tests, artifact checks, and graphify update - -> dispatch independent sub-agent or separate session for GStack /review - -> independent reviewer writes review Markdown - -> main implementation chat applies fixes - -> rerun focused tests and full EEGBCI tests -``` - -Use the strongest available review path: - -1. Prefer GStack `/review` for the final pre-landing diff review because it is - designed to inspect the branch against the base branch and catch regressions, - trust-boundary mistakes, structural issues, and missing tests. -2. If `/review` is unavailable or the review is not PR/diff-shaped, use the - `bug-review` skill against the changed files and generated artifacts. -3. If both are available, run `/review` first, then use `bug-review` only for - focused follow-up on risky areas the review identifies. - -The review must create: - -- `docs/eeg_pattern_discovery/moment_report_review.md` - -Required review document sections: - -```markdown -# EEGBCI Moment Report Implementation Review - -Date: 2026-07-08 -Branch: eegbci-pattern-discovery -Review path: GStack /review - -## Scope Reviewed - -## Verification Commands - -## Findings - -## Required Fixes - -## Fix Implementation Log - -## Post-Fix Verification - -## Final Verdict -``` - -Review independence rule: - -- The same chat can orchestrate the whole workflow because it has the project - context and plan history. -- The actual GStack `/review` must run in an independent sub-agent or separate - session. Do not run the final review inline in the same reasoning thread that - implemented the feature. -- The independent reviewer must inspect the diff cold, write - `docs/eeg_pattern_discovery/moment_report_review.md`, and stop after the - review document is complete. -- Fixes should be implemented by the main implementation chat after the review - is written, so there is one accountable thread for code changes and - verification. - -Do not mark the implementation complete until: - -- review findings are written to Markdown -- each accepted finding is fixed or explicitly documented as deferred -- focused tests for each fix pass -- `.venv/bin/python -m pytest tests/core/test_eegbci.py -v` passes -- generated CSV and Markdown artifacts still satisfy the contract -- `graphify update .` has been run after code changes - -## Self-Review - -### Spec Coverage - -- Rest-normalized evidence: Task 2, Task 3, Task 5, Task 7. -- Parseable quality flags: Task 4, Task 5, Task 7. -- Representative windows: Task 6, Task 7. -- Analysis versioning: Task 1, Task 5, Task 7, Task 8. -- Task-state comparison: Task 4, Task 5, Task 7. -- Stable dataset/task API boundary: Global Constraints and File Structure. -- Empty/capped output behavior: Task 7, Task 8, Task 10. -- README and continuation docs: Task 9. - -### Placeholder Scan - -No implementation steps use TBD, TODO, or open-ended "handle edge cases" instructions. Each test and implementation step includes concrete code or exact commands. - -### Type Consistency - -The plan consistently uses: - -- `build_rest_baselines(rows) -> dict` -- `derive_state_hypothesis(row) -> dict` -- `derive_task_state_relation(row) -> dict` -- `derive_quality_columns(row) -> dict` -- `annotate_moment_rows(rows, baselines) -> list[dict]` -- `select_representative_windows(rows) -> dict` -- `render_summary(rows, config) -> str` -- `write_summary(rows, path, config) -> None` - -## GSTACK ENGINEERING REVIEW - -Status: DONE - -### Review Scope - -Reviewed this implementation plan against: - -- `docs/eeg_pattern_discovery/moment_report_refined_design.md` -- `docs/eeg_pattern_discovery/moment_report_continuation_plan.md` -- `/Users/vihaanagrawal/.gstack/projects/sunlabuiuc-PyHealth/ceo-plans/2026-07-08-eegbci-moment-report.md` -- current `examples/eeg/eegbci/eegbci_pattern_discovery.py` -- current `tests/core/test_eegbci.py` - -### Architecture Verdict - -The architecture boundary is right. - -```text -Reusable PyHealth APIs Example-owned artifact layer ----------------------- ---------------------------- -EEGBCIDataset - -> EEGMotorImageryEEGBCI samples - -> EEGBCIPatternDiscovery bandpower - sample_to_row() - build_rest_baselines() - annotate_moment_rows() - select_representative_windows() - render_summary() - CSV + Markdown -``` - -The plan avoids putting cross-window concepts into `EEGBCIPatternDiscovery`. -That matters because rest baselines, capped output status, and task-state -comparison are artifact-level context, not independent per-sample labels. - -### Data Flow Review - -The critical data-flow fix is present: - -```text -collect all requested rows - -> compute rest baselines - -> annotate all rows - -> apply --max-windows - -> write outputs -``` - -This prevents `--max-windows` from accidentally deleting the rest evidence -needed to interpret non-rest rows. - -### Findings And Required Adjustments - -| Severity | Finding | Plan Adjustment | -| --- | --- | --- | -| High | Empty CSV handling cannot depend on `sample_dataset[0]`; `--max-windows=0` and no-window requests are valid edge cases. | Added `BASE_OUTPUT_COLUMNS`, `MOMENT_REPORT_COLUMNS`, and `OUTPUT_COLUMNS`; Task 8 now writes `pd.DataFrame(output_rows, columns=OUTPUT_COLUMNS)`. | -| Medium | The example may collect more rows than it emits. For the default subjects/runs this is acceptable, but users should see the baseline source count. | `render_summary()` config includes `baseline_row_count`; Markdown Run Configuration reports it. | -| Medium | State scoring thresholds are heuristic and could become brittle if tests overfit exact values. | Tests use obvious synthetic profiles and assert categories plus score bounds, not exact score formulas. | -| Low | The implementation plan adds many helpers in one example file. That is acceptable for this phase, but file growth needs containment. | Helpers are pure, named, and directly tested. No shared module until the example becomes genuinely hard to maintain. | - -### Edge Case Coverage - -```text -No rows - -> render_summary([]) says no windows were produced - -> CSV writes stable OUTPUT_COLUMNS - -No rest rows - -> rest_reference_scope = "unavailable" - -> rest deltas = "" - -> Markdown limitation states missing rest baseline - -All low confidence - -> Executive Result says every window is low confidence - -> Confidence and Quality Audit counts low-confidence rows - -All same state - -> Executive Result says every window maps to the same state - -> Next Checks recommends broader coverage or threshold review - -Capped output - -> baselines computed before cap - -> Executive Result says output was capped -``` - -### Test Coverage Review - -The plan has the right test shape: - -- Pure helper tests are synthetic and offline. -- Dataset/task tests remain intact. -- Rest fallback covers same subject/run, same subject/all runs, global rest, and unavailable. -- Task-state relation table is deterministic. -- Representative selection tests tie-break on evidence, confidence, subject, run, and start time. -- Markdown tests assert required sections and important limitations. -- Manual verification checks the real generated CSV/Markdown artifact. - -One implementation note: when writing the tests, keep the helper class before -`TestEEGBCIRealDataSmoke` so normal test runs do not inherit the real-data skip. - -### Performance And Blast Radius - -Blast radius is low. The plan touches one example script, one existing test file, -one README, and this planning doc. No model APIs, dataset APIs, task exports, or -Sphinx API pages change. - -The only meaningful performance tradeoff is collecting all requested rows before -applying `--max-windows`. That is the correct tradeoff for this artifact because -baseline quality is the point of the report. If future users request large -subject/run sets, add a separate `--baseline-max-windows` or streaming baseline -path later. Do not complicate this PR now. - -### Recommendation - -Proceed with implementation exactly in this plan order. The first implementation -checkpoint should be after Task 5, because that is where the CSV contract and -core data flow become real. The second checkpoint should be after Task 8, before -real-data manual verification. - -## GSTACK REVIEW REPORT - -| Review | Trigger | Why | Runs | Status | Findings | -|--------|---------|-----|------|--------|----------| -| Eng Review | `/plan-eng-review` | Architecture, data flow, edge cases, tests | 1 | DONE | Added stable output schema requirement; confirmed example-owned analysis boundary and baseline-before-cap flow. | - -**VERDICT:** APPROVED FOR IMPLEMENTATION after the `OUTPUT_COLUMNS` adjustment above. diff --git a/docs/eeg_pattern_discovery/moment_report_refined_design.md b/docs/eeg_pattern_discovery/moment_report_refined_design.md deleted file mode 100644 index b7b0f845f..000000000 --- a/docs/eeg_pattern_discovery/moment_report_refined_design.md +++ /dev/null @@ -1,323 +0,0 @@ -# EEGBCI Moment Report Refined Design - -Date: 2026-07-08 -Status: approved for implementation planning -Scope: EEGBCI pattern-discovery moment-report upgrade - -## Purpose - -The existing EEGBCI dataset, tasks, tests, docs, and example have already been -implemented. The remaining problem is artifact quality: the generated CSV and -Markdown prove that the pipeline runs, but they do not yet answer the analysis -question. - -The upgraded artifact should answer: - -> What is the brain doing in each moment, according to its frequency patterns? - -More precisely, for each 2-second EEG segment, the report should infer the most -likely frequency-profile state hypothesis, expose the evidence for that -hypothesis, and compare it with the experimental EEGBCI task label. - -The design should refine and challenge the continuation plan, not restart the -EEGBCI integration. - -## Recommended Approach - -Use an example-owned analysis layer with pure helper functions. - -Keep `EEGBCIDataset`, `EEGMotorImageryEEGBCI`, and `EEGBCIPatternDiscovery` -stable. Do not add report-only fields to the reusable PyHealth task API unless -implementation proves that a field is intrinsically per-window and reusable -outside the report. - -The report flow should be: - -```text -EEGBCIDataset - -> EEGBCIPatternDiscovery - -> sample_to_row() - -> build_rest_baselines() - -> annotate_moment_rows() - -> write CSV - -> render_summary() - -> write Markdown -``` - -The current public task fields remain useful compatibility data: - -- `brain_state_hypothesis` -- `confidence` -- `quality_flags` -- `interpretation` - -The upgraded report should primarily use new example-level fields: - -- `analysis_version` -- `state_hypothesis` -- `state_confidence` -- `evidence_score` -- `evidence_summary` -- `rest_reference_scope` -- `rest_delta_relative_delta` -- `rest_theta_relative_delta` -- `rest_alpha_relative_delta` -- `rest_beta_relative_delta` -- `rest_gamma_relative_delta` -- `task_state_relation` -- `task_state_rationale` -- `task_state_confidence` -- parseable quality booleans - -## Approaches Considered - -### A. Summary-Only Patch - -Only rewrite `write_summary()` and leave CSV rows mostly unchanged. - -This is small and low-risk, but it does not fix the core defect. Rows can still -collapse into weak `mixed_frequency_profile` metadata with no inspectable -evidence. Reject this as underpowered. - -### B. Example-Owned Analysis Helpers - -Add pure helpers in `examples/eeg/eegbci/eegbci_pattern_discovery.py` for rest -baselines, state scoring, quality flags, task/state comparison, representative -window selection, and Markdown rendering. - -This is the recommended path. It keeps cross-window analysis out of the reusable -task API while making the generated artifact substantially more useful. The main -risk is example-file growth, so helpers must be named, pure, and directly tested. - -### C. Promote Moment Fields Into The Task - -Add `state_hypothesis`, `evidence_score`, rest deltas, and task/state comparison -to `EEGBCIPatternDiscovery` samples. - -Reject this for now. Rest baselines and task/state comparisons depend on -cross-window context. PyHealth tasks currently emit independent samples, so this -would blur the abstraction boundary. - -## Architecture - -The upgraded moment report is an analysis layer owned by the example. The public -dataset and task API should remain stable. - -`--max-windows` should cap the final artifact, but rest baselines should be built -from all available requested rows when feasible. Otherwise a small capped run can -accidentally remove rest evidence and make the analysis look weaker than the -data. If full baseline collection is not feasible, the report must say the -baseline was computed from capped rows. - -Keep helper functions pure and testable: - -```python -ANALYSIS_VERSION = "eegbci_pattern_moment_report_v1" - -def build_rest_baselines(rows): ... -def annotate_moment_rows(rows, baselines): ... -def derive_state_hypothesis(row): ... -def derive_quality_columns(row): ... -def derive_task_state_relation(row): ... -def select_representative_windows(rows): ... -def render_summary(rows, config): ... -``` - -Do not create a shared module in this phase. If the example later becomes too -large, these helpers can move to an example utility module without changing the -public PyHealth API. - -## State Vocabulary - -Use frequency-profile names, not cognitive or clinical claims. - -| State | Meaning | -| --- | --- | -| `idle_alpha_profile` | Alpha is elevated and alpha/beta is high enough to look idle-like. | -| `sensorimotor_engagement_profile` | Beta or low-gamma evidence is elevated enough to look motor-engaged. | -| `slow_wave_dominant_pattern` | Delta/theta evidence dominates without implying cognition or diagnosis. | -| `possible_artifact_profile` | Gamma spike, extreme power, or noisy profile suggests inspection. | -| `mixed_ambiguous_profile` | No state wins cleanly or several weak signals conflict. | - -Do not use `slow_wave_drowsy_profile`; it implies a cognitive state. - -## Evidence Scoring - -The state scorer should combine: - -- relative band shares -- absolute ratios such as alpha/beta and theta/beta -- rest-normalized deltas when available -- quality and artifact checks -- margin between the winning state and alternatives - -The output should include: - -- `state_hypothesis` -- `state_confidence` -- `evidence_score` -- `evidence_summary` - -Confidence should not rise merely because a state wins. It should require a -meaningful margin over alternatives or useful rest-normalized evidence. If all -state scores are weak, choose `mixed_ambiguous_profile` with low confidence and -explain near misses in `evidence_summary`. - -## Rest Baselines - -Compute rest baselines before applying the final `--max-windows` cap whenever -possible. - -Fallback order: - -1. Same subject and same run rest windows. -2. Same subject, all requested runs, rest windows. -3. All requested subjects/runs, rest windows. -4. `unavailable`. - -Each annotated row must record `rest_reference_scope`. If no baseline exists: - -- set `rest_reference_scope = "unavailable"` -- set rest-normalized deltas to blank or NaN -- state the limitation in Markdown - -Do not silently substitute missing rest evidence. - -## Task-State Relation - -Compare the inferred frequency-profile state with the EEGBCI experimental task -label using a deterministic first-pass table. - -| Task family | State hypothesis | Relation | -| --- | --- | --- | -| rest | `idle_alpha_profile` | `supports_label` | -| rest | `mixed_ambiguous_profile` | `ambiguous` | -| rest | `possible_artifact_profile` | `not_applicable` | -| motor execution | `sensorimotor_engagement_profile` | `supports_label` | -| motor imagery | `sensorimotor_engagement_profile` | `adds_detail` | -| any motor task | `idle_alpha_profile` | `disagrees` | -| any task | `slow_wave_dominant_pattern` | `adds_detail` | -| any task | `possible_artifact_profile` | `not_applicable` | -| any task | `mixed_ambiguous_profile` | `ambiguous` | - -Each row should include a concise `task_state_rationale` and -`task_state_confidence`. - -## Markdown Report Contract - -The Markdown report should be a compact analysis result, not a run receipt. - -Required sections: - -1. Executive result. -2. Run configuration. -3. Window coverage. -4. Moment-state summary. -5. Task label x state matrix. -6. Rest-normalized bandpower summary. -7. Confidence and quality audit. -8. Representative windows. -9. Limitations. -10. Next checks. - -The executive result must explicitly say when: - -- every row is low confidence -- every row maps to the same state -- no rest baseline is available -- output was capped by `--max-windows` -- no windows were produced - -Move the non-clinical warning to `Limitations`. Do not repeat it in every row. - -## Representative Windows - -Select deterministic representative cards when present: - -- strongest idle-like window -- strongest motor-engaged window -- strongest slow-wave dominant window -- strongest artifact-like window -- most ambiguous window -- strongest task/state disagreement - -Selection rules: - -1. Pick highest `evidence_score` for each state class. -2. Tie-break by higher `state_confidence`. -3. Tie-break by earliest `subject_id`, then `run`, then `start_time`. -4. Omit absent state classes and list them as absent. -5. For the most ambiguous card, pick the `mixed_ambiguous_profile` row with the - lowest `evidence_score`, then use the same stable tie-breaks. -6. Add one disagreement card using the highest-evidence row where - `task_state_relation == "disagrees"`. - -Cards should show evidence and uncertainty so "strongest" does not imply the -evidence is strong in an absolute sense. - -## Edge Cases - -Handle these explicitly: - -- No rows: write an empty CSV with expected columns and a Markdown report - explaining no windows were produced. -- `--max-windows=0`: valid command, no final artifact rows, no crash. -- No rest windows: baseline scope is `unavailable`, rest deltas are blank or - NaN, and the Markdown states the limitation. -- All rows low confidence: state this in the executive result and audit section. -- All rows same state: state that the analysis collapsed and recommend broader - coverage or threshold review. -- All rows ambiguous: treat as a valid weak result, not a silent failure. -- Missing optional legacy task fields: tolerate them where possible. -- Missing required metadata fields: fail clearly. - -## Non-Goals - -Do not implement: - -- HTML report output -- pretrained embedding comparison -- clustering or motif discovery -- a full static brain-state atlas -- clinical or cognitive claims -- new package dependencies -- changes to dataset/task exports or API RST pages -- a rewrite of the existing EEGBCI dataset/task work - -## Test Strategy - -Use synthetic rows for report-helper tests. Do not download EEGBCI data in normal -tests. - -Required test areas: - -- rest baseline fallback: same-run, same-subject, global, unavailable -- state scoring: alpha-like, beta/motor-like, slow-wave, artifact-like, - ambiguous -- confidence: weak winner remains low confidence; strong margin can become - medium -- task-state relation: deterministic relation and non-empty rationale -- quality booleans: `is_low_confidence`, `is_possible_artifact`, and - `is_mixed_or_ambiguous` -- representative windows: deterministic selection and stable tie-breaks -- report rendering: required sections and limitations warning -- CSV schema: `analysis_version` and moment-report fields in every row -- empty and capped outputs: clear Markdown and no crash - -Threshold tests should use obvious synthetic examples rather than overfitting to -exact real EEGBCI values. - -## Documentation Updates - -Update: - -- `examples/eeg/eegbci/README.md` -- `docs/eeg_pattern_discovery/moment_report_continuation_plan.md` - -The README should describe the upgraded CSV and Markdown report fields. The -continuation plan should record implementation progress as work proceeds. - -## Approval - -This refined design was reviewed section by section during brainstorming and -approved for implementation planning on 2026-07-08. diff --git a/docs/eeg_pattern_discovery/moment_report_review.md b/docs/eeg_pattern_discovery/moment_report_review.md deleted file mode 100644 index fec265093..000000000 --- a/docs/eeg_pattern_discovery/moment_report_review.md +++ /dev/null @@ -1,161 +0,0 @@ -# EEGBCI Moment Report Implementation Review - -Date: 2026-07-08 -Branch: eegbci-pattern-discovery -Review path: GStack /review - -## Scope Reviewed - -Reviewed the committed branch at `a077111` against `origin/master`, with primary focus on the moment-report pass: - -- `examples/eeg/eegbci/eegbci_pattern_discovery.py` -- `tests/core/test_eegbci.py` -- `examples/eeg/eegbci/README.md` -- `docs/eeg_pattern_discovery/moment_report_implementation_plan.md` -- `docs/eeg_pattern_discovery/moment_report_continuation_plan.md` - -Also checked the existing generated artifacts under `outputs/eegbci_pattern_discovery/` because Task 10 requires CSV and Markdown contract validation. - -Public API boundary check: the report-only fields stay out of `pyhealth/tasks/eegbci.py` and `pyhealth/datasets/eegbci.py`. Only the legacy `brain_state_hypothesis` task field appears in reusable task code. - -## Verification Commands - -Commands run: - -```bash -graphify query "Review EEGBCI moment report implementation files, task contracts, artifact generation, and tests" --budget 2000 -git diff --stat origin/master...HEAD -git diff --check origin/master...HEAD -.venv/bin/python -m pytest tests/core/test_eegbci.py::TestEEGBCIMomentReportHelpers -v -.venv/bin/python -m pytest tests/core/test_eegbci.py -v -.venv/bin/python - <<'PY' -import pandas as pd -from pathlib import Path -csv = Path("outputs/eegbci_pattern_discovery/eegbci_pattern_windows.csv") -summary = Path("outputs/eegbci_pattern_discovery/eegbci_pattern_summary.md") -required = { - "analysis_version", "state_hypothesis", "state_confidence", "evidence_score", - "evidence_summary", "rest_reference_scope", "rest_alpha_relative_delta", - "task_state_relation", "task_state_rationale", "task_state_confidence", - "is_low_confidence", "is_possible_artifact", "is_mixed_or_ambiguous", -} -df = pd.read_csv(csv) -print("rows", len(df)) -print("missing", sorted(required - set(df.columns))) -print("analysis_version_all", bool((df["analysis_version"] == "eegbci_pattern_moment_report_v1").all())) -text = summary.read_text(encoding="utf-8") -headings = [ - "Executive Result", "Run Configuration", "Window Coverage", "Moment-State Summary", - "Task Label x State Matrix", "Rest-Normalized Bandpower Summary", - "Confidence and Quality Audit", "Representative Windows", "Limitations", "Next Checks", -] -print("missing_headings", [h for h in headings if h not in text]) -PY -rg "Executive Result|Run Configuration|Window Coverage|Moment-State Summary|Task Label x State Matrix|Rest-Normalized Bandpower Summary|Confidence and Quality Audit|Representative Windows|Limitations|Next Checks" outputs/eegbci_pattern_discovery/eegbci_pattern_summary.md -``` - -Results: - -- Helper suite: 28 passed, 12 subtests passed. -- Full EEGBCI test file: 52 passed, 1 skipped, 12 subtests passed. The skipped test is the opt-in real-data smoke test. -- `git diff --check`: clean. -- Existing artifact schema check: 20 rows, no missing required moment-report columns, all rows use `eegbci_pattern_moment_report_v1`. -- Existing Markdown artifact includes all required headings and representative windows. - -Not run: - -- I did not rerun the real-data example command because this reviewer was instructed that the only write target is this review document. The existing generated artifacts were inspected read-only. -- I did not run `graphify update .` because it writes graph files and this reviewer is read-only except for this file. - -## Findings - -1. [P1] (confidence: 9/10) `examples/eeg/eegbci/eegbci_pattern_discovery.py:237`, `examples/eeg/eegbci/eegbci_pattern_discovery.py:444`, `outputs/eegbci_pattern_discovery/eegbci_pattern_summary.md:45` - The report conflates legacy low-confidence flags with the new moment-report state confidence. In the generated artifact, `state_confidence` is `medium` for 16 of 20 rows, but `is_low_confidence` is `True` for all 20 rows because every row inherits legacy `quality_flags=low_confidence`. The Markdown then says "Every window is low confidence" and reports `Low-confidence rows: 20`, contradicting `State confidence: {'low': 4, 'medium': 16}`. This weakens the main artifact the pass is supposed to improve. - -2. [P2] (confidence: 8/10) `docs/eeg_pattern_discovery/moment_report_implementation_plan.md:1406`, `tests/core/test_eegbci.py:556`, `tests/core/test_eegbci.py:658`, `tests/core/test_eegbci.py:1325` - The extensive correctness matrix is not fully satisfied as written. Missing named coverage includes `test_state_confidence_requires_margin`, `test_quality_booleans_do_not_depend_on_string_parsing_only`, and `test_parse_int_list_accepts_ranges_and_singletons`. Existing tests cover broad state detection, string-based quality flags, and invalid parser input, but they do not prove these specific required edge cases. - -3. [P2] (confidence: 9/10) `docs/eeg_pattern_discovery/moment_report_implementation_plan.md:1318`, `docs/eeg_pattern_discovery/moment_report_implementation_plan.md:1396`, `docs/eeg_pattern_discovery/moment_report_continuation_plan.md:471` - Task 10 remains unchecked and the continuation progress log stops at Task 9. The implementation contract required Task 10 verification, artifact inspection, and graph update tracking before completion. The artifacts exist and several checks pass, but the plan state does not show that the implementation session completed those required steps. - -No SQL, shell-injection, LLM trust-boundary, race-condition, CI/CD, or public API boundary issues found in the moment-report pass. - -## Required Fixes - -1. Separate legacy quality flags from moment-report confidence in the CSV/Markdown contract. Either make `is_low_confidence` mean `state_confidence == "low"` and add a separate legacy flag column/count, or keep the boolean as a legacy-quality indicator and change the Markdown wording so it does not claim every moment-report state is low confidence when most `state_confidence` values are medium. Add a regression test using rows with `quality_flags="low_confidence"` and `state_confidence="medium"`. - -2. Add the missing correctness-matrix tests or update the matrix with explicit rationale for merged coverage. Minimum expected tests: margin lowers `state_confidence`, ambiguous state sets `is_mixed_or_ambiguous` without relying on flag text, and `parse_int_list("1,3-5") == [1, 3, 4, 5]`. - -3. Complete Task 10 bookkeeping in the plan documents after the main implementation chat reruns the allowed final verification commands and `graphify update .` after any code changes. Mark the Task 10 checkboxes accurately and append a continuation-plan progress entry. - -## Fix Implementation Log - -- Accepted and fixed Finding 1. `derive_quality_columns()` now makes - `is_low_confidence` depend on moment-report `state_confidence == "low"` rather - than legacy `quality_flags=low_confidence`. This keeps the CSV boolean and - Markdown low-confidence count consistent with the state-confidence audit. - Added regression coverage for a medium-confidence state with legacy - `quality_flags="low_confidence"`. -- Accepted and fixed Finding 2. Added matrix coverage for - `test_state_confidence_requires_margin`, - `test_quality_booleans_do_not_depend_on_string_parsing_only`, and - `test_parse_int_list_accepts_ranges_and_singletons`. -- Accepted and fixed Finding 3. Marked Task 10 checkboxes complete in the - implementation plan and appended final Task 10/post-review progress to the - continuation plan. - -## Post-Fix Verification - -Commands run after fixes: - -```bash -.venv/bin/python -m pytest tests/core/test_eegbci.py::TestEEGBCIMomentReportHelpers -k "state_confidence_requires_margin or quality_booleans_do_not or parse_int_list_accepts" -v -.venv/bin/python -m pytest tests/core/test_eegbci.py::TestEEGBCIMomentReportHelpers -v -.venv/bin/python -m pytest tests/core/test_eegbci.py -v -.venv/bin/python examples/eeg/eegbci/eegbci_pattern_discovery.py \ - --subjects 1 \ - --runs 3 \ - --max-windows 20 \ - --download -.venv/bin/python - <<'PY' -import pandas as pd -from pathlib import Path -df = pd.read_csv("outputs/eegbci_pattern_discovery/eegbci_pattern_windows.csv") -required = { - "analysis_version", "state_hypothesis", "state_confidence", "evidence_score", - "evidence_summary", "rest_reference_scope", "rest_alpha_relative_delta", - "task_state_relation", "task_state_rationale", "task_state_confidence", - "is_low_confidence", "is_possible_artifact", "is_mixed_or_ambiguous", -} -missing = sorted(required - set(df.columns)) -summary = Path("outputs/eegbci_pattern_discovery/eegbci_pattern_summary.md").read_text(encoding="utf-8") -assert len(df) == 20 -assert not missing -assert (df["analysis_version"] == "eegbci_pattern_moment_report_v1").all() -assert int(df["is_low_confidence"].sum()) == int((df["state_confidence"] == "low").sum()) -assert not summary.splitlines()[2].startswith("Brain-state hypotheses are exploratory signal metadata") -assert summary.count("### ") >= 1 -PY -rg "Executive Result|Run Configuration|Window Coverage|Moment-State Summary|Task Label x State Matrix|Rest-Normalized Bandpower Summary|Confidence and Quality Audit|Representative Windows|Limitations|Next Checks" outputs/eegbci_pattern_discovery/eegbci_pattern_summary.md -graphify update . -``` - -Results: - -- Focused review-fix tests: 4 passed. -- Full helper suite: 32 passed, 12 subtests passed. -- Full EEGBCI test file: 56 passed, 1 skipped, 12 subtests passed. The skipped - test is the opt-in real-data smoke test. -- Real-data example regenerated - `outputs/eegbci_pattern_discovery/eegbci_pattern_windows.csv` and - `outputs/eegbci_pattern_discovery/eegbci_pattern_summary.md`. -- Refreshed CSV: 20 rows, no missing required moment-report columns, - `analysis_version` is correct for every row, `state_confidence` counts are - `{'medium': 16, 'low': 4}`, and `is_low_confidence` count is 4. -- Refreshed Markdown: all required headings are present, the old generic caveat - is not the opening body text, and at least one representative window card is - present. -- `graphify update .` completed after code changes. - -## Final Verdict - -Approved after fixes. The confidence contradiction is resolved, the missing -correctness-matrix tests were added, Task 10 bookkeeping is complete, artifacts -were regenerated and validated, full EEGBCI tests pass, and graphify was updated. diff --git a/docs/eeg_pattern_discovery/pattern_analysis_redesign.md b/docs/eeg_pattern_discovery/pattern_analysis_redesign.md deleted file mode 100644 index b6431cc81..000000000 --- a/docs/eeg_pattern_discovery/pattern_analysis_redesign.md +++ /dev/null @@ -1,420 +0,0 @@ -# EEGBCI Pattern Discovery Output Redesign - -Date: 2026-07-08 -Status: draft office-hours design note -Branch: eegbci-pattern-discovery -Mode: Builder / open source research - -## Problem Statement - -The EEGBCI pattern-discovery implementation exists, but the generated artifact does -not yet answer the research question it was designed to answer. - -The stronger question is: - -> What is the brain doing in each moment, according to its frequency patterns? - -More precisely: - -> For each 2-second EEG segment, infer the most likely functional brain-state -> hypothesis from its frequency-band profile, then compare that hypothesis to the -> experimental task label. - -That moves the project from "does the label match the data?" to: - -> At this moment, does the EEG look relaxed, engaged, drowsy, motor-active, -> noisy, mixed, or ambiguous? - -The current output at `outputs/eegbci_pattern_discovery/` is mechanically valid and -analytically weak: - -- `eegbci_pattern_summary.md` reports only counts. -- All 20 inspected windows are `mixed_frequency_profile`. -- All 20 inspected windows have `confidence=low`. -- All 20 inspected windows have `quality_flags=low_confidence`. -- The Markdown summary does not compare task labels with frequency profiles. -- The interpretation sentence repeats the same caveat per row, which makes the - artifact feel defensive instead of informative. - -This is not a copy problem only. The current artifact does not expose enough -analysis for a researcher to tell whether the pattern-discovery layer found -anything, failed to find anything, or needs better thresholds. - -## Evidence From Current Outputs - -Source files inspected: - -- `outputs/eegbci_pattern_discovery/eegbci_pattern_summary.md` -- `outputs/eegbci_pattern_discovery/eegbci_pattern_windows.csv` -- `examples/eeg/eegbci/eegbci_pattern_discovery.py` -- `pyhealth/tasks/eegbci.py` -- `docs/eeg_pattern_discovery/brainstorm.md` -- `docs/eeg_pattern_discovery/design.md` -- `docs/eeg_pattern_discovery/implementation_plan.md` - -Observed current sample: - -| Metric | Value | -| --- | --- | -| Windows | 20 | -| Subjects / runs | S001 / R03 only in current inspected output | -| Task labels | rest: 10, execute_right_fist: 6, execute_left_fist: 4 | -| Hypotheses | mixed_frequency_profile: 20 | -| Confidence | low: 20 | -| Quality flags | low_confidence: 20 | - -Current task-label medians: - -| Task label | Delta rel. | Theta rel. | Alpha rel. | Beta rel. | Gamma rel. | Alpha/beta | Theta/beta | -| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | -| execute_left_fist | 0.585 | 0.159 | 0.103 | 0.116 | 0.036 | 0.857 | 1.485 | -| execute_right_fist | 0.629 | 0.144 | 0.078 | 0.122 | 0.030 | 0.638 | 1.195 | -| rest | 0.559 | 0.157 | 0.083 | 0.123 | 0.027 | 0.692 | 0.929 | - -The data does contain variation. The output just fails to turn it into a useful -analysis. For example, `execute_left_fist` has higher median theta/beta than rest, -and the top alpha/beta window is a rest window. Those are the kinds of signals the -summary should surface. - -## Premises - -1. The output artifact should answer the analysis question, not merely prove the - pipeline runs. -2. A weak result is acceptable if the artifact explains why it is weak using data. -3. The non-clinical warning belongs in one clear methods/limitations section, not - repeated inside every window interpretation. -4. `mixed_frequency_profile` should be treated as an analysis outcome that needs - explanation, not as useful interpretation by itself. -5. The first pass can remain rules-only. It does not need pretrained embeddings to - produce a much stronger report. -6. The artifact should be organized around moment-level state hypotheses, not - around defensive caveats. - -## Product Thesis - -The useful artifact is not "a CSV with bandpower columns." The useful artifact is a -moment-by-moment brain-state ledger. - -Each 2-second segment should tell a researcher: - -- What the subject was instructed to do. -- What the EEG frequency profile looked like. -- Which functional state hypothesis best fits that profile. -- How strong or weak the evidence is. -- Whether the frequency pattern agrees with the experimental label, adds detail - the label does not contain, or looks ambiguous/noisy. - -This should feel like a field guide for EEG moments: - -| Experimental label | Frequency-pattern question | -| --- | --- | -| rest | Does this window look idle/alpha-heavy, drowsy/slow-wave, noisy, or mixed? | -| motor execution | Does this window show motor-active beta/sensorimotor evidence, or does it still look idle/mixed? | -| motor imagery | Does this window show engagement without movement, or does it collapse into ambiguous/rest-like activity? | -| any label | Is this window clean enough to interpret, or should it be treated as noise/low confidence? | - -The current artifact cannot do this because it collapses all windows to the same -state and gives no ranked evidence. A better artifact can still say "ambiguous," -but it must say why. - -## What Strong Output Should Look Like - -The Markdown summary should read like a compact research result: - -1. Executive result. - - What was processed. - - Whether the run produced separable moment-level state hypotheses. - - Whether confidence was useful or collapsed. - - The strongest observed signal in one or two sentences. - -2. Dataset/run coverage. - - Subjects, runs, run families, task labels, windows per label. - - Window size, sample rate, channel mode, preprocessing choices. - - A warning if the output is capped by `--max-windows`. - -3. Moment-state ledger. - - One row per representative or notable window. - - Experimental label, inferred state, evidence score, confidence, and note. - - This is the human-readable answer to "what is the brain doing right now?" - -4. Label vs. hypothesis matrix. - - Crosstab of `task_label` by `brain_state_hypothesis`. - - Percentages by task label. - - Explicit statement when all labels collapse to one hypothesis. - -5. Bandpower profile by task label. - - Median relative delta/theta/alpha/beta/gamma by task label. - - Median alpha/beta and theta/beta ratios. - - Simple deltas from rest, for example execution beta minus rest beta. - -6. Confidence and quality audit. - - Confidence distribution. - - Quality flag distribution. - - Explanation of why low confidence fired. - - A threshold diagnostic: which rule each window almost matched, if any. - -7. Notable windows. - - Top alpha/beta windows. - - Top theta/beta windows. - - Top beta-relative windows. - - Highest gamma-relative windows as possible artifact candidates. - - Include trial id, task label, time range, ratios, and a short reason. - -8. Interpretation. - - Replace repeated generic row text with concise, evidence-specific language. - - Example: `Delta-heavy mixed profile; no rule-specific hypothesis met. Keep as - low-confidence baseline evidence, not a brain-state claim.` - - For the summary, state what the artifact can and cannot conclude. - -9. Next analysis recommendations. - - Whether to run more subjects/runs. - - Whether thresholds are too strict for EEGBCI after normalization. - - Whether a rest-normalized or subject-normalized report should be generated. - -## Functional State Vocabulary - -The current vocabulary has the right caution but too little information. Use a -small set of functional hypotheses that describe what the frequency profile looks -like, not what the person definitely experienced: - -| State hypothesis | Frequency evidence | Confidence rule | Good wording | -| --- | --- | --- | --- | -| `idle_alpha_profile` | Alpha is elevated and alpha/beta is high | Medium when alpha clearly dominates; low when only mildly elevated | `Idle-like alpha profile` | -| `sensorimotor_engagement_profile` | Beta or low-gamma is elevated without artifact flags | Medium when beta is meaningfully above rest/task baseline | `Motor-engaged frequency profile` | -| `slow_wave_drowsy_profile` | Theta or delta/theta dominates and theta/beta is high | Medium only when slow-wave power is not universal across all labels | `Slow-wave/drowsy-like profile` | -| `possible_artifact_profile` | Gamma spike, extreme power, or noisy band mix | Low until inspected | `Possible artifact or muscle activity` | -| `mixed_ambiguous_profile` | No rule wins, or several weak rules conflict | Low | `Mixed or ambiguous frequency profile` | - -This vocabulary is intentionally not clinical. It is still much better than -`mixed_frequency_profile` for every row because it gives the reader a mental model -for what the algorithm is trying to see. - -## Evidence Scoring Contract - -Each row should expose why the state was chosen. At minimum: - -- `state_hypothesis`: one of the functional state names above. -- `state_confidence`: `low`, `medium`, or `high`. -- `evidence_score`: numeric 0.0 to 1.0, even if simple at first. -- `evidence_summary`: compact text, for example - `delta_rel=0.66; alpha_beta=2.35; beta_rel=0.08`. -- `agreement_with_task`: `supports_label`, `adds_detail`, `disagrees`, - `ambiguous`, or `not_applicable`. -- `task_comparison_note`: one sentence comparing the inferred state with the - experimental label. - -The first implementation can compute `evidence_score` from heuristic margins: - -- Alpha score: alpha relative share plus alpha/beta margin. -- Motor score: beta relative share plus beta-above-rest margin when rest baseline - is available. -- Slow-wave score: theta/beta and delta/theta dominance. -- Artifact score: gamma relative share and extreme-power flags. -- Mixed score: inverse of the winning margin. - -Do not overfit this yet. The point is to make uncertainty inspectable. - -## Interpretation Language Contract - -Remove this repeated sentence from per-window output: - -> This is exploratory signal metadata, not evidence of cognition or a clinical diagnosis. - -Keep the safety boundary, but move it into the summary methods section: - -> These labels are signal-pattern summaries from short EEG windows. They are not -> clinical findings and should not be read as evidence of a subject's cognition. - -Per-window interpretation should be short and data-specific: - -| Case | Better interpretation | -| --- | --- | -| No rule match | `Mixed frequency profile; no band-specific rule met. Low confidence.` | -| Alpha-ish rest | `Alpha/beta is elevated versus beta, consistent with an idle-like profile.` | -| Beta-heavy movement | `Beta-relative power is elevated, consistent with active sensorimotor processing.` | -| Gamma-heavy | `Gamma-relative power is elevated; inspect for muscle or movement artifact.` | -| Delta-heavy | `Delta dominates this short window. Treat as low-specificity unless this repeats across runs.` | - -## Approaches Considered - -### Approach A: Summary-Only Repair - -Summary: Keep the current task schema and interpretation rules. Rewrite only the -example summary generator so it computes stronger aggregate tables and removes the -repeated caveat from Markdown/CSV interpretation text. - -Effort: S - -Risk: Low - -Pros: - -- Fastest path to useful outputs. -- Smallest source diff. -- Does not change task behavior or public task schema. - -Cons: - -- Per-row `confidence` still collapses to low on the inspected run. -- Does not explain why thresholds fail unless extra diagnostics are derived in the - example. -- Leaves weak interpretation rules in `pyhealth/tasks/eegbci.py`. - -Reuses: - -- Existing `sample_to_row()`. -- Existing CSV fields. -- Pandas aggregation in `examples/eeg/eegbci/eegbci_pattern_discovery.py`. - -### Approach B: Analysis-Grade Example Contract - -Summary: Upgrade the example into a real artifact generator. Add moment-state -ledger rows, summary tables, near-miss diagnostics, task-vs-rest comparisons, -notable windows, and cleaner interpretation language while keeping the core task -rules conservative. - -Effort: M - -Risk: Low to medium - -Pros: - -- Directly fixes the weak output the user sees. -- Preserves the rules-only first implementation boundary. -- Makes low-confidence collapse informative instead of embarrassing. -- Gives researchers enough evidence to decide the next experiment. - -Cons: - -- More code in the example script. -- Requires focused tests for report sections and edge cases. -- Still depends on simple heuristic thresholds. - -Reuses: - -- Existing bandpower columns. -- Current task labels and quality flags. -- The original design's "answer whether hypotheses line up, sharpen, or disagree" - contract. - -### Approach C: Rule Engine Redesign - -Summary: Redesign `interpret_band_profile()` so it uses richer confidence scoring, -rest-normalized deltas, and graded hypotheses instead of hard thresholds. - -Effort: L - -Risk: Medium - -Pros: - -- Fixes the root cause of all windows becoming low-confidence mixed profiles. -- Produces better per-window interpretation. -- Can make confidence meaningful across subjects and runs. - -Cons: - -- More likely to overclaim from simple bandpower features. -- Needs real-data validation across multiple subjects/runs. -- Changes behavior in the task API, not just the example artifact. - -Reuses: - -- Existing bandpower computation. -- Existing tests around interpretation helper, with expanded cases. - -### Approach D: Moment Atlas - -Summary: Generate a richer static "brain-state atlas" artifact in Markdown plus -CSV. Each state gets representative windows, task-label enrichment, evidence -distributions, and next-inspection recommendations. - -Effort: L - -Risk: Medium - -Pros: - -- Best match for the bigger question: what the brain appears to be doing moment by - moment. -- Produces a compelling research artifact, not just a report. -- Sets up the later embedding/cluster atlas without requiring neural models now. - -Cons: - -- Bigger than a repair pass. -- Needs careful wording to avoid overclaiming. -- More tests and fixture data needed to keep the report deterministic. - -Reuses: - -- The "Brain-State Atlas Explorer" and "Label Disagreement Mining" ideas from - `docs/eeg_pattern_discovery/brainstorm.md`. -- Existing CSV fields, plus evidence scoring and task comparison notes. - -## Recommendation - -Choose Approach B now, with the north-star question from Approach D. - -The current artifact fails at the analysis/reporting layer first. Fixing that layer -turns even an all-low-confidence run into a useful result: "the current thresholds -do not produce separable hypotheses on this run, but these bandpower differences -are visible and these windows are worth inspecting." That is a real research -artifact. - -But do not keep Approach B small in spirit. The report should be designed as the -first version of a moment atlas: - -- "What does this 2-second segment look like?" -- "How confident are we?" -- "Does that fit the task label?" -- "Which windows deserve human inspection?" - -Then Approach C can be justified with evidence instead of taste. - -## Success Criteria - -The output redesign is successful when: - -- The summary no longer starts with the generic exploratory caveat. -- The summary has a one-paragraph result that names the strongest finding and the - biggest limitation. -- The summary directly answers: "What is the brain doing in each moment, according - to its frequency patterns?" -- The CSV has moment-state fields: `state_hypothesis`, `evidence_score`, - `evidence_summary`, `agreement_with_task`, and `task_comparison_note`, or an - explicitly documented equivalent. -- The summary includes a moment-state ledger for representative or notable - windows. -- The summary includes label-vs-hypothesis percentages. -- The summary includes bandpower medians by task label. -- The summary includes confidence and quality-flag audits. -- The summary includes notable windows ranked by alpha/beta, theta/beta, beta, and - gamma. -- The summary explicitly explains an all-low-confidence outcome when it happens. -- Per-window interpretation text is short, evidence-specific, and non-clinical - without repeating legalistic boilerplate. -- Tests cover the report generator on a tiny synthetic row set that includes mixed, - medium-confidence, and artifact-like windows. - -## Open Questions - -- Should the CSV keep `interpretation`, or should row-level explanations move to a - separate `pattern_note` field while `interpretation` remains backward-compatible? -- Should the example default `--max-windows` remain uncapped in docs, or should the - README encourage larger multi-run output for meaningful summaries? -- Should the next iteration add `analysis_version` to the CSV/summary so future - rule changes are traceable? -- Should threshold diagnostics live in the example only, or become fields returned - by `EEGBCIPatternDiscovery`? - -## Next Steps - -1. Implement Approach B in `examples/eeg/eegbci/eegbci_pattern_discovery.py`. -2. Update `tests/core/test_eegbci.py` with focused tests for summary sections and - interpretation text. -3. Regenerate `outputs/eegbci_pattern_discovery/`. -4. Update `docs/eeg_pattern_discovery/implementation_plan.md` with the output - quality correction and verification evidence. -5. Run the existing EEGBCI unit tests plus the example command. - diff --git a/docs/eeg_pattern_discovery/test_verification_plan.md b/docs/eeg_pattern_discovery/test_verification_plan.md deleted file mode 100644 index 0a2ff6686..000000000 --- a/docs/eeg_pattern_discovery/test_verification_plan.md +++ /dev/null @@ -1,280 +0,0 @@ -# EEGBCI Pattern Discovery Test Verification Plan - -Date: 2026-07-07 -Branch: `eegbci-pattern-discovery` -Base branch: `master` -Python: `.venv/bin/python` - -This plan verifies the EEGBCI pattern-discovery implementation against the -Correctness Oracle in `docs/eeg_pattern_discovery/implementation_plan.md`. -Normal/offline checks must not download PhysioNet data. Real EEGBCI checks are -explicitly opt-in and use `/tmp/pyhealth-eegbci-verification` as the data/output -root. - -## Checklist and Commands - -### 1. Offline Unit Tests - -- [ ] Run the default EEGBCI test file without network access. -- [ ] Confirm the real-data smoke test is skipped by default. -- [ ] Confirm run-aware labels, channel selection, normalization, annotation - windowing, task sample generation, bandpower, dataset metadata, and exports - pass together. - -```bash -PYHEALTH_RUN_REAL_EEGBCI=0 .venv/bin/python -m pytest tests/core/test_eegbci.py -v -``` - -### 2. Task and Schema Tests - -- [ ] Verify `EEGMotorImageryEEGBCI` exposes the expected task name, input - schema, and multiclass output schema. -- [ ] Verify `compute_stft=False` removes `stft` from `input_schema`. -- [ ] Verify `EEGBCIPatternDiscovery(compute_stft=False)` keeps the pattern task - name and tensor signal input. -- [ ] Verify STFT generation receives the actual task sample rate. -- [ ] Verify generated supervised samples include fixed-shape `signal`, - decoded `task_label`, numeric model `label`, raw `eegbci_label`, `trial_id`, - timing fields, and `sample_rate`. -- [ ] Verify pattern-discovery samples add `bandpower`, - `brain_state_hypothesis`, `confidence`, `quality_flags`, and - `interpretation`. - -```bash -.venv/bin/python -m pytest \ - tests/core/test_eegbci.py::TestEEGBCITasks::test_task_schema_attributes \ - tests/core/test_eegbci.py::TestEEGBCITasks::test_task_schema_without_stft \ - tests/core/test_eegbci.py::TestEEGBCITasks::test_pattern_discovery_schema_attributes \ - tests/core/test_eegbci.py::TestEEGBCITasks::test_motor_imagery_task_returns_samples_from_raw \ - tests/core/test_eegbci.py::TestEEGBCITasks::test_pattern_discovery_adds_bandpower_metadata \ - tests/core/test_eegbci.py::TestEEGBCITasks::test_stft_uses_current_sample_rate \ - -v -``` - -### 3. Synthetic Bandpower Tests - -- [ ] Verify a synthetic 10 Hz signal produces `dominant_band == "alpha"` and - high `alpha_relative`. -- [ ] Verify a synthetic 20 Hz signal produces `dominant_band == "beta"` and - high `beta_relative`. -- [ ] Verify deterministic interpretation metadata remains cautious and - non-clinical. - -```bash -.venv/bin/python -m pytest \ - tests/core/test_eegbci.py::TestEEGBCIHelpers::test_compute_band_powers_detects_alpha_sinusoid \ - tests/core/test_eegbci.py::TestEEGBCIHelpers::test_compute_band_powers_detects_beta_sinusoid \ - tests/core/test_eegbci.py::TestEEGBCIHelpers::test_interpret_band_profile_returns_cautious_metadata \ - -v -``` - -### 4. Dataset Metadata Tests - -- [ ] Verify `EEGBCIDataset.prepare_metadata()` writes one row per requested - subject/run. -- [ ] Verify required metadata columns are present: - `patient_id`, `record_id`, `subject_id`, `run`, `run_type`, `signal_file`, - and `source`. -- [ ] Verify local-file discovery works without network access. -- [ ] Verify metadata is rebuilt when the same root is reused with a different - subject/run selection. -- [ ] Verify an offline `EEGBCIDataset(...).set_task(...)` integration path - works through `BaseDataset`. -- [ ] Verify `download=True` delegates to `mne.datasets.eegbci.load_data`. -- [ ] Verify missing local files fail clearly when `download=False`. -- [ ] Verify the default task is `EEGBCIPatternDiscovery`. - -```bash -.venv/bin/python -m pytest tests/core/test_eegbci.py::TestEEGBCIDataset -v -``` - -### 5. Import/Export Smoke Tests - -- [ ] Verify public imports work from `pyhealth.datasets`, `pyhealth.tasks`, and - direct modules. -- [ ] Verify run-aware EEGBCI label semantics from the Correctness Oracle. -- [ ] Verify synthetic alpha bandpower from the Correctness Oracle. - -```bash -.venv/bin/python - <<'PY' -import numpy as np - -from pyhealth.datasets import EEGBCIDataset -from pyhealth.datasets.eegbci import EEGBCIDataset as DirectDataset -from pyhealth.tasks import EEGBCIPatternDiscovery, EEGMotorImageryEEGBCI -from pyhealth.tasks.eegbci import ( - compute_band_powers, - task_label_for_event, -) - -assert EEGBCIDataset is DirectDataset -assert EEGMotorImageryEEGBCI.task_name == "EEGBCI_motor_imagery" -assert EEGBCIPatternDiscovery.task_name == "EEGBCI_pattern_discovery" -assert task_label_for_event(3, "T1") == "execute_left_fist" -assert task_label_for_event(4, "T1") == "imagine_left_fist" - -sfreq = 200.0 -times = np.arange(0, 2, 1 / sfreq) -alpha = np.sin(2 * np.pi * 10 * times) -features = compute_band_powers(np.stack([alpha, alpha]), sfreq) -assert features["dominant_band"] == "alpha" -assert features["alpha_relative"] > 0.5 -print("imports and oracle smoke checks ok") -PY -``` - -### 6. Example-Output Validation - -- [ ] Run the example on subject `1`, run `3`, with a small window cap. -- [ ] Verify `eegbci_pattern_windows.csv` exists and has at least one row. -- [ ] Verify `eegbci_pattern_summary.md` exists. -- [ ] Verify at least one CSV row contains task label, dominant band, - hypothesis, confidence, and non-clinical interpretation text. - -```bash -.venv/bin/python examples/eeg/eegbci/eegbci_pattern_discovery.py \ - --root /tmp/pyhealth-eegbci-verification/data \ - --subjects 1 \ - --runs 3 \ - --output-dir /tmp/pyhealth-eegbci-verification/example-output \ - --max-windows 20 \ - --download -.venv/bin/python - <<'PY' -from pathlib import Path - -import pandas as pd - -out = Path("/tmp/pyhealth-eegbci-verification/example-output") -csv_path = out / "eegbci_pattern_windows.csv" -summary_path = out / "eegbci_pattern_summary.md" -assert csv_path.exists(), csv_path -assert summary_path.exists(), summary_path -df = pd.read_csv(csv_path) -assert len(df) >= 1 -required = { - "task_label", - "label", - "eegbci_label", - "model_label", - "dominant_band", - "brain_state_hypothesis", - "confidence", - "interpretation", -} -assert required.issubset(df.columns), sorted(set(required) - set(df.columns)) -assert df["task_label"].notna().any() -assert not df["label"].astype(str).str.contains("tensor").any() -assert df["eegbci_label"].notna().any() -assert df["model_label"].notna().any() -assert df["dominant_band"].notna().any() -assert df["brain_state_hypothesis"].notna().any() -assert df["confidence"].notna().any() -assert df["interpretation"].str.contains("not evidence of cognition|not clinical", case=False, regex=True).any() -print(f"validated {len(df)} example rows") -PY -``` - -### 7. Opt-In Real EEGBCI Smoke Test - -- [ ] Run the skipped-by-default real-data smoke test explicitly. -- [ ] Verify it downloads or reuses subject `1`, run `3`. -- [ ] Verify it produces at least one 16-channel pattern-discovery sample. - -```bash -PYHEALTH_RUN_REAL_EEGBCI=1 .venv/bin/python -m pytest \ - tests/core/test_eegbci.py::TestEEGBCIRealDataSmoke \ - -v -``` - -### 8. Docs/API Smoke Checks - -- [ ] Verify API RST files exist. -- [ ] Verify dataset/task toctrees include EEGBCI pages. -- [ ] Verify Sphinx targets can import the referenced objects/modules. - -```bash -.venv/bin/python - <<'PY' -from pathlib import Path - -from pyhealth.datasets import EEGBCIDataset -from pyhealth.tasks import EEGBCIPatternDiscovery, EEGMotorImageryEEGBCI -import pyhealth.tasks.eegbci as eegbci_module - -dataset_page = Path("docs/api/datasets/pyhealth.datasets.EEGBCIDataset.rst") -task_page = Path("docs/api/tasks/pyhealth.tasks.eegbci.rst") -assert dataset_page.exists() -assert task_page.exists() -assert "datasets/pyhealth.datasets.EEGBCIDataset" in Path("docs/api/datasets.rst").read_text() -assert "tasks/pyhealth.tasks.eegbci" in Path("docs/api/tasks.rst").read_text() -assert EEGBCIDataset.__name__ == "EEGBCIDataset" -assert EEGMotorImageryEEGBCI.task_name == "EEGBCI_motor_imagery" -assert EEGBCIPatternDiscovery.task_name == "EEGBCI_pattern_discovery" -assert hasattr(eegbci_module, "compute_band_powers") -print("docs/api smoke checks ok") -PY -``` - -## Results - -Status after execution: all required checks passed on 2026-07-07. - -| Area | Command | Result | Notes | -| --- | --- | --- | --- | -| Offline unit tests | `PYHEALTH_RUN_REAL_EEGBCI=0 .venv/bin/python -m pytest tests/core/test_eegbci.py -v` | Passed | Final run: 24 passed, 1 skipped in 9.36s. Real-data smoke skipped by default. | -| Task/schema tests | Targeted `TestEEGBCITasks` command | Passed | 6 passed in 8.58s, including STFT sample-rate forwarding. | -| Synthetic bandpower tests | Targeted `TestEEGBCIHelpers` command | Passed | 3 passed in 5.45s. 10 Hz alpha and 20 Hz beta checks passed. | -| Dataset metadata tests | `.venv/bin/python -m pytest tests/core/test_eegbci.py::TestEEGBCIDataset -v` | Passed | 6 passed in 8.60s, including stale selection rebuild and offline `set_task()` integration. | -| Import/export smoke tests | Inline Python smoke script | Passed | Printed `imports and oracle smoke checks ok`. | -| Example-output validation | Example command plus CSV/Markdown validator | Passed | Downloaded/reused subject 1 run 3, wrote CSV and Markdown, validated 20 rows. | -| Opt-in real EEGBCI smoke test | `PYHEALTH_RUN_REAL_EEGBCI=1 ... TestEEGBCIRealDataSmoke` | Passed | 1 passed in 33.61s against subject 1 run 3. | -| Docs/API smoke checks | Inline Python docs/API smoke script | Passed | Printed `docs/api smoke checks ok`. | -| Syntax smoke | `.venv/bin/python -m py_compile ...` | Passed | Dataset, task, example, and test modules compile. | - -## Failures and Fixes Needed - -Independent code review found one critical issue and several important issues -before final status was marked complete. They were fixed and covered by -regression tests: - -- Fixed stale metadata reuse when the same EEGBCI root is instantiated with a - different subject/run selection. Existing `eegbci-pyhealth.csv` is reused only - when it exactly matches the requested pairs. -- Fixed PyHealth cache identity by including the subject/run selection in the - default dataset name passed to `BaseDataset`. -- Added raw `eegbci_label` to task samples and changed the example CSV so - `label`/`eegbci_label` preserve EEGBCI semantics while `model_label` records - the processed PyHealth label. -- Fixed STFT generation to pass the actual `sample_rate` into - `get_stft_torch()`. -- Changed annotation onset conversion to use MNE `raw.time_as_index(..., - use_rounding=True)`. -- Added offline `BaseDataset.set_task()` integration coverage. -- Expanded the example README with output schema, label caveat, root/download, - and cache behavior. - -No remaining failing checks. - -## Correctness Oracle Status - -Satisfied: - -- Run-aware decoding: import/export smoke asserts - `task_label_for_event(3, "T1") == "execute_left_fist"` and - `task_label_for_event(4, "T1") == "imagine_left_fist"`. -- Synthetic 10 Hz alpha: synthetic bandpower and import/export smoke checks pass. -- Metadata rows and columns: dataset metadata tests pass, including changed - subject/run selection rebuild. -- `EEGMotorImageryEEGBCI(compute_stft=False)` schema and sample fields: task - tests and offline integration pass. -- `EEGBCIPatternDiscovery(compute_stft=False)` supervised fields plus - bandpower/hypothesis/confidence/flags/interpretation: task tests pass. -- Default offline test command: passes with the real-data smoke skipped. -- Opt-in real EEGBCI smoke: passes against subject 1, run 3. -- Example artifacts: CSV and Markdown are written and validated with 20 rows, - including task label, dominant band, hypothesis, confidence, and non-clinical - interpretation text. - -## Final Status - -Complete. The Correctness Oracle is satisfied, the independent code-review -findings were addressed, and every command in this plan passed. From 712619a8363a80e92db0c47f6869bcdd37a7cae8 Mon Sep 17 00:00:00 2001 From: Vihaan Agrawal <247244351+vihaan101@users.noreply.github.com> Date: Thu, 9 Jul 2026 19:47:26 +0530 Subject: [PATCH 21/21] fix: address EEGBCI review feedback --- .../eeg/eegbci/eegbci_pattern_discovery.py | 26 ++-- pyhealth/datasets/eegbci.py | 29 ++++- tests/core/test_eegbci.py | 120 ++++++++++++++++-- 3 files changed, 153 insertions(+), 22 deletions(-) diff --git a/examples/eeg/eegbci/eegbci_pattern_discovery.py b/examples/eeg/eegbci/eegbci_pattern_discovery.py index 522a9592e..bd86142b5 100644 --- a/examples/eeg/eegbci/eegbci_pattern_discovery.py +++ b/examples/eeg/eegbci/eegbci_pattern_discovery.py @@ -28,10 +28,17 @@ def scalar_value(value): def parse_int_list(value: str) -> list[int]: items: list[int] = [] - for part in value.split(","): + for raw_part in value.split(","): + part = raw_part.strip() + if not part: + raise ValueError("Empty value in integer list") if "-" in part: - start, end = part.split("-", 1) - items.extend(range(int(start), int(end) + 1)) + start_text, end_text = part.split("-", 1) + start = int(start_text.strip()) + end = int(end_text.strip()) + if start > end: + raise ValueError("Range start must be <= range end") + items.extend(range(start, end + 1)) else: items.append(int(part)) return items @@ -607,15 +614,18 @@ def main() -> None: output_dir = Path(args.output_dir).expanduser() output_dir.mkdir(parents=True, exist_ok=True) + requested_subjects = parse_int_list(args.subjects) + requested_runs = parse_int_list(args.runs) dataset = EEGBCIDataset( root=str(Path(args.root).expanduser()), - subjects=parse_int_list(args.subjects), - runs=parse_int_list(args.runs), + subjects=requested_subjects, + runs=requested_runs, download=args.download, ) sample_dataset = dataset.set_task(EEGBCIPatternDiscovery(compute_stft=False)) all_rows = [sample_to_row(sample) for sample in sample_dataset] + baseline_row_count = sum(row.get("task_label") == "rest" for row in all_rows) baselines = build_rest_baselines(all_rows) annotated_rows = annotate_moment_rows(all_rows, baselines) output_rows = ( @@ -634,10 +644,10 @@ def main() -> None: output_rows, summary_path, { - "subjects": parse_int_list(args.subjects), - "runs": parse_int_list(args.runs), + "subjects": getattr(dataset, "subjects", requested_subjects), + "runs": getattr(dataset, "runs", requested_runs), "max_windows": args.max_windows, - "baseline_row_count": len(all_rows), + "baseline_row_count": baseline_row_count, "output_was_capped": output_was_capped, }, ) diff --git a/pyhealth/datasets/eegbci.py b/pyhealth/datasets/eegbci.py index 92ef113af..8e83022ed 100644 --- a/pyhealth/datasets/eegbci.py +++ b/pyhealth/datasets/eegbci.py @@ -41,10 +41,15 @@ def __init__( if config_path is None: config_path = Path(__file__).parent / "configs" / "eegbci.yaml" self.root = root - self.subjects = list(subjects) if subjects is not None else [1, 2, 3] - self.runs = list(runs) if runs is not None else list(range(3, 15)) + self.subjects = self._normalize_selection( + list(subjects) if subjects is not None else [1, 2, 3] + ) + self.runs = self._normalize_selection( + list(runs) if runs is not None else list(range(3, 15)) + ) self.download = download self.selection_key = self._build_selection_key() + self.metadata_file_name = self._metadata_file_name() self.prepare_metadata() dataset_name = dataset_name or "eegbci" super().__init__( @@ -54,6 +59,12 @@ def __init__( config_path=config_path, **kwargs, ) + if self.config is not None: + self.config.tables["records"].file_path = self.metadata_file_name + + @staticmethod + def _normalize_selection(values: list[int]) -> list[int]: + return sorted({int(value) for value in values}) def _build_selection_key(self) -> str: payload = { @@ -67,10 +78,18 @@ def _build_selection_key(self) -> str: run_part = "-".join(f"{int(run):02d}" for run in self.runs) return f"s{subject_part}_r{run_part}_{digest}" + def _metadata_file_name(self) -> str: + return f"eegbci-pyhealth-{self.selection_key}.csv" + def _find_local_edf(self, subject: int, run: int) -> Path | None: root = Path(self.root) - pattern = f"S{subject:03d}R{run:02d}.edf" - matches = sorted(root.rglob(pattern)) + filename = f"S{subject:03d}R{run:02d}.edf" + canonical_path = ( + root / "files" / "eegmmidb" / "1.0.0" / f"S{subject:03d}" / filename + ) + if canonical_path.exists(): + return canonical_path + matches = sorted(root.rglob(filename)) return matches[0] if matches else None def _requested_pairs(self) -> list[tuple[int, int]]: @@ -92,7 +111,7 @@ def _metadata_matches_request(self, csv_path: Path) -> bool: def prepare_metadata(self) -> None: root = Path(self.root) - csv_path = root / "eegbci-pyhealth.csv" + csv_path = root / self.metadata_file_name if csv_path.exists() and self._metadata_matches_request(csv_path): return diff --git a/tests/core/test_eegbci.py b/tests/core/test_eegbci.py index 549f4d0af..275e8f212 100644 --- a/tests/core/test_eegbci.py +++ b/tests/core/test_eegbci.py @@ -135,6 +135,10 @@ def test_interpret_band_profile_returns_cautious_metadata(self): class TestEEGBCIDataset(unittest.TestCase): + def _set_metadata_identity(self, ds): + ds.selection_key = ds._build_selection_key() + ds.metadata_file_name = ds._metadata_file_name() + def test_prepare_metadata_with_existing_files(self): with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) @@ -147,9 +151,10 @@ def test_prepare_metadata_with_existing_files(self): ds.subjects = [1] ds.runs = [3] ds.download = False + self._set_metadata_identity(ds) ds.prepare_metadata() - csv_path = root / "eegbci-pyhealth.csv" + csv_path = root / ds.metadata_file_name self.assertTrue(csv_path.exists()) df = pd.read_csv(csv_path) self.assertEqual(len(df), 1) @@ -160,7 +165,40 @@ def test_prepare_metadata_with_existing_files(self): self.assertEqual(df.loc[0, "run_type"], "motor_execution_left_right") self.assertEqual(df.loc[0, "source"], "physionet_eegbci") - def test_prepare_metadata_rebuilds_for_changed_selection(self): + def test_selection_inputs_are_normalized_for_stable_identity(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + for subject, run in [(1, 3), (1, 4), (2, 3), (2, 4)]: + edf = ( + root + / "files" + / "eegmmidb" + / "1.0.0" + / f"S{subject:03d}" + / f"S{subject:03d}R{run:02d}.edf" + ) + edf.parent.mkdir(parents=True, exist_ok=True) + edf.write_bytes(b"") + + first = EEGBCIDataset( + root=str(root), + subjects=[2, 1, 1], + runs=[4, 3, 4], + download=False, + ) + second = EEGBCIDataset( + root=str(root), + subjects=[1, 2], + runs=[3, 4], + download=False, + ) + + self.assertEqual(first.subjects, [1, 2]) + self.assertEqual(first.runs, [3, 4]) + self.assertEqual(first.selection_key, second.selection_key) + self.assertEqual(first.dataset_name, second.dataset_name) + + def test_prepare_metadata_uses_selection_specific_files(self): with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) first = root / "files" / "eegmmidb" / "1.0.0" / "S001" / "S001R03.edf" @@ -170,13 +208,40 @@ def test_prepare_metadata_rebuilds_for_changed_selection(self): first.write_bytes(b"") second.write_bytes(b"") - EEGBCIDataset(root=str(root), subjects=[1], runs=[3], download=False) - EEGBCIDataset(root=str(root), subjects=[2], runs=[4], download=False) + ds_first = EEGBCIDataset( + root=str(root), subjects=[1], runs=[3], download=False + ) + ds_second = EEGBCIDataset( + root=str(root), subjects=[2], runs=[4], download=False + ) - df = pd.read_csv(root / "eegbci-pyhealth.csv") - self.assertEqual(len(df), 1) - self.assertEqual(df.loc[0, "subject_id"], 2) - self.assertEqual(df.loc[0, "run"], 4) + first_csv = root / ds_first.metadata_file_name + second_csv = root / ds_second.metadata_file_name + self.assertNotEqual(first_csv, second_csv) + self.assertTrue(first_csv.exists()) + self.assertTrue(second_csv.exists()) + + first_df = pd.read_csv(first_csv) + second_df = pd.read_csv(second_csv) + self.assertEqual(first_df.loc[0, "subject_id"], 1) + self.assertEqual(first_df.loc[0, "run"], 3) + self.assertEqual(second_df.loc[0, "subject_id"], 2) + self.assertEqual(second_df.loc[0, "run"], 4) + + def test_find_local_edf_checks_canonical_mne_path_first(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + canonical = root / "files" / "eegmmidb" / "1.0.0" / "S001" / "S001R03.edf" + fallback = root / "other" / "S001R03.edf" + canonical.parent.mkdir(parents=True) + fallback.parent.mkdir(parents=True) + canonical.write_bytes(b"") + fallback.write_bytes(b"") + + ds = EEGBCIDataset.__new__(EEGBCIDataset) + ds.root = str(root) + + self.assertEqual(ds._find_local_edf(1, 3), canonical) def test_prepare_metadata_download_uses_mne_loader(self): with tempfile.TemporaryDirectory() as tmp: @@ -188,6 +253,7 @@ def test_prepare_metadata_download_uses_mne_loader(self): ds.subjects = [1] ds.runs = [4] ds.download = True + self._set_metadata_identity(ds) with patch( "pyhealth.datasets.eegbci.mne.datasets.eegbci.load_data", @@ -196,7 +262,7 @@ def test_prepare_metadata_download_uses_mne_loader(self): ds.prepare_metadata() load_data.assert_called_once_with(1, [4], path=str(root), update_path=False) - df = pd.read_csv(root / "eegbci-pyhealth.csv") + df = pd.read_csv(root / ds.metadata_file_name) self.assertEqual(df.loc[0, "record_id"], "R04") self.assertEqual(df.loc[0, "run_type"], "motor_imagery_left_right") @@ -207,6 +273,7 @@ def test_prepare_metadata_missing_local_file_raises(self): ds.subjects = [1] ds.runs = [3] ds.download = False + self._set_metadata_identity(ds) with self.assertRaisesRegex(FileNotFoundError, "download=True"): ds.prepare_metadata() @@ -479,6 +546,17 @@ def test_analysis_version_constant(self): self.assertEqual(ANALYSIS_VERSION, "eegbci_pattern_moment_report_v1") + def test_parse_int_list_strips_whitespace(self): + from examples.eeg.eegbci.eegbci_pattern_discovery import parse_int_list + + self.assertEqual(parse_int_list("1, 2, 4-6"), [1, 2, 4, 5, 6]) + + def test_parse_int_list_rejects_descending_ranges(self): + from examples.eeg.eegbci.eegbci_pattern_discovery import parse_int_list + + with self.assertRaisesRegex(ValueError, "Range start must be <= range end"): + parse_int_list("5-3") + def test_build_rest_baselines_uses_rest_rows_only(self): from examples.eeg.eegbci.eegbci_pattern_discovery import build_rest_baselines @@ -515,6 +593,30 @@ def test_build_rest_baselines_handles_no_rest_rows(self): self.assertEqual(baselines["same_subject_all_runs"], {}) self.assertIsNone(baselines["global_rest"]) + def test_render_summary_reports_rest_baseline_source_rows(self): + from examples.eeg.eegbci.eegbci_pattern_discovery import render_summary + + rows = [ + self._moment_row(task_label="rest"), + self._moment_row( + task_label="execute_left_fist", label_family="motor_execution" + ), + self._moment_row(task_label="rest", run=4), + ] + + summary = render_summary( + rows, + { + "subjects": [1], + "runs": [3, 4], + "max_windows": None, + "baseline_row_count": 2, + "output_was_capped": False, + }, + ) + + self.assertIn("- Baseline source rows: 2", summary) + def test_annotate_rest_fallback_scopes(self): from examples.eeg.eegbci.eegbci_pattern_discovery import ( annotate_moment_rows,