OSCR

Anticipatory slow potentials before auditory feedback show posterior predominance but limited condition effects in speech-in-noise.

Code ↔ Paper

5 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 5 matches
  1. [1] § Materials and methods › Statistical analysis ↔ python/run_spn_cnv_analysis.py, lines 741–799 · score 0.93 · Bayesian mixed, ROI interaction models, approximate Bayes factors, hemisphere model, central ROI, ROI terms
  2. [2] § Results › Primary SPN ROI × hemisphere analysis ↔ python/run_spn_cnv_analysis.py, lines 741–799 · score 0.81 · ROI interaction model, Bayes factor, central ROI, ROI terms, primary model, primary ROI
  3. [3] § Materials and methods › Statistical analysis ↔ python/run_spn_cnv_analysis.py, lines 842–895 · score 0.62 · noise minus silence, exploratory spatial, response locked, cluster, permutation, ROIs
  4. [4] § Results › Descriptive sensor-space localization of the SPN condition effect ↔ python/run_spn_cnv_analysis.py, lines 842–895 · score 0.61 · noise minus silence, descriptive ROI, cluster, permutation, localization, exploratory
  5. [5] § Materials and methods › EEG recording and preprocessing ↔ matlab/stage1_prepare_ica.m, lines 266–292 · score 0.57 · ICA weights, transferred, EEGLAB, channels

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 · 1,065 lines · 41 KB · MIT · 4 matches

  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. """
  4. run_spn_cnv_analysis.py
  5. Desktop-oriented analysis script for SPN/CNV EEG data.
  6. This script:
  7. 1. Loads non-interpolated trial-wise long-format EEG data
  8. 2. Optionally loads electrode metadata and run-level QC tables
  9. 3. Applies primary-analysis filtering
  10. 4. Runs ROI × Hemisphere mixed-effects models for SPN and CNV
  11. 5. Optionally fits supplementary models with Sex
  12. 6. Runs electrode-wise mixed-effects models with FDR correction
  13. 7. Adds reviewer-requested ROI-interaction models
  14. 8. Adds BIC-approximated Bayes factors for condition-related terms
  15. 9. Runs cluster-based permutation tests on participant-level difference maps
  16. 10. Generates descriptive topographic and ROI-panel figures
  17. 11. Optionally summarizes speech-in-noise and time-estimation performance and
  18. computes exploratory correlations with EEG measures
  19. Inputs
  20. ------
  21. Required:
  22. anticipatory_electrode_long_noninterp.csv
  23. Optional:
  24. electrode_metadata.csv
  25. run_qc.csv
  26. speech_accuracy.csv
  27. time_estimation.csv or timing.csv
  28. Outputs
  29. -------
  30. Tables and figures are written to the output directory.
  31. """
  32. import warnings
  33. import argparse
  34. from pathlib import Path
  35. import numpy as np
  36. import pandas as pd
  37. import matplotlib
  38. matplotlib.use("Agg")
  39. import matplotlib.pyplot as plt
  40. from scipy.stats import norm, t as student_t, pearsonr, spearmanr, ttest_rel
  41. from statsmodels.formula.api import mixedlm
  42. from statsmodels.stats.multitest import multipletests
  43. import mne
  44. warnings.filterwarnings("ignore")
  45. def find_input_file(input_dir: Path, target_name: str) -> Path:
  46. matches = sorted(input_dir.glob(f"*{target_name}*"))
  47. files = [p for p in matches if p.is_file()]
  48. if not files:
  49. raise FileNotFoundError(f"No file containing '{target_name}' was found in {input_dir}")
  50. return files[0]
  51. def ensure_dir(path: Path) -> None:
  52. path.mkdir(parents=True, exist_ok=True)
  53. def save_table(df: pd.DataFrame, path: Path) -> None:
  54. df.to_csv(path, index=False)
  55. print(f"[Saved] {path}")
  56. def fe_table(res):
  57. fe = res.fe_params
  58. se = res.bse_fe
  59. z = fe / se
  60. p = 2 * norm.sf(np.abs(z))
  61. ci_low = fe - 1.96 * se
  62. ci_high = fe + 1.96 * se
  63. out = (
  64. pd.DataFrame({
  65. "b": fe,
  66. "SE": se,
  67. "z": z,
  68. "p": p,
  69. "CI_low": ci_low,
  70. "CI_high": ci_high
  71. })
  72. .reset_index()
  73. .rename(columns={"index": "Term"})
  74. )
  75. return out
  76. def try_fit_mixed(formula, data, re="~Condition_c", group="Participant"):
  77. kind = "random-slope"
  78. try:
  79. res = mixedlm(
  80. formula,
  81. data=data,
  82. groups=data[group],
  83. re_formula=re
  84. ).fit(method="lbfgs", reml=True)
  85. return res, kind
  86. except Exception:
  87. kind = "random-intercept"
  88. res = mixedlm(
  89. formula,
  90. data=data,
  91. groups=data[group]
  92. ).fit(method="lbfgs", reml=True)
  93. return res, kind
  94. def try_fit_mixed_ml(formula, data, re="~Condition_c", group="Participant"):
  95. """Fit a mixed model with ML, used for BIC-based model comparisons."""
  96. kind = "random-slope"
  97. try:
  98. res = mixedlm(
  99. formula,
  100. data=data,
  101. groups=data[group],
  102. re_formula=re
  103. ).fit(method="lbfgs", reml=False)
  104. return res, kind
  105. except Exception:
  106. kind = "random-intercept"
  107. res = mixedlm(
  108. formula,
  109. data=data,
  110. groups=data[group]
  111. ).fit(method="lbfgs", reml=False)
  112. return res, kind
  113. def bic_bayes_factor(null_res, full_res):
  114. """Return BIC-approximated BF10 and BF01 for full vs null model."""
  115. bf10 = float(np.exp((null_res.bic - full_res.bic) / 2.0))
  116. bf01 = float(1.0 / bf10) if bf10 != 0 else np.inf
  117. return bf10, bf01
  118. def run_bic_bayes_comparison(data, component_label, comparison_label,
  119. null_formula, full_formula, out_rows,
  120. re="~Condition_c", group="Participant"):
  121. """Fit null/full ML mixed models and append a BIC Bayes-factor summary row."""
  122. null_res, null_kind = try_fit_mixed_ml(null_formula, data, re=re, group=group)
  123. full_res, full_kind = try_fit_mixed_ml(full_formula, data, re=re, group=group)
  124. bf10, bf01 = bic_bayes_factor(null_res, full_res)
  125. out_rows.append({
  126. "Component": component_label,
  127. "Comparison": comparison_label,
  128. "Null_formula": null_formula,
  129. "Full_formula": full_formula,
  130. "Null_random_effects": null_kind,
  131. "Full_random_effects": full_kind,
  132. "N_observations": int(full_res.nobs),
  133. "Null_BIC": float(null_res.bic),
  134. "Full_BIC": float(full_res.bic),
  135. "Delta_BIC_full_minus_null": float(full_res.bic - null_res.bic),
  136. "BF10_full_over_null": bf10,
  137. "BF01_null_over_full": bf01
  138. })
  139. def ids_with_two_levels(df, id_col, factor_col):
  140. counts = df.groupby(id_col)[factor_col].nunique()
  141. return counts[counts >= 2].index.tolist()
  142. def encode_condition(series, pos="in silence", neg="in noise"):
  143. s = series.astype(str).str.strip().str.lower()
  144. s = s.replace({"noise": "in noise", "silence": "in silence"})
  145. mapping = {str(pos).lower(): 0.5, str(neg).lower(): -0.5}
  146. return s.map(mapping).astype(float)
  147. def guess_roi(ch):
  148. chU = str(ch).upper()
  149. if chU.startswith("F"):
  150. return "Frontal"
  151. if chU.startswith("C"):
  152. return "Central"
  153. if chU.startswith("P"):
  154. return "Parietal"
  155. if chU.startswith("O"):
  156. return "Occipital"
  157. if chU.startswith("T"):
  158. return "Temporal"
  159. return "Other"
  160. def guess_hemi_from_name(ch):
  161. ch = str(ch).strip()
  162. if ch.endswith(("z", "Z")):
  163. return "Midline"
  164. mp = {"T3": "Left", "T5": "Left", "T7": "Left",
  165. "T4": "Right", "T6": "Right", "T8": "Right"}
  166. if ch in mp:
  167. return mp[ch]
  168. if len(ch) > 0 and ch[-1].isdigit():
  169. return "Left" if int(ch[-1]) % 2 == 1 else "Right"
  170. return "Midline"
  171. def build_meta_from_spn(spn):
  172. elec = sorted(spn["Electrode"].astype(str).str.strip().unique())
  173. meta = pd.DataFrame({"Electrode": elec})
  174. meta["ROI"] = meta["Electrode"].map(guess_roi)
  175. meta["Hemisphere"] = meta["Electrode"].map(guess_hemi_from_name)
  176. return meta
  177. def prepare_meta_safe(meta, df):
  178. meta = meta.copy()
  179. meta.columns = [c.strip() for c in meta.columns]
  180. if "Electrode" not in meta.columns:
  181. raise ValueError("electrode_metadata.csv must contain an 'Electrode' column.")
  182. meta["Electrode"] = meta["Electrode"].astype(str).str.strip()
  183. all_elec = pd.DataFrame({
  184. "Electrode": sorted(df["Electrode"].astype(str).str.strip().unique())
  185. })
  186. meta = all_elec.merge(meta, on="Electrode", how="left")
  187. if "ROI" not in meta.columns or meta["ROI"].isna().any():
  188. aux = build_meta_from_spn(df)[["Electrode", "ROI"]]
  189. meta = meta.drop(columns=[c for c in ["ROI"] if c in meta.columns], errors="ignore")
  190. meta = meta.merge(aux, on="Electrode", how="left")
  191. if "Hemisphere" not in meta.columns or meta["Hemisphere"].isna().any():
  192. aux = build_meta_from_spn(df)[["Electrode", "Hemisphere"]]
  193. meta = meta.drop(columns=[c for c in ["Hemisphere"] if c in meta.columns], errors="ignore")
  194. meta = meta.merge(aux, on="Electrode", how="left")
  195. meta["Hemisphere"] = meta["Hemisphere"].fillna(meta["Electrode"].map(guess_hemi_from_name))
  196. return meta[["Electrode", "ROI", "Hemisphere"]]
  197. def save_fig(fig, path: Path, dpi=300, tight=True):
  198. fig.savefig(
  199. path,
  200. dpi=dpi,
  201. bbox_inches="tight" if tight else None,
  202. facecolor="white"
  203. )
  204. plt.close(fig)
  205. print(f"[Saved] {path}")
  206. def plot_roi_condition_panels(desc_roi, out_path: Path,
  207. roi_order=None,
  208. title="Mean anticipatory activity by ROI and condition"):
  209. if roi_order is None:
  210. roi_order = ["Frontal", "Central", "Parietal", "Occipital"]
  211. roi_order = [r for r in roi_order if r in desc_roi["ROI"].unique()]
  212. cond_order = ["in silence", "in noise"]
  213. n_panels = len(roi_order)
  214. fig, axes = plt.subplots(1, n_panels, figsize=(3.2 * n_panels, 4.2), sharey=True)
  215. if n_panels == 1:
  216. axes = [axes]
  217. rng = np.random.default_rng(42)
  218. for ax, roi in zip(axes, roi_order):
  219. wide = (
  220. desc_roi[desc_roi["ROI"] == roi]
  221. .pivot_table(index="Participant", columns="Condition", values="Mean_uV", aggfunc="mean")
  222. .reindex(columns=cond_order)
  223. )
  224. x = [0, 1]
  225. for _, row in wide.iterrows():
  226. if row.notna().all():
  227. ax.plot(x, row.values, color="0.75", linewidth=1.0, alpha=0.8, zorder=1)
  228. for i, cond in enumerate(cond_order):
  229. vals = wide[cond].dropna().values
  230. jitter = rng.normal(loc=0.0, scale=0.035, size=len(vals))
  231. ax.scatter(np.full(len(vals), i) + jitter, vals, s=26, color="black", alpha=0.85, zorder=2)
  232. if len(vals) >= 2:
  233. mu = np.mean(vals)
  234. se = np.std(vals, ddof=1) / np.sqrt(len(vals))
  235. tcrit = student_t.ppf(1 - 0.05 / 2, len(vals) - 1)
  236. ci_lo = mu - tcrit * se
  237. ci_hi = mu + tcrit * se
  238. ax.hlines(mu, i - 0.18, i + 0.18, color="black", linewidth=2.0, zorder=3)
  239. ax.vlines(i, ci_lo, ci_hi, color="black", linewidth=1.5, zorder=3)
  240. ax.set_xticks(x)
  241. ax.set_xticklabels(["silence", "noise"])
  242. ax.set_title(roi, fontsize=12)
  243. ax.spines["top"].set_visible(False)
  244. ax.spines["right"].set_visible(False)
  245. ax.tick_params(axis="both", labelsize=10)
  246. ax.set_xlim(-0.35, 1.35)
  247. axes[0].set_ylabel("Mean amplitude (µV)", fontsize=12)
  248. fig.suptitle(title, fontsize=14, y=1.02)
  249. save_fig(fig, out_path, dpi=300, tight=True)
  250. def standardize_channel_names(ch_names):
  251. """Map legacy 10-20 temporal labels to MNE standard_1020 names for plotting/adjacency."""
  252. rename_map = {"T3": "T7", "T4": "T8", "T5": "P7", "T6": "P8"}
  253. return [rename_map.get(str(ch), str(ch)) for ch in ch_names]
  254. def make_topomap_from_condition(mean_cond_df, condition_label, title, out_path: Path, vlim=None):
  255. """Draw a topomap. Pass vlim=(low, high) to force a common color scale."""
  256. tmp = mean_cond_df[mean_cond_df["Condition"] == condition_label].copy()
  257. tmp = tmp.sort_values("Electrode")
  258. ch_names = tmp["Electrode"].astype(str).tolist()
  259. ch_names_plot = standardize_channel_names(ch_names)
  260. info = mne.create_info(ch_names=ch_names_plot, sfreq=500, ch_types="eeg")
  261. montage = mne.channels.make_standard_montage("standard_1020")
  262. info.set_montage(montage)
  263. data_v = (tmp["Mean_uV"].values * 1e-6).reshape(-1, 1)
  264. evk = mne.EvokedArray(data_v, info, tmin=0)
  265. plot_kwargs = dict(
  266. times=[0],
  267. time_format="",
  268. scalings=dict(eeg=1e6),
  269. units=dict(eeg="µV"),
  270. show=False
  271. )
  272. if vlim is not None:
  273. plot_kwargs["vlim"] = vlim
  274. try:
  275. fig = evk.plot_topomap(**plot_kwargs)
  276. except TypeError:
  277. if vlim is not None:
  278. plot_kwargs.pop("vlim", None)
  279. plot_kwargs["vmin"], plot_kwargs["vmax"] = vlim
  280. fig = evk.plot_topomap(**plot_kwargs)
  281. fig.suptitle(title)
  282. save_fig(fig, out_path, dpi=300, tight=True)
  283. def build_roi_trial(df, meta):
  284. x = df.merge(meta, on="Electrode", how="left").copy()
  285. x["Hemisphere"] = x["Hemisphere"].fillna(x["Electrode"].map(guess_hemi_from_name))
  286. x["Condition_c"] = encode_condition(x["Condition"], pos="in silence", neg="in noise")
  287. x["Hem_c"] = x["Hemisphere"].map({"Right": -0.5, "Left": 0.5, "Midline": 0.0}).fillna(0.0).astype(float)
  288. roi_trial = (
  289. x.groupby(["Participant", "RunID", "Trial", "Condition", "ROI", "Hemisphere"], as_index=False)
  290. .agg(
  291. Mean_uV=("Value_uV", "mean"),
  292. n_chan_used=("Electrode", "nunique")
  293. )
  294. )
  295. roi_total = (
  296. meta.groupby(["ROI", "Hemisphere"], as_index=False)
  297. .agg(n_chan_total=("Electrode", "nunique"))
  298. )
  299. roi_trial = roi_trial.merge(roi_total, on=["ROI", "Hemisphere"], how="left")
  300. roi_trial["prop_chan_used"] = roi_trial["n_chan_used"] / roi_trial["n_chan_total"]
  301. roi_trial = roi_trial[roi_trial["prop_chan_used"] >= 0.5].copy()
  302. roi_trial["Condition_c"] = encode_condition(roi_trial["Condition"], pos="in silence", neg="in noise")
  303. roi_trial["Hem_c"] = roi_trial["Hemisphere"].map({"Right": -0.5, "Left": 0.5, "Midline": 0.0}).fillna(0.0).astype(float)
  304. return x, roi_trial
  305. def fit_with_sex(roi_df, long_df, out_csv: Path):
  306. if "Sex" not in long_df.columns or long_df["Sex"].isna().all():
  307. return None
  308. aux = long_df[["Participant", "RunID", "Trial", "Sex"]].drop_duplicates()
  309. x = roi_df.merge(aux, on=["Participant", "RunID", "Trial"], how="left")
  310. x["Sex"] = x["Sex"].astype(str)
  311. x["Sex_c"] = x["Sex"].map({"F": -0.5, "M": 0.5})
  312. x = x[x["Sex_c"].notna()].copy()
  313. if len(x) == 0:
  314. return None
  315. formula = "Mean_uV ~ Condition_c * Hem_c + C(ROI) + Sex_c + Condition_c:Sex_c"
  316. res, kind = try_fit_mixed(formula, x, re="~Condition_c", group="Participant")
  317. tbl = fe_table(res)
  318. save_table(tbl, out_csv)
  319. return tbl, kind
  320. def electrodewise_lmm(df, out_csv: Path):
  321. rows = []
  322. for elec, df_e in df.groupby("Electrode"):
  323. try:
  324. model_e = mixedlm(
  325. "Value_uV ~ Condition_c",
  326. data=df_e.assign(Condition_c=encode_condition(df_e["Condition"])),
  327. groups=df_e["Participant"],
  328. re_formula="~Condition_c"
  329. ).fit(method="lbfgs", reml=True)
  330. b = float(model_e.fe_params.get("Condition_c", np.nan))
  331. se = float(model_e.bse_fe.get("Condition_c", np.nan))
  332. z = b / se if np.isfinite(se) and se > 0 else np.nan
  333. p = 2 * norm.sf(abs(z)) if np.isfinite(z) else np.nan
  334. rows.append((elec, b, se, z, p))
  335. except Exception:
  336. rows.append((elec, np.nan, np.nan, np.nan, np.nan))
  337. tbl = pd.DataFrame(rows, columns=["Electrode", "b_Condition", "SE", "z", "p"])
  338. mask = tbl["p"].notna()
  339. tbl.loc[mask, "p_FDR"] = multipletests(tbl.loc[mask, "p"], method="fdr_bh")[1]
  340. tbl = tbl.sort_values("p")
  341. save_table(tbl, out_csv)
  342. return tbl
  343. def roi_desc(roi_df):
  344. return (
  345. roi_df.groupby(["Participant", "Condition", "ROI"], as_index=False)
  346. .agg(Mean_uV=("Mean_uV", "mean"))
  347. )
  348. def cluster_permutation_difference(long_df, out_csv: Path, component_label="SPN",
  349. n_permutations=5000, seed=42):
  350. """Cluster-based permutation test on participant-level noise-minus-silence maps.
  351. The test uses only electrodes with complete paired data across participants.
  352. This avoids interpolation/imputation and keeps the result conservative for a
  353. low-density, non-interpolated montage.
  354. """
  355. mean_cond = (
  356. long_df.groupby(["Participant", "Electrode", "Condition"], as_index=False)
  357. .agg(Mean_uV=("Value_uV", "mean"))
  358. )
  359. wide = mean_cond.pivot_table(
  360. index=["Participant", "Electrode"],
  361. columns="Condition",
  362. values="Mean_uV"
  363. ).reset_index()
  364. required = {"in noise", "in silence"}
  365. if not required.issubset(wide.columns):
  366. tbl = pd.DataFrame([{
  367. "Component": component_label,
  368. "Note": "Skipped: both in noise and in silence conditions were not available."
  369. }])
  370. save_table(tbl, out_csv)
  371. return tbl
  372. wide["Diff_uV"] = wide["in noise"] - wide["in silence"]
  373. mat = wide.pivot(index="Participant", columns="Electrode", values="Diff_uV")
  374. mat = mat.dropna(axis=1, how="any").dropna(axis=0, how="any")
  375. if mat.shape[0] < 3 or mat.shape[1] < 2:
  376. tbl = pd.DataFrame([{
  377. "Component": component_label,
  378. "Note": f"Skipped: insufficient complete paired data for cluster test (n={mat.shape[0]}, electrodes={mat.shape[1]})."
  379. }])
  380. save_table(tbl, out_csv)
  381. return tbl
  382. elec = list(mat.columns)
  383. ch_plot = standardize_channel_names(elec)
  384. info = mne.create_info(ch_names=ch_plot, sfreq=500, ch_types="eeg")
  385. info.set_montage(mne.channels.make_standard_montage("standard_1020"))
  386. adjacency, _ = mne.channels.find_ch_adjacency(info, ch_type="eeg")
  387. X = mat.to_numpy(dtype=float)
  388. t_obs, clusters, cluster_p, _ = mne.stats.permutation_cluster_1samp_test(
  389. X,
  390. n_permutations=n_permutations,
  391. threshold=None,
  392. tail=0,
  393. adjacency=adjacency,
  394. out_type="indices",
  395. seed=seed,
  396. verbose=False
  397. )
  398. rows = []
  399. for i, (clu, pval) in enumerate(zip(clusters, cluster_p), start=1):
  400. if isinstance(clu, tuple):
  401. inds = np.asarray(clu[0], dtype=int)
  402. else:
  403. inds = np.where(np.asarray(clu))[0]
  404. if inds.size == 0:
  405. continue
  406. stat = float(np.sum(t_obs[inds]))
  407. polarity = "positive" if stat > 0 else "negative"
  408. rows.append({
  409. "Component": component_label,
  410. "Cluster": i,
  411. "Polarity": polarity,
  412. "Electrodes": ";".join([elec[j] for j in inds]),
  413. "N_participants": int(mat.shape[0]),
  414. "N_electrodes_in_test": int(mat.shape[1]),
  415. "Cluster_stat_sum_t": stat,
  416. "Cluster_p": float(pval)
  417. })
  418. if not rows:
  419. rows.append({
  420. "Component": component_label,
  421. "Cluster": np.nan,
  422. "Polarity": "none",
  423. "Electrodes": "",
  424. "N_participants": int(mat.shape[0]),
  425. "N_electrodes_in_test": int(mat.shape[1]),
  426. "Cluster_stat_sum_t": np.nan,
  427. "Cluster_p": np.nan
  428. })
  429. tbl = pd.DataFrame(rows).sort_values("Cluster_p", na_position="last")
  430. save_table(tbl, out_csv)
  431. return tbl
  432. def find_timing_file(input_dir: Path):
  433. for target in ["time_estimation", "timing", "time_est", "response_time"]:
  434. try:
  435. return find_input_file(input_dir, target)
  436. except Exception:
  437. pass
  438. return None
  439. def summarize_time_estimation(timing_csv: Path, tables_dir: Path, run_qc=None):
  440. """Summarize absolute timing error by condition if a timing CSV is available."""
  441. timing = pd.read_csv(timing_csv)
  442. timing.columns = [c.strip() for c in timing.columns]
  443. if "Participant" not in timing.columns or "Condition" not in timing.columns:
  444. print("[WARN] Timing file found but skipped: Participant and Condition columns are required.")
  445. return None
  446. timing["Participant"] = (
  447. timing["Participant"].astype(str).str.strip().str.replace("^P", "", regex=True).str.zfill(2)
  448. )
  449. timing["Condition"] = (
  450. timing["Condition"].astype(str).str.strip().str.lower()
  451. .replace({"noise": "in noise", "silence": "in silence"})
  452. )
  453. if run_qc is not None and "keep_primary_participant" in run_qc.columns:
  454. keep_part = run_qc[["Participant", "keep_primary_participant"]].drop_duplicates()
  455. timing = timing.merge(keep_part, on="Participant", how="left")
  456. timing = timing[timing["keep_primary_participant"] == True].copy()
  457. abs_candidates = [
  458. "AbsoluteError_s", "absolute_error_s", "AbsError_s", "abs_error_s",
  459. "AbsoluteError", "absolute_error", "abs_error", "TimingAbsError_s"
  460. ]
  461. estimate_candidates = [
  462. "Estimate_s", "estimate_s", "ResponseTime_s", "response_time_s",
  463. "ResponseTime", "response_time", "Elapsed_s", "elapsed_s", "RT_s", "rt_s"
  464. ]
  465. abs_col = next((c for c in abs_candidates if c in timing.columns), None)
  466. if abs_col is not None:
  467. timing["AbsoluteError_s"] = pd.to_numeric(timing[abs_col], errors="coerce").abs()
  468. else:
  469. est_col = next((c for c in estimate_candidates if c in timing.columns), None)
  470. if est_col is None:
  471. print("[WARN] Timing file found but skipped: no recognizable absolute-error or estimate column.")
  472. return None
  473. timing["AbsoluteError_s"] = (pd.to_numeric(timing[est_col], errors="coerce") - 4.0).abs()
  474. part = (
  475. timing.groupby(["Participant", "Condition"], as_index=False)
  476. .agg(
  477. mean_absolute_error_s=("AbsoluteError_s", "mean"),
  478. n_trials=("AbsoluteError_s", "count")
  479. )
  480. )
  481. save_table(part, tables_dir / "Supp_Table_TimeEstimationAbsoluteError_ParticipantByCondition.csv")
  482. summary = (
  483. part.groupby("Condition", as_index=False)
  484. .agg(
  485. n_participants=("Participant", "nunique"),
  486. mean_absolute_error_s=("mean_absolute_error_s", "mean"),
  487. sd_absolute_error_s=("mean_absolute_error_s", "std"),
  488. mean_n_trials=("n_trials", "mean")
  489. )
  490. )
  491. save_table(summary, tables_dir / "Supp_Table_TimeEstimationAbsoluteError_ByCondition.csv")
  492. wide = part.pivot(index="Participant", columns="Condition", values="mean_absolute_error_s")
  493. if {"in noise", "in silence"}.issubset(wide.columns):
  494. paired = wide[["in silence", "in noise"]].dropna()
  495. if len(paired) >= 2:
  496. t_stat, p_val = ttest_rel(paired["in noise"], paired["in silence"], nan_policy="omit")
  497. diff = paired["in noise"] - paired["in silence"]
  498. test_tbl = pd.DataFrame([{
  499. "Contrast": "in noise - in silence",
  500. "N": int(len(paired)),
  501. "mean_difference_s": float(diff.mean()),
  502. "sd_difference_s": float(diff.std(ddof=1)),
  503. "t": float(t_stat),
  504. "p": float(p_val)
  505. }])
  506. else:
  507. test_tbl = pd.DataFrame([{"Contrast": "in noise - in silence", "N": int(len(paired))}])
  508. save_table(test_tbl, tables_dir / "Supp_Table_TimeEstimationAbsoluteError_PairedTest.csv")
  509. return summary
  510. def corr_row(participant_summary, x, y, xname, yname):
  511. df = participant_summary[[x, y]].dropna()
  512. if len(df) < 3:
  513. return {
  514. "X": xname, "Y": yname, "N": len(df),
  515. "pearson_r": np.nan, "pearson_p": np.nan,
  516. "spearman_rho": np.nan, "spearman_p": np.nan
  517. }
  518. pr, pp = pearsonr(df[x], df[y])
  519. sr, sp = spearmanr(df[x], df[y])
  520. return {
  521. "X": xname, "Y": yname, "N": len(df),
  522. "pearson_r": pr, "pearson_p": pp,
  523. "spearman_rho": sr, "spearman_p": sp
  524. }
  525. def main():
  526. parser = argparse.ArgumentParser()
  527. parser.add_argument("--input_dir", type=str, required=True,
  528. help="Directory containing anticipatory_electrode_long_noninterp.csv")
  529. parser.add_argument("--output_dir", type=str, required=True,
  530. help="Directory for output tables and figures")
  531. parser.add_argument("--data", type=str, default=None,
  532. help="Path to anticipatory_electrode_long_noninterp.csv")
  533. parser.add_argument("--metadata", type=str, default=None,
  534. help="Optional path to electrode_metadata.csv")
  535. parser.add_argument("--run_qc", type=str, default=None,
  536. help="Optional path to run_qc.csv")
  537. parser.add_argument("--speech", type=str, default=None,
  538. help="Optional path to speech_accuracy.csv")
  539. parser.add_argument("--timing", type=str, default=None,
  540. help="Optional path to time-estimation/timing CSV for absolute-error summaries")
  541. args = parser.parse_args()
  542. input_dir = Path(args.input_dir)
  543. output_dir = Path(args.output_dir)
  544. ensure_dir(output_dir)
  545. figures_dir = output_dir / "figures"
  546. tables_dir = output_dir / "tables"
  547. ensure_dir(figures_dir)
  548. ensure_dir(tables_dir)
  549. data_csv = Path(args.data) if args.data else find_input_file(input_dir, "anticipatory_electrode_long_noninterp")
  550. print(f"[INFO] Using data CSV: {data_csv}")
  551. dat = pd.read_csv(data_csv)
  552. if args.metadata:
  553. meta_csv = Path(args.metadata)
  554. meta = pd.read_csv(meta_csv)
  555. print(f"[INFO] Using electrode metadata: {meta_csv}")
  556. else:
  557. try:
  558. meta_csv = find_input_file(input_dir, "electrode_metadata")
  559. meta = pd.read_csv(meta_csv)
  560. print(f"[INFO] Using electrode metadata: {meta_csv}")
  561. except Exception:
  562. print("[WARN] electrode_metadata.csv not found. Metadata will be inferred from channel names.")
  563. meta = build_meta_from_spn(dat)
  564. if args.run_qc:
  565. qc_csv = Path(args.run_qc)
  566. run_qc = pd.read_csv(qc_csv)
  567. print(f"[INFO] Using QC CSV: {qc_csv}")
  568. else:
  569. try:
  570. qc_csv = find_input_file(input_dir, "run_qc")
  571. run_qc = pd.read_csv(qc_csv)
  572. print(f"[INFO] Using QC CSV: {qc_csv}")
  573. except Exception:
  574. run_qc = None
  575. print("[WARN] run_qc.csv not found. Proceeding without QC filtering.")
  576. dat.columns = [c.strip() for c in dat.columns]
  577. meta.columns = [c.strip() for c in meta.columns]
  578. need_cols = {"Participant", "RunID", "Trial", "Condition", "Component", "Electrode", "Value_uV"}
  579. miss = need_cols - set(dat.columns)
  580. if miss:
  581. raise ValueError(f"Missing required columns in anticipatory_electrode_long_noninterp.csv: {miss}")
  582. meta = prepare_meta_safe(meta, dat)
  583. dat["Participant"] = dat["Participant"].astype(str).str.zfill(2)
  584. dat["RunID"] = dat["RunID"].astype(str).str.strip()
  585. dat["Trial"] = pd.to_numeric(dat["Trial"], errors="coerce")
  586. dat["Condition"] = (
  587. dat["Condition"]
  588. .astype(str)
  589. .str.strip()
  590. .str.lower()
  591. .replace({"noise": "in noise", "silence": "in silence"})
  592. )
  593. dat["Component"] = dat["Component"].astype(str).str.strip().str.upper()
  594. if run_qc is not None:
  595. run_qc["participant"] = run_qc["participant"].astype(str).str.zfill(2)
  596. run_qc["runID"] = run_qc["runID"].astype(str).str.strip()
  597. keep_cols = ["runID", "participant", "keep_primary", "keep_primary_participant"]
  598. keep_cols = [c for c in keep_cols if c in run_qc.columns]
  599. run_qc = run_qc[keep_cols].drop_duplicates()
  600. run_qc = run_qc.rename(columns={"runID": "RunID", "participant": "Participant"})
  601. dat = dat.merge(run_qc, on=["RunID", "Participant"], how="left")
  602. if "keep_primary" in dat.columns:
  603. dat = dat[dat["keep_primary"] == True].copy()
  604. ids = ids_with_two_levels(dat, "Participant", "Condition")
  605. dat = dat[dat["Participant"].isin(ids)].copy()
  606. print(f"[INFO] Participants in primary analysis: {dat['Participant'].nunique()}")
  607. print(f"[INFO] Runs in primary analysis : {dat['RunID'].nunique()}")
  608. print(f"[INFO] Rows : {dat.shape[0]}")
  609. spn = dat[dat["Component"] == "SPN"].copy()
  610. cnv = dat[dat["Component"] == "CNV"].copy()
  611. spn_long, spn_roi = build_roi_trial(spn, meta)
  612. cnv_long, cnv_roi = build_roi_trial(cnv, meta)
  613. formula_primary = "Mean_uV ~ Condition_c * Hem_c + C(ROI)"
  614. res_spn, kind_spn = try_fit_mixed(formula_primary, spn_roi, re="~Condition_c", group="Participant")
  615. tbl_spn = fe_table(res_spn)
  616. save_table(tbl_spn, tables_dir / "Table_Primary_SPN_ROI_Hemisphere_LMM.csv")
  617. res_cnv, kind_cnv = try_fit_mixed(formula_primary, cnv_roi, re="~Condition_c", group="Participant")
  618. tbl_cnv = fe_table(res_cnv)
  619. save_table(tbl_cnv, tables_dir / "Table_Control_CNV_ROI_Hemisphere_LMM.csv")
  620. # Reviewer-requested ROI interaction models. The central ROI remains the
  621. # reference level in the default alphabetical coding of C(ROI).
  622. formula_roi_interaction = "Mean_uV ~ Condition_c * C(ROI) + Hem_c + Condition_c:Hem_c"
  623. res_spn_roi_int, kind_spn_roi_int = try_fit_mixed(formula_roi_interaction, spn_roi, re="~Condition_c", group="Participant")
  624. tbl_spn_roi_int = fe_table(res_spn_roi_int)
  625. save_table(tbl_spn_roi_int, tables_dir / "Supp_Table_SPN_ConditionByROI_LMM.csv")
  626. res_cnv_roi_int, kind_cnv_roi_int = try_fit_mixed(formula_roi_interaction, cnv_roi, re="~Condition_c", group="Participant")
  627. tbl_cnv_roi_int = fe_table(res_cnv_roi_int)
  628. save_table(tbl_cnv_roi_int, tables_dir / "Supp_Table_CNV_ConditionByROI_LMM.csv")
  629. # BIC-approximated Bayes factors. These are supplementary sensitivity
  630. # analyses and should be described as BIC approximations, not as fully
  631. # Bayesian mixed models with explicit priors.
  632. bf_rows = []
  633. formula_no_condition = "Mean_uV ~ Hem_c + C(ROI)"
  634. formula_condition_main = "Mean_uV ~ Condition_c + Hem_c + C(ROI)"
  635. formula_primary_no_condition_terms = "Mean_uV ~ Hem_c + C(ROI)"
  636. for label, df_comp in [("SPN", spn_roi), ("CNV", cnv_roi)]:
  637. run_bic_bayes_comparison(
  638. df_comp, label,
  639. "Condition main effect only",
  640. formula_no_condition,
  641. formula_condition_main,
  642. bf_rows
  643. )
  644. run_bic_bayes_comparison(
  645. df_comp, label,
  646. "All condition-related terms in primary ROI x Hemisphere model",
  647. formula_primary_no_condition_terms,
  648. formula_primary,
  649. bf_rows
  650. )
  651. run_bic_bayes_comparison(
  652. df_comp, label,
  653. "Added Condition x ROI terms beyond primary model",
  654. formula_primary,
  655. formula_roi_interaction,
  656. bf_rows
  657. )
  658. bf_tbl = pd.DataFrame(bf_rows)
  659. save_table(bf_tbl, tables_dir / "Supp_Table_BIC_BayesFactors_ConditionTerms.csv")
  660. fit_with_sex(spn_roi, spn_long, tables_dir / "Supp_Table_SPN_LMM_withSex.csv")
  661. fit_with_sex(cnv_roi, cnv_long, tables_dir / "Supp_Table_CNV_LMM_withSex.csv")
  662. electrodewise_lmm(spn_long, tables_dir / "Supp_Table_SPN_ElectrodeWise_LMM_FDR.csv")
  663. electrodewise_lmm(cnv_long, tables_dir / "Supp_Table_CNV_ElectrodeWise_LMM_FDR.csv")
  664. mean_cond_spn = (
  665. spn_long.groupby(["Participant", "Electrode", "Condition"], as_index=False)
  666. .agg(Mean_uV=("Value_uV", "mean"))
  667. )
  668. cond_means_spn = (
  669. mean_cond_spn.groupby(["Condition", "Electrode"], as_index=False)["Mean_uV"]
  670. .mean()
  671. )
  672. # Use the same color scale for the silence and noise maps, as requested by
  673. # the reviewer. The difference map below uses its own symmetric scale.
  674. cond_vals = cond_means_spn[
  675. cond_means_spn["Condition"].isin(["in silence", "in noise"])
  676. ]["Mean_uV"].to_numpy(dtype=float)
  677. cond_lim = float(np.nanmax(np.abs(cond_vals))) if np.isfinite(cond_vals).any() else None
  678. shared_condition_vlim = (-cond_lim, cond_lim) if cond_lim and cond_lim > 0 else None
  679. make_topomap_from_condition(
  680. cond_means_spn,
  681. condition_label="in silence",
  682. title="SPN in silence (-200 to 0 ms)",
  683. out_path=figures_dir / "Fig2a_SPN_topomap_in_silence.png",
  684. vlim=shared_condition_vlim
  685. )
  686. make_topomap_from_condition(
  687. cond_means_spn,
  688. condition_label="in noise",
  689. title="SPN in noise (-200 to 0 ms)",
  690. out_path=figures_dir / "Fig2b_SPN_topomap_in_noise.png",
  691. vlim=shared_condition_vlim
  692. )
  693. pivot_diff = (
  694. mean_cond_spn.pivot_table(index=["Participant", "Electrode"], columns="Condition", values="Mean_uV")
  695. .reset_index()
  696. )
  697. if {"in silence", "in noise"}.issubset(pivot_diff.columns):
  698. pivot_diff["Mean_uV"] = pivot_diff["in noise"] - pivot_diff["in silence"]
  699. diff_mean = pivot_diff.groupby("Electrode", as_index=False)["Mean_uV"].mean()
  700. diff_mean["Condition"] = "noise_minus_silence"
  701. diff_lim = float(np.nanmax(np.abs(diff_mean["Mean_uV"].to_numpy(dtype=float))))
  702. make_topomap_from_condition(
  703. diff_mean,
  704. condition_label="noise_minus_silence",
  705. title="SPN Noise - Silence (µV)",
  706. out_path=figures_dir / "Fig2c_SPN_topomap_difference.png",
  707. vlim=(-diff_lim, diff_lim) if diff_lim > 0 else None
  708. )
  709. # Report the cluster-based permutation test because it is described in the
  710. # manuscript. Results remain exploratory/descriptive for spatial localization.
  711. cluster_permutation_difference(
  712. spn_long,
  713. tables_dir / "Supp_Table_SPN_ClusterPermutation_NoiseMinusSilence.csv",
  714. component_label="SPN",
  715. n_permutations=5000,
  716. seed=42
  717. )
  718. cluster_permutation_difference(
  719. cnv_long,
  720. tables_dir / "Supp_Table_CNV_ClusterPermutation_NoiseMinusSilence.csv",
  721. component_label="CNV",
  722. n_permutations=5000,
  723. seed=42
  724. )
  725. spn_desc = roi_desc(spn_roi)
  726. cnv_desc = roi_desc(cnv_roi)
  727. save_table(spn_desc, tables_dir / "Table_Descriptive_ROI_ByCondition_SPN.csv")
  728. save_table(cnv_desc, tables_dir / "Table_Descriptive_ROI_ByCondition_CNV.csv")
  729. plot_roi_condition_panels(
  730. spn_desc,
  731. out_path=figures_dir / "Fig3_SPN_roi_panels_main.png",
  732. roi_order=["Frontal", "Central", "Parietal", "Occipital"],
  733. title="Mean SPN by ROI and condition"
  734. )
  735. plot_roi_condition_panels(
  736. cnv_desc,
  737. out_path=figures_dir / "Supp_Fig_CNV_roi_panels.png",
  738. roi_order=["Frontal", "Central", "Parietal", "Occipital"],
  739. title="Mean CNV by ROI and condition"
  740. )
  741. notes = [
  742. "Primary SPN analysis uses NON-INTERPOLATED data.",
  743. "CNV is included as a response-locked control analysis.",
  744. "Primary inference is ROI x Hemisphere LMM without Sex covariate.",
  745. "Sex-adjusted models are supplementary only.",
  746. "Central ROI is included in ROI models and is the reference level for C(ROI).",
  747. "Reviewer-requested Condition x ROI models are saved as supplementary tables.",
  748. "BIC-approximated Bayes factors are supplementary sensitivity analyses fitted with ML.",
  749. "Cluster-based permutation tables are exploratory spatial analyses and are not used to define follow-up ROIs.",
  750. "SPN condition topomaps use a common color scale for silence and noise; the difference map uses its own symmetric scale.",
  751. "SPN topomaps are descriptive and are shown in µV."
  752. ]
  753. notes_path = output_dir / "ANALYSIS_NOTES_SPN_CNV_REVISED.txt"
  754. with open(notes_path, "w", encoding="utf-8") as f:
  755. for line in notes:
  756. f.write(line + "\n")
  757. print(f"[Saved] {notes_path}")
  758. if args.timing:
  759. timing_csv = Path(args.timing)
  760. print(f"[INFO] Using timing CSV: {timing_csv}")
  761. summarize_time_estimation(timing_csv, tables_dir, run_qc=run_qc)
  762. else:
  763. timing_csv = find_timing_file(input_dir)
  764. if timing_csv is not None:
  765. print(f"[INFO] Using timing CSV: {timing_csv}")
  766. summarize_time_estimation(timing_csv, tables_dir, run_qc=run_qc)
  767. else:
  768. print("[WARN] time-estimation/timing CSV not found. Skipping absolute-error summaries.")
  769. if args.speech:
  770. speech_csv = Path(args.speech)
  771. speech = pd.read_csv(speech_csv)
  772. print(f"[INFO] Using speech CSV: {speech_csv}")
  773. else:
  774. try:
  775. speech_csv = find_input_file(input_dir, "speech_accuracy")
  776. speech = pd.read_csv(speech_csv)
  777. print(f"[INFO] Using speech CSV: {speech_csv}")
  778. except Exception:
  779. speech = None
  780. print("[WARN] speech_accuracy.csv not found. Skipping speech-in-noise analyses.")
  781. if speech is not None:
  782. speech.columns = [c.strip() for c in speech.columns]
  783. need_speech_cols = {"Participant", "SNR_dB", "Accuracy", "n_trials", "n_correct"}
  784. miss = need_speech_cols - set(speech.columns)
  785. if miss:
  786. raise ValueError(f"Missing required columns in speech_accuracy.csv: {miss}")
  787. speech["Participant"] = (
  788. speech["Participant"]
  789. .astype(str)
  790. .str.strip()
  791. .str.replace("^P", "", regex=True)
  792. .str.zfill(2)
  793. )
  794. if run_qc is not None and "keep_primary_participant" in run_qc.columns:
  795. keep_part = run_qc[["Participant", "keep_primary_participant"]].drop_duplicates()
  796. speech = speech.merge(keep_part, on="Participant", how="left")
  797. speech = speech[speech["keep_primary_participant"] == True].copy()
  798. speech_desc = (
  799. speech.groupby("SNR_dB", as_index=False)
  800. .agg(
  801. n_participants=("Participant", "nunique"),
  802. mean_accuracy=("Accuracy", "mean"),
  803. sd_accuracy=("Accuracy", "std"),
  804. mean_n_correct=("n_correct", "mean"),
  805. sd_n_correct=("n_correct", "std"),
  806. n_trials=("n_trials", "mean")
  807. )
  808. .sort_values("SNR_dB", ascending=False)
  809. )
  810. save_table(speech_desc, tables_dir / "Table1_SpeechAccuracy_BySNR.csv")
  811. speech_0 = speech[speech["SNR_dB"] == 0].copy()
  812. speech_0 = (
  813. speech_0.groupby("Participant", as_index=False)
  814. .agg(
  815. accuracy_0dB=("Accuracy", "mean"),
  816. n_correct_0dB=("n_correct", "mean")
  817. )
  818. )
  819. speech_all = (
  820. speech.groupby("Participant", as_index=False)
  821. .agg(
  822. accuracy_allSNR=("Accuracy", "mean"),
  823. n_correct_allSNR=("n_correct", "mean")
  824. )
  825. )
  826. speech_diff = speech[speech["SNR_dB"].isin([-5, -10, -15])].copy()
  827. speech_diff = (
  828. speech_diff.groupby("Participant", as_index=False)
  829. .agg(
  830. accuracy_diffSNR=("Accuracy", "mean"),
  831. n_correct_diffSNR=("n_correct", "mean")
  832. )
  833. )
  834. speech_part = speech_all.merge(speech_0, on="Participant", how="outer")
  835. speech_part = speech_part.merge(speech_diff, on="Participant", how="outer")
  836. save_table(speech_part, tables_dir / "Supp_Table_SpeechAccuracy_ParticipantSummary.csv")
  837. spn_part = (
  838. spn_roi.groupby(["Participant", "Condition"], as_index=False)
  839. .agg(SPN_mean_uV=("Mean_uV", "mean"))
  840. )
  841. spn_wide = spn_part.pivot(index="Participant", columns="Condition", values="SPN_mean_uV").reset_index()
  842. if {"in silence", "in noise"}.issubset(spn_wide.columns):
  843. spn_wide["SPN_noise_minus_silence"] = spn_wide["in noise"] - spn_wide["in silence"]
  844. cnv_part = (
  845. cnv_roi.groupby(["Participant", "Condition"], as_index=False)
  846. .agg(CNV_mean_uV=("Mean_uV", "mean"))
  847. )
  848. cnv_wide = cnv_part.pivot(index="Participant", columns="Condition", values="CNV_mean_uV").reset_index()
  849. if {"in silence", "in noise"}.issubset(cnv_wide.columns):
  850. cnv_wide["CNV_noise_minus_silence"] = cnv_wide["in noise"] - cnv_wide["in silence"]
  851. spn_wide = spn_wide.rename(columns={"in silence": "SPN_silence", "in noise": "SPN_noise"})
  852. cnv_wide = cnv_wide.rename(columns={"in silence": "CNV_silence", "in noise": "CNV_noise"})
  853. participant_summary = speech_part.merge(spn_wide, on="Participant", how="inner")
  854. participant_summary = participant_summary.merge(cnv_wide, on="Participant", how="inner")
  855. save_table(participant_summary, tables_dir / "Supp_Table_ParticipantSummary_SPN_CNV_Speech.csv")
  856. corr_rows = []
  857. predictors = [
  858. ("accuracy_0dB", "Accuracy at 0 dB"),
  859. ("accuracy_allSNR", "Accuracy across all SNRs"),
  860. ("accuracy_diffSNR", "Accuracy at difficult SNRs")
  861. ]
  862. outcomes = [
  863. ("SPN_noise_minus_silence", "SPN noise-silence"),
  864. ("SPN_noise", "SPN in noise"),
  865. ("CNV_noise_minus_silence", "CNV noise-silence"),
  866. ("CNV_noise", "CNV in noise")
  867. ]
  868. for px, pxname in predictors:
  869. for oy, oyname in outcomes:
  870. if px in participant_summary.columns and oy in participant_summary.columns:
  871. corr_rows.append(corr_row(participant_summary, px, oy, pxname, oyname))
  872. corr_tbl = pd.DataFrame(corr_rows)
  873. save_table(corr_tbl, tables_dir / "Supp_Table_Exploratory_SpeechBrain_Relations.csv")
  874. fig, ax = plt.subplots(figsize=(4.5, 4.0))
  875. tmp = speech[speech["SNR_dB"] == 0].copy()
  876. if len(tmp) > 0:
  877. vals = tmp.groupby("Participant")["Accuracy"].mean().values
  878. rng = np.random.default_rng(42)
  879. jitter = rng.normal(0, 0.04, size=len(vals))
  880. ax.scatter(np.ones(len(vals)) + jitter, vals, color="black", alpha=0.85, s=30)
  881. mu = np.mean(vals)
  882. if len(vals) >= 2:
  883. se = np.std(vals, ddof=1) / np.sqrt(len(vals))
  884. tcrit = student_t.ppf(1 - 0.05 / 2, len(vals) - 1)
  885. ci_lo = mu - tcrit * se
  886. ci_hi = mu + tcrit * se
  887. ax.hlines(mu, 0.82, 1.18, color="black", linewidth=2.0)
  888. ax.vlines(1.0, ci_lo, ci_hi, color="black", linewidth=1.5)
  889. ax.set_xlim(0.7, 1.3)
  890. ax.set_xticks([1.0])
  891. ax.set_xticklabels(["0 dB"])
  892. ax.set_ylabel("Accuracy")
  893. ax.set_title("Speech accuracy at 0 dB SNR")
  894. ax.spines["top"].set_visible(False)
  895. ax.spines["right"].set_visible(False)
  896. save_fig(fig, figures_dir / "Supp_Fig_SpeechAccuracy_0dB.png", dpi=300, tight=True)
  897. print("\n=== Analysis complete ===")
  898. if __name__ == "__main__":
  899. main()

run_spn_cnv_analysis.py at commit 7ad7b19, under MIT · at the source

Overview

Authors: Kazuhiro Okamoto1, Kengo Hoyano1, Tomomi Nomura1, Keisuke Irie2, Naoya Obama3, Narihiro Kodama3, Yasutaka Kobayashi1
ORCID iDs: Kazuhiro Okamoto
  1. Department of Rehabilitation, Faculty of Health Science, Fukui Health Science University, Fukui, Japan
  2. Cognitive Motor Neuroscience, Department of Human Health Sciences, Graduate School of Medicine, Kyoto University, Kyoto, Japan
  3. Department of Speech and Language Therapy, Faculty of Health Rehabilitation, Kawasaki University of Medical Welfare, Kurashiki, Japan
Journal: IBRO neuroscience reports, volume 21, pages 32-41
Dates: received 23 October 2025; accepted 29 May 2026; published online 1 June 2026
Type: Research article · Language: English
License: CC BY
Identifiers: DOI 10.1016/j.ibneur.2026.05.013 · PMID 42290943 · PMCID PMC13254385 · OpenAlex W7163041632
Open access: gold, a free copy (OpenAlex)
Status: code verified
Categories: EEG (modality), human (organism), cognitive (subfield)
Methods: Preprocessing, Statistics, Smoothing, state filtering, decompositions, Evoked potentials, fMRI & imaging
Keywords: Speech-in-noise, Stimulus-preceding negativity, Contingent negative variation, EEG, Anticipatory neural activity, Linear mixed-effects model
Topic: Neuroscience and Music Perception (Cognitive Neuroscience, Neuroscience), according to OpenAlex
Funding: Japan Society for the Promotion of Science (22K17620)
Citations: not cited yet (Europe PMC); 51 references in the paper

Abstract

It remains unclear whether noisy listening reliably modulates anticipatory EEG activity before feedback and whether such activity explains interindividual differences in speech-in-noise performance. We examined these questions in an independent cohort of young adults with normal hearing using a time-estimation task with auditory feedback presented in silence and in continuous multi-talker noise. Stimulus-preceding negativity (SPN) was quantified as the mean EEG amplitude in the −200–0 ms interval before feedback onset. The primary analysis used ROI × hemisphere linear mixed-effects models. A response-locked contingent negative variation (CNV) analysis was included as a control. Speech-in-noise performance was summarized descriptively across 0, −5, −10, and −15 dB signal-to-noise ratios, and brain–behavior associations were treated as exploratory. The primary SPN analysis showed a clear posterior predominance, with more negative amplitudes at parietal and occipital than at central and frontal regions. However, the main effect of listening condition was not significant, and neither hemisphere nor the condition × hemisphere interaction reached significance. The CNV control analysis likewise showed no reliable condition effect. Speech-in-noise performance declined monotonically as the signal-to-noise ratio decreased, indicating that the independent behavioral test captured the expected effect of increasing acoustic difficulty across SNR levels. Exploratory analyses did not reveal robust associations between speech-in-noise performance and SPN or CNV measures. These findings indicate that the task elicited anticipatory slow potentials with posterior predominance, but provided limited evidence for reliable modulation by background noise under the present task parameters and sample characteristics.

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 5 matches between paragraphs and lines of code.

kokamoto46/analyze_spn

License: MIT
State: the link answers, verified on 27 September 2026
Evidence: files inventoried
Commit: 7ad7b19eb9a0b9edaddfc561731f1e5ca0f8c9e9, 1 May 2026
Languages: MATLAB (6), Python (1)
Size: 15 files, 7 scripts
Software Heritage: not archived
Found in: the text, “Data Statement”
Holds: README, license file, environment (requirements.txt), documentation
Not found: CITATION.cff, tests, continuous integration
Tools: EEGLAB (5 files), Matplotlib (1 file), MNE-Python (1 file), NumPy (1 file), pandas (1 file), SciPy (1 file), statsmodels (1 file)
Availability: 1 check, the latest on 27 September 2026: the link answers
  • 27 September 2026: the link answers
9 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;
  • 7 scripts, each with its path and the digest of its content;
  • 5 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 2, 28 September 2026

  • Authors: added Kazuhiro Okamoto (0000-0003-4553-4850); removed Kazuhiro Okamoto

Version 1, 27 September 2026: the first record

Recorded: type, language, journal, volume, pages, dates, 7 authors, 6 keywords, 1 funder, 50 references.

Cite

This paper

Okamoto, K., Hoyano, K., Nomura, T., Irie, K., Obama, N., Kodama, N., & Kobayashi, Y. (2026). Anticipatory slow potentials before auditory feedback show posterior predominance but limited condition effects in speech-in-noise. IBRO neuroscience reports, 21, 32-41. https://doi.org/10.1016/j.ibneur.2026.05.013

BibTeX

@article{okamoto2026anticipatory,
author = {Okamoto, Kazuhiro and Hoyano, Kengo and Nomura, Tomomi and Irie, Keisuke and Obama, Naoya and Kodama, Narihiro and Kobayashi, Yasutaka},
title = {{Anticipatory slow potentials before auditory feedback show posterior predominance but limited condition effects in speech-in-noise}},
journal = {IBRO neuroscience reports},
year = {2026},
month = jun,
volume = {21},
pages = {32--41},
publisher = {Elsevier},
issn = {2667-2421},
doi = {10.1016/j.ibneur.2026.05.013},
url = {https://doi.org/10.1016/j.ibneur.2026.05.013},
pmid = {42290943},
pmcid = {PMC13254385}
}

RIS

TY - JOUR
AU - Okamoto, Kazuhiro
AU - Hoyano, Kengo
AU - Nomura, Tomomi
AU - Irie, Keisuke
AU - Obama, Naoya
AU - Kodama, Narihiro
AU - Kobayashi, Yasutaka
TI - Anticipatory slow potentials before auditory feedback show posterior predominance but limited condition effects in speech-in-noise
T2 - IBRO neuroscience reports
J2 - IBRO Neurosci Rep
PY - 2026
DA - 2026/06/01
VL - 21
SP - 32
EP - 41
SN - 2667-2421
PB - Elsevier
DO - 10.1016/j.ibneur.2026.05.013
UR - https://doi.org/10.1016/j.ibneur.2026.05.013
LA - en
ER -

CSL-JSON

{
"id": "10.1016/j.ibneur.2026.05.013",
"type": "article-journal",
"title": "Anticipatory slow potentials before auditory feedback show posterior predominance but limited condition effects in speech-in-noise",
"container-title": "IBRO neuroscience reports",
"author": [
{
"family": "Okamoto",
"given": "Kazuhiro"
},
{
"family": "Hoyano",
"given": "Kengo"
},
{
"family": "Nomura",
"given": "Tomomi"
},
{
"family": "Irie",
"given": "Keisuke"
},
{
"family": "Obama",
"given": "Naoya"
},
{
"family": "Kodama",
"given": "Narihiro"
},
{
"family": "Kobayashi",
"given": "Yasutaka"
}
],
"container-title-short": "IBRO Neurosci Rep",
"volume": "21",
"page": "32-41",
"DOI": "10.1016/j.ibneur.2026.05.013",
"PMID": "42290943",
"PMCID": "PMC13254385",
"ISSN": "2667-2421",
"publisher": "Elsevier",
"URL": "https://doi.org/10.1016/j.ibneur.2026.05.013",
"language": "en",
"issued": {
"date-parts": [
[
2026,
6,
1
]
]
}
}

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/s42003-026-10394-7 [code]
Cognitive load weakens neural speech tracking without altering response timing.
Journal: Communications biology
In common: MNE-Python, statsmodels, pandas, 3 other tools, EEG, cognitive, 4 references
[2] doi:10.1097/j.pain.0000000000004044 [code]
No effect of rhythmic visual stimulation on experimental pain perception.
Journal: Pain
In common: MNE-Python, statsmodels, pandas, 3 other tools, EEG, cognitive, 4 references
[3] doi:10.1162/imag.a.105 [code]
Right posterior theta reflects human parahippocampal phase resetting by salient cues during goal-directed navigation
Journal: n/a
In common: EEGLAB, MNE-Python, statsmodels, 4 other tools, EEG, cognitive, 2 references
[4] doi:10.1038/s42003-026-10108-z [code]
Time-resolved EEG decoding reveals altered neural dynamics of affective semantic evaluation in depression and suicidality.
Journal: Communications biology
In common: EEGLAB, MNE-Python, statsmodels, 4 other tools, EEG, cognitive, 1 reference
[5] doi:10.1093/cercor/bhag077 [code]
The longitudinal development of intrinsic timescales in infancy and their relation to alpha brain rhythm.
Journal: Cerebral cortex (New York, N.Y. : 1991)
In common: EEGLAB, MNE-Python, statsmodels, 4 other tools, EEG, 1 reference
[6] doi:10.1002/mds.70348 [code]
Electroencephalography-Based Clustering Reveals Robust Neurophysiological Subtypes in Parkinson's Disease.
Journal: Movement disorders : official journal of the Movement Disorder Society
In common: EEGLAB, MNE-Python, statsmodels, 4 other tools, EEG, 1 reference
[7] doi:10.3390/s26134019 [code]
NeuroStat: An Open-Source EEG Connectivity Platform for Randomised Controlled Trials.
Journal: Sensors (Basel, Switzerland)
In common: MNE-Python, pandas, SciPy, 2 other tools, EEG, 3 references
[8] doi:10.7554/elife.107088 [code]
Development of auditory and spontaneous movement responses to music over the first postnatal year.
Journal: eLife
In common: EEGLAB, MNE-Python, pandas, 3 other tools, EEG, 2 references
[9] doi:10.1038/s41467-026-74824-0 [code]
Learning regularities in noise engages both neural predictive activity and representational changes.
Journal: Nature communications
In common: MNE-Python, statsmodels, pandas, 3 other tools, cognitive, 2 references
[10] doi:10.1111/ejn.70503
Modulation of Predictive Coding in Auditory Paradigms of Varying Complexity in Children With Developmental Language Disorder.
Journal: The European journal of neuroscience
In common: EEG, cognitive, 5 references

Contribute

The authors of this paper can claim it, correct its record and validate its tracing map, and the maintainers of its code (its owner, or a public member of its organization) correct what it says of their repository; anyone signed in can ask for its removal. Every request goes to OSCR's own machine, which answers it; your account page follows them.

Sign in with ORCID to claim this paper as one of its authors, correct its record or validate its tracing map: when the paper's metadata lists your ORCID iD, you are recognized at once. Maintainers of its code: sign in with GitHub, then claim the repository on your account page.

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.