Subject-Wise Depression Screening from Eight-Channel Resting-State EEG Using Asymmetry-Aware Spectral Features and Connectivity Ablation.
The 10 matches
- [1] § 2. Materials and Methods › 2.4. Classifiers ↔ EEG_Depression_Analysis_Public_Notebook.ipynb, lines 346–401 · score 0.97 · depthwise spatial convolution, temporal kernel length, pointwise filters, separable convolution, temporal filters, depth multiplier
- [2] § 2. Materials and Methods › 2.4. Classifiers ↔ EEG_Depression_Analysis_Public_Notebook.ipynb, lines 628–737 · score 0.78 · MLPClassifier, hidden layers, validation fraction, Extra Trees, activations, baseline
- [3] § 2. Materials and Methods › 2.2. Channel Selection and Signal Processing ↔ EEG_Depression_Analysis_Public_Notebook.ipynb, lines 447–530 · score 0.77 · inner training split, training partition, score normalisation, epochs, validation, fitted
- [4] § 2. Materials and Methods › 2.5. Evaluation Protocol and Experimental Design ↔ EEG_Depression_Analysis_Public_Notebook.ipynb, lines 875–987 · score 0.71 · fitted Extra Trees, Gini importance, importance ranking, evaluated Extra Trees, dimensional spectral, probed
- [5] § 3. Results › 3.3. Spectral Feature-Selection Ablation ↔ EEG_Depression_Analysis_Public_Notebook.ipynb, lines 875–987 · score 0.68 · Gini importance ranking, feature selection ablation, random seed, Extra Trees, row, refitted
- [6] § 2. Materials and Methods › 2.4. Classifiers ↔ EEG_Depression_Analysis_Public_Notebook.ipynb, lines 312–343 · score 0.66 · ReLU, adaptive, max, kernel, head, linear
- [7] § 3. Results › 3.1. Compact Eight-Channel Baselines ↔ EEG_Depression_Analysis_Public_Notebook.ipynb, lines 1098–1155 · score 0.66 · raw signal deep, EEGNet, ShallowConvNet, balanced accuracy, Extra Trees, spectral feature
- [8] § 2. Materials and Methods › 2.3. Feature Extraction › 2.3.2. Connectivity Features and Fusion ↔ EEG_Depression_Analysis_Public_Notebook.ipynb, lines 116–171 · score 0.58 · 12–30 Hz, 8–12 Hz, beta, bands, alpha, connectivity
- [9] § 2. Materials and Methods › 2.5. Evaluation Protocol and Experimental Design ↔ EEG_Depression_Analysis_Public_Notebook.ipynb, lines 537–553 · score 0.57 · percentile bootstrap, confidence interval, bounds, resamples
- [10] § 2. Materials and Methods › 2.3. Feature Extraction › 2.3.1. Spectral Features ↔ EEG_Depression_Analysis_Public_Notebook.ipynb, lines 116–171 · score 0.56 · 12–30 Hz, 8–12 Hz, beta, bands, alpha, channel
Paper
Loaded from Europe PMC by your browser, not stored by OSCR: doi.org · Europe PMC
The paper is loaded when this pane is shown.
The authors' code
Jupyter notebook · 1,297 lines · 48 KB · no license · 10 matches
- # %% [markdown]
- # # EEG-Based Depression Screening from 8-Channel Resting-State EEG
- # by Hassan Ugail, Newton Howard, Ali Ahmed Elmahmudi and Zied Mnasri
- #
- # ## Subject-wise evaluation, compact baselines, connectivity ablation, deep-learning baselines
- #
- # Cite as: H Ugail, N Howard, A A Elmahmudi and Z Mnasri, Subject-Wise Depression Screening from 8-Channel Resting-State EEG using Asymmetry-Aware Spectral Features and Connectivity Ablation, Sensors, To Appear, 2026.
- #
- # ## What this notebook does
- # It loads the processed outputs from the preprocessing notebook and reproduces the final experimental package:
- #
- # 1. **Correct subject identity handling**
- # - uses `subject_key = label + "_" + subject_id`
- # - avoids merging `H_S1` and `MDD_S1`
- #
- # 2. **Experiment 1 — Compact 8-channel baselines**
- # - Extra Trees on asymmetry-aware spectral features
- # - MLP on the same feature vector
- # - compact 1D CNN on raw 8-channel segments
- # - **EEGNet** (Lawhern et al., 2018) on raw 8-channel segments
- # - **ShallowConvNet** (Schirrmeister et al., 2017) on raw 8-channel segments
- #
- # 3. **Experiment 2 — Connectivity ablation**
- # - Extra Trees on spectral features only
- # - Extra Trees on connectivity features only
- # - Extra Trees on spectral + connectivity fusion
- # - MLP on spectral + connectivity fusion
- #
- # 4. **Experiment 3 — Spectral feature-selection ablation**
- # - Top-$K$ Extra Trees over $K \in \{5, 10, 15, 20, 30, 50, 70, 90\}$ features
- # - Tests whether the 90-dimensional spectral vector is over-parameterised
- #
- # 5. **Bootstrap inference**
- # - 95% percentile bootstrap confidence intervals of the mean across the 10 repeats
- # - Reported for every metric in every experiment
- #
- # 6. **Publication-ready outputs**
- # - manuscript tables with both SD and 95% CI columns
- # - compact results CSV files
- # - high-resolution figures with non-overlapping labels and 95% CI error bars
- #
- # ## Dataset
- # This notebook assumes you already generated the processed files from the public dataset:
- #
- # **Mumtaz, Wajid (2016). _MDD Patients and Healthy Controls EEG Data (New)._ figshare. Dataset.**
- # DOI: `10.6084/m9.figshare.4244171.v2`
- #
- # ## Input assumption
- # This notebook expects the processed outputs from the earlier preprocessing stage under:
- #
- # `/content/drive/MyDrive/.../run_subjectwise_baselines_v1/processed/`
- #
- # Note: "run_subjectwise_baselines_v1/processed/" is available at:
- # https://drive.google.com/drive/folders/1K4J-jqfWtoG7njM21aOgc2Ct7QgYf4W9?usp=sharing
- #
- # ## Output philosophy
- # Only a small set of result files is written so analysis can be done elsewhere if needed.
- # %%
- !pip install -q pandas numpy scipy scikit-learn matplotlib openpyxl torch
- # %%
- from google.colab import drive
- drive.mount('/content/drive')
- # %% [markdown]
- # ## Imports and configuration
- # %%
- import os
- import re
- import json
- import math
- import random
- import warnings
- from pathlib import Path
- from itertools import combinations
- import numpy as np
- import pandas as pd
- import matplotlib.pyplot as plt
- from scipy.signal import coherence
- from sklearn.base import clone
- from sklearn.pipeline import Pipeline
- from sklearn.impute import SimpleImputer
- from sklearn.preprocessing import StandardScaler
- from sklearn.model_selection import GroupShuffleSplit
- from sklearn.metrics import (
- accuracy_score, balanced_accuracy_score, f1_score, matthews_corrcoef,
- confusion_matrix, roc_auc_score, precision_score, recall_score
- )
- from sklearn.ensemble import ExtraTreesClassifier
- from sklearn.neural_network import MLPClassifier
- import torch
- import torch.nn as nn
- from torch.utils.data import TensorDataset, DataLoader
- warnings.filterwarnings("ignore")
- pd.set_option("display.max_columns", 200)
- plt.rcParams.update({
- "font.size": 11,
- "axes.titlesize": 15,
- "axes.labelsize": 13,
- "xtick.labelsize": 11,
- "ytick.labelsize": 11,
- "legend.fontsize": 11,
- "figure.dpi": 150,
- "savefig.dpi": 300,
- "axes.spines.top": False,
- "axes.spines.right": False,
- })
- # %%
- # =========================
- # USER CONFIGURATION
- # =========================
- BASE_DIR = Path("/content/drive/MyDrive/...")
- INPUT_DIR = BASE_DIR / "run_subjectwise_baselines_v1" / "processed"
- RUN_DIR = BASE_DIR / "REVISION/run_final_public_notebook_v1"
- RUN_DIR.mkdir(parents=True, exist_ok=True)
- # Output files
- FINAL_REPEAT_METRICS = RUN_DIR / "final_repeat_metrics.csv"
- FINAL_SUMMARY = RUN_DIR / "final_summary.csv"
- FINAL_SUBJECT_PREDICTIONS = RUN_DIR / "final_subject_predictions.csv"
- FINAL_TOP_FEATURES = RUN_DIR / "final_top_features.csv"
- FINAL_MANUSCRIPT_TABLE = RUN_DIR / "final_manuscript_table.csv"
- FINAL_MANUSCRIPT_TABLE_XLSX = RUN_DIR / "final_manuscript_table.xlsx"
- FINAL_FEATURE_SELECTION = RUN_DIR / "final_feature_selection.csv"
- FINAL_FEATURE_SELECTION_SUMMARY = RUN_DIR / "final_feature_selection_summary.csv"
- FIG_BASELINES = RUN_DIR / "fig_baselines_8ch.png"
- FIG_FUSION = RUN_DIR / "fig_fusion_ablation.png"
- FIG_IMPORTANCE = RUN_DIR / "fig_top_feature_importance.png"
- FIG_FEATURE_SELECTION = RUN_DIR / "fig_feature_selection.png"
- MANIFEST_PATH = RUN_DIR / "manifest.json"
- RANDOM_SEED = 42
- N_REPEATS = 10
- TEST_SIZE_SUBJECT_FRACTION = 0.20
- VAL_SIZE_FROM_TRAIN_SUBJECTS = 0.20
- BATCH_SIZE = 64
- CNN_EPOCHS = 25
- CNN_LR = 1e-3
- EARLY_STOPPING_PATIENCE = 5
- DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
- FULL_CHANNELS = [
- "Fp1", "Fp2", "F7", "F3", "Fz", "F4", "F8",
- "T7", "C3", "Cz", "C4", "T8",
- "P7", "P3", "Pz", "P4", "P8", "O1", "O2"
- ]
- CHANNELS_8CH = ["Fp1", "Fp2", "F3", "F4", "C3", "C4", "P3", "P4"]
- CONNECTIVITY_BANDS = {
- "alpha": (8.0, 12.0),
- "beta": (12.0, 30.0),
- }
- # Feature-selection ablation grid (top-K from Gini importance)
- K_GRID = [5, 10, 15, 20, 30, 50, 70, 90]
- # Bootstrap configuration for 95% CIs of the mean across repeats
- BOOTSTRAP_N = 10000
- BOOTSTRAP_CI = 95
- BOOTSTRAP_SEED = 42
- # %% [markdown]
- # ## Helper functions
- # %%
- def set_all_seeds(seed: int):
- random.seed(seed)
- np.random.seed(seed)
- torch.manual_seed(seed)
- if torch.cuda.is_available():
- torch.cuda.manual_seed_all(seed)
- def specificity_from_cm(cm: np.ndarray) -> float:
- if cm.shape != (2, 2):
- return np.nan
- tn, fp, fn, tp = cm.ravel()
- return tn / (tn + fp) if (tn + fp) > 0 else np.nan
- def safe_auc(y_true, y_prob) -> float:
- try:
- if len(np.unique(y_true)) < 2:
- return np.nan
- return roc_auc_score(y_true, y_prob)
- except Exception:
- return np.nan
- def compute_metrics(y_true, y_pred, y_prob) -> dict:
- cm = confusion_matrix(y_true, y_pred, labels=[0, 1])
- sens = recall_score(y_true, y_pred, zero_division=0)
- spec = specificity_from_cm(cm)
- prec = precision_score(y_true, y_pred, zero_division=0)
- return {
- "accuracy": accuracy_score(y_true, y_pred),
- "balanced_accuracy": balanced_accuracy_score(y_true, y_pred),
- "precision": prec,
- "recall_sensitivity": sens,
- "specificity": spec,
- "f1": f1_score(y_true, y_pred, zero_division=0),
- "mcc": matthews_corrcoef(y_true, y_pred) if len(np.unique(y_true)) > 1 and len(np.unique(y_pred)) > 1 else np.nan,
- "auroc": safe_auc(y_true, y_prob),
- "tn": int(cm[0, 0]),
- "fp": int(cm[0, 1]),
- "fn": int(cm[1, 0]),
- "tp": int(cm[1, 1]),
- }
- def aggregate_subject_predictions(df_pred: pd.DataFrame, subject_col: str = "subject_key") -> pd.DataFrame:
- agg = df_pred.groupby(subject_col).agg(
- y_true=("y_true", "first"),
- prob=("prob", "mean"),
- n_segments=("prob", "size"),
- label=("label", "first"),
- condition=("condition", lambda s: "|".join(sorted(pd.unique(s.astype(str))))),
- subject_id=("subject_id", "first"),
- ).reset_index()
- agg["y_pred"] = (agg["prob"] >= 0.5).astype(int)
- return agg
- def stratified_subject_holdout(subject_df: pd.DataFrame, test_fraction=0.2, seed=42):
- rng = np.random.RandomState(seed)
- train_subjects, test_subjects = [], []
- for label_value in sorted(subject_df["y"].unique()):
- sub = subject_df[subject_df["y"] == label_value]["subject_key"].tolist()
- sub = list(sub)
- rng.shuffle(sub)
- n_test = max(1, int(round(len(sub) * test_fraction)))
- test_subjects.extend(sub[:n_test])
- train_subjects.extend(sub[n_test:])
- return sorted(train_subjects), sorted(test_subjects)
- def make_subject_label_table(df: pd.DataFrame) -> pd.DataFrame:
- subj = df.groupby("subject_key").agg(
- y=("label", lambda s: 1 if s.iloc[0] == "MDD" else 0),
- label=("label", "first"),
- subject_id=("subject_id", "first")
- ).reset_index()
- return subj
- def get_spectral_feature_columns(feature_table: pd.DataFrame, selected_channels: list) -> list:
- meta_cols = {"file_name", "subject_id", "subject_key", "label", "condition"}
- cols = [c for c in feature_table.columns if c not in meta_cols]
- base_cols = []
- asym_cols = []
- for c in cols:
- if "__ASYM_" in c:
- asym_cols.append(c)
- continue
- parts = c.split("__")
- if len(parts) != 2:
- continue
- prefix, ch = parts
- if ch not in selected_channels:
- continue
- if prefix.endswith("_logabs") or prefix.endswith("_rel"):
- base_cols.append(c)
- keep_asym = []
- for c in asym_cols:
- m = re.search(r"__ASYM_([A-Za-z0-9]+)_([A-Za-z0-9]+)$", c)
- if m and m.group(1) in selected_channels and m.group(2) in selected_channels:
- keep_asym.append(c)
- return sorted(base_cols + keep_asym)
- def build_connectivity_features(X_raw_8ch, sfreq, channel_names, bands):
- edge_pairs = list(combinations(range(len(channel_names)), 2))
- edge_names = []
- rows = []
- for i, j in edge_pairs:
- for band_name in bands.keys():
- edge_names.append(f"coh_{band_name}__{channel_names[i]}__{channel_names[j]}")
- for seg in X_raw_8ch:
- feats = []
- for i, j in edge_pairs:
- x = seg[i]
- y = seg[j]
- f, cxy = coherence(x, y, fs=sfreq, nperseg=min(len(x), 512))
- for band_name, (fmin, fmax) in bands.items():
- mask = (f >= fmin) & (f < fmax)
- val = float(np.mean(cxy[mask])) if np.any(mask) else 0.0
- feats.append(val)
- rows.append(feats)
- return pd.DataFrame(rows, columns=edge_names)
- def fit_predict_prob(model, X_train, y_train, X_test):
- fitted = clone(model)
- fitted.fit(X_train, y_train)
- if hasattr(fitted, "predict_proba"):
- prob = fitted.predict_proba(X_test)[:, 1]
- else:
- score = fitted.decision_function(X_test)
- prob = 1 / (1 + np.exp(-score))
- pred = (prob >= 0.5).astype(int)
- return fitted, prob, pred
- # ---------------------------------------------------------------------
- # Deep-learning baselines: Compact 1D CNN, EEGNet, ShallowConvNet
- # ---------------------------------------------------------------------
- class CompactEEGCNN(nn.Module):
- """
- Compact 1D CNN baseline operating directly on (B, C, T) raw segments.
- """
- def __init__(self, n_channels: int, n_samples: int):
- super().__init__()
- self.net = nn.Sequential(
- nn.Conv1d(n_channels, 32, kernel_size=7, padding=3),
- nn.BatchNorm1d(32),
- nn.ReLU(),
- nn.MaxPool1d(2),
- nn.Conv1d(32, 64, kernel_size=5, padding=2),
- nn.BatchNorm1d(64),
- nn.ReLU(),
- nn.MaxPool1d(2),
- nn.Conv1d(64, 128, kernel_size=5, padding=2),
- nn.BatchNorm1d(128),
- nn.ReLU(),
- nn.AdaptiveAvgPool1d(1)
- )
- self.head = nn.Sequential(
- nn.Flatten(),
- nn.Dropout(0.3),
- nn.Linear(128, 64),
- nn.ReLU(),
- nn.Dropout(0.3),
- nn.Linear(64, 1)
- )
- def forward(self, x):
- x = self.net(x)
- x = self.head(x)
- return x.squeeze(1)
- class EEGNet(nn.Module):
- """
- EEGNet (Lawhern et al., 2018). Compact 8-channel adaptation in PyTorch.
- Inputs are shaped (B, C, T) and lifted to (B, 1, C, T) internally.
- Notes
- -----
- F1 is the number of temporal filters in Block 1, D is the depth multiplier
- for the depthwise spatial convolution, and F2 is the number of pointwise
- filters in the separable convolution of Block 2. The temporal kernel
- length defaults to half the sampling rate.
- """
- def __init__(self, n_channels, n_samples, sfreq=256.0,
- F1=8, D=2, F2=16, dropout=0.5, kern_length=None):
- super().__init__()
- if kern_length is None:
- kern_length = max(8, int(sfreq // 2))
- # Block 1: temporal convolution
- self.block1 = nn.Sequential(
- nn.Conv2d(1, F1, (1, kern_length),
- padding=(0, kern_length // 2), bias=False),
- nn.BatchNorm2d(F1),
- )
- # Block 1 cont: depthwise spatial convolution across channels
- self.depthwise = nn.Sequential(
- nn.Conv2d(F1, F1 * D, (n_channels, 1), groups=F1, bias=False),
- nn.BatchNorm2d(F1 * D),
- nn.ELU(),
- nn.AvgPool2d((1, 4)),
- nn.Dropout(dropout),
- )
- # Block 2: separable convolution (depthwise + pointwise)
- self.separable = nn.Sequential(
- nn.Conv2d(F1 * D, F1 * D, (1, 16),
- padding=(0, 8), groups=F1 * D, bias=False),
- nn.Conv2d(F1 * D, F2, (1, 1), bias=False),
- nn.BatchNorm2d(F2),
- nn.ELU(),
- nn.AvgPool2d((1, 8)),
- nn.Dropout(dropout),
- )
- with torch.no_grad():
- x = torch.zeros(1, 1, n_channels, n_samples)
- x = self.block1(x); x = self.depthwise(x); x = self.separable(x)
- n_flat = x.numel()
- self.head = nn.Linear(n_flat, 1)
- def forward(self, x):
- x = x.unsqueeze(1)
- x = self.block1(x)
- x = self.depthwise(x)
- x = self.separable(x)
- x = x.flatten(start_dim=1)
- x = self.head(x)
- return x.squeeze(1)
- class ShallowConvNet(nn.Module):
- """
- ShallowConvNet (Schirrmeister et al., 2017).
- Temporal convolution, spatial convolution across channels, square
- activation, mean pooling, log activation, dropout, and a linear head.
- Inputs are shaped (B, C, T) and lifted to (B, 1, C, T) internally.
- """
- def __init__(self, n_channels, n_samples,
- n_filters_time=40, filter_time_length=25,
- n_filters_spat=40, pool_time_length=75, pool_time_stride=15,
- dropout=0.5):
- super().__init__()
- self.temporal = nn.Conv2d(1, n_filters_time, (1, filter_time_length), bias=False)
- self.spatial = nn.Conv2d(n_filters_time, n_filters_spat, (n_channels, 1), bias=False)
- self.bn = nn.BatchNorm2d(n_filters_spat)
- self.pool = nn.AvgPool2d((1, pool_time_length), stride=(1, pool_time_stride))
- self.dropout = nn.Dropout(dropout)
- with torch.no_grad():
- x = torch.zeros(1, 1, n_channels, n_samples)
- x = self.temporal(x)
- x = self.spatial(x)
- x = self.bn(x)
- x = x * x
- x = self.pool(x)
- x = torch.log(torch.clamp(x, min=1e-7, max=1e7))
- n_flat = x.numel()
- self.head = nn.Linear(n_flat, 1)
- def forward(self, x):
- x = x.unsqueeze(1)
- x = self.temporal(x)
- x = self.spatial(x)
- x = self.bn(x)
- x = x * x
- x = self.pool(x)
- x = torch.log(torch.clamp(x, min=1e-7, max=1e7))
- x = self.dropout(x)
- x = x.flatten(start_dim=1)
- x = self.head(x)
- return x.squeeze(1)
- def fit_predict_torch_model(
- model_factory, X_train, y_train, groups_train, X_test, seed,
- val_fraction=0.2, batch_size=64, epochs=25, lr=1e-3, patience=5, device="cpu"
- ):
- """
- Generic subject-wise trainer for any PyTorch (B, C, T) -> logit model.
- model_factory(n_channels, n_samples) -> nn.Module returning a single
- pre-sigmoid output per segment. Validation subjects are drawn from the
- training partition with GroupShuffleSplit so that no subject appears in
- both training and validation. Per-channel z-score normalisation
- statistics are computed on the inner training split only.
- """
- set_all_seeds(seed)
- train_meta = pd.DataFrame({"subject_key": groups_train, "y": y_train}).drop_duplicates()
- gss = GroupShuffleSplit(n_splits=1, test_size=val_fraction, random_state=seed)
- tr_sub_idx, va_sub_idx = next(gss.split(train_meta[["y"]], train_meta["y"], groups=train_meta["subject_key"]))
- tr_subjects = set(train_meta.iloc[tr_sub_idx]["subject_key"])
- va_subjects = set(train_meta.iloc[va_sub_idx]["subject_key"])
- tr_mask = np.array([g in tr_subjects for g in groups_train])
- va_mask = np.array([g in va_subjects for g in groups_train])
- X_tr = X_train[tr_mask]
- y_tr = y_train[tr_mask]
- X_va = X_train[va_mask]
- y_va = y_train[va_mask]
- mean = X_tr.mean(axis=(0, 2), keepdims=True)
- std = X_tr.std(axis=(0, 2), keepdims=True) + 1e-6
- X_tr = (X_tr - mean) / std
- X_va = (X_va - mean) / std
- X_te = (X_test - mean) / std
- tr_ds = TensorDataset(torch.tensor(X_tr, dtype=torch.float32), torch.tensor(y_tr, dtype=torch.float32))
- va_ds = TensorDataset(torch.tensor(X_va, dtype=torch.float32), torch.tensor(y_va, dtype=torch.float32))
- te_x = torch.tensor(X_te, dtype=torch.float32)
- tr_loader = DataLoader(tr_ds, batch_size=batch_size, shuffle=True)
- va_loader = DataLoader(va_ds, batch_size=batch_size, shuffle=False)
- n_channels = X_train.shape[1]
- n_samples = X_train.shape[2]
- model = model_factory(n_channels, n_samples).to(device)
- optimizer = torch.optim.Adam(model.parameters(), lr=lr)
- criterion = nn.BCEWithLogitsLoss()
- best_state = None
- best_val = np.inf
- bad_epochs = 0
- for _ in range(epochs):
- model.train()
- for xb, yb in tr_loader:
- xb = xb.to(device); yb = yb.to(device)
- optimizer.zero_grad()
- logits = model(xb)
- loss = criterion(logits, yb)
- loss.backward()
- optimizer.step()
- model.eval()
- val_losses = []
- with torch.no_grad():
- for xb, yb in va_loader:
- xb = xb.to(device); yb = yb.to(device)
- logits = model(xb)
- val_losses.append(criterion(logits, yb).item())
- val_loss = float(np.mean(val_losses)) if val_losses else np.inf
- if val_loss < best_val:
- best_val = val_loss
- best_state = {k: v.cpu().clone() for k, v in model.state_dict().items()}
- bad_epochs = 0
- else:
- bad_epochs += 1
- if bad_epochs >= patience:
- break
- if best_state is not None:
- model.load_state_dict(best_state)
- model.eval()
- with torch.no_grad():
- logits = model(te_x.to(device)).cpu().numpy()
- prob = 1 / (1 + np.exp(-logits))
- pred = (prob >= 0.5).astype(int)
- return prob, pred
- # ---------------------------------------------------------------------
- # Bootstrap CI helper
- # ---------------------------------------------------------------------
- def bootstrap_ci_mean(values, n_bootstrap=10000, ci=95, seed=42):
- """
- Percentile bootstrap confidence interval of the mean.
- Returns (mean, lower, upper). NaNs are dropped before resampling. If
- fewer than two finite values remain, the bounds are returned as NaN.
- """
- values = np.asarray(values, dtype=float)
- values = values[~np.isnan(values)]
- if len(values) < 2:
- m = float(np.mean(values)) if len(values) else float("nan")
- return m, float("nan"), float("nan")
- rng = np.random.RandomState(seed)
- n = len(values)
- boots = rng.choice(values, size=(n_bootstrap, n), replace=True).mean(axis=1)
- alpha = (100 - ci) / 2.0
- return float(np.mean(values)), float(np.percentile(boots, alpha)), float(np.percentile(boots, 100 - alpha))
- def make_public_label(model_name: str) -> str:
- mapping = {
- "extratrees_8ch_features": "Extra Trees (features)",
- "mlp_8ch_features": "MLP (features)",
- "cnn1d_8ch_raw": "1D CNN (raw 8ch)",
- "eegnet_8ch_raw": "EEGNet (raw 8ch)",
- "shallowconvnet_8ch_raw": "ShallowConvNet (raw 8ch)",
- "extratrees_spectral": "ET (spectral)",
- "extratrees_connectivity": "ET (connectivity)",
- "extratrees_fusion": "ET (fusion)",
- "mlp_fusion": "MLP (fusion)",
- }
- return mapping.get(model_name, model_name)
- # %% [markdown]
- # ## Load processed data and create the corrected subject key
- # %%
- feature_table = pd.read_csv(INPUT_DIR / "feature_table.csv")
- X_segments = np.load(INPUT_DIR / "X_segments.npy")
- y_segments = np.load(INPUT_DIR / "y.npy")
- groups_segments = np.load(INPUT_DIR / "groups.npy", allow_pickle=True)
- conditions_segments = np.load(INPUT_DIR / "conditions.npy", allow_pickle=True)
- feature_table["subject_id"] = feature_table["subject_id"].astype(str)
- feature_table["label"] = feature_table["label"].astype(str)
- feature_table["condition"] = feature_table["condition"].astype(str)
- feature_table["subject_key"] = feature_table["label"] + "_" + feature_table["subject_id"]
- group_subject_ids = pd.Series(groups_segments.astype(str))
- group_labels = pd.Series(np.where(y_segments == 1, "MDD", "H"))
- group_subject_keys = (group_labels + "_" + group_subject_ids).values
- subject_df = make_subject_label_table(feature_table)
- print("feature_table:", feature_table.shape)
- print("X_segments:", X_segments.shape)
- print("Unique subject_key count:", len(subject_df))
- print("Subjects by class:")
- print(subject_df["label"].value_counts())
- # %% [markdown]
- # ## Prepare feature matrices and raw 8-channel tensor
- # %%
- channel_idx = [FULL_CHANNELS.index(ch) for ch in CHANNELS_8CH]
- X_raw_8ch = X_segments[:, channel_idx, :]
- sfreq = 256.0
- spectral_cols = get_spectral_feature_columns(feature_table, CHANNELS_8CH)
- X_spectral = feature_table[spectral_cols].copy()
- X_connectivity = build_connectivity_features(
- X_raw_8ch=X_raw_8ch,
- sfreq=sfreq,
- channel_names=CHANNELS_8CH,
- bands=CONNECTIVITY_BANDS
- )
- X_fusion = pd.concat([X_spectral.reset_index(drop=True), X_connectivity.reset_index(drop=True)], axis=1)
- y_all = feature_table["label"].map({"H": 0, "MDD": 1}).astype(int).values
- subject_keys_all = feature_table["subject_key"].astype(str).values
- subject_ids_all = feature_table["subject_id"].astype(str).values
- conditions_all = feature_table["condition"].astype(str).values
- print("Spectral features:", X_spectral.shape)
- print("Connectivity features:", X_connectivity.shape)
- print("Fusion features:", X_fusion.shape)
- print("Raw 8ch tensor:", X_raw_8ch.shape)
- # %% [markdown]
- # ## Define the experimental models
- # %%
- compact_models = {
- "extratrees_8ch_features": Pipeline([
- ("imputer", SimpleImputer(strategy="median")),
- ("clf", ExtraTreesClassifier(
- n_estimators=300,
- class_weight="balanced",
- random_state=RANDOM_SEED,
- n_jobs=-1
- ))
- ]),
- "mlp_8ch_features": Pipeline([
- ("imputer", SimpleImputer(strategy="median")),
- ("scaler", StandardScaler()),
- ("clf", MLPClassifier(
- hidden_layer_sizes=(128, 64),
- activation="relu",
- alpha=1e-4,
- batch_size=64,
- learning_rate_init=1e-3,
- max_iter=200,
- early_stopping=True,
- validation_fraction=0.15,
- random_state=RANDOM_SEED
- ))
- ]),
- }
- # Deep raw-signal baselines: each entry maps a model id to a factory taking
- # (n_channels, n_samples) and returning an nn.Module. All are trained with
- # fit_predict_torch_model under the same subject-wise inner validation split.
- deep_models = {
- "cnn1d_8ch_raw": (
- lambda n_channels, n_samples: CompactEEGCNN(n_channels, n_samples)
- ),
- "eegnet_8ch_raw": (
- lambda n_channels, n_samples: EEGNet(
- n_channels=n_channels, n_samples=n_samples, sfreq=sfreq,
- F1=8, D=2, F2=16, dropout=0.5
- )
- ),
- "shallowconvnet_8ch_raw": (
- lambda n_channels, n_samples: ShallowConvNet(
- n_channels=n_channels, n_samples=n_samples,
- n_filters_time=40, filter_time_length=25,
- n_filters_spat=40, pool_time_length=75, pool_time_stride=15,
- dropout=0.5
- )
- ),
- }
- fusion_models = {
- "extratrees_spectral": {
- "X": X_spectral,
- "model": Pipeline([
- ("imputer", SimpleImputer(strategy="median")),
- ("clf", ExtraTreesClassifier(
- n_estimators=300,
- class_weight="balanced",
- random_state=RANDOM_SEED,
- n_jobs=-1
- ))
- ])
- },
- "extratrees_connectivity": {
- "X": X_connectivity,
- "model": Pipeline([
- ("imputer", SimpleImputer(strategy="median")),
- ("clf", ExtraTreesClassifier(
- n_estimators=300,
- class_weight="balanced",
- random_state=RANDOM_SEED,
- n_jobs=-1
- ))
- ])
- },
- "extratrees_fusion": {
- "X": X_fusion,
- "model": Pipeline([
- ("imputer", SimpleImputer(strategy="median")),
- ("clf", ExtraTreesClassifier(
- n_estimators=300,
- class_weight="balanced",
- random_state=RANDOM_SEED,
- n_jobs=-1
- ))
- ])
- },
- "mlp_fusion": {
- "X": X_fusion,
- "model": Pipeline([
- ("imputer", SimpleImputer(strategy="median")),
- ("scaler", StandardScaler()),
- ("clf", MLPClassifier(
- hidden_layer_sizes=(128, 64),
- activation="relu",
- alpha=1e-4,
- batch_size=64,
- learning_rate_init=1e-3,
- max_iter=200,
- early_stopping=True,
- validation_fraction=0.15,
- random_state=RANDOM_SEED
- ))
- ])
- },
- }
- # %% [markdown]
- # ## Run all experiments under repeated subject-wise holdout
- # %%
- all_metric_rows = []
- all_subject_pred_rows = []
- feature_importance_rows = []
- for repeat_idx in range(N_REPEATS):
- seed = RANDOM_SEED + repeat_idx
- set_all_seeds(seed)
- train_subjects, test_subjects = stratified_subject_holdout(
- subject_df,
- test_fraction=TEST_SIZE_SUBJECT_FRACTION,
- seed=seed
- )
- train_mask = np.isin(subject_keys_all, train_subjects)
- test_mask = np.isin(subject_keys_all, test_subjects)
- y_train = y_all[train_mask]
- y_test = y_all[test_mask]
- g_train = subject_keys_all[train_mask]
- g_test = subject_keys_all[test_mask]
- sid_test = subject_ids_all[test_mask]
- cond_test = conditions_all[test_mask]
- label_test = np.where(y_test == 1, "MDD", "H")
- print(f"===== Repeat {repeat_idx+1}/{N_REPEATS} | seed={seed} =====")
- tmp = pd.DataFrame({"subject_key": train_subjects + test_subjects, "split": ["train"]*len(train_subjects)+["test"]*len(test_subjects)})
- tmp = tmp.merge(subject_df[["subject_key", "label"]], on="subject_key", how="left")
- print(tmp.groupby(["split", "label"]).size())
- # -----------------------------------------------------------------
- # Experiment 1a: tabular feature-based compact models
- # -----------------------------------------------------------------
- Xf_train = X_spectral.loc[train_mask].reset_index(drop=True)
- Xf_test = X_spectral.loc[test_mask].reset_index(drop=True)
- for model_name, model in compact_models.items():
- fitted, prob, pred = fit_predict_prob(model, Xf_train, y_train, Xf_test)
- seg_df = pd.DataFrame({
- "repeat": repeat_idx, "seed": seed, "model": model_name,
- "subject_key": g_test, "subject_id": sid_test, "label": label_test,
- "condition": cond_test, "y_true": y_test, "prob": prob, "y_pred": pred,
- })
- subj_df = aggregate_subject_predictions(seg_df, subject_col="subject_key")
- subj_df["repeat"] = repeat_idx
- subj_df["seed"] = seed
- subj_df["model"] = model_name
- all_subject_pred_rows.append(subj_df)
- metrics = compute_metrics(subj_df["y_true"].values, subj_df["y_pred"].values, subj_df["prob"].values)
- metrics.update({"repeat": repeat_idx, "seed": seed, "model": model_name, "experiment": "compact_baselines", "n_subjects": len(subj_df), "n_segments": len(seg_df)})
- all_metric_rows.append(metrics)
- if model_name == "extratrees_8ch_features":
- clf = fitted.named_steps["clf"]
- feat_names = list(Xf_train.columns)
- importances = clf.feature_importances_
- top_idx = np.argsort(importances)[::-1][:15]
- for rank, idx in enumerate(top_idx, start=1):
- feature_importance_rows.append({
- "repeat": repeat_idx, "model": model_name, "rank": rank,
- "feature": feat_names[idx], "importance": float(importances[idx]),
- "family": "spectral",
- })
- # -----------------------------------------------------------------
- # Experiment 1b: deep raw-signal baselines (1D CNN, EEGNet, ShallowConvNet)
- # -----------------------------------------------------------------
- Xr_train = X_raw_8ch[train_mask]
- Xr_test = X_raw_8ch[test_mask]
- for model_name, factory in deep_models.items():
- prob_dl, pred_dl = fit_predict_torch_model(
- model_factory=factory,
- X_train=Xr_train, y_train=y_train, groups_train=g_train, X_test=Xr_test,
- seed=seed, val_fraction=VAL_SIZE_FROM_TRAIN_SUBJECTS, batch_size=BATCH_SIZE,
- epochs=CNN_EPOCHS, lr=CNN_LR, patience=EARLY_STOPPING_PATIENCE, device=DEVICE,
- )
- seg_df = pd.DataFrame({
- "repeat": repeat_idx, "seed": seed, "model": model_name,
- "subject_key": g_test, "subject_id": sid_test, "label": label_test,
- "condition": cond_test, "y_true": y_test, "prob": prob_dl, "y_pred": pred_dl,
- })
- subj_df = aggregate_subject_predictions(seg_df, subject_col="subject_key")
- subj_df["repeat"] = repeat_idx
- subj_df["seed"] = seed
- subj_df["model"] = model_name
- all_subject_pred_rows.append(subj_df)
- metrics = compute_metrics(subj_df["y_true"].values, subj_df["y_pred"].values, subj_df["prob"].values)
- metrics.update({"repeat": repeat_idx, "seed": seed, "model": model_name, "experiment": "compact_baselines", "n_subjects": len(subj_df), "n_segments": len(seg_df)})
- all_metric_rows.append(metrics)
- # -----------------------------------------------------------------
- # Experiment 2: connectivity ablation (unchanged)
- # -----------------------------------------------------------------
- for model_name, spec in fusion_models.items():
- X_df = spec["X"]
- model = spec["model"]
- X_train_df = X_df.loc[train_mask].reset_index(drop=True)
- X_test_df = X_df.loc[test_mask].reset_index(drop=True)
- fitted, prob, pred = fit_predict_prob(model, X_train_df, y_train, X_test_df)
- seg_df = pd.DataFrame({
- "repeat": repeat_idx, "seed": seed, "model": model_name,
- "subject_key": g_test, "subject_id": sid_test, "label": label_test,
- "condition": cond_test, "y_true": y_test, "prob": prob, "y_pred": pred,
- })
- subj_df = aggregate_subject_predictions(seg_df, subject_col="subject_key")
- subj_df["repeat"] = repeat_idx
- subj_df["seed"] = seed
- subj_df["model"] = model_name
- all_subject_pred_rows.append(subj_df)
- metrics = compute_metrics(subj_df["y_true"].values, subj_df["y_pred"].values, subj_df["prob"].values)
- metrics.update({"repeat": repeat_idx, "seed": seed, "model": model_name, "experiment": "fusion_ablation", "n_subjects": len(subj_df), "n_segments": len(seg_df)})
- all_metric_rows.append(metrics)
- if model_name.startswith("extratrees"):
- clf = fitted.named_steps["clf"]
- feat_names = list(X_df.columns)
- importances = clf.feature_importances_
- top_idx = np.argsort(importances)[::-1][:15]
- family = "spectral" if model_name == "extratrees_spectral" else ("connectivity" if model_name == "extratrees_connectivity" else "fusion")
- for rank, idx in enumerate(top_idx, start=1):
- feature_importance_rows.append({
- "repeat": repeat_idx, "model": model_name, "rank": rank,
- "feature": feat_names[idx], "importance": float(importances[idx]),
- "family": family,
- })
- # %% [markdown]
- # ## Experiment 3 — Spectral feature-selection ablation
- #
- # To test for potential overfitting from a 90-dimensional spectral feature set, we run a top-$K$ Gini-importance ablation. For each repeat we fit Extra Trees on the full 90-feature set, rank the columns by importance, then refit and re-evaluate Extra Trees on the top $K \in \{5, 10, 15, 20, 30, 50, 70, 90\}$ features under the same subject-wise test split. Subject-level metrics are reported as mean and 95% bootstrap CI across the 10 repeats.
- # %%
- fs_metric_rows = []
- for repeat_idx in range(N_REPEATS):
- seed = RANDOM_SEED + repeat_idx
- set_all_seeds(seed)
- train_subjects, test_subjects = stratified_subject_holdout(
- subject_df, test_fraction=TEST_SIZE_SUBJECT_FRACTION, seed=seed
- )
- train_mask = np.isin(subject_keys_all, train_subjects)
- test_mask = np.isin(subject_keys_all, test_subjects)
- y_train = y_all[train_mask]
- y_test = y_all[test_mask]
- g_test = subject_keys_all[test_mask]
- sid_test = subject_ids_all[test_mask]
- cond_test = conditions_all[test_mask]
- label_test = np.where(y_test == 1, "MDD", "H")
- Xf_train = X_spectral.loc[train_mask].reset_index(drop=True)
- Xf_test = X_spectral.loc[test_mask].reset_index(drop=True)
- # Fit on the full feature set to obtain Gini-importance ranking
- full_pipe = Pipeline([
- ("imputer", SimpleImputer(strategy="median")),
- ("clf", ExtraTreesClassifier(
- n_estimators=300, class_weight="balanced",
- random_state=seed, n_jobs=-1
- ))
- ])
- full_pipe.fit(Xf_train, y_train)
- importances = full_pipe.named_steps["clf"].feature_importances_
- feature_names = list(Xf_train.columns)
- n_total = len(feature_names)
- sorted_idx = np.argsort(importances)[::-1]
- for K in K_GRID:
- k_use = min(K, n_total)
- keep_idx = sorted_idx[:k_use]
- keep_cols = [feature_names[i] for i in keep_idx]
- Xs_train = Xf_train[keep_cols]
- Xs_test = Xf_test[keep_cols]
- sub_pipe = Pipeline([
- ("imputer", SimpleImputer(strategy="median")),
- ("clf", ExtraTreesClassifier(
- n_estimators=300, class_weight="balanced",
- random_state=seed, n_jobs=-1
- ))
- ])
- _, prob, pred = fit_predict_prob(sub_pipe, Xs_train, y_train, Xs_test)
- seg_df = pd.DataFrame({
- "repeat": repeat_idx, "seed": seed,
- "model": f"extratrees_topk_{K}",
- "subject_key": g_test, "subject_id": sid_test,
- "label": label_test, "condition": cond_test,
- "y_true": y_test, "prob": prob, "y_pred": pred,
- })
- subj_df = aggregate_subject_predictions(seg_df, subject_col="subject_key")
- m = compute_metrics(subj_df["y_true"].values, subj_df["y_pred"].values, subj_df["prob"].values)
- m.update({
- "repeat": repeat_idx, "seed": seed,
- "K": K, "n_features_used": k_use,
- "experiment": "feature_selection",
- "model": f"extratrees_topk_{K}",
- "n_subjects": len(subj_df), "n_segments": len(seg_df),
- })
- fs_metric_rows.append(m)
- print(f"FS ablation repeat {repeat_idx+1}/{N_REPEATS} done")
- fs_metrics_df = pd.DataFrame(fs_metric_rows)
- fs_metrics_df.to_csv(FINAL_FEATURE_SELECTION, index=False)
- # Per-K summary with bootstrap 95% CI of the mean
- fs_summary_rows = []
- for K, grp in fs_metrics_df.groupby("K"):
- rec = {"K": int(K), "n_features_used": int(grp["n_features_used"].iloc[0])}
- for raw_name, out_name in [
- ("balanced_accuracy", "balanced_accuracy"),
- ("auroc", "auroc"),
- ("recall_sensitivity", "sensitivity"),
- ("specificity", "specificity"),
- ("precision", "precision"),
- ("f1", "f1"),
- ("mcc", "mcc"),
- ]:
- vals = grp[raw_name].values
- m, lo, hi = bootstrap_ci_mean(vals, n_bootstrap=BOOTSTRAP_N, ci=BOOTSTRAP_CI, seed=BOOTSTRAP_SEED)
- rec[f"{out_name}_mean"] = m
- rec[f"{out_name}_std"] = float(np.nanstd(vals, ddof=1)) if np.sum(~np.isnan(vals)) > 1 else float("nan")
- rec[f"{out_name}_ci_lower"] = lo
- rec[f"{out_name}_ci_upper"] = hi
- fs_summary_rows.append(rec)
- fs_summary_df = pd.DataFrame(fs_summary_rows).sort_values("K").reset_index(drop=True)
- fs_summary_df.to_csv(FINAL_FEATURE_SELECTION_SUMMARY, index=False)
- print("Saved:")
- print(FINAL_FEATURE_SELECTION)
- print(FINAL_FEATURE_SELECTION_SUMMARY)
- display(fs_summary_df[[
- "K", "balanced_accuracy_mean", "balanced_accuracy_ci_lower", "balanced_accuracy_ci_upper",
- "auroc_mean", "auroc_ci_lower", "auroc_ci_upper",
- ]])
- # %% [markdown]
- # ## Save tables
- # %%
- repeat_metrics_df = pd.DataFrame(all_metric_rows)
- subject_predictions_df = pd.concat(all_subject_pred_rows, axis=0, ignore_index=True) if all_subject_pred_rows else pd.DataFrame()
- top_features_df = pd.DataFrame(feature_importance_rows)
- # Mean and SD across the 10 repeats per (experiment, model)
- summary_df = (
- repeat_metrics_df
- .groupby(["experiment", "model"], as_index=False)
- .agg(
- balanced_accuracy_mean=("balanced_accuracy", "mean"),
- balanced_accuracy_std=("balanced_accuracy", "std"),
- auroc_mean=("auroc", "mean"),
- auroc_std=("auroc", "std"),
- sensitivity_mean=("recall_sensitivity", "mean"),
- sensitivity_std=("recall_sensitivity", "std"),
- specificity_mean=("specificity", "mean"),
- specificity_std=("specificity", "std"),
- precision_mean=("precision", "mean"),
- precision_std=("precision", "std"),
- f1_mean=("f1", "mean"),
- f1_std=("f1", "std"),
- mcc_mean=("mcc", "mean"),
- mcc_std=("mcc", "std"),
- n_subjects_mean=("n_subjects", "mean"),
- )
- .sort_values(["experiment", "balanced_accuracy_mean", "auroc_mean"], ascending=[True, False, False])
- .reset_index(drop=True)
- )
- summary_df["public_label"] = summary_df["model"].map(make_public_label)
- # Bootstrap 95% CI of the mean across the 10 repeats, per (experiment, model)
- metric_columns = {
- "balanced_accuracy": "balanced_accuracy",
- "auroc": "auroc",
- "recall_sensitivity": "sensitivity",
- "specificity": "specificity",
- "precision": "precision",
- "f1": "f1",
- "mcc": "mcc",
- }
- ci_records = []
- for (exp_name, model_name), grp in repeat_metrics_df.groupby(["experiment", "model"]):
- rec = {"experiment": exp_name, "model": model_name}
- for raw_name, out_name in metric_columns.items():
- vals = grp[raw_name].values
- m, lo, hi = bootstrap_ci_mean(vals, n_bootstrap=BOOTSTRAP_N, ci=BOOTSTRAP_CI, seed=BOOTSTRAP_SEED)
- rec[f"{out_name}_ci_lower"] = lo
- rec[f"{out_name}_ci_upper"] = hi
- ci_records.append(rec)
- ci_df = pd.DataFrame(ci_records)
- summary_df = summary_df.merge(ci_df, on=["experiment", "model"], how="left")
- # Top features summary (unchanged)
- if len(top_features_df):
- top_features_summary = (
- top_features_df
- .groupby(["model", "family", "feature"], as_index=False)
- .agg(
- importance_mean=("importance", "mean"),
- importance_count=("importance", "size"),
- )
- .sort_values(["model", "importance_mean"], ascending=[True, False])
- .groupby("model", as_index=False)
- .head(12)
- .reset_index(drop=True)
- )
- else:
- top_features_summary = pd.DataFrame()
- # Manuscript table now includes both SD and 95% CI columns for the headline
- # metrics. Editors and reviewers can pick whichever uncertainty representation
- # they prefer when reading Tables 1 and 2.
- manuscript_table = summary_df[[
- "experiment", "public_label",
- "balanced_accuracy_mean", "balanced_accuracy_std",
- "balanced_accuracy_ci_lower", "balanced_accuracy_ci_upper",
- "auroc_mean", "auroc_std",
- "auroc_ci_lower", "auroc_ci_upper",
- "sensitivity_mean", "sensitivity_std",
- "specificity_mean", "specificity_std",
- "precision_mean", "precision_std",
- "f1_mean", "f1_std",
- "mcc_mean", "mcc_std",
- ]].copy()
- repeat_metrics_df.to_csv(FINAL_REPEAT_METRICS, index=False)
- summary_df.to_csv(FINAL_SUMMARY, index=False)
- subject_predictions_df.to_csv(FINAL_SUBJECT_PREDICTIONS, index=False)
- top_features_summary.to_csv(FINAL_TOP_FEATURES, index=False)
- manuscript_table.to_csv(FINAL_MANUSCRIPT_TABLE, index=False)
- manuscript_table.to_excel(FINAL_MANUSCRIPT_TABLE_XLSX, index=False)
- print("Saved:")
- print(FINAL_REPEAT_METRICS)
- print(FINAL_SUMMARY)
- print(FINAL_SUBJECT_PREDICTIONS)
- print(FINAL_TOP_FEATURES)
- print(FINAL_MANUSCRIPT_TABLE)
- print(FINAL_MANUSCRIPT_TABLE_XLSX)
- display(manuscript_table)
- # %% [markdown]
- # ## Figure 1 — Compact 8-channel baselines
- # %%
- fig, ax = plt.subplots(figsize=(10.0, 4.8), constrained_layout=True)
- df_plot = summary_df[summary_df["experiment"] == "compact_baselines"].copy()
- order = [
- "extratrees_8ch_features",
- "mlp_8ch_features",
- "cnn1d_8ch_raw",
- "eegnet_8ch_raw",
- "shallowconvnet_8ch_raw",
- ]
- df_plot["order"] = df_plot["model"].map({m: i for i, m in enumerate(order)})
- df_plot = df_plot.sort_values("order")
- labels = df_plot["model"].map(make_public_label).tolist()
- means = df_plot["balanced_accuracy_mean"].values
- lower = df_plot["balanced_accuracy_ci_lower"].values
- upper = df_plot["balanced_accuracy_ci_upper"].values
- err_low = means - lower
- err_high = upper - means
- # Wong colour-blind-safe palette. Feature-based models use cool tones,
- # raw-signal deep models use warm tones, and the strongest model (Extra
- # Trees on spectral features) anchors the palette in deep bluish-green.
- bar_colors = [
- "#009E73", # Extra Trees (spectral) - winner, deep bluish-green
- "#56B4E9", # MLP (spectral) - feature-based, sky blue
- "#E69F00", # 1D CNN (raw) - raw-signal, orange
- "#D55E00", # EEGNet (raw) - raw-signal, vermilion
- "#CC79A7", # ShallowConvNet (raw) - raw-signal, reddish purple
- ]
- edge_colors = ["#006B4F", "#2A7BB0", "#A06D00", "#933F00", "#8C4F75"]
- ax.set_axisbelow(True)
- bars = ax.bar(
- range(len(labels)), means,
- yerr=[err_low, err_high],
- color=bar_colors, edgecolor=edge_colors, linewidth=0.9,
- capsize=4, error_kw={"elinewidth": 1.2, "ecolor": "#333333"},
- alpha=0.95,
- )
- for i, v in enumerate(means):
- ax.text(i, upper[i] + 0.015, f"{v:.3f}", ha="center", va="bottom", fontsize=11)
- ax.set_xticks(range(len(labels)))
- ax.set_xticklabels(labels, rotation=12, ha="right")
- ax.set_ylabel("Subject-level balanced accuracy")
- ax.set_title("Compact 8-channel baselines (mean and 95% bootstrap CI)")
- ax.set_ylim(0.0, 1.05)
- ax.set_yticks(np.arange(0.0, 1.01, 0.2))
- ax.grid(axis="y", linewidth=0.4, alpha=0.3)
- fig.savefig(FIG_BASELINES, bbox_inches="tight")
- plt.show()
- print(FIG_BASELINES)
- # %% [markdown]
- # ## Figure 2 — Spectral vs connectivity vs fusion
- # %%
- fig, ax = plt.subplots(figsize=(8.8, 4.8), constrained_layout=True)
- df_plot = summary_df[summary_df["experiment"] == "fusion_ablation"].copy()
- order = ["extratrees_spectral", "extratrees_connectivity", "extratrees_fusion", "mlp_fusion"]
- df_plot["order"] = df_plot["model"].map({m: i for i, m in enumerate(order)})
- df_plot = df_plot.sort_values("order")
- labels = df_plot["model"].map(make_public_label).tolist()
- means = df_plot["balanced_accuracy_mean"].values
- lower = df_plot["balanced_accuracy_ci_lower"].values
- upper = df_plot["balanced_accuracy_ci_upper"].values
- err_low = means - lower
- err_high = upper - means
- # Distinct colour per feature condition. The spectral bar uses the same
- # green as the winning model in Figure 1, providing visual continuity
- # across the two experiments. Connectivity gets a warm orange to mark its
- # different feature family. Fusion gets a deep unifying blue. MLP fusion
- # gets a complementary reddish purple.
- bar_colors = [
- "#009E73", # ET (spectral) - matches Fig 1 winner
- "#E69F00", # ET (connectivity) - warm orange for connectivity family
- "#0072B2", # ET (fusion) - deep blue for combined representation
- "#CC79A7", # MLP (fusion) - reddish purple
- ]
- edge_colors = ["#006B4F", "#A06D00", "#004F7C", "#8C4F75"]
- ax.set_axisbelow(True)
- bars = ax.bar(
- range(len(labels)), means,
- yerr=[err_low, err_high],
- color=bar_colors, edgecolor=edge_colors, linewidth=0.9,
- capsize=4, error_kw={"elinewidth": 1.2, "ecolor": "#333333"},
- alpha=0.95,
- )
- for i, v in enumerate(means):
- ax.text(i, upper[i] + 0.015, f"{v:.3f}", ha="center", va="bottom", fontsize=11)
- ax.set_xticks(range(len(labels)))
- ax.set_xticklabels(labels)
- ax.set_ylabel("Subject-level balanced accuracy")
- ax.set_title("Spectral vs connectivity vs fusion (mean and 95% bootstrap CI)")
- ax.set_ylim(0.0, 1.05)
- ax.set_yticks(np.arange(0.0, 1.01, 0.2))
- ax.grid(axis="y", linewidth=0.4, alpha=0.3)
- fig.savefig(FIG_FUSION, bbox_inches="tight")
- plt.show()
- print(FIG_FUSION)
- # %% [markdown]
- # ## Figure 3 — Top feature importance preview
- # %%
- fig, axs = plt.subplots(1, 2, figsize=(12.5, 4.8), constrained_layout=True)
- # (a) Spectral winner. Sequential green ramp keeps the spectral colour
- # family from Figures 1 and 2, with deeper colour for higher importance.
- spec_df = top_features_summary[top_features_summary["model"] == "extratrees_8ch_features"].head(10).copy()
- spec_df = spec_df.sort_values("importance_mean", ascending=True)
- spec_colors = plt.cm.YlGn(np.linspace(0.45, 0.85, len(spec_df)))
- axs[0].set_axisbelow(True)
- axs[0].barh(spec_df["feature"], spec_df["importance_mean"],
- color=spec_colors, edgecolor="#2D5F3E", linewidth=0.6)
- axs[0].set_title("(a) Top spectral/asymmetry features")
- axs[0].set_xlabel("Mean importance")
- axs[0].grid(axis="x", linewidth=0.4, alpha=0.3)
- # (b) Connectivity winner. Sequential orange ramp matches the
- # connectivity colour family used for the connectivity bar in Figure 2.
- conn_df = top_features_summary[top_features_summary["model"] == "extratrees_connectivity"].head(10).copy()
- conn_df = conn_df.sort_values("importance_mean", ascending=True)
- conn_colors = plt.cm.YlOrBr(np.linspace(0.40, 0.80, len(conn_df)))
- axs[1].set_axisbelow(True)
- axs[1].barh(conn_df["feature"], conn_df["importance_mean"],
- color=conn_colors, edgecolor="#7A4F1F", linewidth=0.6)
- axs[1].set_title("(b) Top connectivity features")
- axs[1].set_xlabel("Mean importance")
- axs[1].grid(axis="x", linewidth=0.4, alpha=0.3)
- fig.savefig(FIG_IMPORTANCE, bbox_inches="tight")
- plt.show()
- print(FIG_IMPORTANCE)
- # %% [markdown]
- # ## Figure 4 — Spectral feature-selection ablation curve
- #
- # Subject-level balanced accuracy and 95% bootstrap CI as a function of the number of selected features. The full 90-feature baseline is shown as a horizontal reference. If accuracy plateaus well below $K=90$, the spectral representation is not over-parameterised; if it improves at smaller $K$, top-$K$ pruning offers a parsimonious alternative.
- # %%
- fig, ax = plt.subplots(figsize=(8.6, 4.8), constrained_layout=True)
- xs = fs_summary_df["K"].values
- means = fs_summary_df["balanced_accuracy_mean"].values
- los = fs_summary_df["balanced_accuracy_ci_lower"].values
- his = fs_summary_df["balanced_accuracy_ci_upper"].values
- # Reuse the spectral-winner colour from Figure 1 since this figure
- # describes the same Extra Trees on spectral features at varying K.
- PRIMARY = "#009E73"
- PRIMARY_DARK = "#006B4F"
- ax.set_axisbelow(True)
- ax.fill_between(xs, los, his, color=PRIMARY, alpha=0.18, label="95% bootstrap CI")
- ax.plot(xs, means, "o-", color=PRIMARY, linewidth=2.0, markersize=7,
- markerfacecolor="white", markeredgecolor=PRIMARY_DARK, markeredgewidth=1.8,
- label="Top-$K$ Extra Trees mean")
- # Reference line for the full 90-feature baseline
- full_row = fs_summary_df[fs_summary_df["K"] == 90]
- if len(full_row):
- ref = float(full_row["balanced_accuracy_mean"].iloc[0])
- ax.axhline(ref, linestyle="--", color="#555555", linewidth=1.0, alpha=0.7,
- label=f"Full 90-feature baseline ({ref:.3f})")
- for x, m, lo, hi in zip(xs, means, los, his):
- ax.text(x, hi + 0.012, f"{m:.3f}", ha="center", va="bottom", fontsize=10)
- ax.set_xlabel("Number of selected spectral and asymmetry features ($K$)")
- ax.set_ylabel("Subject-level balanced accuracy")
- ax.set_title("Spectral feature-selection ablation (Extra Trees, mean and 95% CI)")
- ax.set_xticks(xs)
- ymin = float(np.nanmin(los)) - 0.04
- ymax = max(1.02, float(np.nanmax(his)) + 0.05)
- ax.set_ylim(max(0.0, ymin), ymax)
- ax.grid(axis="y", linewidth=0.4, alpha=0.3)
- ax.legend(loc="lower right", frameon=False)
- fig.savefig(FIG_FEATURE_SELECTION, bbox_inches="tight")
- plt.show()
- print(FIG_FEATURE_SELECTION)
EEG_Depression_Analysis_Public_Notebook.ipynb at commit 0a6888f, no license · at the source
Overview
- Centre for Visual Computing and Intelligent Systems, University of Bradford, Bradford BD7 1DP, UK
- The Howard Brain Sciences Foundation, Washington, DC 20001, USA
Abstract
Major depressive disorder remains difficult to diagnose objectively, as routine assessment is still largely dependent on clinical interview and rating scales. Resting-state electroencephalography (EEG) is an attractive complementary modality because it is non-invasive, low-cost, and compatible with wearable sensing, but many reported EEG classification results are weakened by segment-level leakage and unclear subject identity handling. This study evaluates whether depression can be distinguished from healthy controls using a compact eight-channel resting-state EEG configuration under a strictly leakage-free subject-wise protocol. Using a widely used public EEG dataset, we first corrected a previously overlooked subject-identity ambiguity by constructing a class-aware composite key, yielding 56 valid unique participants. We then applied ten repeated subject-wise holdout splits and compared five compact baselines spanning Extra Trees and a multi-layer perceptron on asymmetry-aware spectral features and three convolutional networks on raw signals, including the EEG-specific EEGNet and ShallowConvNet architectures. Uncertainty was quantified through 95% bootstrap confidence intervals of the mean across repeats. The best model, an Extra Trees classifier using eight-channel spectral and asymmetry features, achieved a mean balanced accuracy of 93.5% with a 95% bootstrap confidence interval of 89.6% to 96.8% and a mean area under the receiver operating characteristic curve of 98.6% with a 95% bootstrap confidence interval of 96.2% to 100.0%. A connectivity ablation showed that inter-channel coherence was informative in isolation but did not improve performance when naively fused with spectral features. A feature-selection ablation did not show evidence that the 90-dimensional spectral representation was dominated by noisy or uninformative dimensions under this evaluation protocol. These results support compact, subject-wise evaluated EEG screening pipelines while highlighting the importance of rigorous leakage control.
Reproduced under the paper's license (CC BY), from the paper cited above.
Repository
Its files are read in the Code ↔ Paper reader above, with 10 matches between paragraphs and lines of code.
ugail/EEG_Based_Depression_Screening
0a6888f3caaafcad451a447db807154eaa1f73d7, 15 May 2026Availability: 1 check, the latest on 28 September 2026: the link answers
- 28 September 2026: the link answers
2 files
- EEG_Depression_Analysis_
Public_Notebook.ipynb , Jupyter, 1,297 lines, 10 matches - README.md, Text, 110 lines
The paper's code and data availability statement is in the Data section.
Tracing map
Proposed by the machine: these links were found in the paper and verified at the source, without human review. The map will receive a Zenodo DOI once one of the paper's authors has validated it with their ORCID.
What the map holds:
- 1 repository of the authors' code, each at its verified commit, with its license and how the link was found in the paper;
- 1 script, each with its path and the digest of its content;
- 10 matches between paragraphs of the paper and lines of the code (method lexical-v1);
- neither the text of the paper nor the code itself.
Its JSON (tracing-map.json) is deposited on Zenodo with its DOI once the map is validated.
Data
Datasets cited
- figshare:4244171, at figshare; found in “Data Availability Statement”
Data Availability Statement
The EEG dataset analysed in this study is publicly available on figshare, DOI https://
Reproduced under the paper's license (CC BY), from the paper cited above.
Versions
The history of this record: each version stored by the harvester or made by a correction of its authors or of the maintainers of its code, and what changed in its facts. The texts of the paper (its abstract, its availability statements) are not part of it; versions that changed only those are not listed.
Version 1, 28 September 2026: the first record
Recorded: type, language, journal, volume, issue, pages, dates, 4 authors, 10 keywords, 11 MeSH terms, 1 funder, 29 references.
Cite
This paper
Ugail, H., Howard, N., Elmahmudi, A. A., & Mnasri, Z. (2026). Subject-Wise Depression Screening from Eight-Channel Resting-State EEG Using Asymmetry-Aware Spectral Features and Connectivity Ablation. Sensors (Basel, Switzerland), 26(10), 3065. https://
BibTeX
@article{ugail2026subjec
author = {Ugail, Hassan and Howard, Newton and Elmahmudi, Ali Ahmed and Mnasri, Zied},
title = {{Subject-Wise Depression Screening from Eight-Channel Resting-State EEG Using Asymmetry-Aware Spectral Features and Connectivity Ablation}},
journal = {Sensors (Basel, Switzerland)},
year = {2026},
month = may,
volume = {26},
number = {10},
pages = {3065},
publisher = {Multidisciplinary Digital Publishing Institute (MDPI)},
issn = {1424-8220},
doi = {10.3390/
url = {https://
pmid = {42197874},
pmcid = {PMC13210619}
}
RIS
TY - JOUR
AU - Ugail, Hassan
AU - Howard, Newton
AU - Elmahmudi, Ali Ahmed
AU - Mnasri, Zied
TI - Subject-Wise Depression Screening from Eight-Channel Resting-State EEG Using Asymmetry-Aware Spectral Features and Connectivity Ablation
T2 - Sensors (Basel, Switzerland)
J2 - Sensors (Basel)
PY - 2026
DA - 2026/
VL - 26
IS - 10
SP - 3065
SN - 1424-8220
PB - Multidisciplinary Digital Publishing Institute (MDPI)
DO - 10.3390/
UR - https://
LA - en
ER -
CSL-JSON
{
"id": "10.3390/
"type": "article-journal",
"title": "Subject-Wise Depression Screening from Eight-Channel Resting-State EEG Using Asymmetry-Aware Spectral Features and Connectivity Ablation",
"container-title": "Sensors (Basel, Switzerland)",
"author": [
{
"family": "Ugail",
"given": "Hassan"
},
{
"family": "Howard",
"given": "Newton"
},
{
"family": "Elmahmudi",
"given": "Ali Ahmed"
},
{
"family": "Mnasri",
"given": "Zied"
}
],
"container-title-short":
"volume": "26",
"issue": "10",
"page": "3065",
"DOI": "10.3390/
"PMID": "42197874",
"PMCID": "PMC13210619",
"ISSN": "1424-8220",
"publisher": "Multidisciplinary Digital Publishing Institute (MDPI)",
"URL": "https://
"language": "en",
"issued": {
"date-parts": [
[
2026,
5,
12
]
]
}
}
The tracing map gets a citation of its own once an author has validated it and it has a DOI.
Similar papers
The papers with a page that share the most with this one: the tools found in their code, their categories, datasets, cited references and authors, the rarest counting most.
- [1] doi:10.1007/s00422-026-01049-1 [code]
- A three-component dynamical index of consciousness-related neural organisation.Journal: Biological cyberneticsIn common: scikit-learn, pandas, SciPy, 2 other tools, EEG, author Hassan Ugail
- [2] doi:10.3389/fnins.2026.1874302 [code]
- Architecture-data matching for EEG-EMG decoding: compact deep models match classical spectral decoders on the WAY-EEG-GAL grasp-and-lift dataset.Journal: Frontiers in neuroscienceIn common: PyTorch, scikit-learn, pandas, 3 other tools, other, EEG, 3 references
- [3] doi:10.1371/journal.pone.0347671 [code]
- RMETNet: A cross-subject motor imagery EEG signal classification model based on TSLANet and riemannian geometry features.Journal: PloS oneIn common: PyTorch, scikit-learn, pandas, 3 other tools, EEG, 3 references
- [4] doi:10.1038/s41598-026-68186-2 [code]
- NeuroStream: spectral-spatio-temporal
deep learning for visual stimulus classification from EEG. Journal: Scientific reportsIn common: PyTorch, scikit-learn, pandas, 3 other tools, EEG, 3 references - [5] doi:10.1002/hbm.70528 [code]
- Explainable AI Insights Into EEG Classification and Its Alignment to Neural Correlates.Journal: Human brain mappingIn common: PyTorch, scikit-learn, pandas, 3 other tools, EEG, 2 references
- [6] doi:10.1371/journal.pone.0351872 [code]
- Decoding visual object recognition from EEG signals.Journal: PloS oneIn common: PyTorch, scikit-learn, pandas, 3 other tools, EEG, 2 references
- [7] doi:10.1038/s41746-026-02778-0 [code]
- Trust-gated synthetic EEG augmentation reduces performance drops when generalizing to new patients.Journal: NPJ digital medicineIn common: PyTorch, scikit-learn, pandas, 3 other tools, EEG, 2 references
- [8] doi:10.1038/s41398-026-04079-2 [code]
- Neural Responses to Affective Sentences Reveal Signatures of Depression.Journal: Translational psychiatryIn common: PyTorch, scikit-learn, pandas, 3 other tools, depression, EEG, 1 reference
- [9] doi:10.3390/bios16070394
- Hybrid Edge-Cloud Asymmetric Analytics for Portable Multimodal BCI Biosensors.Journal: BiosensorsIn common: other, EEG, 5 references
- [10] doi:10.1186/s13634-026-01330-2 [code]
- Leednet: a lightweight network for event detection in EEG signals.Journal: Journal on advances in signal processingIn common: PyTorch, scikit-learn, pandas, 3 other tools, EEG, 2 references
Contribute
The authors of this paper can claim it, correct its record and validate its tracing map, and the maintainers of its code (its owner, or a public member of its organization) correct what it says of their repository; anyone signed in can ask for its removal. Every request goes to OSCR's own machine, which answers it; your account page follows them.
Sign in with ORCID to claim this paper as one of its authors, correct its record or validate its tracing map: when the paper's metadata lists your ORCID iD, you are recognized at once. Maintainers of its code: sign in with GitHub, then claim the repository on your account page.
Claim this paper
Correct its record
Say what each link of this record is, remove the ones that are not the paper's, add the ones that are missing. The correction becomes a new version of the record, in its Versions section.
Validate its tracing map
You validate the map as this page shows it: 1 repository of the authors' code, each at its verified commit and with its license, 1 script, and 10 matches between paragraphs and code (see the Code and Map sections). It then receives a DOI on Zenodo, with you (your ORCID iD) and OSCR as its creators; the code itself is not deposited.
The map's fingerprint: sha256:98613aadc5450193…
Add the badge to its README
The badge links the code to this page. Copy one of these into the README of the paper's code: only you decide where it goes, and nothing is changed for you.
Markdown
[, paste the snippet at the top, then “Commit changes…” and, to review it first, “Create a new branch and start a pull request”. You open the pull request; OSCR asks for no permission.
Request its removal
To ask OSCR to remove this record, the copies of its authors' scripts or its tracing map, use the removal request page: signed in, you say who you are, what to remove and why, then review and confirm the request. Published rules decide every request (how).
Discussion, reproductions, activity
Discussion: questions and error reports about this paper and its code, from signed-in readers and its authors. It opens with sign-in.
Reproductions: reports from readers who ran the authors' code: what they reproduced, with which environment, commit and data. It opens with sign-in.
Activity: what happens around this paper: new versions of its record, its map's validation, discussions and reproductions. It opens with sign-in.
