MCLF: Montage consistent CNN-Liquid fusion for long-term scalp EEG seizure detection.
The 7 matches
- [1] § Method validation › Comparison with baseline methods ↔ tcn_bilstm.py, lines 1–9 · score 0.69 · temporal convolution, TCN BiLSTM, models
- [2] § Method validation › Comparison with baseline methods ↔ eval_tuev_external.py, lines 93–119 · score 0.69 · TCN BiLSTM, CNN Transformer, CNN Informer, models
- [3] § Method validation › Comparison with baseline methods ↔ eval_tuev_external.py, lines 93–119 · score 0.58 · TCN Bi LSTM, CNN Informer, seizure
- [4] § Method validation › Experimental setup ↔ train_eval_main.py, lines 337–408 · score 0.57 · training loss, Adam, patience, batch, model
- [5] § Method validation › Evaluation metrics ↔ train_eval_main.py, lines 676–711 · score 0.52 · segment sensitivity, FN, FP, TN, TP, accuracy
- [6] § Method validation › Dataset and annotations ↔ config.py, lines 20–21 · score 0.51 · CHB MIT scalp, signals, EEG, seizure
- [7] § Method validation › External robustness analysis on artifact-labeled EEG events ↔ eval_tuev_external.py, lines 318–349 · score 0.50 · positive prediction rates, confusion, bckg, eyem, artf, External
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
Python · 657 lines · 22 KB · no license · 3 matches
- # -*- coding: utf-8 -*-
- import os
- import csv
- import glob
- import argparse
- import numpy as np
- import torch
- import mne
- import matplotlib.pyplot as plt
- from sklearn.metrics import accuracy_score, recall_score, confusion_matrix, roc_auc_score, f1_score
- from config import setup_env
- from model import SeizureLiquidChannelNet
- from cnn_informer import CNNInformerNet
- from tcn_bilstm import TCN_BiLSTM
- from dataProcess import normalize_eeg, dwt_filter_eeg_0_32hz
- # =========================
- # 1) 与原 CHB 保持一致的输入定义
- # =========================
- FS_TARGET = 256
- EPOCH_SEC = 4.0
- EPOCH_LEN = int(FS_TARGET * EPOCH_SEC)
- CH_LABELS_18 = [
- 'FP1-F7', 'F7-T7', 'T7-P7', 'P7-O1',
- 'FP1-F3', 'F3-C3', 'C3-P3', 'P3-O1',
- 'FP2-F4', 'F4-C4', 'C4-P4', 'P4-O2',
- 'FP2-F8', 'F8-T8', 'T8-P8', 'P8-O2',
- 'FZ-CZ', 'CZ-PZ'
- ]
- REF_CANDIDATES = {
- "FP1": ["EEG FP1-REF", "FP1-REF", "FP1"],
- "F7": ["EEG F7-REF", "F7-REF", "F7"],
- "T7": ["EEG T7-REF", "T7-REF", "T7", "EEG T3-REF", "T3-REF", "T3"],
- "P7": ["EEG P7-REF", "P7-REF", "P7", "EEG T5-REF", "T5-REF", "T5"],
- "O1": ["EEG O1-REF", "O1-REF", "O1"],
- "F3": ["EEG F3-REF", "F3-REF", "F3"],
- "C3": ["EEG C3-REF", "C3-REF", "C3"],
- "P3": ["EEG P3-REF", "P3-REF", "P3"],
- "FP2": ["EEG FP2-REF", "FP2-REF", "FP2"],
- "F4": ["EEG F4-REF", "F4-REF", "F4"],
- "C4": ["EEG C4-REF", "C4-REF", "C4"],
- "P4": ["EEG P4-REF", "P4-REF", "P4"],
- "O2": ["EEG O2-REF", "O2-REF", "O2"],
- "F8": ["EEG F8-REF", "F8-REF", "F8"],
- "T8": ["EEG T8-REF", "T8-REF", "T8", "EEG T4-REF", "T4-REF", "T4"],
- "P8": ["EEG P8-REF", "P8-REF", "P8", "EEG T6-REF", "T6-REF", "T6"],
- "FZ": ["EEG FZ-REF", "FZ-REF", "FZ"],
- "CZ": ["EEG CZ-REF", "CZ-REF", "CZ"],
- "PZ": ["EEG PZ-REF", "PZ-REF", "PZ"],
- }
- BIPOLAR_MAP = [
- ("FP1", "F7"),
- ("F7", "T7"),
- ("T7", "P7"),
- ("P7", "O1"),
- ("FP1", "F3"),
- ("F3", "C3"),
- ("C3", "P3"),
- ("P3", "O1"),
- ("FP2", "F4"),
- ("F4", "C4"),
- ("C4", "P4"),
- ("P4", "O2"),
- ("FP2", "F8"),
- ("F8", "T8"),
- ("T8", "P8"),
- ("P8", "O2"),
- ("FZ", "CZ"),
- ("CZ", "PZ"),
- ]
- LABEL_ID_TO_NAME = {
- 1: "spsw",
- 2: "gped",
- 3: "pled",
- 4: "eyem",
- 5: "artf",
- 6: "bckg",
- }
- POSITIVE_LABELS = {"spsw"}
- NEGATIVE_LABELS = {"artf", "eyem", "bckg"}
- AUX_OOD_LABELS = {"gped", "pled"}
- THRESH_CANDIDATES = np.round(np.linspace(0.01, 0.90, 90), 2)
- # =========================
- # 2) 多模型支持
- # =========================
- def build_model(model_type: str):
- model_type = model_type.lower()
- if model_type == "liquid":
- return SeizureLiquidChannelNet()
- elif model_type == "cnn_transformer":
- return CNNInformerNet(model_type="cnn_transformer")
- elif model_type == "cnn_informer":
- return CNNInformerNet(model_type="cnn_informer", factor=3)
- elif model_type == "cnn_informer_nodistill":
- return CNNInformerNet(model_type="cnn_informer_nodistill", factor=3)
- elif model_type == "tcn_bilstm":
- return TCN_BiLSTM()
- else:
- raise ValueError(
- f"Unsupported model_type: {model_type}. "
- f"Choose from: liquid, cnn_transformer, cnn_informer, "
- f"cnn_informer_nodistill, tcn_bilstm"
- )
- # =========================
- # 3) 数据读取
- # =========================
- def find_channel(raw, aliases):
- for a in aliases:
- if a in raw.ch_names:
- return a
- return None
- def load_tuev_18ch_uV_consistent(edf_path):
- raw = mne.io.read_raw_edf(edf_path, preload=True, verbose=False)
- if int(raw.info["sfreq"]) != FS_TARGET:
- raw.resample(FS_TARGET)
- fs = float(raw.info["sfreq"])
- ref_data = {}
- mapping_used = {}
- for key, aliases in REF_CANDIDATES.items():
- ch = find_channel(raw, aliases)
- if ch is None:
- raw.close()
- raise ValueError(f"Missing REF channel for {key} in {edf_path}")
- mapping_used[key] = ch
- ref_data[key] = raw.get_data(picks=[ch])[0] * 1e6
- raw.close()
- data = []
- for a, b in BIPOLAR_MAP:
- data.append(ref_data[a] - ref_data[b])
- data = np.stack(data, axis=0).astype(np.float32)
- # 与训练保持一致:robust normalize -> DWT
- data = normalize_eeg(data, mode="robust", eps=1e-6, clip=5.0)
- data = dwt_filter_eeg_0_32hz(data, wavelet="db4", level=5, mode="symmetric")
- return data, fs, mapping_used
- def read_rec_events(rec_path):
- events = []
- with open(rec_path, "r", encoding="utf-8") as f:
- for line in f:
- line = line.strip()
- if not line or line.startswith("#"):
- continue
- parts = line.split(",")
- if len(parts) != 4:
- continue
- ch_idx = int(parts[0])
- st = float(parts[1])
- ed = float(parts[2])
- lab_id = int(parts[3])
- lab_name = LABEL_ID_TO_NAME.get(lab_id, "unknown")
- events.append({
- "channel": ch_idx,
- "start": st,
- "end": ed,
- "label_id": lab_id,
- "label_name": lab_name,
- })
- return events
- def crop_center_epoch(data_18ch, fs, start_sec, end_sec, epoch_len=EPOCH_LEN):
- total_len = data_18ch.shape[1]
- center = 0.5 * (start_sec + end_sec)
- center_idx = int(center * fs)
- half = epoch_len // 2
- s0 = center_idx - half
- s1 = center_idx + half
- if s0 < 0:
- s0 = 0
- s1 = epoch_len
- if s1 > total_len:
- s1 = total_len
- s0 = total_len - epoch_len
- if s0 < 0 or s1 > total_len or (s1 - s0) != epoch_len:
- return None
- seg = data_18ch[:, s0:s1]
- if seg.shape != (18, epoch_len):
- return None
- return seg.astype(np.float32)
- def build_tuev_eval_dataset(eval_root, save_mapping_preview_path=None, max_preview_files=10):
- edf_files = sorted(glob.glob(os.path.join(eval_root, "*", "*.edf")))
- X_list, y_list, fine_list = [], [], []
- X_aux_list, fine_aux_list = [], []
- preview_rows = []
- preview_count = 0
- for edf_path in edf_files:
- rec_path = edf_path.replace(".edf", ".rec")
- if not os.path.exists(rec_path):
- continue
- try:
- data_18ch, fs, mapping_used = load_tuev_18ch_uV_consistent(edf_path)
- events = read_rec_events(rec_path)
- except Exception as e:
- print(f"[SKIP] {edf_path}: {e}")
- continue
- if preview_count < max_preview_files:
- row = {"edf": os.path.basename(edf_path)}
- row.update(mapping_used)
- preview_rows.append(row)
- preview_count += 1
- for ev in events:
- lab = ev["label_name"]
- seg = crop_center_epoch(data_18ch, fs, ev["start"], ev["end"], epoch_len=EPOCH_LEN)
- if seg is None:
- continue
- if lab in POSITIVE_LABELS:
- X_list.append(seg)
- y_list.append(1)
- fine_list.append(lab)
- elif lab in NEGATIVE_LABELS:
- X_list.append(seg)
- y_list.append(0)
- fine_list.append(lab)
- elif lab in AUX_OOD_LABELS:
- X_aux_list.append(seg)
- fine_aux_list.append(lab)
- if len(X_list) == 0:
- raise RuntimeError("No valid main-eval samples built.")
- X = np.stack(X_list, axis=0).astype(np.float32)
- X = X[:, np.newaxis, :, :]
- y = np.array(y_list, dtype=np.int32)
- fine = np.array(fine_list)
- if len(X_aux_list) > 0:
- X_aux = np.stack(X_aux_list, axis=0).astype(np.float32)
- X_aux = X_aux[:, np.newaxis, :, :]
- fine_aux = np.array(fine_aux_list)
- else:
- X_aux = None
- fine_aux = None
- if save_mapping_preview_path and len(preview_rows) > 0:
- os.makedirs(os.path.dirname(save_mapping_preview_path), exist_ok=True)
- keys = list(preview_rows[0].keys())
- with open(save_mapping_preview_path, "w", newline="", encoding="utf-8") as f:
- w = csv.DictWriter(f, fieldnames=keys)
- w.writeheader()
- for r in preview_rows:
- w.writerow(r)
- return X, y, fine, X_aux, fine_aux
- # =========================
- # 4) 推理
- # =========================
- @torch.no_grad()
- def predict_batches(model, X, device, batch_size=256):
- model.eval()
- probs = []
- for i in range(0, len(X), batch_size):
- xb = torch.from_numpy(X[i:i+batch_size]).to(device, non_blocking=True)
- logits = model(xb).squeeze(1)
- p = torch.sigmoid(logits).detach().cpu().numpy().astype(np.float32)
- probs.append(p)
- return np.concatenate(probs, axis=0)
- # =========================
- # 5) 统计与可视化
- # =========================
- def summarize_by_fine_label_at_threshold(pred, fine_labels):
- out = {}
- for lab in sorted(set(fine_labels)):
- idx = (fine_labels == lab)
- if idx.sum() == 0:
- continue
- out[lab] = {
- "n": int(idx.sum()),
- "positive_prediction_rate": float(pred[idx].mean())
- }
- return out
- def compute_metrics_at_threshold(y_true, probs, fine_labels, threshold):
- pred = (probs >= threshold).astype(np.int32)
- acc = accuracy_score(y_true, pred)
- sens = recall_score(y_true, pred, pos_label=1, zero_division=0)
- tn, fp, fn, tp = confusion_matrix(y_true, pred, labels=[0, 1]).ravel()
- spec = tn / (tn + fp + 1e-9)
- prec = tp / (tp + fp + 1e-9)
- f1 = f1_score(y_true, pred, zero_division=0)
- bal_acc = 0.5 * (sens + spec)
- by_label = summarize_by_fine_label_at_threshold(pred, fine_labels)
- row = {
- "threshold": float(threshold),
- "accuracy": float(acc),
- "sensitivity": float(sens),
- "specificity": float(spec),
- "precision": float(prec),
- "f1": float(f1),
- "balanced_accuracy": float(bal_acc),
- "tp": int(tp),
- "fp": int(fp),
- "tn": int(tn),
- "fn": int(fn),
- "artf_pos_rate": float(by_label.get("artf", {}).get("positive_prediction_rate", np.nan)),
- "eyem_pos_rate": float(by_label.get("eyem", {}).get("positive_prediction_rate", np.nan)),
- "bckg_pos_rate": float(by_label.get("bckg", {}).get("positive_prediction_rate", np.nan)),
- "spsw_pos_rate": float(by_label.get("spsw", {}).get("positive_prediction_rate", np.nan)),
- }
- return row
- def save_threshold_tables(rows, out_dir):
- os.makedirs(out_dir, exist_ok=True)
- metrics_csv = os.path.join(out_dir, "threshold_metrics.csv")
- keys = list(rows[0].keys())
- with open(metrics_csv, "w", newline="", encoding="utf-8") as f:
- w = csv.DictWriter(f, fieldnames=keys)
- w.writeheader()
- for r in rows:
- w.writerow(r)
- by_label_csv = os.path.join(out_dir, "threshold_by_label.csv")
- label_rows = []
- for r in rows:
- thr = r["threshold"]
- for lab in ["spsw", "artf", "eyem", "bckg"]:
- label_rows.append({
- "threshold": thr,
- "label": lab,
- "positive_prediction_rate": r[f"{lab}_pos_rate"]
- })
- with open(by_label_csv, "w", newline="", encoding="utf-8") as f:
- w = csv.DictWriter(f, fieldnames=["threshold", "label", "positive_prediction_rate"])
- w.writeheader()
- for rr in label_rows:
- w.writerow(rr)
- return metrics_csv, by_label_csv
- def plot_threshold_curves(rows, out_dir):
- os.makedirs(out_dir, exist_ok=True)
- th = np.array([r["threshold"] for r in rows], dtype=np.float32)
- sens = np.array([r["sensitivity"] for r in rows], dtype=np.float32)
- spec = np.array([r["specificity"] for r in rows], dtype=np.float32)
- artf = np.array([r["artf_pos_rate"] for r in rows], dtype=np.float32)
- eyem = np.array([r["eyem_pos_rate"] for r in rows], dtype=np.float32)
- bckg = np.array([r["bckg_pos_rate"] for r in rows], dtype=np.float32)
- plt.figure(figsize=(8, 5))
- plt.plot(th, sens, label="Sensitivity", linewidth=2)
- plt.plot(th, spec, label="Specificity", linewidth=2)
- plt.plot(th, artf, label="ARTF positive rate", linewidth=2)
- plt.plot(th, eyem, label="EYEM positive rate", linewidth=1.5, linestyle="--")
- plt.plot(th, bckg, label="BCKG positive rate", linewidth=1.5, linestyle=":")
- plt.xlabel("Threshold")
- plt.ylabel("Rate")
- plt.title("Threshold sweep: sensitivity / specificity / artifact-related rates")
- plt.ylim(-0.02, 1.02)
- plt.grid(alpha=0.3)
- plt.legend()
- plt.tight_layout()
- plt.savefig(os.path.join(out_dir, "threshold_sens_spec_artf.png"), dpi=200)
- plt.close()
- plt.figure(figsize=(6, 6))
- plt.plot(1 - spec, sens, linewidth=2, marker="o", markersize=3)
- for i in range(0, len(th), max(1, len(th) // 10)):
- plt.text((1 - spec[i]) + 0.002, sens[i], f"{th[i]:.2f}", fontsize=8)
- plt.xlabel("1 - Specificity")
- plt.ylabel("Sensitivity")
- plt.title("Operating points under threshold sweep")
- plt.grid(alpha=0.3)
- plt.tight_layout()
- plt.savefig(os.path.join(out_dir, "threshold_roc_like.png"), dpi=200)
- plt.close()
- def summarize_prob_distribution_by_label(probs, fine_labels, out_dir, filename_prefix="prob_distribution_by_label"):
- os.makedirs(out_dir, exist_ok=True)
- labels = sorted(set(fine_labels))
- rows = []
- print("\n===== Probability distribution by original label =====")
- for lab in labels:
- idx = (fine_labels == lab)
- p = probs[idx]
- if len(p) == 0:
- continue
- row = {
- "label": lab,
- "n": int(len(p)),
- "mean": float(np.mean(p)),
- "median": float(np.median(p)),
- "std": float(np.std(p)),
- "p10": float(np.percentile(p, 10)),
- "p25": float(np.percentile(p, 25)),
- "p75": float(np.percentile(p, 75)),
- "p90": float(np.percentile(p, 90)),
- "p95": float(np.percentile(p, 95)),
- "max": float(np.max(p)),
- "min": float(np.min(p)),
- }
- rows.append(row)
- print(
- f"{lab:>5s} | n={row['n']:5d} | "
- f"mean={row['mean']:.4f} | median={row['median']:.4f} | std={row['std']:.4f} | "
- f"p90={row['p90']:.4f} | p95={row['p95']:.4f} | max={row['max']:.4f}"
- )
- csv_path = os.path.join(out_dir, f"{filename_prefix}.csv")
- with open(csv_path, "w", newline="", encoding="utf-8") as f:
- w = csv.DictWriter(f, fieldnames=list(rows[0].keys()))
- w.writeheader()
- for r in rows:
- w.writerow(r)
- plt.figure(figsize=(10, 6))
- bins = np.linspace(0, 1, 51)
- for lab in labels:
- idx = (fine_labels == lab)
- p = probs[idx]
- if len(p) == 0:
- continue
- plt.hist(p, bins=bins, alpha=0.35, density=True, label=lab)
- plt.xlabel("Predicted probability")
- plt.ylabel("Density")
- plt.title("Probability distribution by original label")
- plt.legend()
- plt.grid(alpha=0.3)
- plt.tight_layout()
- fig_path = os.path.join(out_dir, f"{filename_prefix}.png")
- plt.savefig(fig_path, dpi=200)
- plt.close()
- return csv_path, fig_path
- def summarize_binary_prob_distribution(probs, y_true, out_dir):
- os.makedirs(out_dir, exist_ok=True)
- pos = probs[y_true == 1]
- neg = probs[y_true == 0]
- def stats(arr, name):
- return {
- "group": name,
- "n": int(len(arr)),
- "mean": float(np.mean(arr)),
- "median": float(np.median(arr)),
- "std": float(np.std(arr)),
- "p90": float(np.percentile(arr, 90)),
- "p95": float(np.percentile(arr, 95)),
- "max": float(np.max(arr)),
- "min": float(np.min(arr)),
- }
- rows = [stats(pos, "positive"), stats(neg, "negative")]
- print("\n===== Probability distribution by binary class =====")
- for r in rows:
- print(
- f"{r['group']:>8s} | n={r['n']:5d} | "
- f"mean={r['mean']:.4f} | median={r['median']:.4f} | std={r['std']:.4f} | "
- f"p90={r['p90']:.4f} | p95={r['p95']:.4f} | max={r['max']:.4f}"
- )
- csv_path = os.path.join(out_dir, "prob_distribution_binary.csv")
- with open(csv_path, "w", newline="", encoding="utf-8") as f:
- w = csv.DictWriter(f, fieldnames=list(rows[0].keys()))
- w.writeheader()
- for r in rows:
- w.writerow(r)
- plt.figure(figsize=(8, 5))
- bins = np.linspace(0, 1, 51)
- plt.hist(pos, bins=bins, alpha=0.45, density=True, label="positive")
- plt.hist(neg, bins=bins, alpha=0.45, density=True, label="negative")
- plt.xlabel("Predicted probability")
- plt.ylabel("Density")
- plt.title("Probability distribution by binary class")
- plt.legend()
- plt.grid(alpha=0.3)
- plt.tight_layout()
- fig_path = os.path.join(out_dir, "prob_distribution_binary.png")
- plt.savefig(fig_path, dpi=200)
- plt.close()
- return csv_path, fig_path
- def print_best_points(rows):
- rows_sorted_bal = sorted(rows, key=lambda x: x["balanced_accuracy"], reverse=True)
- rows_sorted_f1 = sorted(rows, key=lambda x: x["f1"], reverse=True)
- rows_sorted_artf = sorted(rows, key=lambda x: (x["artf_pos_rate"], -x["sensitivity"]))
- print("\n===== Best threshold by balanced accuracy =====")
- print(rows_sorted_bal[0])
- print("\n===== Best threshold by F1 =====")
- print(rows_sorted_f1[0])
- print("\n===== Threshold with lowest ARTF positive rate (tie -> higher sensitivity) =====")
- print(rows_sorted_artf[0])
- # =========================
- # 6) 主程序
- # =========================
- def main():
- parser = argparse.ArgumentParser()
- parser.add_argument("--eval_root", type=str, default="archive/edf/eval")
- parser.add_argument("--ckpt", type=str, default="torch_models/GLOBAL_SeizureLiquidChannelNet.pt")
- parser.add_argument("--model_type", type=str, default="liquid",
- choices=[
- "liquid",
- "cnn_transformer",
- "cnn_informer",
- "cnn_informer_nodistill",
- "tcn_bilstm",
- ],
- help="which model architecture the checkpoint belongs to")
- parser.add_argument("--batch_size", type=int, default=256)
- parser.add_argument("--out_dir", type=str, default="tuev_external_eval_spsw")
- args = parser.parse_args()
- device = setup_env()
- os.makedirs(args.out_dir, exist_ok=True)
- mapping_preview_csv = os.path.join(args.out_dir, "channel_mapping_preview.csv")
- print("[INFO] Building TUEV eval dataset...")
- X, y, fine, X_aux, fine_aux = build_tuev_eval_dataset(
- args.eval_root,
- save_mapping_preview_path=mapping_preview_csv,
- max_preview_files=10
- )
- print(f"[INFO] Main task X={X.shape}, positive={int(y.sum())}, negative={int((y == 0).sum())}")
- if X_aux is not None:
- print(f"[INFO] Auxiliary OOD samples X_aux={X_aux.shape}")
- print(f"[INFO] Loading model: {args.model_type}")
- model = build_model(args.model_type)
- sd = torch.load(args.ckpt, map_location="cpu")
- model.load_state_dict(sd)
- model.to(device)
- model.eval()
- print("[INFO] Predicting main task once...")
- probs = predict_batches(model, X, device, batch_size=args.batch_size)
- try:
- auc = roc_auc_score(y, probs)
- except Exception:
- auc = float("nan")
- print("\n===== Threshold-free summary =====")
- print(f"AUROC: {auc:.4f}")
- dist_csv, dist_fig = summarize_prob_distribution_by_label(probs, fine, args.out_dir, "prob_distribution_by_label")
- bin_csv, bin_fig = summarize_binary_prob_distribution(probs, y, args.out_dir)
- print("[INFO] Sweeping thresholds...")
- rows = []
- for th in THRESH_CANDIDATES:
- row = compute_metrics_at_threshold(y, probs, fine, float(th))
- rows.append(row)
- metrics_csv, by_label_csv = save_threshold_tables(rows, args.out_dir)
- plot_threshold_curves(rows, args.out_dir)
- print_best_points(rows)
- print("\nSaved:")
- print(f" {metrics_csv}")
- print(f" {by_label_csv}")
- print(f" {dist_csv}")
- print(f" {bin_csv}")
- print(f" {dist_fig}")
- print(f" {bin_fig}")
- print(f" {mapping_preview_csv}")
- print(f" {os.path.join(args.out_dir, 'threshold_sens_spec_artf.png')}")
- print(f" {os.path.join(args.out_dir, 'threshold_roc_like.png')}")
- print("\n===== Quick view at selected thresholds =====")
- for target_th in [0.01, 0.02, 0.05, 0.10, 0.20, 0.30, 0.50]:
- cand = min(rows, key=lambda r: abs(r["threshold"] - target_th))
- print(
- f"th={cand['threshold']:.2f} | "
- f"Acc={cand['accuracy']*100:.2f}% | "
- f"Sens={cand['sensitivity']*100:.2f}% | "
- f"Spec={cand['specificity']*100:.2f}% | "
- f"F1={cand['f1']:.4f} | "
- f"ARTF={cand['artf_pos_rate']*100:.2f}% | "
- f"EYEM={cand['eyem_pos_rate']*100:.2f}% | "
- f"BCKG={cand['bckg_pos_rate']*100:.2f}%"
- )
- if X_aux is not None and len(X_aux) > 0:
- print("\n[INFO] Predicting auxiliary OOD labels (gped / pled)...")
- probs_aux = predict_batches(model, X_aux, device, batch_size=args.batch_size)
- aux_csv, aux_fig = summarize_prob_distribution_by_label(
- probs_aux, fine_aux, args.out_dir, "prob_distribution_aux_gped_pled"
- )
- print("\n===== Auxiliary OOD-label probability summary (not used in main binary metrics) =====")
- print(f" {aux_csv}")
- print(f" {aux_fig}")
- if __name__ == "__main__":
- main()
eval_tuev_external.py at commit 2ae9222, no license · at the source
Overview
Abstract
Long-term scalp EEG monitoring yields hours of multi-channel recordings in which seizure-related patterns may appear only on a subset of derivations and can be obscured by transient artifacts. This work presents MCLF, a montage-consistent CNN–Liquid fusion method that implements cross-channel evidence integration as a state-based accumulation process within each epoch. Specifically, a shared 1D CNN encodes each channel into a common embedding space; embeddings are then arranged in a montage-consistent order and integrated by liquid state evolution to form an epoch-level representation for seizure scoring. A lightweight event-formation step converts the score sequence into clinically interpretable seizure events. Validation on the CHB-MIT dataset reports 100% event sensitivity with an FDR of 0.98/
Apply per-epoch DWT reconstruction (Db4, 5 levels) followed by z-score normalization to standardize inputs for long-term recordings.
Perform montage-consistent channel serialization and fuse the resulting channel stream via liquid state evolution for state-based evidence accumulation.
Form seizure events from epoch-level scores using MAF smoothing, thresholding, collar expansion, and event merging with validation-based parameter calibration.
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 7 matches between paragraphs and lines of code.
Yinxin111/MCLF
2ae92225b4b25a44576847b4eaa365424fae3ef1, 12 April 2026Availability: 1 check, the latest on 30 September 2026: the link answers
- 30 September 2026: the link answers
16 files
- cnn_informer.py, Python, 359 lines
- config.py, Python, 80 lines, 1 match
- dataProcess.py, Python, 447 lines
- eval_tuev_external.py, Python, 657 lines, 3 matches
- model.py, Python, 94 lines
- networks/
__init__.py , Python, 4 lines - networks/
causal.py , Python, 35 lines - networks/
cell.py , Python, 63 lines - networks/
factory.py , Python, 48 lines - networks/
functions.py , Python, 49 lines - networks/
norm.py , Python, 37 lines - networks/
recurent.py , Python, 145 lines - tcn_bilstm.py, Python, 135 lines, 1 match
- train_eval_main.py, Python, 1,057 lines, 2 matches
- visualize.py, Python, 363 lines
- README.md, Text, 279 lines
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;
- 15 scripts, each with its path and the digest of its content;
- 7 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
- physionet.org/
content/ , at PhysioNet; found in the resources tablechbmit
Data availability
Data will be made available on request.
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, 30 September 2026: the first record
Recorded: type, language, journal, volume, pages, dates, 3 authors, 7 keywords, 9 references.
Cite
This paper
Wang, Y., Zhao, X., & Yin, X. (2026). MCLF: Montage consistent CNN-Liquid fusion for long-term scalp EEG seizure detection. MethodsX, 16, 103929. https://
BibTeX
@article{wang2026mclf,
author = {Wang, Ying and Zhao, Xuelian and Yin, Xin},
title = {{MCLF: Montage consistent CNN-Liquid fusion for long-term scalp EEG seizure detection}},
journal = {MethodsX},
year = {2026},
month = apr,
volume = {16},
pages = {103929},
publisher = {Elsevier},
issn = {2215-0161},
doi = {10.1016/
url = {https://
pmid = {42403954},
pmcid = {PMC13329098}
}
RIS
TY - JOUR
AU - Wang, Ying
AU - Zhao, Xuelian
AU - Yin, Xin
TI - MCLF: Montage consistent CNN-Liquid fusion for long-term scalp EEG seizure detection
T2 - MethodsX
J2 - MethodsX
PY - 2026
DA - 2026/
VL - 16
SP - 103929
SN - 2215-0161
PB - Elsevier
DO - 10.1016/
UR - https://
LA - en
ER -
CSL-JSON
{
"id": "10.1016/
"type": "article-journal",
"title": "MCLF: Montage consistent CNN-Liquid fusion for long-term scalp EEG seizure detection",
"container-title": "MethodsX",
"author": [
{
"family": "Wang",
"given": "Ying"
},
{
"family": "Zhao",
"given": "Xuelian"
},
{
"family": "Yin",
"given": "Xin"
}
],
"container-title-short":
"volume": "16",
"page": "103929",
"DOI": "10.1016/
"PMID": "42403954",
"PMCID": "PMC13329098",
"ISSN": "2215-0161",
"publisher": "Elsevier",
"URL": "https://
"language": "en",
"issued": {
"date-parts": [
[
2026,
4,
25
]
]
}
}
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.1038/s41598-026-47627-y [code]
- QuantumNeuroXAI: a quantum-inspired deep learning framework with explainability for brain signal analysis and neurological disorder detection.Journal: Scientific reportsIn common: MNE-Python, PyTorch, scikit-learn, 2 other tools, physionet.org/content/chbmit, EEG
- [2] doi:10.1371/journal.pone.0352191 [code]
- Bayesian Uncertainty-aware Deep Learning with noisy labels: Tackling annotation ambiguity in EEG seizure detection.Journal: PloS oneIn common: PyTorch, scikit-learn, Matplotlib, 1 other tool, physionet.org/content/chbmit, epilepsy, EEG
- [3] doi:10.1038/s41746-026-02946-2 [code]
- Fully-automated sleep staging for Parkinson's disease and isolated REM sleep behavior disorder.Journal: NPJ digital medicineIn common: WFDB Python, MNE-Python, PyTorch, 3 other tools
- [4] doi:10.1002/hbm.70628 [code]
- EEG Biomarkers for Affective Disorders Diagnosis: An Evaluation and Validation Study.Journal: Human brain mappingIn common: PyWavelets, MNE-Python, PyTorch, 3 other tools, EEG
- [5] 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: PyWavelets, MNE-Python, PyTorch, 3 other tools, EEG
- [6] doi:10.1016/j.isci.2026.117068 [code]
- Directed graph neural networks with partial directed coherence for seizure prediction and epileptogenic network characterization.Journal: iScienceIn common: PyTorch, scikit-learn, NumPy, physionet.org/content/chbmit, epilepsy
- [7] doi:10.1038/s41598-026-68506-6
- Frequency-aware transformer networks for robust and generalizable EEG-based seizure detection.Journal: Scientific reportsIn common: physionet.org/content/chbmit, epilepsy, EEG, 1 reference
- [8] doi:10.3390/brainsci16070680 [code]
- ION-Sim: A Novel Open-Source Simulation Framework for Intraoperative Neurophysiological Monitoring.Journal: Brain sciencesIn common: PyWavelets, MNE-Python, PyTorch, 2 other tools, EEG
- [9] doi:10.7554/elife.100605 [code]
- Age-related changes in ‘cortical’ 1/
f dynamics are linked to cardiac activity Journal: n/aIn common: WFDB Python, MNE-Python, scikit-learn, 2 other tools - [10] doi:10.1371/journal.pone.0354510 [code]
- Personalized adaptive virtual reality experience driven by electroencephalography-b
ased pain recognition. Journal: PloS oneIn common: PyWavelets, MNE-Python, scikit-learn, 2 other tools, EEG
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, 15 scripts, and 7 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:1f9f4564b19f568c…
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.
