OSCR

Demographics-robust spontaneous eye blinking slowing in patients with severe acquired brain injury.

Code ↔ Paper

6 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 6 matches
  1. [1] § Methods and materials › EOG blink waveform processing ↔ src/eogtools/eog.py, lines 51–145 · score 0.74 · filtered signal, EOG signal, MNE, rejected, log, threshold
  2. [2] § Methods and materials › EOG blink waveform processing ↔ scripts/feature_stats.py, lines 97–147 · score 0.72 · standard deviation, blink interval variability, overlap, logarithm, onset, LIBIV
  3. [3] § Methods and materials › Statistical analysis ↔ scripts/feature_stats.py, lines 291–365 · score 0.69 · Wilcoxon signed rank, Kruskal Wallis, post hoc, eMCS, Holm, pDoC
  4. [4] § Results › Blink features ↔ scripts/feature_stats.py, lines 291–365 · score 0.66 · Wilcoxon signed rank, Kruskal Wallis, Post hoc, eMCS, KW, Holm
  5. [5] § Methods and materials › Statistical analysis ↔ src/utils/features.py, lines 915–1064 · score 0.64 · multivariate logistic regression, logistic regression models, predictor, binary, fitted, HC
  6. [6] § Methods and materials › Statistical analysis ↔ scripts/demographics_data.py, lines 225–242 · score 0.63 · Mann Whitney, Kruskal Wallis, eMCS, pDoC, Demographic, age

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 · 651 lines · 29 KB · MIT · 3 matches

  1. import os
  2. import re
  3. import traceback
  4. from collections import namedtuple
  5. import matplotlib.pyplot as plt
  6. import numpy as np
  7. import pandas as pd
  8. import yaml
  9. from dotenv import load_dotenv
  10. from utils.features import (
  11. aggregate_features,
  12. annotate_panels,
  13. export_significant_html_table,
  14. libiv,
  15. logreg_covariate,
  16. plot_logreg_effects,
  17. merge_and_check_features,
  18. plot_features_summary,
  19. plot_recording_durations,
  20. run_stat_tests_ncheck,
  21. run_stat_tests_wilcoxon,
  22. waveforms_plot,
  23. )
  24. #%% Load opt and environment variables
  25. load_dotenv() # Load environment variables from a .env file
  26. results_dir = os.getenv('RESULTS_DIR', './results')
  27. data_dir = os.getenv('DATA_DIR', './data')
  28. config_path = os.getenv('CONFIG_PATH', './configs/opt.yml')
  29. # Load options from YAML configuration file
  30. with open(os.path.abspath(config_path)) as file:
  31. opt = yaml.safe_load(file)
  32. opt = namedtuple('opt', opt.keys())(*opt.values()) # dict2namedtuple
  33. print(f"\nLoaded options from {config_path}:")
  34. _ = [print(f" - {k}: {v}") for k, v in opt._asdict().items()]
  35. def nonagg_EBR(grouped, start_time, end_time):
  36. """
  37. Compute the Eye Blink Rate (EBR) for each group in the grouped DataFrame.
  38. EBR is defined as the number of blinks per minute.
  39. """
  40. all_dfs = []
  41. for (grp,cnd, sbj), gdf in grouped:
  42. #print(grp, cnd,sbj,gdf.shape)
  43. base = f"sub-{sbj}_task-{cnd.upper()}_eog"
  44. to_load = f"{os.path.dirname(__file__)}/../data/sub-{sbj}/eog/{base}.edf"
  45. from mne.io import read_raw_edf
  46. raw = read_raw_edf(to_load,verbose='ERROR')
  47. remove_movimento_s = 0
  48. for ann in raw.annotations:
  49. if ann['description'] == 'Movimento':
  50. # Find the preceding annotation that is either 'Movimento' or 'OHMETER MEASURE'
  51. prev_ann = None
  52. for candidate in reversed([a for a in raw.annotations if a['onset'] < ann['onset']]):
  53. if candidate['description'] in ['Movimento', 'OHMETER MEASURE']:
  54. prev_ann = candidate
  55. break
  56. if prev_ann:
  57. # Calculate the delta with the preceding annotation's onset + duration
  58. delta_prev_ann = ann['onset'] - (prev_ann['onset'] + prev_ann.get('duration', 0))
  59. else:
  60. # If no preceding annotation, calculate delta with the start
  61. delta_prev_ann = ann['onset']
  62. # Add the minimum of 10 seconds or the calculated delta
  63. remove_movimento_s += min(10, max(0, delta_prev_ann))
  64. elif ann['description'] == 'OHMETER MEASURE':
  65. # Add the duration of the OHMETER MEASURE annotation
  66. remove_movimento_s += ann['duration']
  67. else:
  68. continue
  69. T_min_nocorr = ( (end_time
  70. -start_time)
  71. / 60)
  72. T_min_w_corr = ( (min(end_time, max(raw.times))
  73. -max(start_time, min(raw.times))
  74. -remove_movimento_s)
  75. / 60 )
  76. ebr_nocorr = gdf.shape[0] / T_min_nocorr
  77. ebr_w_corr = gdf.shape[0] / T_min_w_corr
  78. print(f"[{os.path.basename(to_load)}] - ({min(raw.times):.0f},{max(raw.times):.0f}) out of ({start_time},{end_time}) - {remove_movimento_s}; corr: {ebr_nocorr:.1f} >> {ebr_w_corr}")
  79. all_dfs.append(pd.DataFrame({'Group':grp,'Condition':cnd,'Subject':sbj,'EBR':ebr_w_corr},index=[0]))
  80. return pd.concat(all_dfs, ignore_index=True)
  81. def nonagg_libiv(grouped, start_time, end_time):
  82. """
  83. Compute the Inter-Blink Interval Variability (IBIV) for each group in the grouped DataFrame.
  84. IBIV is defined as the standard deviation of the logarithm of inter-blink intervals.
  85. """
  86. # libiv use bb interval from grouped; we need to remove bb intervals that
  87. # overlap with artifacts such as:
  88. # Movimento.onset-10:Movimento.onset
  89. # OHMETER.onset:OHMETER.onset+OHMETER.duration
  90. def _disjoint(a,b,strict=False):
  91. a, b = sorted(a), sorted(b)
  92. if strict:
  93. return a[1] < b[0] or b[1] < a[0]
  94. else:
  95. return a[1] <= b[0] or b[1] <= a[0]
  96. all_dfs = []
  97. for (grp,cnd, sbj), gdf in grouped:
  98. libiv_no_corr = libiv(gdf.copy())
  99. #print(grp, cnd,sbj,gdf.shape)
  100. base = f"sub-{sbj}_task-{cnd.upper()}_eog"
  101. to_load = f"{os.path.dirname(__file__)}/../data/sub-{sbj}/eog/{base}.edf"
  102. from mne.io import read_raw_edf
  103. raw = read_raw_edf(to_load,verbose='ERROR')
  104. for ann in raw.annotations:
  105. if ann['description'] == 'Movimento':
  106. a = (ann['onset'] - 10, ann['onset'])
  107. def bb_int(row):
  108. if np.isnan(row['BB']):
  109. return (row['Time'], row['Time'])
  110. else:
  111. return (row['Time'] - row['BB'], row['Time'])
  112. # filter rows of gdf
  113. gdf = gdf[gdf.apply(lambda row: _disjoint(a, bb_int(row)),
  114. axis=1)]
  115. elif ann['description'] == 'OHMETER MEASURE':
  116. a = (ann['onset'], ann['onset'] + ann['duration'])
  117. # filter rows of gdf
  118. gdf = gdf[gdf.apply(lambda row: _disjoint(a, bb_int(row)),
  119. axis=1)]
  120. else:
  121. continue
  122. libiv_w_corr = libiv(gdf)
  123. print(f"[{os.path.basename(to_load)}] - libiv corr from {libiv_no_corr} to {libiv_w_corr}")
  124. all_dfs.append(pd.DataFrame({'Group':grp,'Condition':cnd,'Subject':sbj,'LIBIV':libiv_w_corr},index=[0]))
  125. return pd.concat(all_dfs, ignore_index=True)
  126. #%% Main
  127. if __name__ == '__main__':
  128. demographics_df = pd.read_csv(os.path.join(data_dir, "demographics.csv"),dtype={"Subject": str})
  129. subject2group = demographics_df.copy().set_index("Subject")["Group"].to_dict()
  130. print(f"Working on output folder: {results_dir}")
  131. os.makedirs(results_dir, exist_ok=True)
  132. print("Checking record durations")
  133. fig = plot_recording_durations(data_dir, subject2group=subject2group,
  134. palette=opt.palette)
  135. #plt.show(block=False)
  136. fig.savefig(os.path.join(results_dir,'recording_duration.svg'))
  137. print("Merging and checking features")
  138. data = merge_and_check_features(os.path.join(results_dir,
  139. 'eog\\*_features.csv'),
  140. subject2group=subject2group,
  141. filter_similarity=opt.filter_similarity)
  142. print("Visualizing waveforms")
  143. fig = waveforms_plot(os.path.join(results_dir,
  144. 'eog\\*_waveforms.csv'),
  145. subject2group=subject2group,
  146. palette=opt.palette,
  147. filter_similarity=opt.filter_similarity
  148. )
  149. #plt.show(block=False)
  150. fig.savefig(os.path.join(results_dir,'waveforms.svg'))
  151. print('Extracting aggregated features for each subject')
  152. window_definitions = {"6mins": {'Resting': (30,30+6*60), 'Oddball': (30,30+6*60)}}
  153. features_agg_funcs = {
  154. "Mean Amplitude (µV)": ('Amplitude', 'mean'),
  155. "Mean Duration (ms)": ('Duration', 'mean'),
  156. "Mean Rise Time (ms)": ('Rise', 'mean'),
  157. "Mean Fall Time (ms)": ('Fall', 'mean'),
  158. }
  159. features_nonagg_funcs = {
  160. "EBR (blink/min)": nonagg_EBR,
  161. "LIBIV": nonagg_libiv,
  162. }
  163. # first time column 0 for resting, first stimulation for oddball
  164. records = []
  165. for sub in data['Subject'].unique():
  166. for cond in data['Condition'].unique():
  167. if cond.upper() == 'RESTING':
  168. first_time = 0
  169. elif cond.upper() == 'ODDBALL':
  170. stim_path = os.path.join(results_dir,"STIM", f"sub-{sub}_task-{cond}_eog_stims.csv")
  171. try:
  172. first_time = float(pd.read_csv(stim_path)['onset'].iloc[0])
  173. except Exception as e:
  174. print(f"Failed reading stim file for {sub} at "
  175. f"{stim_path}: {e}.")
  176. first_time = np.nan
  177. records.append({'Subject': sub, 'Condition': cond, 'FirstTime': first_time})
  178. first_time_df = pd.DataFrame.from_records(records)
  179. data = data.merge(first_time_df, on=['Subject','Condition'], how='left')
  180. dagg = [aggregate_features(data.query(f"Condition == '{cond}'"),
  181. window_name=name,
  182. time_bounds=bounds[cond],
  183. first_time_col='FirstTime',
  184. groupby_columns=['Group','Condition','Subject'],
  185. bound_window_on=['Time'],
  186. aggregate_funcs=features_agg_funcs,
  187. nonaggregate_funcs=features_nonagg_funcs
  188. )
  189. for name, bounds in window_definitions.items()
  190. for cond in bounds]
  191. dagg = pd.concat(dagg, ignore_index=True)
  192. # Keep only the 6mins window and remove Window column
  193. dagg = dagg[dagg['Window'] == '6mins'].drop(columns=['Window']).reset_index(drop=True)
  194. print("Imputation of missing data")
  195. # Imputation of missing values by group, subject, condition, window
  196. how_many_nans = dagg.isna().sum().sum()
  197. # Report data to be imputed
  198. for col in dagg.columns:
  199. if dagg[col].isna().any():
  200. nan_rows = dagg[dagg[col].isna()]
  201. for ridx, _ in nan_rows.iterrows():
  202. print(f"{pd.DataFrame(dagg.loc[ridx,['Group','Subject','Condition','Window',col]]).T}")
  203. # Impute with median stratifying by group, condition and window
  204. for col2impute in [col for col in dagg.columns if col.startswith('std_')]:
  205. # Impute missing values in the column col2impute
  206. dagg[col2impute] = dagg.groupby(['Group', 'Condition', 'Window'])[col2impute].transform(
  207. lambda x: x.fillna(x.median()))
  208. print(f"Imputed {how_many_nans} NaN values, remaining NaNs: {dagg.isna().sum().sum()}")
  209. dagg['Group'] = pd.Categorical(dagg['Group'], categories=opt.palette.keys(), ordered=True)
  210. dagg = dagg.sort_values(by=['Group', 'Subject', 'Condition'],
  211. ascending=[True, True, False])
  212. # sort columns
  213. first_cols = ['Group', 'Subject', 'Condition']
  214. dagg = dagg[ first_cols +
  215. # with EBR and LIBIV first if present
  216. [col for col in dagg.columns
  217. if col not in first_cols and
  218. col.startswith(('EBR (blink/min)', 'LIBIV'))] +
  219. # then the rest
  220. [col for col in dagg.columns
  221. if col not in first_cols and
  222. not col.startswith(('EBR (blink/min)', 'LIBIV'))]
  223. ]
  224. dagg.to_csv(os.path.join(results_dir, 'aggregated_features.csv'),
  225. index=False)
  226. print(f'Aggregated features, saved in {os.path.join(results_dir, "aggregated_features.csv")}:')
  227. print(dagg)
  228. # Join aggregated results with demographics
  229. dagg_and_demo = dagg.merge(demographics_df, on=['Group', 'Subject'], how='left')
  230. if 'Window' in dagg_and_demo.columns:
  231. dagg_and_demo = dagg_and_demo.drop(columns=['Window'])
  232. print(f"Results with demographics: {dagg_and_demo.shape[0]} rows, {dagg_and_demo.shape[1]} columns")
  233. # Save joined results
  234. out_path = os.path.join(results_dir, 'aggregated_features_with_demographics.csv')
  235. dagg_and_demo.to_csv(out_path, index=False)
  236. print(f"Saved: {out_path}")
  237. # Fold conditions into columns
  238. demo_cols = [c for c in demographics_df.columns if c in dagg_and_demo.columns]
  239. value_cols = [c for c in dagg_and_demo.columns if c not in set(['Group', 'Subject', 'Condition']) | set(demo_cols)]
  240. dagg_and_demo_folded = (
  241. dagg_and_demo.copy().fillna(-1, inplace = False) # needed to pivot
  242. .pivot_table(index=demo_cols, columns='Condition', values=value_cols, aggfunc='first')
  243. .reset_index()
  244. )
  245. dagg_and_demo_folded.replace(-1, np.nan, inplace=True)
  246. # Flatten MultiIndex columns like "<Condition> <Feature>"
  247. new_cols = []
  248. for col in dagg_and_demo_folded.columns:
  249. if isinstance(col, tuple):
  250. feat, cond = col
  251. new_cols.append(f"{cond} {feat}".strip())
  252. else:
  253. new_cols.append(col)
  254. dagg_and_demo_folded.columns = new_cols
  255. # Revert placeholder demographics back to NaN
  256. dagg_and_demo_folded[demo_cols] = dagg_and_demo_folded[demo_cols].replace(-1, pd.NA)
  257. print(f"Results with demographics and folded conditions: {dagg_and_demo_folded.shape[0]} rows, {dagg_and_demo_folded.shape[1]} columns")
  258. # Save folded results
  259. out_path_folded = os.path.join(results_dir, 'aggregated_features_folded_with_demographics.csv')
  260. dagg_and_demo_folded.to_csv(out_path_folded, index=False)
  261. print(f"Saved: {out_path_folded}")
  262. group_stats_df = run_stat_tests_ncheck(dagg,
  263. condition_col='Condition',
  264. group_col='Group',
  265. alpha=0.05,
  266. p_adjust='holm',
  267. group_comparisons=[('HC', 'eMCS'),
  268. ('eMCS', 'pDoC'),
  269. ('HC', 'pDoC')])
  270. print('\nStatistical test results:\n')
  271. group_stats_df = group_stats_df.loc[:, [col for col in group_stats_df.columns
  272. if col[1] != 'Statistic']]
  273. group_stats_df = group_stats_df.applymap(lambda x: round(x, 4)
  274. if isinstance(x, float) else x)
  275. cols_mdn_iqr_nrm = [c for c in group_stats_df.columns if c[0].startswith('Median')]
  276. group_stats_df_mdn_iqr_nrm = group_stats_df[cols_mdn_iqr_nrm]
  277. print("Median, IQR, and Normality test results:\n")
  278. print(group_stats_df_mdn_iqr_nrm.to_csv(sep=';'))
  279. cols_kruskal_ph = [c for c in group_stats_df.columns if not c[0].startswith('Median') and not c[0].startswith('Normality')]
  280. group_stats_df_kruskal_ph = group_stats_df[cols_kruskal_ph]
  281. print("Kruskal-Wallis and Conover post-hoc test results:\n")
  282. group_stats_df_kruskal_ph = group_stats_df_kruskal_ph.applymap(lambda x: round(x, 2) if isinstance(x, float) else x)
  283. print(group_stats_df_kruskal_ph.to_csv(sep=';'))
  284. export_significant_html_table(group_stats_df,
  285. os.path.join(results_dir, 'KW-Dunn_test.html'),
  286. hlines=[4,6,8,10,12],
  287. vlines=[])
  288. try:
  289. wilcoxon_stats_df = run_stat_tests_wilcoxon(dagg,
  290. split_by='Group',
  291. pair_by='Condition')
  292. print("\nWilcoxon Signed-Rank Test Results:\n")
  293. wilcoxon_stats_df.index.name = None
  294. wilcoxon_stats_df = wilcoxon_stats_df.applymap(lambda x: round(x, 2)
  295. if isinstance(x, float) else x)
  296. print(wilcoxon_stats_df.to_csv(sep=';'))
  297. except Exception as e:
  298. print(f"Error occurred while processing Wilcoxon test: {e}")
  299. print(traceback.format_exc())
  300. plot_layout = np.array([
  301. [('6mins', 'EBR (blink/min)'), ('6mins', 'LIBIV'), ('6mins', 'Mean Amplitude (µV)'),
  302. ],
  303. [ ('6mins', 'Mean Duration (ms)'), ('6mins', 'Mean Rise Time (ms)'), ('6mins', 'Mean Fall Time (ms)')
  304. ],
  305. ])
  306. # Plot the features summary for each group and condition
  307. plot_features_summary(dagg.query("Condition == 'Resting' or Condition == 'Oddball'"),
  308. plot_layout,
  309. palette=opt.palette)
  310. # hack for duration fall time and rise time axes
  311. plt.gcf().get_axes()[-1].sharey(plt.gcf().get_axes()[-2]) # rise and fall time share y with duration
  312. plt.gcf().set_size_inches(7.7, 5.5)
  313. [ax.set_title('') for ax in plt.gcf().get_axes()]
  314. # Add statistical annotations to the boxplots
  315. from statannotations.Annotator import Annotator
  316. # Define pairs for comparisons
  317. pairs = [
  318. (("HC", "Resting"), ("eMCS", "Resting")),
  319. (("HC", "Resting"), ("pDoC", "Resting")),
  320. (("eMCS", "Resting"), ("pDoC", "Resting")),
  321. (("HC", "Oddball"), ("eMCS", "Oddball")),
  322. (("HC", "Oddball"), ("pDoC", "Oddball")),
  323. (("eMCS", "Oddball"), ("pDoC", "Oddball")),
  324. ]
  325. # Add annotations to the features summary plot
  326. fig = plt.gcf()
  327. axes = fig.get_axes()
  328. for ax, (window, feature) in zip(axes, plot_layout.reshape(-1, 2), strict=True):
  329. if window is None or feature is None:
  330. continue
  331. # Filter data for the current feature and window
  332. data_to_annotate = dagg[['Group', 'Condition', feature]]
  333. data_to_annotate = data_to_annotate.rename(columns={feature: "Value"})
  334. # Initialize Annotator
  335. annotator = Annotator(
  336. ax=ax,
  337. pairs=pairs,
  338. data=data_to_annotate,
  339. x="Group",
  340. hue="Condition",
  341. y="Value",
  342. order=["HC", "eMCS", "pDoC"],
  343. hue_order=["Resting", "Oddball"],
  344. perform_stat_test=False, # We will set p-values manually
  345. verbose=False,
  346. )
  347. # Get post-hoc test results
  348. custom_pvalues = []
  349. for pair in pairs:
  350. group1, condition1 = pair[0]
  351. group2, condition2 = pair[1]
  352. feature_name = feature.split('[')[0].strip()
  353. # Extract the p-value
  354. try:
  355. p_value = group_stats_df.loc[(feature_name, condition1), (f"Conover", f"{group1} vs. {group2}")]
  356. if isinstance(p_value, str):
  357. match = re.findall(r'(0\.\d+)', p_value)
  358. p_value = float(match[1]) if match else np.nan# take the adjusted one
  359. else:
  360. p_value = np.nan # If the p-value is not found, set it to NaN
  361. except KeyError:
  362. p_value = np.nan # If the p-value is not found, set it to NaN
  363. if pd.isna(p_value) or p_value == '':
  364. p_value = 1
  365. # Add p-value to the list
  366. custom_pvalues.append(p_value)
  367. # Ensure the number of custom_pvalues matches the number of pairs
  368. if len(custom_pvalues) != len(pairs):
  369. raise ValueError("Mismatch between the number of pairs and custom p-values.")
  370. # Customize annotation format
  371. # Ensure the annotator object is properly initialized
  372. if not hasattr(annotator, "configure"):
  373. raise AttributeError("The annotator object is not properly initialized. Ensure it is created with a valid plotter.")
  374. # Configure the annotator with the correct parameters
  375. annotator.configure(
  376. text_format="star",
  377. loc="inside",
  378. hide_non_significant=True,
  379. )
  380. print(f"Adding annotations: { {pair: p
  381. for pair, p in zip(pairs, custom_pvalues, strict=True) } }")
  382. # Apply the p-values and annotate
  383. if custom_pvalues:
  384. annotator.set_pvalues_and_annotate(custom_pvalues)
  385. else:
  386. raise ValueError("Custom p-values are missing. Ensure they are calculated and passed correctly.")
  387. annotate_panels(plt.gcf().get_axes(),xy =(-0.35, 1.1))
  388. plt.tight_layout()
  389. plt.savefig(os.path.abspath(os.path.join(results_dir,'features_boxplot.svg')))
  390. plt.show(block=False)
  391. #'''
  392. # Logistic regression with age/sex covariates
  393. try:
  394. # Work on merged features + demographics
  395. df_lr = dagg_and_demo.copy()
  396. # Find and standardize age/sex columns
  397. def _find_col(df, names):
  398. for n in names:
  399. if n in df.columns:
  400. return n
  401. for c in df.columns:
  402. if c.lower() in {n.lower() for n in names}:
  403. return c
  404. return None
  405. age_src = _find_col(df_lr, ['age', 'Age', 'AGE'])
  406. sex_src = _find_col(df_lr, ['sex', 'Sex', 'SEX', 'Gender', 'gender'])
  407. if age_src is None or sex_src is None:
  408. print("Age/sex columns not found in demographics. Skipping logistic regression.")
  409. else:
  410. # Normalize age to numeric
  411. df_lr['age'] = pd.to_numeric(df_lr[age_src], errors='coerce')
  412. # Normalize sex to numeric (0/1), try common string encodings first
  413. if df_lr[sex_src].dtype == object:
  414. sex_map = {
  415. 'm': 1, 'male': 1, 'man': 1, 'maschio': 1,
  416. 'f': 0, 'female': 0, 'woman': 0, 'femmina': 0
  417. }
  418. sex_norm = (
  419. df_lr[sex_src].astype(str).str.strip().str.lower().map(sex_map)
  420. )
  421. # Fallback: attempt numeric coercion
  422. sex_norm = sex_norm.where(~sex_norm.isna(),
  423. pd.to_numeric(df_lr[sex_src], errors='coerce'))
  424. df_lr['sex'] = sex_norm
  425. else:
  426. df_lr['sex'] = pd.to_numeric(df_lr[sex_src], errors='coerce')
  427. # Ensure required columns
  428. features2screen = [
  429. 'EBR (blink/min)',
  430. 'LIBIV',
  431. 'Mean Amplitude (µV)',
  432. 'Mean Duration (ms)',
  433. 'Mean Rise Time (ms)',
  434. 'Mean Fall Time (ms)'
  435. ]
  436. features2screen = [f for f in features2screen if f in df_lr.columns]
  437. if not features2screen:
  438. print("No matching features found for logistic regression. Skipping.")
  439. else:
  440. # Run per condition
  441. def _run_condition(cond):
  442. sub = df_lr.query("Condition == @cond").copy()
  443. # Drop rows missing essential covariates or target
  444. sub = sub.dropna(subset=['age', 'sex', 'Group'])
  445. if sub.empty:
  446. return None
  447. dfs = []
  448. for feat in features2screen:
  449. sub_feat = sub.dropna(subset=[feat])
  450. if sub_feat.empty:
  451. continue
  452. try:
  453. res = logreg_covariate(sub_feat, feat, ['age', 'sex'], 'Group',
  454. verbose=True, plot=False)
  455. figro = plot_logreg_effects(res, feat, ['age','sex'],'Group', sub,
  456. group2col = {g:d['Resting']
  457. for g,d in opt.palette.items()},
  458. show_prob=False
  459. )
  460. figmp = plot_logreg_effects(res, feat, ['age','sex'],'Group', sub,
  461. group2col = {g:d['Resting']
  462. for g,d in opt.palette.items()},
  463. show_prob=True
  464. )
  465. os.makedirs(results_dir+'/logreg_plots/', exist_ok=True)
  466. # save relative odds fig
  467. featpath = feat.replace(" (blink/min)", "").replace(" ","_")
  468. figro.savefig(os.path.join(results_dir+'/logreg_plots/',
  469. f'logreg_{cond}_{featpath}_odds_ratio.svg'))
  470. # save mapped probabilities fig
  471. figmp.savefig(os.path.join(results_dir+'/logreg_plots/',
  472. f'logreg_{cond}_{featpath}_mapped_probabilities.svg'))
  473. #plt.show(block=True)
  474. # Attach feature name to index if needed
  475. if isinstance(res, pd.Series):
  476. res = res.to_frame().T
  477. res.index = pd.Index([feat], name='Feature')
  478. dfs.append(res)
  479. except Exception as _e:
  480. print(f"logreg failed for {cond} / {feat}: {_e}")
  481. if not dfs:
  482. return None
  483. out = pd.concat(dfs, axis=0, ignore_index=False)
  484. out['Condition'] = cond
  485. return out
  486. lr_age_df_resting = _run_condition('Resting')
  487. lr_age_df_oddball = _run_condition('Oddball')
  488. frames = [d for d in [lr_age_df_resting, lr_age_df_oddball] if d is not None]
  489. if not frames:
  490. print("No logistic regression output produced.")
  491. else:
  492. lr_age_df = pd.concat(frames, axis=0, ignore_index=False)
  493. # Formatting table
  494. req_cols = {
  495. 'feat_OR', 'feat_CI_lower', 'feat_CI_upper',
  496. 'feat_p-value', 'feat_R2_Nagelkerke', 'feat_AUC',
  497. 'feat_corrected_OR', 'feat_corrected_CI_lower', 'feat_corrected_CI_upper',
  498. 'feat_corrected_p-value', 'feat_corrected_R2_Nagelkerke', 'feat_corrected_AUC'
  499. }
  500. if req_cols.issubset(set(lr_age_df.columns)):
  501. lr_age_df_fmt = lr_age_df.round(4).assign(
  502. Uncorrected=lr_age_df['feat_OR'].round(4).astype(str) + " (" +
  503. lr_age_df['feat_CI_lower'].round(4).astype(str) + ", " +
  504. lr_age_df['feat_CI_upper'].round(4).astype(str) + ")",
  505. Corrected=lr_age_df['feat_corrected_OR'].round(4).astype(str) + " (" +
  506. lr_age_df['feat_corrected_CI_lower'].round(4).astype(str) + ", " +
  507. lr_age_df['feat_corrected_CI_upper'].round(4).astype(str) + ")"
  508. )
  509. lr_age_df_fmt = lr_age_df_fmt[['Condition',
  510. 'Uncorrected', 'feat_p-value',
  511. 'feat_R2_Nagelkerke', 'feat_AUC',
  512. 'Corrected', 'feat_corrected_p-value',
  513. 'feat_corrected_R2_Nagelkerke', 'feat_corrected_AUC']]
  514. # Prepare MultiIndex table
  515. lr_age_df_midx = lr_age_df_fmt.copy().reset_index(names=['Feature'])
  516. featorder = [feat.split(' [')[0] for feat in features2screen]
  517. # Enforce sorting by Feature (using features2screen order) and then by Condition
  518. lr_age_df_midx['Feature'] = pd.Categorical(lr_age_df_midx['Feature'],
  519. categories=featorder,
  520. ordered=True)
  521. lr_age_df_midx.sort_values(by=['Feature', 'Condition'],
  522. ascending=[True, False],
  523. inplace=True)
  524. lr_age_df_midx = lr_age_df_midx.set_index(
  525. pd.MultiIndex.from_arrays(
  526. [lr_age_df_midx['Feature'].values,
  527. lr_age_df_midx['Condition'].values],
  528. names=['Feature', 'Condition']
  529. )
  530. ).drop(columns=['Feature', 'Condition'])
  531. lr_age_df_midx.columns = pd.MultiIndex.from_tuples([
  532. ('Uncorrected', 'OR, median (CI 95%)'),
  533. ('Uncorrected', 'p'),
  534. ('Uncorrected', 'Nagelkerke R2'),
  535. ('Uncorrected', 'AUC'),
  536. ('Corrected', 'OR, median (CI 95%)'),
  537. ('Corrected', 'p'),
  538. ('Corrected', 'Nagelkerke R2'),
  539. ('Corrected', 'AUC')
  540. ])
  541. print("Logistic regression results:")
  542. print(lr_age_df_midx.to_csv(sep=';'))
  543. # Save
  544. out_csv = os.path.join(results_dir, 'logreg_covariate_results.csv')
  545. lr_age_df_midx.to_csv(out_csv)
  546. print(f"Saved: {out_csv}")
  547. else:
  548. # Fallback: save raw output
  549. print("Unexpected columns from logreg_covariate; saving raw output.")
  550. out_csv = os.path.join(results_dir, 'logreg_covariate_results_raw.csv')
  551. lr_age_df.to_csv(out_csv)
  552. print(f"Saved: {out_csv}")
  553. except Exception as e:
  554. print(f"Error during logistic regression analysis: {e}")
  555. print(traceback.format_exc())

feature_stats.py at commit e3894ca, under MIT · at the source

Overview

  1. IRCCS Fondazione Don Carlo Gnocchi ETS, Via di Scandicci 269, 50143 Firenze, FI, Italy
  2. The BioRobotics Institute, Scuola Superiore Sant’Anna, Viale Rinaldo Piaggio, 34, 56025 Pontedera PI, Italy
Institutions: Scuola Superiore Sant'Anna (Italy)
Journal: Neuroscience of consciousness, volume 2026, issue 1, article niag043
Dates: received 25 November 2025; accepted 2 July 2026; published online 12 August 2026
Type: Research article · Language: English
License: CC BY
Identifiers: DOI 10.1093/nc/niag043 · PMID 42592187 · PMCID PMC13464740 · OpenAlex W7202297577
Open access: gold, a free copy (OpenAlex)
Status: code verified
Categories: human (organism), other condition (population), clinical / translational (subfield)
Methods: Spectral & time-frequency, Connectivity, Statistics, Machine learning, Preprocessing, Smoothing, state filtering, decompositions, Physiology & signal measures
Keywords: severe acquired brain injury, disorders of consciousness, eye blink, electro-oculography, rehabilitation
Topic: Vestibular and auditory disorders (Neurology, Neuroscience), according to OpenAlex
Funding: Ministero della Salute (H43C21000180001)
Citations: not cited yet (Europe PMC); 41 references in the paper

Abstract

This study aimed to explore whether spontaneous eye blinking features from a brief vertical electro-oculogram recording can distinguish patients with severe acquired brain injury from healthy volunteers. Given evidence for shared neurobiological substrates in blinking control and attention, we expected altered blinking features in patients. In this observational cross-sectional study we extracted eye blink rate, amplitude, rise time, fall time, duration, and blink-blink interval variability from electro-oculography in 19 patients with severe acquired brain injury and 21 healthy controls during resting state and an auditory oddball task. We tested group differences with Kruskal-Wallis and Conover-Iman post-hoc comparisons. Differences covarying age and sex were assessed with Wald tests for odds ratios from logistic regression. Univariate Kruskal-Wallis testing showed longer rise time and duration and smaller amplitude in patients relative to healthy controls. When considering demographics as covariate, we show that amplitude is strongly confounded by demographics whereas rise time, duration and, to a lesser extent, fall time, are associated to the brain injury condition regardless of age or sex. Eye blink rate and blink-blink interval variability did not show statistically significant differences. Condition moderated effects: group contrasts were strongest at rest and attenuated during oddball. We found preliminary evidence that rise time and total duration are prolonged in patients independently of demographic factors, although with no difference between different levels of consciousness. Our results suggest a general slowing of blink dynamics consistent with altered basal ganglia dopaminergic activity, contributing physiological information relevant for monitoring and rehabilitation planning in brain injury.

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

Leonardo-Corsi/blink-waveforms-sabi

License: MIT
State: the link answers, verified on 27 September 2026
Evidence: files inventoried
Commit: e3894ca9960220cead95803de90fbb816992a7aa, 23 April 2026
Languages: Python (13)
Size: 114 files, 13 scripts
Software Heritage: not archived
Found in: “Data availability”
Holds: README, license file, CITATION.cff, environment (pyproject.toml)
Not found: tests, continuous integration, documentation
Tools: pandas (9 files), NumPy (8 files), Matplotlib (6 files), MNE-Python (6 files), SciPy (4 files), seaborn (2 files), statsmodels (2 files), scikit-learn (1 file), scikit-posthocs (1 file), statannotations (1 file)
Availability: 1 check, the latest on 27 September 2026: the link answers
  • 27 September 2026: the link answers
15 files

The paper's code and data availability statement is in the Data section.

Tracing map

Proposed by the machine: these links were found in the paper and verified at the source, without human review. The map will receive a Zenodo DOI once one of the paper's authors has validated it with their ORCID.

What the map holds:

  • 1 repository of the authors' code, each at its verified commit, with its license and how the link was found in the paper;
  • 13 scripts, each with its path and the digest of its content;
  • 6 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

No dataset and no data link were found in the paper.

Data availability

The full data supporting this study are available with the code reproducing the article results at https://github.com/Leonardo-Corsi/blink-waveforms-sabi.

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

Versions

The history of this record: each version stored by the harvester or made by a correction of its authors or of the maintainers of its code, and what changed in its facts. The texts of the paper (its abstract, its availability statements) are not part of it; versions that changed only those are not listed.

Version 3, 28 September 2026

  • Funding: added Ministero della Salute: H43C21000180001

Version 1, 27 September 2026: the first record

Recorded: type, language, journal, volume, issue, pages, dates, 6 authors, 5 keywords, 40 references.

Cite

This paper

Magliacano, A., Corsi, L., Liuzzi, P., Oddo, C. M., Mannini, A., & Estraneo, A. (2026). Demographics-robust spontaneous eye blinking slowing in patients with severe acquired brain injury. Neuroscience of consciousness, 2026(1), niag043. https://doi.org/10.1093/nc/niag043

BibTeX

@article{magliacano2026demographics,
author = {Magliacano, Alfonso and Corsi, Leonardo and Liuzzi, Piergiuseppe and Oddo, Calogero Maria and Mannini, Andrea and Estraneo, Anna},
title = {{Demographics-robust spontaneous eye blinking slowing in patients with severe acquired brain injury}},
journal = {Neuroscience of consciousness},
year = {2026},
month = aug,
volume = {2026},
number = {1},
pages = {niag043},
publisher = {Oxford University Press},
issn = {2057-2107},
doi = {10.1093/nc/niag043},
url = {https://doi.org/10.1093/nc/niag043},
pmid = {42592187},
pmcid = {PMC13464740}
}

RIS

TY - JOUR
AU - Magliacano, Alfonso
AU - Corsi, Leonardo
AU - Liuzzi, Piergiuseppe
AU - Oddo, Calogero Maria
AU - Mannini, Andrea
AU - Estraneo, Anna
TI - Demographics-robust spontaneous eye blinking slowing in patients with severe acquired brain injury
T2 - Neuroscience of consciousness
J2 - Neurosci Conscious
PY - 2026
DA - 2026/08/12
VL - 2026
IS - 1
SP - niag043
SN - 2057-2107
PB - Oxford University Press
DO - 10.1093/nc/niag043
UR - https://doi.org/10.1093/nc/niag043
LA - en
ER -

CSL-JSON

{
"id": "10.1093/nc/niag043",
"type": "article-journal",
"title": "Demographics-robust spontaneous eye blinking slowing in patients with severe acquired brain injury",
"container-title": "Neuroscience of consciousness",
"author": [
{
"family": "Magliacano",
"given": "Alfonso"
},
{
"family": "Corsi",
"given": "Leonardo"
},
{
"family": "Liuzzi",
"given": "Piergiuseppe"
},
{
"family": "Oddo",
"given": "Calogero Maria"
},
{
"family": "Mannini",
"given": "Andrea"
},
{
"family": "Estraneo",
"given": "Anna"
}
],
"container-title-short": "Neurosci Conscious",
"volume": "2026",
"issue": "1",
"page": "niag043",
"DOI": "10.1093/nc/niag043",
"PMID": "42592187",
"PMCID": "PMC13464740",
"ISSN": "2057-2107",
"publisher": "Oxford University Press",
"URL": "https://doi.org/10.1093/nc/niag043",
"language": "en",
"issued": {
"date-parts": [
[
2026,
8,
12
]
]
}
}

The tracing map gets a citation of its own once an author has validated it and it has a DOI.

Similar papers

The papers with a page that share the most with this one: the tools found in their code, their categories, datasets, cited references and authors, the rarest counting most.

[1] doi:10.1093/brain/awaf412 [code]
Multimodal multicentre investigation of diagnostic and prognostic markers in disorders of consciousness.
Journal: Brain : a journal of neurology
In common: statannotations, MNE-Python, seaborn, 5 other tools, other condition, 3 references
[2] doi:10.1016/j.isci.2026.115329 [code]
Brain metastases converge on shared geometric architecture and transcriptomic landscape yet remain distinct from gliomas.
Journal: iScience
In common: scikit-posthocs, statannotations, statsmodels, 6 other tools, clinical / translational, other condition
[3] doi:10.1038/s41531-026-01380-1 [code]
Identifying maximal beta power from directional subthalamic local field potentials in Parkinson's disease.
Journal: NPJ Parkinson's disease
In common: statannotations, MNE-Python, statsmodels, 6 other tools, clinical / translational
[4] doi:10.1038/s41467-026-75662-w [code]
Distinct Roles of Deep and Superficial Cortical Layers in Tone Prediction, Comparison, and Adaptation in Human Auditory Cortices.
Journal: Nature communications
In common: scikit-posthocs, MNE-Python, statsmodels, 6 other tools
[5] doi:10.1117/1.nph.13.3.035006 [code]
Characterizing developmental changes in infant habituation using functional change point detection.
Journal: Neurophotonics
In common: scikit-posthocs, statannotations, statsmodels, 5 other tools
[6] doi:10.1038/s43856-026-01518-5 [code]
Machine learning-based identification of abnormal functional connectivity in obesity across different metabolic states.
Journal: Communications medicine
In common: statannotations, statsmodels, seaborn, 5 other tools, clinical / translational, other condition
[7] doi:10.1371/journal.pone.0345651 [code]
Non-concussive head impacts sustained during American football correlate with changes in gut microbiome diversity and composition.
Journal: PloS one
In common: scikit-posthocs, statsmodels, seaborn, 5 other tools, clinical / translational
[8] 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: statannotations, MNE-Python, statsmodels, 5 other tools
[9] doi:10.1002/epi4.70336 [code]
Quantifying extra-lesional interhemispheric cortical asymmetry in focal cortical dysplasia type II.
Journal: Epilepsia open
In common: statannotations, statsmodels, seaborn, 5 other tools, clinical / translational
[10] doi:10.1002/alz.71649 [code]
Postmortem brain MRI reveals differential associations of subcortical and limbic volumes with cortical thinning and neurodegenerative pathologies.
Journal: Alzheimer's & dementia : the journal of the Alzheimer's Association
In common: statannotations, statsmodels, seaborn, 5 other tools, other condition

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.