OSCR

MCLF: Montage consistent CNN-Liquid fusion for long-term scalp EEG seizure detection.

Code ↔ Paper

7 matches between paragraphs of the paper and lines of its authors' code, computed by the harvester (lexical-v1). Click a colored paragraph or line to see its counterpart.

The 7 matches
  1. [1] § Method validation › Comparison with baseline methods ↔ tcn_bilstm.py, lines 1–9 · score 0.69 · temporal convolution, TCN BiLSTM, models
  2. [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. [3] § Method validation › Comparison with baseline methods ↔ eval_tuev_external.py, lines 93–119 · score 0.58 · TCN Bi LSTM, CNN Informer, seizure
  4. [4] § Method validation › Experimental setup ↔ train_eval_main.py, lines 337–408 · score 0.57 · training loss, Adam, patience, batch, model
  5. [5] § Method validation › Evaluation metrics ↔ train_eval_main.py, lines 676–711 · score 0.52 · segment sensitivity, FN, FP, TN, TP, accuracy
  6. [6] § Method validation › Dataset and annotations ↔ config.py, lines 20–21 · score 0.51 · CHB MIT scalp, signals, EEG, seizure
  7. [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

  1. # -*- coding: utf-8 -*-
  2. import os
  3. import csv
  4. import glob
  5. import argparse
  6. import numpy as np
  7. import torch
  8. import mne
  9. import matplotlib.pyplot as plt
  10. from sklearn.metrics import accuracy_score, recall_score, confusion_matrix, roc_auc_score, f1_score
  11. from config import setup_env
  12. from model import SeizureLiquidChannelNet
  13. from cnn_informer import CNNInformerNet
  14. from tcn_bilstm import TCN_BiLSTM
  15. from dataProcess import normalize_eeg, dwt_filter_eeg_0_32hz
  16. # =========================
  17. # 1) 与原 CHB 保持一致的输入定义
  18. # =========================
  19. FS_TARGET = 256
  20. EPOCH_SEC = 4.0
  21. EPOCH_LEN = int(FS_TARGET * EPOCH_SEC)
  22. CH_LABELS_18 = [
  23. 'FP1-F7', 'F7-T7', 'T7-P7', 'P7-O1',
  24. 'FP1-F3', 'F3-C3', 'C3-P3', 'P3-O1',
  25. 'FP2-F4', 'F4-C4', 'C4-P4', 'P4-O2',
  26. 'FP2-F8', 'F8-T8', 'T8-P8', 'P8-O2',
  27. 'FZ-CZ', 'CZ-PZ'
  28. ]
  29. REF_CANDIDATES = {
  30. "FP1": ["EEG FP1-REF", "FP1-REF", "FP1"],
  31. "F7": ["EEG F7-REF", "F7-REF", "F7"],
  32. "T7": ["EEG T7-REF", "T7-REF", "T7", "EEG T3-REF", "T3-REF", "T3"],
  33. "P7": ["EEG P7-REF", "P7-REF", "P7", "EEG T5-REF", "T5-REF", "T5"],
  34. "O1": ["EEG O1-REF", "O1-REF", "O1"],
  35. "F3": ["EEG F3-REF", "F3-REF", "F3"],
  36. "C3": ["EEG C3-REF", "C3-REF", "C3"],
  37. "P3": ["EEG P3-REF", "P3-REF", "P3"],
  38. "FP2": ["EEG FP2-REF", "FP2-REF", "FP2"],
  39. "F4": ["EEG F4-REF", "F4-REF", "F4"],
  40. "C4": ["EEG C4-REF", "C4-REF", "C4"],
  41. "P4": ["EEG P4-REF", "P4-REF", "P4"],
  42. "O2": ["EEG O2-REF", "O2-REF", "O2"],
  43. "F8": ["EEG F8-REF", "F8-REF", "F8"],
  44. "T8": ["EEG T8-REF", "T8-REF", "T8", "EEG T4-REF", "T4-REF", "T4"],
  45. "P8": ["EEG P8-REF", "P8-REF", "P8", "EEG T6-REF", "T6-REF", "T6"],
  46. "FZ": ["EEG FZ-REF", "FZ-REF", "FZ"],
  47. "CZ": ["EEG CZ-REF", "CZ-REF", "CZ"],
  48. "PZ": ["EEG PZ-REF", "PZ-REF", "PZ"],
  49. }
  50. BIPOLAR_MAP = [
  51. ("FP1", "F7"),
  52. ("F7", "T7"),
  53. ("T7", "P7"),
  54. ("P7", "O1"),
  55. ("FP1", "F3"),
  56. ("F3", "C3"),
  57. ("C3", "P3"),
  58. ("P3", "O1"),
  59. ("FP2", "F4"),
  60. ("F4", "C4"),
  61. ("C4", "P4"),
  62. ("P4", "O2"),
  63. ("FP2", "F8"),
  64. ("F8", "T8"),
  65. ("T8", "P8"),
  66. ("P8", "O2"),
  67. ("FZ", "CZ"),
  68. ("CZ", "PZ"),
  69. ]
  70. LABEL_ID_TO_NAME = {
  71. 1: "spsw",
  72. 2: "gped",
  73. 3: "pled",
  74. 4: "eyem",
  75. 5: "artf",
  76. 6: "bckg",
  77. }
  78. POSITIVE_LABELS = {"spsw"}
  79. NEGATIVE_LABELS = {"artf", "eyem", "bckg"}
  80. AUX_OOD_LABELS = {"gped", "pled"}
  81. THRESH_CANDIDATES = np.round(np.linspace(0.01, 0.90, 90), 2)
  82. # =========================
  83. # 2) 多模型支持
  84. # =========================
  85. def build_model(model_type: str):
  86. model_type = model_type.lower()
  87. if model_type == "liquid":
  88. return SeizureLiquidChannelNet()
  89. elif model_type == "cnn_transformer":
  90. return CNNInformerNet(model_type="cnn_transformer")
  91. elif model_type == "cnn_informer":
  92. return CNNInformerNet(model_type="cnn_informer", factor=3)
  93. elif model_type == "cnn_informer_nodistill":
  94. return CNNInformerNet(model_type="cnn_informer_nodistill", factor=3)
  95. elif model_type == "tcn_bilstm":
  96. return TCN_BiLSTM()
  97. else:
  98. raise ValueError(
  99. f"Unsupported model_type: {model_type}. "
  100. f"Choose from: liquid, cnn_transformer, cnn_informer, "
  101. f"cnn_informer_nodistill, tcn_bilstm"
  102. )
  103. # =========================
  104. # 3) 数据读取
  105. # =========================
  106. def find_channel(raw, aliases):
  107. for a in aliases:
  108. if a in raw.ch_names:
  109. return a
  110. return None
  111. def load_tuev_18ch_uV_consistent(edf_path):
  112. raw = mne.io.read_raw_edf(edf_path, preload=True, verbose=False)
  113. if int(raw.info["sfreq"]) != FS_TARGET:
  114. raw.resample(FS_TARGET)
  115. fs = float(raw.info["sfreq"])
  116. ref_data = {}
  117. mapping_used = {}
  118. for key, aliases in REF_CANDIDATES.items():
  119. ch = find_channel(raw, aliases)
  120. if ch is None:
  121. raw.close()
  122. raise ValueError(f"Missing REF channel for {key} in {edf_path}")
  123. mapping_used[key] = ch
  124. ref_data[key] = raw.get_data(picks=[ch])[0] * 1e6
  125. raw.close()
  126. data = []
  127. for a, b in BIPOLAR_MAP:
  128. data.append(ref_data[a] - ref_data[b])
  129. data = np.stack(data, axis=0).astype(np.float32)
  130. # 与训练保持一致:robust normalize -> DWT
  131. data = normalize_eeg(data, mode="robust", eps=1e-6, clip=5.0)
  132. data = dwt_filter_eeg_0_32hz(data, wavelet="db4", level=5, mode="symmetric")
  133. return data, fs, mapping_used
  134. def read_rec_events(rec_path):
  135. events = []
  136. with open(rec_path, "r", encoding="utf-8") as f:
  137. for line in f:
  138. line = line.strip()
  139. if not line or line.startswith("#"):
  140. continue
  141. parts = line.split(",")
  142. if len(parts) != 4:
  143. continue
  144. ch_idx = int(parts[0])
  145. st = float(parts[1])
  146. ed = float(parts[2])
  147. lab_id = int(parts[3])
  148. lab_name = LABEL_ID_TO_NAME.get(lab_id, "unknown")
  149. events.append({
  150. "channel": ch_idx,
  151. "start": st,
  152. "end": ed,
  153. "label_id": lab_id,
  154. "label_name": lab_name,
  155. })
  156. return events
  157. def crop_center_epoch(data_18ch, fs, start_sec, end_sec, epoch_len=EPOCH_LEN):
  158. total_len = data_18ch.shape[1]
  159. center = 0.5 * (start_sec + end_sec)
  160. center_idx = int(center * fs)
  161. half = epoch_len // 2
  162. s0 = center_idx - half
  163. s1 = center_idx + half
  164. if s0 < 0:
  165. s0 = 0
  166. s1 = epoch_len
  167. if s1 > total_len:
  168. s1 = total_len
  169. s0 = total_len - epoch_len
  170. if s0 < 0 or s1 > total_len or (s1 - s0) != epoch_len:
  171. return None
  172. seg = data_18ch[:, s0:s1]
  173. if seg.shape != (18, epoch_len):
  174. return None
  175. return seg.astype(np.float32)
  176. def build_tuev_eval_dataset(eval_root, save_mapping_preview_path=None, max_preview_files=10):
  177. edf_files = sorted(glob.glob(os.path.join(eval_root, "*", "*.edf")))
  178. X_list, y_list, fine_list = [], [], []
  179. X_aux_list, fine_aux_list = [], []
  180. preview_rows = []
  181. preview_count = 0
  182. for edf_path in edf_files:
  183. rec_path = edf_path.replace(".edf", ".rec")
  184. if not os.path.exists(rec_path):
  185. continue
  186. try:
  187. data_18ch, fs, mapping_used = load_tuev_18ch_uV_consistent(edf_path)
  188. events = read_rec_events(rec_path)
  189. except Exception as e:
  190. print(f"[SKIP] {edf_path}: {e}")
  191. continue
  192. if preview_count < max_preview_files:
  193. row = {"edf": os.path.basename(edf_path)}
  194. row.update(mapping_used)
  195. preview_rows.append(row)
  196. preview_count += 1
  197. for ev in events:
  198. lab = ev["label_name"]
  199. seg = crop_center_epoch(data_18ch, fs, ev["start"], ev["end"], epoch_len=EPOCH_LEN)
  200. if seg is None:
  201. continue
  202. if lab in POSITIVE_LABELS:
  203. X_list.append(seg)
  204. y_list.append(1)
  205. fine_list.append(lab)
  206. elif lab in NEGATIVE_LABELS:
  207. X_list.append(seg)
  208. y_list.append(0)
  209. fine_list.append(lab)
  210. elif lab in AUX_OOD_LABELS:
  211. X_aux_list.append(seg)
  212. fine_aux_list.append(lab)
  213. if len(X_list) == 0:
  214. raise RuntimeError("No valid main-eval samples built.")
  215. X = np.stack(X_list, axis=0).astype(np.float32)
  216. X = X[:, np.newaxis, :, :]
  217. y = np.array(y_list, dtype=np.int32)
  218. fine = np.array(fine_list)
  219. if len(X_aux_list) > 0:
  220. X_aux = np.stack(X_aux_list, axis=0).astype(np.float32)
  221. X_aux = X_aux[:, np.newaxis, :, :]
  222. fine_aux = np.array(fine_aux_list)
  223. else:
  224. X_aux = None
  225. fine_aux = None
  226. if save_mapping_preview_path and len(preview_rows) > 0:
  227. os.makedirs(os.path.dirname(save_mapping_preview_path), exist_ok=True)
  228. keys = list(preview_rows[0].keys())
  229. with open(save_mapping_preview_path, "w", newline="", encoding="utf-8") as f:
  230. w = csv.DictWriter(f, fieldnames=keys)
  231. w.writeheader()
  232. for r in preview_rows:
  233. w.writerow(r)
  234. return X, y, fine, X_aux, fine_aux
  235. # =========================
  236. # 4) 推理
  237. # =========================
  238. @torch.no_grad()
  239. def predict_batches(model, X, device, batch_size=256):
  240. model.eval()
  241. probs = []
  242. for i in range(0, len(X), batch_size):
  243. xb = torch.from_numpy(X[i:i+batch_size]).to(device, non_blocking=True)
  244. logits = model(xb).squeeze(1)
  245. p = torch.sigmoid(logits).detach().cpu().numpy().astype(np.float32)
  246. probs.append(p)
  247. return np.concatenate(probs, axis=0)
  248. # =========================
  249. # 5) 统计与可视化
  250. # =========================
  251. def summarize_by_fine_label_at_threshold(pred, fine_labels):
  252. out = {}
  253. for lab in sorted(set(fine_labels)):
  254. idx = (fine_labels == lab)
  255. if idx.sum() == 0:
  256. continue
  257. out[lab] = {
  258. "n": int(idx.sum()),
  259. "positive_prediction_rate": float(pred[idx].mean())
  260. }
  261. return out
  262. def compute_metrics_at_threshold(y_true, probs, fine_labels, threshold):
  263. pred = (probs >= threshold).astype(np.int32)
  264. acc = accuracy_score(y_true, pred)
  265. sens = recall_score(y_true, pred, pos_label=1, zero_division=0)
  266. tn, fp, fn, tp = confusion_matrix(y_true, pred, labels=[0, 1]).ravel()
  267. spec = tn / (tn + fp + 1e-9)
  268. prec = tp / (tp + fp + 1e-9)
  269. f1 = f1_score(y_true, pred, zero_division=0)
  270. bal_acc = 0.5 * (sens + spec)
  271. by_label = summarize_by_fine_label_at_threshold(pred, fine_labels)
  272. row = {
  273. "threshold": float(threshold),
  274. "accuracy": float(acc),
  275. "sensitivity": float(sens),
  276. "specificity": float(spec),
  277. "precision": float(prec),
  278. "f1": float(f1),
  279. "balanced_accuracy": float(bal_acc),
  280. "tp": int(tp),
  281. "fp": int(fp),
  282. "tn": int(tn),
  283. "fn": int(fn),
  284. "artf_pos_rate": float(by_label.get("artf", {}).get("positive_prediction_rate", np.nan)),
  285. "eyem_pos_rate": float(by_label.get("eyem", {}).get("positive_prediction_rate", np.nan)),
  286. "bckg_pos_rate": float(by_label.get("bckg", {}).get("positive_prediction_rate", np.nan)),
  287. "spsw_pos_rate": float(by_label.get("spsw", {}).get("positive_prediction_rate", np.nan)),
  288. }
  289. return row
  290. def save_threshold_tables(rows, out_dir):
  291. os.makedirs(out_dir, exist_ok=True)
  292. metrics_csv = os.path.join(out_dir, "threshold_metrics.csv")
  293. keys = list(rows[0].keys())
  294. with open(metrics_csv, "w", newline="", encoding="utf-8") as f:
  295. w = csv.DictWriter(f, fieldnames=keys)
  296. w.writeheader()
  297. for r in rows:
  298. w.writerow(r)
  299. by_label_csv = os.path.join(out_dir, "threshold_by_label.csv")
  300. label_rows = []
  301. for r in rows:
  302. thr = r["threshold"]
  303. for lab in ["spsw", "artf", "eyem", "bckg"]:
  304. label_rows.append({
  305. "threshold": thr,
  306. "label": lab,
  307. "positive_prediction_rate": r[f"{lab}_pos_rate"]
  308. })
  309. with open(by_label_csv, "w", newline="", encoding="utf-8") as f:
  310. w = csv.DictWriter(f, fieldnames=["threshold", "label", "positive_prediction_rate"])
  311. w.writeheader()
  312. for rr in label_rows:
  313. w.writerow(rr)
  314. return metrics_csv, by_label_csv
  315. def plot_threshold_curves(rows, out_dir):
  316. os.makedirs(out_dir, exist_ok=True)
  317. th = np.array([r["threshold"] for r in rows], dtype=np.float32)
  318. sens = np.array([r["sensitivity"] for r in rows], dtype=np.float32)
  319. spec = np.array([r["specificity"] for r in rows], dtype=np.float32)
  320. artf = np.array([r["artf_pos_rate"] for r in rows], dtype=np.float32)
  321. eyem = np.array([r["eyem_pos_rate"] for r in rows], dtype=np.float32)
  322. bckg = np.array([r["bckg_pos_rate"] for r in rows], dtype=np.float32)
  323. plt.figure(figsize=(8, 5))
  324. plt.plot(th, sens, label="Sensitivity", linewidth=2)
  325. plt.plot(th, spec, label="Specificity", linewidth=2)
  326. plt.plot(th, artf, label="ARTF positive rate", linewidth=2)
  327. plt.plot(th, eyem, label="EYEM positive rate", linewidth=1.5, linestyle="--")
  328. plt.plot(th, bckg, label="BCKG positive rate", linewidth=1.5, linestyle=":")
  329. plt.xlabel("Threshold")
  330. plt.ylabel("Rate")
  331. plt.title("Threshold sweep: sensitivity / specificity / artifact-related rates")
  332. plt.ylim(-0.02, 1.02)
  333. plt.grid(alpha=0.3)
  334. plt.legend()
  335. plt.tight_layout()
  336. plt.savefig(os.path.join(out_dir, "threshold_sens_spec_artf.png"), dpi=200)
  337. plt.close()
  338. plt.figure(figsize=(6, 6))
  339. plt.plot(1 - spec, sens, linewidth=2, marker="o", markersize=3)
  340. for i in range(0, len(th), max(1, len(th) // 10)):
  341. plt.text((1 - spec[i]) + 0.002, sens[i], f"{th[i]:.2f}", fontsize=8)
  342. plt.xlabel("1 - Specificity")
  343. plt.ylabel("Sensitivity")
  344. plt.title("Operating points under threshold sweep")
  345. plt.grid(alpha=0.3)
  346. plt.tight_layout()
  347. plt.savefig(os.path.join(out_dir, "threshold_roc_like.png"), dpi=200)
  348. plt.close()
  349. def summarize_prob_distribution_by_label(probs, fine_labels, out_dir, filename_prefix="prob_distribution_by_label"):
  350. os.makedirs(out_dir, exist_ok=True)
  351. labels = sorted(set(fine_labels))
  352. rows = []
  353. print("\n===== Probability distribution by original label =====")
  354. for lab in labels:
  355. idx = (fine_labels == lab)
  356. p = probs[idx]
  357. if len(p) == 0:
  358. continue
  359. row = {
  360. "label": lab,
  361. "n": int(len(p)),
  362. "mean": float(np.mean(p)),
  363. "median": float(np.median(p)),
  364. "std": float(np.std(p)),
  365. "p10": float(np.percentile(p, 10)),
  366. "p25": float(np.percentile(p, 25)),
  367. "p75": float(np.percentile(p, 75)),
  368. "p90": float(np.percentile(p, 90)),
  369. "p95": float(np.percentile(p, 95)),
  370. "max": float(np.max(p)),
  371. "min": float(np.min(p)),
  372. }
  373. rows.append(row)
  374. print(
  375. f"{lab:>5s} | n={row['n']:5d} | "
  376. f"mean={row['mean']:.4f} | median={row['median']:.4f} | std={row['std']:.4f} | "
  377. f"p90={row['p90']:.4f} | p95={row['p95']:.4f} | max={row['max']:.4f}"
  378. )
  379. csv_path = os.path.join(out_dir, f"{filename_prefix}.csv")
  380. with open(csv_path, "w", newline="", encoding="utf-8") as f:
  381. w = csv.DictWriter(f, fieldnames=list(rows[0].keys()))
  382. w.writeheader()
  383. for r in rows:
  384. w.writerow(r)
  385. plt.figure(figsize=(10, 6))
  386. bins = np.linspace(0, 1, 51)
  387. for lab in labels:
  388. idx = (fine_labels == lab)
  389. p = probs[idx]
  390. if len(p) == 0:
  391. continue
  392. plt.hist(p, bins=bins, alpha=0.35, density=True, label=lab)
  393. plt.xlabel("Predicted probability")
  394. plt.ylabel("Density")
  395. plt.title("Probability distribution by original label")
  396. plt.legend()
  397. plt.grid(alpha=0.3)
  398. plt.tight_layout()
  399. fig_path = os.path.join(out_dir, f"{filename_prefix}.png")
  400. plt.savefig(fig_path, dpi=200)
  401. plt.close()
  402. return csv_path, fig_path
  403. def summarize_binary_prob_distribution(probs, y_true, out_dir):
  404. os.makedirs(out_dir, exist_ok=True)
  405. pos = probs[y_true == 1]
  406. neg = probs[y_true == 0]
  407. def stats(arr, name):
  408. return {
  409. "group": name,
  410. "n": int(len(arr)),
  411. "mean": float(np.mean(arr)),
  412. "median": float(np.median(arr)),
  413. "std": float(np.std(arr)),
  414. "p90": float(np.percentile(arr, 90)),
  415. "p95": float(np.percentile(arr, 95)),
  416. "max": float(np.max(arr)),
  417. "min": float(np.min(arr)),
  418. }
  419. rows = [stats(pos, "positive"), stats(neg, "negative")]
  420. print("\n===== Probability distribution by binary class =====")
  421. for r in rows:
  422. print(
  423. f"{r['group']:>8s} | n={r['n']:5d} | "
  424. f"mean={r['mean']:.4f} | median={r['median']:.4f} | std={r['std']:.4f} | "
  425. f"p90={r['p90']:.4f} | p95={r['p95']:.4f} | max={r['max']:.4f}"
  426. )
  427. csv_path = os.path.join(out_dir, "prob_distribution_binary.csv")
  428. with open(csv_path, "w", newline="", encoding="utf-8") as f:
  429. w = csv.DictWriter(f, fieldnames=list(rows[0].keys()))
  430. w.writeheader()
  431. for r in rows:
  432. w.writerow(r)
  433. plt.figure(figsize=(8, 5))
  434. bins = np.linspace(0, 1, 51)
  435. plt.hist(pos, bins=bins, alpha=0.45, density=True, label="positive")
  436. plt.hist(neg, bins=bins, alpha=0.45, density=True, label="negative")
  437. plt.xlabel("Predicted probability")
  438. plt.ylabel("Density")
  439. plt.title("Probability distribution by binary class")
  440. plt.legend()
  441. plt.grid(alpha=0.3)
  442. plt.tight_layout()
  443. fig_path = os.path.join(out_dir, "prob_distribution_binary.png")
  444. plt.savefig(fig_path, dpi=200)
  445. plt.close()
  446. return csv_path, fig_path
  447. def print_best_points(rows):
  448. rows_sorted_bal = sorted(rows, key=lambda x: x["balanced_accuracy"], reverse=True)
  449. rows_sorted_f1 = sorted(rows, key=lambda x: x["f1"], reverse=True)
  450. rows_sorted_artf = sorted(rows, key=lambda x: (x["artf_pos_rate"], -x["sensitivity"]))
  451. print("\n===== Best threshold by balanced accuracy =====")
  452. print(rows_sorted_bal[0])
  453. print("\n===== Best threshold by F1 =====")
  454. print(rows_sorted_f1[0])
  455. print("\n===== Threshold with lowest ARTF positive rate (tie -> higher sensitivity) =====")
  456. print(rows_sorted_artf[0])
  457. # =========================
  458. # 6) 主程序
  459. # =========================
  460. def main():
  461. parser = argparse.ArgumentParser()
  462. parser.add_argument("--eval_root", type=str, default="archive/edf/eval")
  463. parser.add_argument("--ckpt", type=str, default="torch_models/GLOBAL_SeizureLiquidChannelNet.pt")
  464. parser.add_argument("--model_type", type=str, default="liquid",
  465. choices=[
  466. "liquid",
  467. "cnn_transformer",
  468. "cnn_informer",
  469. "cnn_informer_nodistill",
  470. "tcn_bilstm",
  471. ],
  472. help="which model architecture the checkpoint belongs to")
  473. parser.add_argument("--batch_size", type=int, default=256)
  474. parser.add_argument("--out_dir", type=str, default="tuev_external_eval_spsw")
  475. args = parser.parse_args()
  476. device = setup_env()
  477. os.makedirs(args.out_dir, exist_ok=True)
  478. mapping_preview_csv = os.path.join(args.out_dir, "channel_mapping_preview.csv")
  479. print("[INFO] Building TUEV eval dataset...")
  480. X, y, fine, X_aux, fine_aux = build_tuev_eval_dataset(
  481. args.eval_root,
  482. save_mapping_preview_path=mapping_preview_csv,
  483. max_preview_files=10
  484. )
  485. print(f"[INFO] Main task X={X.shape}, positive={int(y.sum())}, negative={int((y == 0).sum())}")
  486. if X_aux is not None:
  487. print(f"[INFO] Auxiliary OOD samples X_aux={X_aux.shape}")
  488. print(f"[INFO] Loading model: {args.model_type}")
  489. model = build_model(args.model_type)
  490. sd = torch.load(args.ckpt, map_location="cpu")
  491. model.load_state_dict(sd)
  492. model.to(device)
  493. model.eval()
  494. print("[INFO] Predicting main task once...")
  495. probs = predict_batches(model, X, device, batch_size=args.batch_size)
  496. try:
  497. auc = roc_auc_score(y, probs)
  498. except Exception:
  499. auc = float("nan")
  500. print("\n===== Threshold-free summary =====")
  501. print(f"AUROC: {auc:.4f}")
  502. dist_csv, dist_fig = summarize_prob_distribution_by_label(probs, fine, args.out_dir, "prob_distribution_by_label")
  503. bin_csv, bin_fig = summarize_binary_prob_distribution(probs, y, args.out_dir)
  504. print("[INFO] Sweeping thresholds...")
  505. rows = []
  506. for th in THRESH_CANDIDATES:
  507. row = compute_metrics_at_threshold(y, probs, fine, float(th))
  508. rows.append(row)
  509. metrics_csv, by_label_csv = save_threshold_tables(rows, args.out_dir)
  510. plot_threshold_curves(rows, args.out_dir)
  511. print_best_points(rows)
  512. print("\nSaved:")
  513. print(f" {metrics_csv}")
  514. print(f" {by_label_csv}")
  515. print(f" {dist_csv}")
  516. print(f" {bin_csv}")
  517. print(f" {dist_fig}")
  518. print(f" {bin_fig}")
  519. print(f" {mapping_preview_csv}")
  520. print(f" {os.path.join(args.out_dir, 'threshold_sens_spec_artf.png')}")
  521. print(f" {os.path.join(args.out_dir, 'threshold_roc_like.png')}")
  522. print("\n===== Quick view at selected thresholds =====")
  523. for target_th in [0.01, 0.02, 0.05, 0.10, 0.20, 0.30, 0.50]:
  524. cand = min(rows, key=lambda r: abs(r["threshold"] - target_th))
  525. print(
  526. f"th={cand['threshold']:.2f} | "
  527. f"Acc={cand['accuracy']*100:.2f}% | "
  528. f"Sens={cand['sensitivity']*100:.2f}% | "
  529. f"Spec={cand['specificity']*100:.2f}% | "
  530. f"F1={cand['f1']:.4f} | "
  531. f"ARTF={cand['artf_pos_rate']*100:.2f}% | "
  532. f"EYEM={cand['eyem_pos_rate']*100:.2f}% | "
  533. f"BCKG={cand['bckg_pos_rate']*100:.2f}%"
  534. )
  535. if X_aux is not None and len(X_aux) > 0:
  536. print("\n[INFO] Predicting auxiliary OOD labels (gped / pled)...")
  537. probs_aux = predict_batches(model, X_aux, device, batch_size=args.batch_size)
  538. aux_csv, aux_fig = summarize_prob_distribution_by_label(
  539. probs_aux, fine_aux, args.out_dir, "prob_distribution_aux_gped_pled"
  540. )
  541. print("\n===== Auxiliary OOD-label probability summary (not used in main binary metrics) =====")
  542. print(f" {aux_csv}")
  543. print(f" {aux_fig}")
  544. if __name__ == "__main__":
  545. main()

eval_tuev_external.py at commit 2ae9222, no license · at the source

Overview

Authors: Ying Wang1, Xuelian Zhao1, Xin Yin1
ORCID iDs: Xin Yin
  1. Zibo Central Hospital, 255020, Shandong, China
Institutions: Central Hospital of Zibo (China)
Journal: MethodsX, volume 16, article 103929
Dates: published online 25 April 2026
Type: Research article · Language: English
License: CC BY
Identifiers: DOI 10.1016/j.mex.2026.103929 · PMID 42403954 · PMCID PMC13329098 · OpenAlex W7155612520
Open access: gold, a free copy (OpenAlex)
Status: code verified
Categories: EEG (modality), epilepsy (population)
Methods: Connectivity, Statistics, Smoothing, state filtering, decompositions, Preprocessing, Spectral & time-frequency, Machine learning, Physiology & signal measures
Keywords: Scalp EEG, Seizure detection, Multi-channel fusion, Channel serialization, Liquid neural networks, Evidence accumulation, MCLF: Montage consistent CNN...Liquid Fusion for seizure detection
Topic: EEG and Brain-Computer Interfaces (Cognitive Neuroscience, Neuroscience), according to OpenAlex
Citations: not cited yet (Europe PMC); 17 references in the paper

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/h and a mean latency of 2.33 s, while maintaining competitive segment-level performance relative to representative baselines. Key steps of the proposed method include:

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

License: none: the authors keep all their rights
State: the link answers, verified on 30 September 2026
Evidence: files inventoried
Commit: 2ae92225b4b25a44576847b4eaa365424fae3ef1, 12 April 2026
Languages: Python (15)
Size: 23 files, 15 scripts
Software Heritage: not archived
Found in: the resources table
Holds: README
Not found: license file, CITATION.cff, environment file, tests, continuous integration, documentation
Tools: PyTorch (12 files), NumPy (6 files), Matplotlib (3 files), MNE-Python (3 files), PyWavelets (1 file), scikit-learn (1 file), WFDB Python (1 file)
Availability: 1 check, the latest on 30 September 2026: the link answers
  • 30 September 2026: the link answers
16 files

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

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://doi.org/10.1016/j.mex.2026.103929

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/j.mex.2026.103929},
url = {https://doi.org/10.1016/j.mex.2026.103929},
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/04/25
VL - 16
SP - 103929
SN - 2215-0161
PB - Elsevier
DO - 10.1016/j.mex.2026.103929
UR - https://doi.org/10.1016/j.mex.2026.103929
LA - en
ER -

CSL-JSON

{
"id": "10.1016/j.mex.2026.103929",
"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": "MethodsX",
"volume": "16",
"page": "103929",
"DOI": "10.1016/j.mex.2026.103929",
"PMID": "42403954",
"PMCID": "PMC13329098",
"ISSN": "2215-0161",
"publisher": "Elsevier",
"URL": "https://doi.org/10.1016/j.mex.2026.103929",
"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 reports
In 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 one
In 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 medicine
In 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 mapping
In 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 processing
In 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: iScience
In 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 reports
In 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 sciences
In 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/a
In 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-based pain recognition.
Journal: PloS one
In 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.

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.