OSCR

The Geometric Signatures of Brain State Transitions: Recursive Informational Curvature Reveals Hidden Dynamics in Primate Cortex.

Code ↔ Paper

12 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 12 matches · 1 of them tie a paragraph to a whole file, not to given lines: a weak match, whose lines are not tinted
  1. [1] § Methods › Behavioral and State Labeling ↔ ric_complete_pipeline.ipynb, lines 283–368 · score 0.78 · RecoveryEyesClosed, AwakeEyesClosed, intervals, anesthetized, benchmark, epoch
  2. [2] § Results: RIC Features Reveal State-Dependent Complexity Across ECoG Datasets › UMAP Embedding and Microstate/Attractor Analysis ↔ ric_complete_pipeline.ipynb, lines 1569–1708 · score 0.71 · Bottom row, Top row, logistic regression, UMAP, George Session, DBSCAN
  3. [3] § Methods › Visualization and Benchmarking ↔ 3dclosedopenheatmapbrain.py, lines 41–88 · score 0.69 · surf stat map, Surface heatmaps, fsaverage, dorsal, lateral, medial
  4. [4] § Methods › Visualization and Benchmarking ↔ heatcompare.py, lines 28–75 · score 0.69 · surf stat map, Surface heatmaps, fsaverage, dorsal, lateral, medial
  5. [5] § Methods › Quantitative State Decoding, Comparative Benchmarking, and Sensitivity Analyses › CEBRA Comparison ↔ ric_complete_pipeline.ipynb, lines 1569–1708 · score 0.68 · ROC curve, cross validation, logistic regression, George Session, score, food tracking
  6. [6] § Methods › Quantitative State Decoding, Comparative Benchmarking, and Sensitivity Analyses ↔ ric_complete_pipeline.ipynb, lines 665–783 · score 0.61 · RandomForest, logistic regression, concatenated, vectors, RMS, classifiers
  7. [7] § Methods › Preprocessing and Epoching ↔ ric_complete_pipeline.ipynb, lines 665–783 · score 0.58 · 1–100 Hz, numpy, sosfiltfilt, zero, segmented, filtered
  8. [8] § Methods › Data Acquisition and Experimental Paradigms ↔ ric_complete_pipeline.ipynb, lines 283–368 · score 0.52 · awake eyes closed, awake eyes opened, intervals, anesthesia, ECoG, channel
  9. [9] § Methods › Preprocessing and Epoching ↔ loadstep1.py, the whole file · a weak match · score 0.50 · 1–100 Hz, sosfiltfilt, ECoG, filtered, preprocessed, scored
  10. [10] § Methods › Quantitative State Decoding, Comparative Benchmarking, and Sensitivity Analyses ↔ ric_complete_pipeline.ipynb, lines 1187–1266 · score 0.50 · F1 score, balanced accuracy, ROC, models, entropy, Benchmarking
  11. [11] § Results: RIC Features Reveal State-Dependent Complexity Across ECoG Datasets › 3D Brain Mapping and Surface Heatmaps ↔ 3dclosedopenheatmapbrain.py, lines 41–88 · score 0.50 · surface heatmaps, eyes closed, eyes open, fsaverage, dorsal, lateral
  12. [12] § Methods › RIC Feature Extraction ↔ step2_ric_epoching.py, lines 23–51 · score 0.50 · Shannon entropy, Recursive gain, symbols, mutual, discretized, bins

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,711 lines · 52 KB · Apache-2.0 · 7 matches

  1. # %%
  2. import os
  3. import json
  4. import warnings
  5. from pathlib import Path
  6. import numpy as np
  7. import pandas as pd
  8. import scipy.io as sio
  9. from scipy.signal import butter, sosfiltfilt
  10. from scipy.stats import entropy as shannon_entropy
  11. from sklearn.metrics import mutual_info_score
  12. from sklearn.model_selection import StratifiedKFold, cross_val_predict
  13. from sklearn.pipeline import Pipeline
  14. from sklearn.impute import SimpleImputer
  15. from sklearn.preprocessing import StandardScaler
  16. from sklearn.linear_model import LogisticRegression
  17. from sklearn.ensemble import RandomForestClassifier
  18. from sklearn.metrics import (
  19. accuracy_score,
  20. balanced_accuracy_score,
  21. f1_score,
  22. roc_auc_score,
  23. confusion_matrix,
  24. silhouette_score,
  25. adjusted_rand_score
  26. )
  27. from sklearn.cluster import DBSCAN
  28. import umap.umap_ as umap
  29. import matplotlib.pyplot as plt
  30. import seaborn as sns
  31. from tqdm.auto import tqdm
  32. warnings.filterwarnings("ignore")
  33. # -----------------------------
  34. # Exact dataset roots on your PC
  35. # -----------------------------
  36. ANES_ROOT = Path(
  37. r"C:\Users\mahsa\Downloads\Cleaned_data_for_ecog\20120803PF_Anesthesia+and+Sleep_George_Toru+Yanagawa_mat_ECoG128\20120803PF_Anesthesia+and+Sleep_George_Toru+Yanagawa_mat_ECoG128"
  38. )
  39. FOOD_ROOT = Path(
  40. r"C:\Users\mahsa\Downloads\Cleaned_data_for_ecog\20100705S1_Epidural-ECoG+Food-Tracking_B_Kentaro+Shimoda_mat_ECoG64-Motion6\20100705S1_Epidural-ECoG+Food-Tracking_B_Kentaro+Shimoda_mat_ECoG64-Motion6"
  41. )
  42. OUT_ROOT = Path(r"C:\Users\mahsa\Downloads\Cleaned_data_for_ecog\ric_outputs")
  43. FIG_DIR = OUT_ROOT / "figures"
  44. TAB_DIR = OUT_ROOT / "tables"
  45. MID_DIR = OUT_ROOT / "intermediate"
  46. for p in [OUT_ROOT, FIG_DIR, TAB_DIR, MID_DIR]:
  47. p.mkdir(parents=True, exist_ok=True)
  48. FS_ECOG = 1000
  49. FS_MOTION = 120
  50. SEED = 42
  51. np.random.seed(SEED)
  52. plt.rcParams["figure.dpi"] = 140
  53. plt.rcParams["savefig.dpi"] = 600
  54. plt.rcParams["font.size"] = 10
  55. plt.rcParams["axes.spines.top"] = False
  56. plt.rcParams["axes.spines.right"] = False
  57. print("Anesthesia root exists:", ANES_ROOT.exists())
  58. print("Food root exists:", FOOD_ROOT.exists())
  59. print("Output root:", OUT_ROOT)
  60. # %%
  61. def load_mat(path):
  62. return sio.loadmat(path, squeeze_me=True, struct_as_record=False)
  63. def get_nonmeta_keys(mat):
  64. return [k for k in mat.keys() if not k.startswith("__")]
  65. def load_channel_file(path):
  66. """
  67. Mirrors your existing repo logic:
  68. if 'data' exists, use it
  69. otherwise use the first non-meta variable
  70. """
  71. mat = load_mat(path)
  72. keys = get_nonmeta_keys(mat)
  73. if "data" in keys:
  74. x = np.asarray(mat["data"]).squeeze()
  75. else:
  76. x = np.asarray(mat[keys[0]]).squeeze()
  77. return x.astype(float)
  78. def load_vector_file(path):
  79. mat = load_mat(path)
  80. keys = get_nonmeta_keys(mat)
  81. x = np.asarray(mat[keys[0]]).squeeze()
  82. return x.astype(float)
  83. def bandpass_filter_1_100(x, fs=1000, order=4):
  84. sos = butter(order, [1, 100], btype="bandpass", fs=fs, output="sos")
  85. return sosfiltfilt(sos, x)
  86. def zscore_signal(x):
  87. sd = np.std(x)
  88. if sd == 0:
  89. return np.zeros_like(x)
  90. return (x - np.mean(x)) / sd
  91. def symbolic_discretize(x, n_bins=6):
  92. edges = np.quantile(x, np.linspace(0, 1, n_bins + 1)[1:-1])
  93. return np.digitize(x, edges)
  94. def epoch_start_times_from_timevec(time_vec, samples_per_epoch):
  95. n_epochs = len(time_vec) // samples_per_epoch
  96. return time_vec[: n_epochs * samples_per_epoch : samples_per_epoch]
  97. def compute_channel_epoch_features(ecog_data, epoch_length_sec=2, fs=1000, n_bins=6, alpha=1.0, beta=1.0):
  98. """
  99. ecog_data shape = (n_channels, n_timepoints)
  100. Returns dict with matrices shape (n_channels, n_epochs)
  101. """
  102. n_channels, n_timepoints = ecog_data.shape
  103. samples_per_epoch = int(epoch_length_sec * fs)
  104. n_epochs = n_timepoints // samples_per_epoch
  105. curvature_mat = np.zeros((n_channels, n_epochs))
  106. entropy_mat = np.zeros((n_channels, n_epochs))
  107. rec_gain_mat = np.zeros((n_channels, n_epochs))
  108. rms_mat = np.zeros((n_channels, n_epochs))
  109. meanabs_mat = np.zeros((n_channels, n_epochs))
  110. for ch in range(n_channels):
  111. x = ecog_data[ch]
  112. prev_H = 0.0
  113. for ep in range(n_epochs):
  114. segment = x[ep * samples_per_epoch : (ep + 1) * samples_per_epoch]
  115. symbols = symbolic_discretize(segment, n_bins=n_bins)
  116. counts = np.bincount(symbols, minlength=n_bins)
  117. prob = counts / counts.sum()
  118. H = shannon_entropy(prob, base=np.e)
  119. rec_gain = mutual_info_score(symbols[:-1], symbols[1:])
  120. dH = H - prev_H if ep > 0 else 0.0
  121. K = alpha * rec_gain - beta * dH
  122. curvature_mat[ch, ep] = K
  123. entropy_mat[ch, ep] = H
  124. rec_gain_mat[ch, ep] = rec_gain
  125. rms_mat[ch, ep] = np.sqrt(np.mean(segment ** 2))
  126. meanabs_mat[ch, ep] = np.mean(np.abs(segment))
  127. prev_H = H
  128. return {
  129. "ric": curvature_mat,
  130. "entropy": entropy_mat,
  131. "rec_gain": rec_gain_mat,
  132. "rms": rms_mat,
  133. "meanabs": meanabs_mat,
  134. "n_epochs": n_epochs,
  135. "samples_per_epoch": samples_per_epoch
  136. }
  137. def save_numpy_dict(feature_dict, out_dir):
  138. out_dir.mkdir(parents=True, exist_ok=True)
  139. for k, v in feature_dict.items():
  140. if isinstance(v, np.ndarray):
  141. np.save(out_dir / f"{k}.npy", v)
  142. def make_single_feature(X_channel_epoch):
  143. return np.mean(X_channel_epoch, axis=0).reshape(-1, 1)
  144. def make_multichannel_feature(X_channel_epoch):
  145. return X_channel_epoch.T
  146. def concat_features(*arrays_2d):
  147. return np.concatenate(arrays_2d, axis=1)
  148. def build_models():
  149. logreg = Pipeline([
  150. ("imputer", SimpleImputer(strategy="median")),
  151. ("scaler", StandardScaler()),
  152. ("clf", LogisticRegression(max_iter=2000, class_weight="balanced", random_state=SEED))
  153. ])
  154. rf = Pipeline([
  155. ("imputer", SimpleImputer(strategy="median")),
  156. ("clf", RandomForestClassifier(
  157. n_estimators=300,
  158. max_depth=None,
  159. min_samples_leaf=1,
  160. class_weight="balanced",
  161. random_state=SEED,
  162. n_jobs=-1
  163. ))
  164. ])
  165. return {"LogReg": logreg, "RandomForest": rf}
  166. def evaluate_binary_model(X, y, model, cv=10):
  167. skf = StratifiedKFold(n_splits=cv, shuffle=True, random_state=SEED)
  168. y_pred = cross_val_predict(model, X, y, cv=skf, method="predict")
  169. try:
  170. y_score = cross_val_predict(model, X, y, cv=skf, method="predict_proba")[:, 1]
  171. except Exception:
  172. y_score = cross_val_predict(model, X, y, cv=skf, method="decision_function")
  173. return {
  174. "accuracy": accuracy_score(y, y_pred),
  175. "balanced_accuracy": balanced_accuracy_score(y, y_pred),
  176. "f1": f1_score(y, y_pred),
  177. "auc": roc_auc_score(y, y_score),
  178. "confusion_matrix": confusion_matrix(y, y_pred)
  179. }
  180. def run_feature_benchmarks(dataset_name, y, feature_sets):
  181. models = build_models()
  182. rows = []
  183. for feature_name, X in feature_sets.items():
  184. for model_name, model in models.items():
  185. metrics = evaluate_binary_model(X, y, model, cv=10)
  186. rows.append({
  187. "dataset": dataset_name,
  188. "feature_set": feature_name,
  189. "model": model_name,
  190. "n_samples": X.shape[0],
  191. "n_features": X.shape[1],
  192. "accuracy": metrics["accuracy"],
  193. "balanced_accuracy": metrics["balanced_accuracy"],
  194. "f1": metrics["f1"],
  195. "auc": metrics["auc"]
  196. })
  197. return pd.DataFrame(rows)
  198. def run_umap_dbscan(X, y, title, out_png):
  199. scaler = StandardScaler()
  200. Xs = scaler.fit_transform(X)
  201. reducer = umap.UMAP(
  202. n_neighbors=15,
  203. min_dist=0.1,
  204. random_state=SEED
  205. )
  206. emb = reducer.fit_transform(Xs)
  207. clusterer = DBSCAN(eps=0.8, min_samples=5)
  208. clusters = clusterer.fit_predict(emb)
  209. non_noise = clusters != -1
  210. if np.sum(non_noise) > 5 and len(np.unique(clusters[non_noise])) > 1:
  211. sil = silhouette_score(emb[non_noise], clusters[non_noise])
  212. ari = adjusted_rand_score(y[non_noise], clusters[non_noise])
  213. else:
  214. sil = np.nan
  215. ari = np.nan
  216. fig, ax = plt.subplots(1, 2, figsize=(12, 5))
  217. sc1 = ax[0].scatter(emb[:, 0], emb[:, 1], c=y, s=16)
  218. ax[0].set_title(f"{title} — colored by state")
  219. sc2 = ax[1].scatter(emb[:, 0], emb[:, 1], c=clusters, s=16)
  220. ax[1].set_title(f"{title} — DBSCAN clusters")
  221. for a in ax:
  222. a.set_xlabel("UMAP1")
  223. a.set_ylabel("UMAP2")
  224. plt.tight_layout()
  225. plt.savefig(out_png, bbox_inches="tight")
  226. plt.close()
  227. return {
  228. "embedding": emb,
  229. "clusters": clusters,
  230. "silhouette_non_noise": sil,
  231. "ari_non_noise": ari
  232. }
  233. # %%
  234. def load_anesthesia_session(session_dir, epoch_length_sec=2, n_bins=6, alpha=1.0, beta=1.0):
  235. channel_files = [session_dir / f"ECoG_ch{i}.mat" for i in range(1, 129)]
  236. time_file = session_dir / "ECoGTime.mat"
  237. cond_file = session_dir / "Condition.mat"
  238. # Load and preprocess all channels
  239. all_channels = []
  240. for f in tqdm(channel_files, desc=f"Loading {session_dir.name} channels"):
  241. x = load_channel_file(f)
  242. x = bandpass_filter_1_100(x, fs=FS_ECOG, order=4)
  243. x = zscore_signal(x)
  244. all_channels.append(x)
  245. ecog_data = np.vstack(all_channels)
  246. # Load time vector
  247. time_vec = load_vector_file(time_file)
  248. # Feature extraction
  249. feats = compute_channel_epoch_features(
  250. ecog_data,
  251. epoch_length_sec=epoch_length_sec,
  252. fs=FS_ECOG,
  253. n_bins=n_bins,
  254. alpha=alpha,
  255. beta=beta
  256. )
  257. # Load condition file
  258. mat = load_mat(cond_file)
  259. ct = np.asarray(mat["ConditionTime"]).squeeze().astype(float)
  260. cl = [str(x).strip() for x in np.ravel(mat["ConditionLabel"])]
  261. epoch_times = epoch_start_times_from_timevec(time_vec, feats["samples_per_epoch"])
  262. epoch_times = epoch_times[:feats["n_epochs"]]
  263. # labels:
  264. # 0 = unlabeled / excluded
  265. # 1 = eyes opened
  266. # 2 = eyes closed
  267. # 3 = anesthetized
  268. labels = np.zeros(feats["n_epochs"], dtype=int)
  269. # Build interval dictionary from Start/End labels
  270. interval_dict = {}
  271. for i, lbl in enumerate(cl):
  272. if lbl.endswith("-Start"):
  273. base = lbl.replace("-Start", "")
  274. if i + 1 < len(cl) and cl[i + 1] == f"{base}-End":
  275. t0 = ct[i]
  276. t1 = ct[i + 1]
  277. interval_dict[base] = (t0, t1)
  278. # Map the intervals we care about
  279. label_map = {
  280. "AwakeEyesOpened": 1,
  281. "RecoveryEyesOpened": 1,
  282. "AwakeEyesClosed": 2,
  283. "RecoveryEyesClosed": 2,
  284. "Anesthetized": 3
  285. }
  286. for base_name, code in label_map.items():
  287. if base_name in interval_dict:
  288. t0, t1 = interval_dict[base_name]
  289. labels[(epoch_times >= t0) & (epoch_times < t1)] = code
  290. # Keep only open/closed epochs for the manuscript benchmark
  291. keep = np.isin(labels, [1, 2])
  292. out = {
  293. "session_name": session_dir.name,
  294. "ecog_data": ecog_data,
  295. "time_vec": time_vec,
  296. "condition_time": ct,
  297. "condition_label_raw": cl,
  298. "interval_dict": interval_dict,
  299. "epoch_times": epoch_times,
  300. "labels_raw": labels,
  301. "keep_mask": keep
  302. }
  303. out.update(feats)
  304. return out
  305. # %%
  306. for sdir in anes_sessions:
  307. mat = load_mat(sdir / "Condition.mat")
  308. print("\n==========", sdir.name, "==========")
  309. print("Keys:", get_nonmeta_keys(mat))
  310. print("ConditionTime raw:")
  311. print(np.asarray(mat["ConditionTime"]).squeeze())
  312. if "ConditionLabel" in mat:
  313. print("ConditionLabel raw:")
  314. print(mat["ConditionLabel"])
  315. # %%
  316. def load_anesthesia_session(session_dir, epoch_length_sec=2, n_bins=6, alpha=1.0, beta=1.0):
  317. channel_files = [session_dir / f"ECoG_ch{i}.mat" for i in range(1, 129)]
  318. time_file = session_dir / "ECoGTime.mat"
  319. cond_file = session_dir / "Condition.mat"
  320. # Load and preprocess all channels
  321. all_channels = []
  322. for f in tqdm(channel_files, desc=f"Loading {session_dir.name} channels"):
  323. x = load_channel_file(f)
  324. x = bandpass_filter_1_100(x, fs=FS_ECOG, order=4)
  325. x = zscore_signal(x)
  326. all_channels.append(x)
  327. ecog_data = np.vstack(all_channels)
  328. # Load time vector
  329. time_vec = load_vector_file(time_file)
  330. # Feature extraction
  331. feats = compute_channel_epoch_features(
  332. ecog_data,
  333. epoch_length_sec=epoch_length_sec,
  334. fs=FS_ECOG,
  335. n_bins=n_bins,
  336. alpha=alpha,
  337. beta=beta
  338. )
  339. # Condition labeling exactly as your current repo assumes
  340. mat = load_mat(cond_file)
  341. ct = np.asarray(mat["ConditionTime"]).flatten().astype(float)
  342. cond_labels = None
  343. if "ConditionLabel" in mat:
  344. try:
  345. cond_labels = [str(x) for x in np.ravel(mat["ConditionLabel"])]
  346. except Exception:
  347. cond_labels = None
  348. epoch_times = epoch_start_times_from_timevec(time_vec, feats["samples_per_epoch"])
  349. epoch_times = epoch_times[:feats["n_epochs"]]
  350. labels = np.zeros(feats["n_epochs"], dtype=int) # 0 unlabeled, 1 opened, 2 closed
  351. # Same convention used in your current epochlabel.py
  352. labels[(epoch_times >= ct[0]) & (epoch_times < ct[1])] = 1
  353. labels[(epoch_times >= ct[2]) & (epoch_times < ct[3])] = 2
  354. keep = labels > 0
  355. out = {
  356. "session_name": session_dir.name,
  357. "ecog_data": ecog_data,
  358. "time_vec": time_vec,
  359. "condition_time": ct,
  360. "condition_label_raw": cond_labels,
  361. "epoch_times": epoch_times,
  362. "labels_raw": labels,
  363. "keep_mask": keep
  364. }
  365. out.update(feats)
  366. return out
  367. def load_food_dataset(food_dir, wrist_marker_index=2, epoch_length_sec=2, n_bins=6, alpha=1.0, beta=1.0):
  368. # ignore __MACOSX junk by using the real inner folder only
  369. channel_files = [food_dir / f"ECoG_ch{i}.mat" for i in range(1, 65)]
  370. time_file = food_dir / "ECoG_time.mat"
  371. motion_file = food_dir / "Motion.mat"
  372. # Load and preprocess channels
  373. all_channels = []
  374. for f in tqdm(channel_files, desc="Loading food-tracking channels"):
  375. x = load_channel_file(f)
  376. x = bandpass_filter_1_100(x, fs=FS_ECOG, order=4)
  377. x = zscore_signal(x)
  378. all_channels.append(x)
  379. ecog_data = np.vstack(all_channels)
  380. time_vec = load_vector_file(time_file)
  381. feats = compute_channel_epoch_features(
  382. ecog_data,
  383. epoch_length_sec=epoch_length_sec,
  384. fs=FS_ECOG,
  385. n_bins=n_bins,
  386. alpha=alpha,
  387. beta=beta
  388. )
  389. # Motion handling from your current scripts:
  390. # MotionData[wrist_marker_index, 0] has marker XYZ with shape ~ (124230, 3)
  391. mot = load_mat(motion_file)
  392. MotionData = mot["MotionData"]
  393. wrist_xyz = MotionData[wrist_marker_index, 0].astype(float)
  394. speed = np.linalg.norm(np.diff(wrist_xyz, axis=0), axis=1) * FS_MOTION
  395. speed = np.concatenate(([0.0], speed))
  396. motion_time = np.arange(len(speed)) / FS_MOTION
  397. epoch_times = epoch_start_times_from_timevec(time_vec, feats["samples_per_epoch"])
  398. epoch_times = epoch_times[:feats["n_epochs"]]
  399. epoch_end = epoch_times + epoch_length_sec
  400. epoch_motion_speed = np.full(feats["n_epochs"], np.nan)
  401. for i, (t0, t1) in enumerate(zip(epoch_times, epoch_end)):
  402. mask = (motion_time >= t0) & (motion_time < t1)
  403. if np.any(mask):
  404. epoch_motion_speed[i] = np.median(speed[mask])
  405. valid = ~np.isnan(epoch_motion_speed)
  406. thr = np.percentile(epoch_motion_speed[valid], 70)
  407. labels = np.zeros(feats["n_epochs"], dtype=int) # 0 not moving, 1 moving
  408. labels[valid & (epoch_motion_speed > thr)] = 1
  409. out = {
  410. "session_name": "FoodTracking",
  411. "ecog_data": ecog_data,
  412. "time_vec": time_vec,
  413. "motion_time": motion_time,
  414. "motion_speed": speed,
  415. "epoch_times": epoch_times,
  416. "epoch_motion_speed": epoch_motion_speed,
  417. "motion_threshold_70": thr,
  418. "labels_raw": labels,
  419. "keep_mask": valid
  420. }
  421. out.update(feats)
  422. return out
  423. # %%
  424. def load_anesthesia_session_v2(session_dir, epoch_length_sec=2, n_bins=6, alpha=1.0, beta=1.0):
  425. channel_files = [session_dir / f"ECoG_ch{i}.mat" for i in range(1, 129)]
  426. time_file = session_dir / "ECoGTime.mat"
  427. cond_file = session_dir / "Condition.mat"
  428. # Load and preprocess all channels
  429. all_channels = []
  430. for f in tqdm(channel_files, desc=f"Loading {session_dir.name} channels"):
  431. x = load_channel_file(f)
  432. x = bandpass_filter_1_100(x, fs=FS_ECOG, order=4)
  433. x = zscore_signal(x)
  434. all_channels.append(x)
  435. ecog_data = np.vstack(all_channels)
  436. # Load time vector
  437. time_vec = load_vector_file(time_file)
  438. # Feature extraction
  439. feats = compute_channel_epoch_features(
  440. ecog_data,
  441. epoch_length_sec=epoch_length_sec,
  442. fs=FS_ECOG,
  443. n_bins=n_bins,
  444. alpha=alpha,
  445. beta=beta
  446. )
  447. # Load condition file
  448. mat = load_mat(cond_file)
  449. ct = np.asarray(mat["ConditionTime"]).squeeze().astype(float)
  450. cl = [str(x).strip() for x in np.ravel(mat["ConditionLabel"])]
  451. epoch_times = epoch_start_times_from_timevec(time_vec, feats["samples_per_epoch"])
  452. epoch_times = epoch_times[:feats["n_epochs"]]
  453. # 0 = exclude, 1 = eyes opened, 2 = eyes closed, 3 = anesthetized
  454. labels = np.zeros(feats["n_epochs"], dtype=int)
  455. # Build intervals from Start/End labels
  456. interval_dict = {}
  457. for i, lbl in enumerate(cl):
  458. if lbl.endswith("-Start"):
  459. base = lbl.replace("-Start", "")
  460. if i + 1 < len(cl) and cl[i + 1] == f"{base}-End":
  461. interval_dict[base] = (ct[i], ct[i + 1])
  462. # Map intervals to codes
  463. label_map = {
  464. "AwakeEyesOpened": 1,
  465. "RecoveryEyesOpened": 1,
  466. "AwakeEyesClosed": 2,
  467. "RecoveryEyesClosed": 2,
  468. "Anesthetized": 3,
  469. }
  470. for base_name, code in label_map.items():
  471. if base_name in interval_dict:
  472. t0, t1 = interval_dict[base_name]
  473. labels[(epoch_times >= t0) & (epoch_times < t1)] = code
  474. # Keep only opened/closed for manuscript benchmark
  475. keep = np.isin(labels, [1, 2])
  476. out = {
  477. "session_name": session_dir.name,
  478. "ecog_data": ecog_data,
  479. "time_vec": time_vec,
  480. "condition_time": ct,
  481. "condition_label_raw": cl,
  482. "interval_dict": interval_dict,
  483. "epoch_times": epoch_times,
  484. "labels_raw": labels,
  485. "keep_mask": keep
  486. }
  487. out.update(feats)
  488. return out
  489. # %%
  490. print(load_anesthesia_session_v2.__name__)
  491. # %%
  492. from pathlib import Path
  493. import warnings
  494. import numpy as np
  495. import pandas as pd
  496. import scipy.io as sio
  497. from scipy.signal import butter, sosfiltfilt
  498. from scipy.stats import entropy as shannon_entropy
  499. from sklearn.metrics import mutual_info_score
  500. from sklearn.model_selection import StratifiedKFold, cross_val_predict
  501. from sklearn.pipeline import Pipeline
  502. from sklearn.impute import SimpleImputer
  503. from sklearn.preprocessing import StandardScaler
  504. from sklearn.linear_model import LogisticRegression
  505. from sklearn.ensemble import RandomForestClassifier
  506. from sklearn.metrics import (
  507. accuracy_score,
  508. balanced_accuracy_score,
  509. f1_score,
  510. roc_auc_score,
  511. confusion_matrix,
  512. silhouette_score,
  513. adjusted_rand_score
  514. )
  515. from sklearn.cluster import DBSCAN
  516. import umap.umap_ as umap
  517. import matplotlib.pyplot as plt
  518. import seaborn as sns
  519. from tqdm.auto import tqdm
  520. warnings.filterwarnings("ignore")
  521. ANES_ROOT = Path(
  522. r"C:\Users\mahsa\Downloads\Cleaned_data_for_ecog\20120803PF_Anesthesia+and+Sleep_George_Toru+Yanagawa_mat_ECoG128\20120803PF_Anesthesia+and+Sleep_George_Toru+Yanagawa_mat_ECoG128"
  523. )
  524. FOOD_ROOT = Path(
  525. r"C:\Users\mahsa\Downloads\Cleaned_data_for_ecog\20100705S1_Epidural-ECoG+Food-Tracking_B_Kentaro+Shimoda_mat_ECoG64-Motion6\20100705S1_Epidural-ECoG+Food-Tracking_B_Kentaro+Shimoda_mat_ECoG64-Motion6"
  526. )
  527. OUT_ROOT = Path(r"C:\Users\mahsa\Downloads\Cleaned_data_for_ecog\ric_outputs")
  528. FIG_DIR = OUT_ROOT / "figures"
  529. TAB_DIR = OUT_ROOT / "tables"
  530. MID_DIR = OUT_ROOT / "intermediate"
  531. for p in [OUT_ROOT, FIG_DIR, TAB_DIR, MID_DIR]:
  532. p.mkdir(parents=True, exist_ok=True)
  533. FS_ECOG = 1000
  534. FS_MOTION = 120
  535. SEED = 42
  536. np.random.seed(SEED)
  537. plt.rcParams["figure.dpi"] = 140
  538. plt.rcParams["savefig.dpi"] = 600
  539. plt.rcParams["font.size"] = 10
  540. plt.rcParams["axes.spines.top"] = False
  541. plt.rcParams["axes.spines.right"] = False
  542. print("ANES_ROOT exists:", ANES_ROOT.exists())
  543. print("FOOD_ROOT exists:", FOOD_ROOT.exists())
  544. # %%
  545. def load_mat(path):
  546. return sio.loadmat(path, squeeze_me=True, struct_as_record=False)
  547. def get_nonmeta_keys(mat):
  548. return [k for k in mat.keys() if not k.startswith("__")]
  549. def load_channel_file(path):
  550. mat = load_mat(path)
  551. keys = get_nonmeta_keys(mat)
  552. if "data" in keys:
  553. x = np.asarray(mat["data"]).squeeze()
  554. else:
  555. x = np.asarray(mat[keys[0]]).squeeze()
  556. return x.astype(float)
  557. def load_vector_file(path):
  558. mat = load_mat(path)
  559. keys = get_nonmeta_keys(mat)
  560. x = np.asarray(mat[keys[0]]).squeeze()
  561. return x.astype(float)
  562. def bandpass_filter_1_100(x, fs=1000, order=4):
  563. sos = butter(order, [1, 100], btype="bandpass", fs=fs, output="sos")
  564. return sosfiltfilt(sos, x)
  565. def zscore_signal(x):
  566. sd = np.std(x)
  567. if sd == 0:
  568. return np.zeros_like(x)
  569. return (x - np.mean(x)) / sd
  570. def symbolic_discretize(x, n_bins=6):
  571. edges = np.quantile(x, np.linspace(0, 1, n_bins + 1)[1:-1])
  572. return np.digitize(x, edges)
  573. def epoch_start_times_from_timevec(time_vec, samples_per_epoch):
  574. n_epochs = len(time_vec) // samples_per_epoch
  575. return time_vec[: n_epochs * samples_per_epoch : samples_per_epoch]
  576. def compute_channel_epoch_features(ecog_data, epoch_length_sec=2, fs=1000, n_bins=6, alpha=1.0, beta=1.0):
  577. n_channels, n_timepoints = ecog_data.shape
  578. samples_per_epoch = int(epoch_length_sec * fs)
  579. n_epochs = n_timepoints // samples_per_epoch
  580. curvature_mat = np.zeros((n_channels, n_epochs))
  581. entropy_mat = np.zeros((n_channels, n_epochs))
  582. rec_gain_mat = np.zeros((n_channels, n_epochs))
  583. rms_mat = np.zeros((n_channels, n_epochs))
  584. meanabs_mat = np.zeros((n_channels, n_epochs))
  585. for ch in range(n_channels):
  586. x = ecog_data[ch]
  587. prev_H = 0.0
  588. for ep in range(n_epochs):
  589. segment = x[ep * samples_per_epoch : (ep + 1) * samples_per_epoch]
  590. symbols = symbolic_discretize(segment, n_bins=n_bins)
  591. counts = np.bincount(symbols, minlength=n_bins)
  592. prob = counts / counts.sum()
  593. H = shannon_entropy(prob, base=np.e)
  594. rec_gain = mutual_info_score(symbols[:-1], symbols[1:])
  595. dH = H - prev_H if ep > 0 else 0.0
  596. K = alpha * rec_gain - beta * dH
  597. curvature_mat[ch, ep] = K
  598. entropy_mat[ch, ep] = H
  599. rec_gain_mat[ch, ep] = rec_gain
  600. rms_mat[ch, ep] = np.sqrt(np.mean(segment ** 2))
  601. meanabs_mat[ch, ep] = np.mean(np.abs(segment))
  602. prev_H = H
  603. return {
  604. "ric": curvature_mat,
  605. "entropy": entropy_mat,
  606. "rec_gain": rec_gain_mat,
  607. "rms": rms_mat,
  608. "meanabs": meanabs_mat,
  609. "n_epochs": n_epochs,
  610. "samples_per_epoch": samples_per_epoch
  611. }
  612. def save_numpy_dict(feature_dict, out_dir):
  613. out_dir.mkdir(parents=True, exist_ok=True)
  614. for k, v in feature_dict.items():
  615. if isinstance(v, np.ndarray):
  616. np.save(out_dir / f"{k}.npy", v)
  617. def make_single_feature(X_channel_epoch):
  618. return np.mean(X_channel_epoch, axis=0).reshape(-1, 1)
  619. def make_multichannel_feature(X_channel_epoch):
  620. return X_channel_epoch.T
  621. def concat_features(*arrays_2d):
  622. return np.concatenate(arrays_2d, axis=1)
  623. def build_models():
  624. logreg = Pipeline([
  625. ("imputer", SimpleImputer(strategy="median")),
  626. ("scaler", StandardScaler()),
  627. ("clf", LogisticRegression(max_iter=2000, class_weight="balanced", random_state=SEED))
  628. ])
  629. rf = Pipeline([
  630. ("imputer", SimpleImputer(strategy="median")),
  631. ("clf", RandomForestClassifier(
  632. n_estimators=300,
  633. max_depth=None,
  634. min_samples_leaf=1,
  635. class_weight="balanced",
  636. random_state=SEED,
  637. n_jobs=-1
  638. ))
  639. ])
  640. return {"LogReg": logreg, "RandomForest": rf}
  641. # %%
  642. def load_anesthesia_session_v2(session_dir, epoch_length_sec=2, n_bins=6, alpha=1.0, beta=1.0):
  643. channel_files = [session_dir / f"ECoG_ch{i}.mat" for i in range(1, 129)]
  644. time_file = session_dir / "ECoGTime.mat"
  645. cond_file = session_dir / "Condition.mat"
  646. all_channels = []
  647. for f in tqdm(channel_files, desc=f"Loading {session_dir.name} channels"):
  648. x = load_channel_file(f)
  649. x = bandpass_filter_1_100(x, fs=FS_ECOG, order=4)
  650. x = zscore_signal(x)
  651. all_channels.append(x)
  652. ecog_data = np.vstack(all_channels)
  653. time_vec = load_vector_file(time_file)
  654. feats = compute_channel_epoch_features(
  655. ecog_data,
  656. epoch_length_sec=epoch_length_sec,
  657. fs=FS_ECOG,
  658. n_bins=n_bins,
  659. alpha=alpha,
  660. beta=beta
  661. )
  662. mat = load_mat(cond_file)
  663. ct = np.asarray(mat["ConditionTime"]).squeeze().astype(float)
  664. cl = [str(x).strip() for x in np.ravel(mat["ConditionLabel"])]
  665. epoch_times = epoch_start_times_from_timevec(time_vec, feats["samples_per_epoch"])
  666. epoch_times = epoch_times[:feats["n_epochs"]]
  667. labels = np.zeros(feats["n_epochs"], dtype=int)
  668. interval_dict = {}
  669. for i, lbl in enumerate(cl):
  670. if lbl.endswith("-Start"):
  671. base = lbl.replace("-Start", "")
  672. if i + 1 < len(cl) and cl[i + 1] == f"{base}-End":
  673. interval_dict[base] = (ct[i], ct[i + 1])
  674. label_map = {
  675. "AwakeEyesOpened": 1,
  676. "RecoveryEyesOpened": 1,
  677. "AwakeEyesClosed": 2,
  678. "RecoveryEyesClosed": 2,
  679. "Anesthetized": 3,
  680. }
  681. for base_name, code in label_map.items():
  682. if base_name in interval_dict:
  683. t0, t1 = interval_dict[base_name]
  684. labels[(epoch_times >= t0) & (epoch_times < t1)] = code
  685. keep = np.isin(labels, [1, 2])
  686. out = {
  687. "session_name": session_dir.name,
  688. "ecog_data": ecog_data,
  689. "time_vec": time_vec,
  690. "condition_time": ct,
  691. "condition_label_raw": cl,
  692. "interval_dict": interval_dict,
  693. "epoch_times": epoch_times,
  694. "labels_raw": labels,
  695. "keep_mask": keep
  696. }
  697. out.update(feats)
  698. return out
  699. # %%
  700. anes_sessions = [ANES_ROOT / "Session1", ANES_ROOT / "Session2", ANES_ROOT / "Session3"]
  701. anes_results = []
  702. for sdir in anes_sessions:
  703. res = load_anesthesia_session_v2(
  704. sdir,
  705. epoch_length_sec=2,
  706. n_bins=6,
  707. alpha=1.0,
  708. beta=1.0
  709. )
  710. anes_results.append(res)
  711. print("Loaded anesthesia sessions:", [r["session_name"] for r in anes_results])
  712. # %%
  713. for r in anes_results:
  714. print("\n==========", r["session_name"], "==========")
  715. print("Intervals found:", r["interval_dict"])
  716. print("Opened epochs:", np.sum(r["labels_raw"] == 1))
  717. print("Closed epochs:", np.sum(r["labels_raw"] == 2))
  718. print("Anesthetized epochs:", np.sum(r["labels_raw"] == 3))
  719. print("Kept epochs:", np.sum(r["keep_mask"]))
  720. # %%
  721. # Primary benchmark: Session1 only (cleanest open vs closed)
  722. anes_session1 = anes_results[0]
  723. anesthesia_primary = {
  724. "ric": anes_session1["ric"][:, anes_session1["keep_mask"]],
  725. "entropy": anes_session1["entropy"][:, anes_session1["keep_mask"]],
  726. "rec_gain": anes_session1["rec_gain"][:, anes_session1["keep_mask"]],
  727. "rms": anes_session1["rms"][:, anes_session1["keep_mask"]],
  728. "meanabs": anes_session1["meanabs"][:, anes_session1["keep_mask"]],
  729. "y": (anes_session1["labels_raw"][anes_session1["keep_mask"]] == 2).astype(int), # 0=open, 1=closed
  730. "session": np.array(["Session1"] * np.sum(anes_session1["keep_mask"]))
  731. }
  732. print("Primary anesthesia benchmark")
  733. print("Samples:", anesthesia_primary["ric"].shape[1])
  734. print("Opened:", np.sum(anesthesia_primary["y"] == 0))
  735. print("Closed:", np.sum(anesthesia_primary["y"] == 1))
  736. # Secondary benchmark: recovery only
  737. # Session2 contributes closed, Session3 contributes opened
  738. rec_s2 = anes_results[1]
  739. rec_s3 = anes_results[2]
  740. s2_keep_closed = rec_s2["labels_raw"] == 2
  741. s3_keep_open = rec_s3["labels_raw"] == 1
  742. anesthesia_recovery = {
  743. "ric": np.concatenate([
  744. rec_s3["ric"][:, s3_keep_open], # opened
  745. rec_s2["ric"][:, s2_keep_closed] # closed
  746. ], axis=1),
  747. "entropy": np.concatenate([
  748. rec_s3["entropy"][:, s3_keep_open],
  749. rec_s2["entropy"][:, s2_keep_closed]
  750. ], axis=1),
  751. "rec_gain": np.concatenate([
  752. rec_s3["rec_gain"][:, s3_keep_open],
  753. rec_s2["rec_gain"][:, s2_keep_closed]
  754. ], axis=1),
  755. "rms": np.concatenate([
  756. rec_s3["rms"][:, s3_keep_open],
  757. rec_s2["rms"][:, s2_keep_closed]
  758. ], axis=1),
  759. "meanabs": np.concatenate([
  760. rec_s3["meanabs"][:, s3_keep_open],
  761. rec_s2["meanabs"][:, s2_keep_closed]
  762. ], axis=1),
  763. "y": np.concatenate([
  764. np.zeros(np.sum(s3_keep_open), dtype=int), # opened
  765. np.ones(np.sum(s2_keep_closed), dtype=int) # closed
  766. ]),
  767. "session": np.concatenate([
  768. np.array(["Session3"] * np.sum(s3_keep_open)),
  769. np.array(["Session2"] * np.sum(s2_keep_closed))
  770. ])
  771. }
  772. print("\nRecovery anesthesia benchmark")
  773. print("Samples:", anesthesia_recovery["ric"].shape[1])
  774. print("Opened:", np.sum(anesthesia_recovery["y"] == 0))
  775. print("Closed:", np.sum(anesthesia_recovery["y"] == 1))
  776. # %%
  777. food_motion_mat = load_mat(FOOD_ROOT / "Motion.mat")
  778. print(get_nonmeta_keys(food_motion_mat))
  779. for k in get_nonmeta_keys(food_motion_mat):
  780. try:
  781. arr = np.asarray(food_motion_mat[k])
  782. print(k, "shape:", arr.shape, "dtype:", arr.dtype)
  783. except Exception as e:
  784. print(k, "could not print shape:", e)
  785. # %%
  786. food_time_mat = load_mat(FOOD_ROOT / "ECoG_time.mat")
  787. print(get_nonmeta_keys(food_time_mat))
  788. for k in get_nonmeta_keys(food_time_mat):
  789. arr = np.asarray(food_time_mat[k])
  790. print(k, "shape:", arr.shape, "dtype:", arr.dtype)
  791. # %%
  792. food_ch1_mat = load_mat(FOOD_ROOT / "ECoG_ch1.mat")
  793. print(get_nonmeta_keys(food_ch1_mat))
  794. for k in get_nonmeta_keys(food_ch1_mat):
  795. arr = np.asarray(food_ch1_mat[k])
  796. print(k, "shape:", arr.shape, "dtype:", arr.dtype)
  797. # %%
  798. def unwrap_numeric_array(obj):
  799. """
  800. Recursively unwrap MATLAB object containers until a numeric ndarray is found.
  801. """
  802. if isinstance(obj, np.ndarray):
  803. if obj.dtype != object and np.issubdtype(obj.dtype, np.number):
  804. return np.asarray(obj)
  805. if obj.dtype == object:
  806. if obj.size == 1:
  807. return unwrap_numeric_array(obj.item())
  808. candidates = []
  809. for item in obj.ravel():
  810. try:
  811. arr = unwrap_numeric_array(item)
  812. candidates.append(arr)
  813. except Exception:
  814. pass
  815. if len(candidates) == 0:
  816. raise ValueError("Could not unwrap numeric array from object ndarray.")
  817. candidates = sorted(candidates, key=lambda a: np.prod(a.shape), reverse=True)
  818. return np.asarray(candidates[0])
  819. if isinstance(obj, (list, tuple)):
  820. candidates = []
  821. for item in obj:
  822. try:
  823. arr = unwrap_numeric_array(item)
  824. candidates.append(arr)
  825. except Exception:
  826. pass
  827. if len(candidates) == 0:
  828. raise ValueError("Could not unwrap numeric array from list/tuple.")
  829. candidates = sorted(candidates, key=lambda a: np.prod(a.shape), reverse=True)
  830. return np.asarray(candidates[0])
  831. if hasattr(obj, "__dict__"):
  832. candidates = []
  833. for _, v in obj.__dict__.items():
  834. try:
  835. arr = unwrap_numeric_array(v)
  836. candidates.append(arr)
  837. except Exception:
  838. pass
  839. if len(candidates) == 0:
  840. raise ValueError("Could not unwrap numeric array from MATLAB struct-like object.")
  841. candidates = sorted(candidates, key=lambda a: np.prod(a.shape), reverse=True)
  842. return np.asarray(candidates[0])
  843. raise ValueError(f"Unsupported object type for unwrapping: {type(obj)}")
  844. # %%
  845. def load_food_dataset_v2(food_dir, wrist_marker_index=2, epoch_length_sec=2, n_bins=6, alpha=1.0, beta=1.0):
  846. channel_files = [food_dir / f"ECoG_ch{i}.mat" for i in range(1, 65)]
  847. time_file = food_dir / "ECoG_time.mat"
  848. motion_file = food_dir / "Motion.mat"
  849. # Load and preprocess ECoG
  850. all_channels = []
  851. for f in tqdm(channel_files, desc="Loading food-tracking channels"):
  852. x = load_channel_file(f)
  853. x = bandpass_filter_1_100(x, fs=FS_ECOG, order=4)
  854. x = zscore_signal(x)
  855. all_channels.append(x)
  856. ecog_data = np.vstack(all_channels)
  857. ecog_time = load_vector_file(time_file)
  858. feats = compute_channel_epoch_features(
  859. ecog_data,
  860. epoch_length_sec=epoch_length_sec,
  861. fs=FS_ECOG,
  862. n_bins=n_bins,
  863. alpha=alpha,
  864. beta=beta
  865. )
  866. # Load motion
  867. mot = load_mat(motion_file)
  868. MotionData = mot["MotionData"]
  869. MotionTime = np.asarray(mot["MotionTime"]).squeeze().astype(float)
  870. wrist_xyz = unwrap_numeric_array(MotionData[wrist_marker_index])
  871. wrist_xyz = np.asarray(wrist_xyz, dtype=float).squeeze()
  872. # Force shape to (N, 3)
  873. if wrist_xyz.ndim == 1:
  874. if wrist_xyz.size % 3 != 0:
  875. raise ValueError(f"Wrist marker cannot be reshaped to Nx3, shape={wrist_xyz.shape}")
  876. wrist_xyz = wrist_xyz.reshape(-1, 3)
  877. if wrist_xyz.ndim == 2 and wrist_xyz.shape[1] != 3 and wrist_xyz.shape[0] == 3:
  878. wrist_xyz = wrist_xyz.T
  879. if wrist_xyz.ndim != 2 or wrist_xyz.shape[1] != 3:
  880. raise ValueError(f"Unexpected wrist marker shape after unwrapping: {wrist_xyz.shape}")
  881. # Align motion arrays
  882. n_motion = min(len(MotionTime), wrist_xyz.shape[0])
  883. MotionTime = MotionTime[:n_motion]
  884. wrist_xyz = wrist_xyz[:n_motion]
  885. # Compute speed using actual motion timestamps
  886. dxyz = np.diff(wrist_xyz, axis=0)
  887. dt = np.diff(MotionTime)
  888. positive_dt = dt[dt > 0]
  889. if len(positive_dt) == 0:
  890. raise ValueError("MotionTime has no positive time differences.")
  891. dt[dt <= 0] = np.median(positive_dt)
  892. speed = np.linalg.norm(dxyz, axis=1) / dt
  893. speed = np.concatenate([[0.0], speed])
  894. # Epoch-level motion summary aligned to ECoG epochs
  895. epoch_times = epoch_start_times_from_timevec(ecog_time, feats["samples_per_epoch"])
  896. epoch_times = epoch_times[:feats["n_epochs"]]
  897. epoch_end = epoch_times + epoch_length_sec
  898. epoch_motion_speed = np.full(feats["n_epochs"], np.nan)
  899. for i, (t0, t1) in enumerate(zip(epoch_times, epoch_end)):
  900. mask = (MotionTime >= t0) & (MotionTime < t1)
  901. if np.any(mask):
  902. epoch_motion_speed[i] = np.median(speed[mask])
  903. valid = ~np.isnan(epoch_motion_speed)
  904. # Binary movement label using the 70th percentile of valid epoch speeds
  905. thr = np.percentile(epoch_motion_speed[valid], 70)
  906. labels = np.zeros(feats["n_epochs"], dtype=int) # 0 = not moving
  907. labels[valid & (epoch_motion_speed > thr)] = 1 # 1 = moving
  908. out = {
  909. "session_name": "FoodTracking",
  910. "ecog_data": ecog_data,
  911. "ecog_time": ecog_time,
  912. "motion_time": MotionTime,
  913. "wrist_xyz": wrist_xyz,
  914. "motion_speed": speed,
  915. "epoch_times": epoch_times,
  916. "epoch_motion_speed": epoch_motion_speed,
  917. "motion_threshold_70": thr,
  918. "labels_raw": labels,
  919. "keep_mask": valid
  920. }
  921. out.update(feats)
  922. return out
  923. # %%
  924. food_results = load_food_dataset_v2(
  925. FOOD_ROOT,
  926. wrist_marker_index=2,
  927. epoch_length_sec=2,
  928. n_bins=6,
  929. alpha=1.0,
  930. beta=1.0
  931. )
  932. print("FoodTracking loaded")
  933. print("ECoG data shape:", food_results["ecog_data"].shape)
  934. print("Wrist XYZ shape:", food_results["wrist_xyz"].shape)
  935. print("Motion time length:", len(food_results["motion_time"]))
  936. print("Epoch count:", food_results["ric"].shape[1])
  937. print("Valid motion-aligned epochs:", np.sum(food_results["keep_mask"]))
  938. print("Not moving epochs:", np.sum(food_results["labels_raw"][food_results["keep_mask"]] == 0))
  939. print("Moving epochs:", np.sum(food_results["labels_raw"][food_results["keep_mask"]] == 1))
  940. print("70th percentile threshold:", food_results["motion_threshold_70"])
  941. # %%
  942. food_keep = food_results["keep_mask"]
  943. food_all = {
  944. "ric": food_results["ric"][:, food_keep],
  945. "entropy": food_results["entropy"][:, food_keep],
  946. "rec_gain": food_results["rec_gain"][:, food_keep],
  947. "rms": food_results["rms"][:, food_keep],
  948. "meanabs": food_results["meanabs"][:, food_keep],
  949. "y": food_results["labels_raw"][food_keep].astype(int)
  950. }
  951. print("Anesthesia primary shape:", anesthesia_primary["ric"].shape)
  952. print("Food shape:", food_all["ric"].shape)
  953. # %%
  954. food_keep = food_results["keep_mask"]
  955. food_all = {
  956. "ric": food_results["ric"][:, food_keep],
  957. "entropy": food_results["entropy"][:, food_keep],
  958. "rec_gain": food_results["rec_gain"][:, food_keep],
  959. "rms": food_results["rms"][:, food_keep],
  960. "meanabs": food_results["meanabs"][:, food_keep],
  961. "y": food_results["labels_raw"][food_keep].astype(int)
  962. }
  963. print("Anesthesia primary shape:", anesthesia_primary["ric"].shape)
  964. print("Food shape:", food_all["ric"].shape)
  965. # %%
  966. def make_feature_sets(data_dict):
  967. ric_mc = make_multichannel_feature(data_dict["ric"])
  968. ent_mc = make_multichannel_feature(data_dict["entropy"])
  969. rg_mc = make_multichannel_feature(data_dict["rec_gain"])
  970. rms_mc = make_multichannel_feature(data_dict["rms"])
  971. feature_sets = {
  972. "Mean_RIC": make_single_feature(data_dict["ric"]),
  973. "Mean_Entropy": make_single_feature(data_dict["entropy"]),
  974. "Mean_RecGain": make_single_feature(data_dict["rec_gain"]),
  975. "Mean_RMS": make_single_feature(data_dict["rms"]),
  976. "RIC_Multichannel": ric_mc,
  977. "Entropy_Multichannel": ent_mc,
  978. "RecGain_Multichannel": rg_mc,
  979. "RMS_Multichannel": rms_mc,
  980. "RIC_plus_RMS": concat_features(ric_mc, rms_mc),
  981. "Entropy_plus_RecGain": concat_features(ent_mc, rg_mc),
  982. "RIC_plus_Entropy_plus_RecGain": concat_features(ric_mc, ent_mc, rg_mc),
  983. }
  984. return feature_sets
  985. def evaluate_binary_model(X, y, model, cv=10):
  986. skf = StratifiedKFold(n_splits=cv, shuffle=True, random_state=SEED)
  987. y_pred = cross_val_predict(model, X, y, cv=skf, method="predict")
  988. try:
  989. y_score = cross_val_predict(model, X, y, cv=skf, method="predict_proba")[:, 1]
  990. except Exception:
  991. y_score = cross_val_predict(model, X, y, cv=skf, method="decision_function")
  992. return {
  993. "accuracy": accuracy_score(y, y_pred),
  994. "balanced_accuracy": balanced_accuracy_score(y, y_pred),
  995. "f1": f1_score(y, y_pred),
  996. "auc": roc_auc_score(y, y_score),
  997. "confusion_matrix": confusion_matrix(y, y_pred)
  998. }
  999. def run_feature_benchmarks(dataset_name, y, feature_sets):
  1000. models = build_models()
  1001. rows = []
  1002. for feature_name, X in feature_sets.items():
  1003. for model_name, model in models.items():
  1004. metrics = evaluate_binary_model(X, y, model, cv=10)
  1005. rows.append({
  1006. "dataset": dataset_name,
  1007. "feature_set": feature_name,
  1008. "model": model_name,
  1009. "n_samples": X.shape[0],
  1010. "n_features": X.shape[1],
  1011. "accuracy": metrics["accuracy"],
  1012. "balanced_accuracy": metrics["balanced_accuracy"],
  1013. "f1": metrics["f1"],
  1014. "auc": metrics["auc"]
  1015. })
  1016. return pd.DataFrame(rows)
  1017. anes_feature_sets = make_feature_sets(anesthesia_primary)
  1018. food_feature_sets = make_feature_sets(food_all)
  1019. bench_anes = run_feature_benchmarks(
  1020. "Anesthesia_Session1_Open_vs_Closed",
  1021. anesthesia_primary["y"],
  1022. anes_feature_sets
  1023. )
  1024. bench_food = run_feature_benchmarks(
  1025. "Food_Moving_vs_NotMoving",
  1026. food_all["y"],
  1027. food_feature_sets
  1028. )
  1029. bench_df = pd.concat([bench_anes, bench_food], ignore_index=True)
  1030. bench_df.to_csv(TAB_DIR / "benchmark_results.csv", index=False)
  1031. bench_df.sort_values(["dataset", "auc", "accuracy"], ascending=[True, False, False]).head(30)
  1032. # %%
  1033. ablation_rows = bench_df[
  1034. bench_df["feature_set"].isin([
  1035. "Mean_Entropy",
  1036. "Mean_RecGain",
  1037. "Mean_RIC",
  1038. "RIC_Multichannel",
  1039. "Entropy_Multichannel",
  1040. "RecGain_Multichannel",
  1041. "RIC_plus_Entropy_plus_RecGain"
  1042. ])
  1043. ].copy()
  1044. ablation_rows = ablation_rows.sort_values(["dataset", "model", "auc"], ascending=[True, True, False])
  1045. ablation_rows.to_csv(TAB_DIR / "ablation_table.csv", index=False)
  1046. ablation_rows
  1047. # %%
  1048. plt.figure(figsize=(14, 6))
  1049. sns.barplot(
  1050. data=bench_df,
  1051. x="feature_set",
  1052. y="auc",
  1053. hue="dataset"
  1054. )
  1055. plt.xticks(rotation=60, ha="right")
  1056. plt.title("AUC across feature sets")
  1057. plt.tight_layout()
  1058. plt.savefig(FIG_DIR / "benchmark_auc_barplot.png", bbox_inches="tight")
  1059. plt.show()
  1060. plt.figure(figsize=(14, 6))
  1061. sns.barplot(
  1062. data=bench_df,
  1063. x="feature_set",
  1064. y="accuracy",
  1065. hue="dataset"
  1066. )
  1067. plt.xticks(rotation=60, ha="right")
  1068. plt.title("Accuracy across feature sets")
  1069. plt.tight_layout()
  1070. plt.savefig(FIG_DIR / "benchmark_accuracy_barplot.png", bbox_inches="tight")
  1071. plt.show()
  1072. # %%
  1073. def rerun_anesthesia_primary_bins(n_bins):
  1074. r = load_anesthesia_session_v2(ANES_ROOT / "Session1", epoch_length_sec=2, n_bins=n_bins, alpha=1.0, beta=1.0)
  1075. X = make_multichannel_feature(r["ric"][:, r["keep_mask"]])
  1076. y = (r["labels_raw"][r["keep_mask"]] == 2).astype(int)
  1077. model = build_models()["LogReg"]
  1078. return evaluate_binary_model(X, y, model, cv=10)
  1079. def rerun_food_bins(n_bins):
  1080. r = load_food_dataset_v2(FOOD_ROOT, wrist_marker_index=2, epoch_length_sec=2, n_bins=n_bins, alpha=1.0, beta=1.0)
  1081. keep = r["keep_mask"]
  1082. X = make_multichannel_feature(r["ric"][:, keep])
  1083. y = r["labels_raw"][keep].astype(int)
  1084. model = build_models()["LogReg"]
  1085. return evaluate_binary_model(X, y, model, cv=10)
  1086. bin_rows = []
  1087. for nb in [4, 6, 8]:
  1088. ma = rerun_anesthesia_primary_bins(nb)
  1089. mf = rerun_food_bins(nb)
  1090. bin_rows.append({
  1091. "dataset": "Anesthesia_Session1_Open_vs_Closed",
  1092. "n_bins": nb,
  1093. "accuracy": ma["accuracy"],
  1094. "auc": ma["auc"]
  1095. })
  1096. bin_rows.append({
  1097. "dataset": "Food_Moving_vs_NotMoving",
  1098. "n_bins": nb,
  1099. "accuracy": mf["accuracy"],
  1100. "auc": mf["auc"]
  1101. })
  1102. bin_sens_df = pd.DataFrame(bin_rows)
  1103. bin_sens_df.to_csv(TAB_DIR / "sensitivity_bins.csv", index=False)
  1104. bin_sens_df
  1105. # %%
  1106. def rerun_anesthesia_primary_epochlen(epoch_sec):
  1107. r = load_anesthesia_session_v2(ANES_ROOT / "Session1", epoch_length_sec=epoch_sec, n_bins=6, alpha=1.0, beta=1.0)
  1108. X = make_multichannel_feature(r["ric"][:, r["keep_mask"]])
  1109. y = (r["labels_raw"][r["keep_mask"]] == 2).astype(int)
  1110. model = build_models()["LogReg"]
  1111. return evaluate_binary_model(X, y, model, cv=10)
  1112. def rerun_food_epochlen(epoch_sec):
  1113. r = load_food_dataset_v2(FOOD_ROOT, wrist_marker_index=2, epoch_length_sec=epoch_sec, n_bins=6, alpha=1.0, beta=1.0)
  1114. keep = r["keep_mask"]
  1115. X = make_multichannel_feature(r["ric"][:, keep])
  1116. y = r["labels_raw"][keep].astype(int)
  1117. model = build_models()["LogReg"]
  1118. return evaluate_binary_model(X, y, model, cv=10)
  1119. epoch_rows = []
  1120. for ep_sec in [1, 2, 4]:
  1121. ma = rerun_anesthesia_primary_epochlen(ep_sec)
  1122. mf = rerun_food_epochlen(ep_sec)
  1123. epoch_rows.append({
  1124. "dataset": "Anesthesia_Session1_Open_vs_Closed",
  1125. "epoch_sec": ep_sec,
  1126. "accuracy": ma["accuracy"],
  1127. "auc": ma["auc"]
  1128. })
  1129. epoch_rows.append({
  1130. "dataset": "Food_Moving_vs_NotMoving",
  1131. "epoch_sec": ep_sec,
  1132. "accuracy": mf["accuracy"],
  1133. "auc": mf["auc"]
  1134. })
  1135. epoch_sens_df = pd.DataFrame(epoch_rows)
  1136. epoch_sens_df.to_csv(TAB_DIR / "sensitivity_epoch_length.csv", index=False)
  1137. epoch_sens_df
  1138. # %%
  1139. bench_df.groupby("dataset")[["feature_set","model","accuracy","balanced_accuracy","f1","auc"]].apply(lambda x: x.sort_values("auc", ascending=False).head(5))
  1140. # %%
  1141. # Comparator / ablation figure for the manuscript
  1142. import matplotlib.pyplot as plt
  1143. import seaborn as sns
  1144. import pandas as pd
  1145. # Keep only the clearest comparator feature sets
  1146. plot_df = bench_df.copy()
  1147. plot_df = plot_df[
  1148. plot_df["feature_set"].isin([
  1149. "Mean_Entropy",
  1150. "Mean_RecGain",
  1151. "Mean_RIC",
  1152. "RIC_plus_RMS",
  1153. "RIC_plus_Entropy_plus_RecGain"
  1154. ])
  1155. ].copy()
  1156. # Use only the better-performing linear model for a cleaner figure
  1157. plot_df = plot_df[plot_df["model"] == "LogReg"].copy()
  1158. # Rename features for publication-ready labels
  1159. label_map = {
  1160. "Mean_Entropy": "Entropy",
  1161. "Mean_RecGain": "Recursive Gain",
  1162. "Mean_RIC": "K (Curvature)",
  1163. "RIC_plus_RMS": "K + RMS",
  1164. "RIC_plus_Entropy_plus_RecGain": "K + Entropy + RecGain"
  1165. }
  1166. plot_df["Feature"] = plot_df["feature_set"].map(label_map)
  1167. dataset_map = {
  1168. "Anesthesia_Session1_Open_vs_Closed": "George Session 1\nEyes Opened vs Closed",
  1169. "Food_Moving_vs_NotMoving": "Food Tracking\nMoving vs Not Moving"
  1170. }
  1171. plot_df["Dataset"] = plot_df["dataset"].map(dataset_map)
  1172. feature_order = [
  1173. "Entropy",
  1174. "Recursive Gain",
  1175. "K (Curvature)",
  1176. "K + RMS",
  1177. "K + Entropy + RecGain"
  1178. ]
  1179. fig, axes = plt.subplots(1, 2, figsize=(14, 5), constrained_layout=True)
  1180. sns.barplot(
  1181. data=plot_df,
  1182. x="Feature",
  1183. y="auc",
  1184. hue="Dataset",
  1185. order=feature_order,
  1186. ax=axes[0]
  1187. )
  1188. axes[0].set_title("Comparator Analysis by AUC")
  1189. axes[0].set_ylabel("AUC")
  1190. axes[0].set_xlabel("")
  1191. axes[0].tick_params(axis="x", rotation=35)
  1192. sns.barplot(
  1193. data=plot_df,
  1194. x="Feature",
  1195. y="balanced_accuracy",
  1196. hue="Dataset",
  1197. order=feature_order,
  1198. ax=axes[1]
  1199. )
  1200. axes[1].set_title("Comparator Analysis by Balanced Accuracy")
  1201. axes[1].set_ylabel("Balanced Accuracy")
  1202. axes[1].set_xlabel("")
  1203. axes[1].tick_params(axis="x", rotation=35)
  1204. # Keep only one legend
  1205. axes[1].legend_.remove()
  1206. axes[0].legend(title="Dataset", frameon=False)
  1207. # Save high-resolution output
  1208. fig_path_png = FIG_DIR / "Figure_Ablation_Comparator.png"
  1209. fig_path_tiff = FIG_DIR / "Figure_Ablation_Comparator.tiff"
  1210. plt.savefig(fig_path_png, dpi=600, bbox_inches="tight")
  1211. plt.savefig(fig_path_tiff, dpi=600, bbox_inches="tight")
  1212. plt.show()
  1213. print("Saved to:")
  1214. print(fig_path_png)
  1215. print(fig_path_tiff)
  1216. # %%
  1217. # Optional supplementary figure: sensitivity across symbolic bins and epoch length
  1218. import matplotlib.pyplot as plt
  1219. import seaborn as sns
  1220. import pandas as pd
  1221. # Clean dataset labels for plotting
  1222. bin_plot = bin_sens_df.copy()
  1223. epoch_plot = epoch_sens_df.copy()
  1224. dataset_map = {
  1225. "Anesthesia_Session1_Open_vs_Closed": "George Session 1\nEyes Opened vs Closed",
  1226. "Food_Moving_vs_NotMoving": "Food Tracking\nMoving vs Not Moving"
  1227. }
  1228. bin_plot["Dataset"] = bin_plot["dataset"].map(dataset_map)
  1229. epoch_plot["Dataset"] = epoch_plot["dataset"].map(dataset_map)
  1230. fig, axes = plt.subplots(2, 2, figsize=(12, 8), constrained_layout=True)
  1231. # AUC vs number of bins
  1232. sns.lineplot(
  1233. data=bin_plot,
  1234. x="n_bins",
  1235. y="auc",
  1236. hue="Dataset",
  1237. marker="o",
  1238. ax=axes[0, 0]
  1239. )
  1240. axes[0, 0].set_title("Sensitivity to Symbolic Bin Count")
  1241. axes[0, 0].set_xlabel("Number of bins")
  1242. axes[0, 0].set_ylabel("AUC")
  1243. # Accuracy vs number of bins
  1244. sns.lineplot(
  1245. data=bin_plot,
  1246. x="n_bins",
  1247. y="accuracy",
  1248. hue="Dataset",
  1249. marker="o",
  1250. ax=axes[0, 1]
  1251. )
  1252. axes[0, 1].set_title("Accuracy across Symbolic Bin Counts")
  1253. axes[0, 1].set_xlabel("Number of bins")
  1254. axes[0, 1].set_ylabel("Accuracy")
  1255. # AUC vs epoch length
  1256. sns.lineplot(
  1257. data=epoch_plot,
  1258. x="epoch_sec",
  1259. y="auc",
  1260. hue="Dataset",
  1261. marker="o",
  1262. ax=axes[1, 0]
  1263. )
  1264. axes[1, 0].set_title("Sensitivity to Epoch Length")
  1265. axes[1, 0].set_xlabel("Epoch length (s)")
  1266. axes[1, 0].set_ylabel("AUC")
  1267. # Accuracy vs epoch length
  1268. sns.lineplot(
  1269. data=epoch_plot,
  1270. x="epoch_sec",
  1271. y="accuracy",
  1272. hue="Dataset",
  1273. marker="o",
  1274. ax=axes[1, 1]
  1275. )
  1276. axes[1, 1].set_title("Accuracy across Epoch Lengths")
  1277. axes[1, 1].set_xlabel("Epoch length (s)")
  1278. axes[1, 1].set_ylabel("Accuracy")
  1279. # Keep only one legend
  1280. axes[0, 1].legend_.remove()
  1281. axes[1, 0].legend_.remove()
  1282. axes[1, 1].legend_.remove()
  1283. axes[0, 0].legend(title="Dataset", frameon=False)
  1284. fig_path_png = FIG_DIR / "Figure_Sensitivity_Bins_Epochs.png"
  1285. fig_path_tiff = FIG_DIR / "Figure_Sensitivity_Bins_Epochs.tiff"
  1286. plt.savefig(fig_path_png, dpi=600, bbox_inches="tight")
  1287. plt.savefig(fig_path_tiff, dpi=600, bbox_inches="tight")
  1288. plt.show()
  1289. print("Saved to:")
  1290. print(fig_path_png)
  1291. print(fig_path_tiff)
  1292. # %%
  1293. # Corrected Figure 5: ROC + UMAP/DBSCAN using updated pipeline results
  1294. import numpy as np
  1295. import matplotlib.pyplot as plt
  1296. from sklearn.model_selection import StratifiedKFold, cross_val_predict
  1297. from sklearn.pipeline import Pipeline
  1298. from sklearn.impute import SimpleImputer
  1299. from sklearn.preprocessing import StandardScaler
  1300. from sklearn.linear_model import LogisticRegression
  1301. from sklearn.metrics import roc_curve, auc
  1302. from sklearn.cluster import DBSCAN
  1303. import umap.umap_ as umap
  1304. # -------------------------
  1305. # Helper feature builders
  1306. # -------------------------
  1307. def make_multichannel_feature(X_channel_epoch):
  1308. return X_channel_epoch.T
  1309. def concat_features(*arrays_2d):
  1310. return np.concatenate(arrays_2d, axis=1)
  1311. # -------------------------
  1312. # Build corrected figure-5 feature sets
  1313. # Top row:
  1314. # Food = K + RMS (LogReg) [updated best model]
  1315. # George = multichannel K (LogReg)
  1316. # Bottom row:
  1317. # UMAP on multichannel K for both datasets
  1318. # -------------------------
  1319. X_food_cls = concat_features(
  1320. make_multichannel_feature(food_all["ric"]),
  1321. make_multichannel_feature(food_all["rms"])
  1322. )
  1323. y_food = food_all["y"].astype(int)
  1324. X_george_cls = make_multichannel_feature(anesthesia_primary["ric"])
  1325. y_george = anesthesia_primary["y"].astype(int)
  1326. X_food_umap = make_multichannel_feature(food_all["ric"])
  1327. X_george_umap = make_multichannel_feature(anesthesia_primary["ric"])
  1328. # -------------------------
  1329. # Models
  1330. # -------------------------
  1331. clf = Pipeline([
  1332. ("imputer", SimpleImputer(strategy="median")),
  1333. ("scaler", StandardScaler()),
  1334. ("clf", LogisticRegression(max_iter=2000, class_weight="balanced", random_state=SEED))
  1335. ])
  1336. cv = StratifiedKFold(n_splits=10, shuffle=True, random_state=SEED)
  1337. # Cross-validated ROC scores
  1338. food_scores = cross_val_predict(clf, X_food_cls, y_food, cv=cv, method="predict_proba")[:, 1]
  1339. george_scores = cross_val_predict(clf, X_george_cls, y_george, cv=cv, method="predict_proba")[:, 1]
  1340. fpr_food, tpr_food, _ = roc_curve(y_food, food_scores)
  1341. fpr_george, tpr_george, _ = roc_curve(y_george, george_scores)
  1342. auc_food = auc(fpr_food, tpr_food)
  1343. auc_george = auc(fpr_george, tpr_george)
  1344. # -------------------------
  1345. # UMAP + DBSCAN on K-space
  1346. # -------------------------
  1347. def run_umap_dbscan(X, n_neighbors=15, min_dist=0.1, eps=0.8, min_samples=5):
  1348. Xs = StandardScaler().fit_transform(X)
  1349. reducer = umap.UMAP(
  1350. n_neighbors=n_neighbors,
  1351. min_dist=min_dist,
  1352. random_state=SEED
  1353. )
  1354. emb = reducer.fit_transform(Xs)
  1355. clusterer = DBSCAN(eps=eps, min_samples=min_samples)
  1356. clusters = clusterer.fit_predict(emb)
  1357. return emb, clusters
  1358. emb_food, clusters_food = run_umap_dbscan(X_food_umap)
  1359. emb_george, clusters_george = run_umap_dbscan(X_george_umap)
  1360. # -------------------------
  1361. # Plot
  1362. # -------------------------
  1363. fig, axes = plt.subplots(2, 2, figsize=(12, 10), constrained_layout=True)
  1364. # Top-left: Food ROC
  1365. axes[0, 0].plot(fpr_food, tpr_food, lw=2, label=f"AUC = {auc_food:.3f}")
  1366. axes[0, 0].plot([0, 1], [0, 1], linestyle="--", lw=1)
  1367. axes[0, 0].set_title("Food Tracking: K + RMS (LogReg)")
  1368. axes[0, 0].set_xlabel("False Positive Rate")
  1369. axes[0, 0].set_ylabel("True Positive Rate")
  1370. axes[0, 0].legend(frameon=False, loc="lower right")
  1371. # Top-right: George ROC
  1372. axes[0, 1].plot(fpr_george, tpr_george, lw=2, label=f"AUC = {auc_george:.3f}")
  1373. axes[0, 1].plot([0, 1], [0, 1], linestyle="--", lw=1)
  1374. axes[0, 1].set_title("George Session 1: Multichannel K (LogReg)")
  1375. axes[0, 1].set_xlabel("False Positive Rate")
  1376. axes[0, 1].set_ylabel("True Positive Rate")
  1377. axes[0, 1].legend(frameon=False, loc="lower right")
  1378. # Bottom-left: Food UMAP clusters
  1379. sc1 = axes[1, 0].scatter(
  1380. emb_food[:, 0],
  1381. emb_food[:, 1],
  1382. c=clusters_food,
  1383. s=18
  1384. )
  1385. axes[1, 0].set_title("Food Tracking: UMAP + DBSCAN in K-space")
  1386. axes[1, 0].set_xlabel("UMAP1")
  1387. axes[1, 0].set_ylabel("UMAP2")
  1388. # Bottom-right: George UMAP clusters
  1389. sc2 = axes[1, 1].scatter(
  1390. emb_george[:, 0],
  1391. emb_george[:, 1],
  1392. c=clusters_george,
  1393. s=18
  1394. )
  1395. axes[1, 1].set_title("George Session 1: UMAP + DBSCAN in K-space")
  1396. axes[1, 1].set_xlabel("UMAP1")
  1397. axes[1, 1].set_ylabel("UMAP2")
  1398. # Save
  1399. fig_path_png = FIG_DIR / "Figure5_corrected_ROC_UMAP.png"
  1400. fig_path_tiff = FIG_DIR / "Figure5_corrected_ROC_UMAP.tiff"
  1401. plt.savefig(fig_path_png, dpi=600, bbox_inches="tight")
  1402. plt.savefig(fig_path_tiff, dpi=600, bbox_inches="tight")
  1403. plt.show()
  1404. print("Saved to:")
  1405. print(fig_path_png)
  1406. print(fig_path_tiff)
  1407. print(f"Food AUC = {auc_food:.3f}")
  1408. print(f"George AUC = {auc_george:.3f}")
  1409. # %%

ric_complete_pipeline.ipynb at commit 53038a8, under Apache-2.0 · at the source

Overview

Authors: Mahsa Asadi Anar1, Seyed Kiarash Sadat Rafiei1, Soroosh Najafi2, Parham Mahmoudi3, Hossein Gharedaghi4, Sasan Ghazanafar Ahari5, Maryam Rafiei6, Pouya Asgari7, Zahra Narimani1, Mohammad Saeed Soleimani Meigoli8
  1. School of Medicine, Shahid Beheshti University of Medical Sciences, Tehran, Iran
  2. Shahid Sadoughi University of Medical Sciences and Health Services, Yazd, Iran
  3. School of Medicine, Kermanshah University of Medical Sciences, Iran
  4. School of Medicine, Zanjan University of Medical Sciences, Iran
  5. Tabriz University of Medical Sciences, Iran
  6. Department of Biomedical Engineering, Islamic Azad University, Shahrekord, Iran
  7. Ahvaz Jundishapur University of Medical Sciences, Iran
  8. School of Medicine, Fasa University of Medical Sciences, Iran
Journal: Neuroscience insights, volume 21, article 26331055261460858
Dates: received 26 November 2025; accepted 1 June 2026; published online 28 June 2026
Type: Research article · Language: English
License: CC BY-NC
Identifiers: DOI 10.1177/26331055261460858 · PMID 42375499 · PMCID PMC13311254 · OpenAlex W7166449996
Open access: gold, a free copy (OpenAlex)
Status: code verified
Categories: intracranial EEG (iEEG / ECoG / SEEG) (modality), systems (subfield)
Methods: Spectral & time-frequency, Smoothing, state filtering, decompositions, Machine learning, Preprocessing, Evoked potentials, Connectivity, Statistics
Keywords: recursive informational curvature, information geometry, neural dynamics, electrocorticography (ECoG), microstate mapping, nonlinear signal analysis, biomarker
Topic: Functional Brain Connectivity Studies (Cognitive Neuroscience, Neuroscience), according to OpenAlex
Citations: not cited yet (Europe PMC); 30 references in the paper

Abstract

Objective: Recursive Informational Curvature (RIC) was recently introduced as an information-geometric framework for describing the balance between recursive structure and entropy change in dynamical systems. Here, we present the first empirical implementation and benchmark evaluation of its scalar curvature term, K in high-density macaque electrocorticography (ECoG).

Methods: We analyzed 2 open-access datasets: a 128-channel eyes-open versus eyes-closed benchmark derived from the George anesthesia-and-sleep recording package, and a 64-channel food-tracking dataset with synchronized motion capture. Following standardized preprocessing and symbolic discretization, we extracted Shannon entropy, recursive gain, and empirical curvature for each channel and epoch. We then evaluated these features using single-feature, multichannel, and combined-feature models, together with ablation and sensitivity analyses across symbolic bin counts, epoch lengths, and classifier families.

Results: In the primary eyes-open versus eyes-closed benchmark, multichannel curvature features supported near-perfect state discrimination. In the food-tracking task, the strongest performance was obtained from combined curvature and amplitude features. Across analyses, entropy alone was weak, whereas recursive gain and curvature showed closely matched performance profiles, indicating that in the present implementation much of the discriminative structure captured by curvature is concentrated in the recursive term.

Conclusion: These findings establish the empirical RIC curvature term as an interpretable and state-sensitive descriptor of neural dynamics in ECoG, provide a reproducible benchmark for future refinement of the framework, and clarify both the promise and the current limits of curvature-based analysis in neural states.

Reproduced under the paper's license (CC BY-NC), from the paper cited above.

Repository

Its files are read in the Code ↔ Paper reader above, with 12 matches between paragraphs and lines of code.

explorerquantummind/benchmarkecogric

License: Apache-2.0
State: the link answers, verified on 27 September 2026
Evidence: files inventoried
Commit: 53038a8cfa60d601c160ddc5335008459d550f69, 14 March 2026
Languages: Python (36), Jupyter (1)
Size: 39 files, 37 scripts
Software Heritage: not archived
Found in: the text, “Footnotes”
Holds: README, license file, 1 notebook
Not found: CITATION.cff, environment file, tests, continuous integration, documentation
Tools: NumPy (28 files), SciPy (19 files), Matplotlib (18 files), scikit-learn (14 files), UMAP (6 files), Pillow (4 files), Nilearn (3 files), seaborn (3 files), pandas (1 file)
Availability: 1 check, the latest on 27 September 2026: the link answers
  • 27 September 2026: the link answers
39 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;
  • 37 scripts, each with its path and the digest of its content;
  • 12 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

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, 27 September 2026: the first record

Recorded: type, language, journal, volume, pages, dates, 10 authors, 7 keywords, 28 references.

Cite

This paper

Asadi Anar, M., Sadat Rafiei, S. K., Najafi, S., Mahmoudi, P., Gharedaghi, H., Ghazanafar Ahari, S., Rafiei, M., Asgari, P., Narimani, Z., & Soleimani Meigoli, M. S. (2026). The Geometric Signatures of Brain State Transitions: Recursive Informational Curvature Reveals Hidden Dynamics in Primate Cortex. Neuroscience insights, 21, 26331055261460858. https://doi.org/10.1177/26331055261460858

BibTeX

@article{asadianar2026geometric,
author = {Asadi Anar, Mahsa and Sadat Rafiei, Seyed Kiarash and Najafi, Soroosh and Mahmoudi, Parham and Gharedaghi, Hossein and Ghazanafar Ahari, Sasan and Rafiei, Maryam and Asgari, Pouya and Narimani, Zahra and Soleimani Meigoli, Mohammad Saeed},
title = {{The Geometric Signatures of Brain State Transitions: Recursive Informational Curvature Reveals Hidden Dynamics in Primate Cortex}},
journal = {Neuroscience insights},
year = {2026},
month = jun,
volume = {21},
pages = {26331055261460858},
publisher = {SAGE Publications},
issn = {2633-1055},
doi = {10.1177/26331055261460858},
url = {https://doi.org/10.1177/26331055261460858},
pmid = {42375499},
pmcid = {PMC13311254}
}

RIS

TY - JOUR
AU - Asadi Anar, Mahsa
AU - Sadat Rafiei, Seyed Kiarash
AU - Najafi, Soroosh
AU - Mahmoudi, Parham
AU - Gharedaghi, Hossein
AU - Ghazanafar Ahari, Sasan
AU - Rafiei, Maryam
AU - Asgari, Pouya
AU - Narimani, Zahra
AU - Soleimani Meigoli, Mohammad Saeed
TI - The Geometric Signatures of Brain State Transitions: Recursive Informational Curvature Reveals Hidden Dynamics in Primate Cortex
T2 - Neuroscience insights
J2 - Neurosci Insights
PY - 2026
DA - 2026/06/28
VL - 21
SP - 26331055261460858
SN - 2633-1055
PB - SAGE Publications
DO - 10.1177/26331055261460858
UR - https://doi.org/10.1177/26331055261460858
LA - en
ER -

CSL-JSON

{
"id": "10.1177/26331055261460858",
"type": "article-journal",
"title": "The Geometric Signatures of Brain State Transitions: Recursive Informational Curvature Reveals Hidden Dynamics in Primate Cortex",
"container-title": "Neuroscience insights",
"author": [
{
"family": "Asadi Anar",
"given": "Mahsa"
},
{
"family": "Sadat Rafiei",
"given": "Seyed Kiarash"
},
{
"family": "Najafi",
"given": "Soroosh"
},
{
"family": "Mahmoudi",
"given": "Parham"
},
{
"family": "Gharedaghi",
"given": "Hossein"
},
{
"family": "Ghazanafar Ahari",
"given": "Sasan"
},
{
"family": "Rafiei",
"given": "Maryam"
},
{
"family": "Asgari",
"given": "Pouya"
},
{
"family": "Narimani",
"given": "Zahra"
},
{
"family": "Soleimani Meigoli",
"given": "Mohammad Saeed"
}
],
"container-title-short": "Neurosci Insights",
"volume": "21",
"page": "26331055261460858",
"DOI": "10.1177/26331055261460858",
"PMID": "42375499",
"PMCID": "PMC13311254",
"ISSN": "2633-1055",
"publisher": "SAGE Publications",
"URL": "https://doi.org/10.1177/26331055261460858",
"language": "en",
"issued": {
"date-parts": [
[
2026,
6,
28
]
]
}
}

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.1371/journal.pcbi.1014162 [code]
Exploring neural manifolds across a wide range of intrinsic dimensions.
Journal: PLoS computational biology
In common: scikit-learn, pandas, SciPy, 2 other tools, 7 references
[2] doi:10.1038/s41467-026-75347-4 [code]
Sleep reveals dynamics integrating and segregating movement and stimulus representations in V1.
Journal: Nature communications
In common: UMAP, Pillow, scikit-learn, 4 other tools, systems, 2 references
[3] doi:10.3390/biomimetics11080569 [code]
Pretraining of Embodied Recurrent Networks Bridges the Gap Between Artificial and Cortical Neural Activities.
Journal: Biomimetics (Basel, Switzerland)
In common: scikit-learn, SciPy, Matplotlib, 1 other tool, systems, 5 references
[4] doi:10.3389/fnsys.2026.1822122 [code]
Convergence-divergence circuits for multimodal integration of innate and learned opponent valences.
Journal: Frontiers in systems neuroscience
In common: UMAP, Pillow, seaborn, 5 other tools, systems, 1 reference
[5] doi:10.1002/advs.202520220 [code]
Learnable Diffusion Framework for Mouse V1 Neural Decoding.
Journal: Advanced science (Weinheim, Baden-Wurttemberg, Germany)
In common: UMAP, Pillow, seaborn, 5 other tools, systems, 1 reference
[6] doi:10.1038/s41467-026-72057-9 [code]
Sex-specific behavioral feedback modulates sensorimotor processing and drives flexible social behavior.
Journal: Nature communications
In common: UMAP, Pillow, seaborn, 5 other tools, systems, 1 reference
[7] doi:10.1162/imag.a.1276 [code]
High-resolution whole-brain magnetic resonance spectroscopic imaging in youth at risk for psychosis.
Journal: Imaging neuroscience (Cambridge, Mass.)
In common: UMAP, Nilearn, Pillow, 6 other tools
[8] doi:10.1038/s41467-026-71151-2 [code]
Common and distinct neural correlates of social interaction processing and theory of mind in narratives.
Journal: Nature communications
In common: UMAP, Nilearn, Pillow, 6 other tools
[9] doi:10.1038/s41597-026-06869-1 [code]
Individual Brain Charting: fifth release of high-resolution fMRI data for cognitive mapping.
Journal: Scientific data
In common: UMAP, Nilearn, Pillow, 6 other tools
[10] doi:10.1371/journal.pbio.3003684 [code]
The retrieval of previously learned motor memories is facilitated by the reinstatement of default mode network manifold structures.
Journal: PLoS biology
In common: UMAP, Nilearn, seaborn, 5 other tools, 1 reference

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.