OSCR

Electroencephalography-Based Clustering Reveals Robust Neurophysiological Subtypes in Parkinson's Disease.

Code ↔ Paper

14 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 14 matches · 4 of them tie a paragraph to a whole file, not to given lines: weak matches, whose lines are not tinted
  1. [1] § Methods › Clustering Analysis ↔ ClusteringUtilz.py, lines 446–516 · score 0.83 · Calinski Harabasz, Davies Bouldin, Silhouette score, DBI, CH, validity
  2. [2] § Results › Neurophysiological Profiles ↔ src/processEEGWithFOOOF2.m, the whole file · a weak match · score 0.79 · beta power, alpha power, gamma power, relative power, frequency bands, absolute
  3. [3] § Methods › Statistical Testing ↔ ClusteringUtilz.py, lines 310–442 · score 0.79 · chi squared, way ANOVA, FDR, Tukey, variables, numerical
  4. [4] § Methods › Feature Engineering ↔ FeatureExtraction.py, lines 593–670 · score 0.77 · Lempel Ziv complexity, permutation entropy, PermEn, LZC, mapping, temporal
  5. [5] § Methods › EEG Acquisition and Preprocessing ↔ src/remove_chans.m, the whole file · a weak match · score 0.77 · bandpass filtering, channel rejection, EEGLAB, eyes, MATLAB, 40 Hz
  6. [6] § Methods › Statistical Testing ↔ Resting State - Classic - DBSCAN.ipynb, lines 47–71 · score 0.74 · gait speed, disease duration, MoCA, CTT1, CTT2, age
  7. [7] § Methods › Feature Engineering ↔ src/processEEGWithFOOOF2.m, the whole file · a weak match · score 0.72 · relative power, frequency bands, spectrum, gamma, algorithm, domains
  8. [8] § Methods › EEG Acquisition and Preprocessing ↔ src/flag_and_remove_artifacts.m, the whole file · a weak match · score 0.67 · independent component, EEGLAB, artifact, eyes, ICA, channel
  9. [9] § Results › Feature Importance ↔ FeatureExtraction.py, lines 593–670 · score 0.59 · Lempel Ziv complexity, permutation entropy, PermEn, LZC
  10. [10] § Results › Clustering Performance ↔ ClusteringUtilz.py, lines 446–516 · score 0.56 · CH scores, validity scores, DBI, Silhouette, noise, DBSCAN
  11. [11] § Results › Clinical Profiles ↔ Resting State - Classic - DBSCAN.ipynb, lines 47–71 · score 0.56 · gait speed, disease duration, age, III, LEDD, UPDRS
  12. [12] § Results › Clinical Profiles ↔ Resting State - Classic - GMM.ipynb, lines 46–62 · score 0.56 · gait speed, disease duration, age, III, LEDD, UPDRS
  13. [13] § Methods › Clinical Profile ↔ Resting State - Classic - GMM.ipynb, lines 46–62 · score 0.56 · disease duration, MoCA, age, III, LEDD, UPDRS
  14. [14] § Methods › Dimensionality Reduction ↔ Resting State - Classic - DBSCAN.ipynb, lines 99–122 · score 0.51 · latent dimensionality, UMAP, PCA, component

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,118 lines · 51 KB · MIT · 3 matches

  1. import os
  2. import mne
  3. import scipy
  4. import random
  5. import joblib
  6. import numpy as np
  7. import pandas as pd
  8. from tqdm import tqdm
  9. import seaborn as sns
  10. import umap.umap_ as umap
  11. import matplotlib.pyplot as plt
  12. from itertools import combinations
  13. from sklearn.impute import SimpleImputer
  14. from sklearn.mixture import GaussianMixture
  15. from sklearn.model_selection import train_test_split, KFold, LeaveOneOut, BaseCrossValidator
  16. from sklearn.base import clone
  17. from sklearn.metrics import (
  18. calinski_harabasz_score,
  19. davies_bouldin_score,
  20. silhouette_score,
  21. adjusted_rand_score
  22. )
  23. from statsmodels.stats.multitest import multipletests
  24. from statsmodels.stats.multicomp import pairwise_tukeyhsd
  25. from scipy.stats import f_oneway, chi2_contingency
  26. SEED = 42
  27. class LeaveOneOutTrainEval(BaseCrossValidator):
  28. """
  29. A Leave-One-Out (LOO) like CrossValidator object that fits and scores on each N-1 samples in the data.
  30. """
  31. def get_n_splits(self, X=None, y=None, groups=None):
  32. return len(X)
  33. def split(self, X, y=None, groups=None):
  34. n = len(X)
  35. idx = np.arange(n)
  36. for holdout in range(n):
  37. train_idx = np.delete(idx, holdout)
  38. yield train_idx, train_idx
  39. class IOUtilz:
  40. @staticmethod
  41. def read_clinical_features(path: str) -> pd.DataFrame:
  42. """
  43. Reads the data from the clinical features .xlsx into a dataframe.
  44. :param path: a path to the .xlsx file of the clinical data.
  45. """
  46. data_file = pd.ExcelFile(path)
  47. cl_data = pd.concat([data_file.parse(sheet_name=sheet) for sheet in data_file.sheet_names])
  48. cl_data['IsHC'] = cl_data['Subject'].str.startswith('HC')
  49. cl_data.loc[~cl_data['IsHC'], 'Group'] = cl_data.loc[~cl_data['IsHC'], 'Group'].fillna('Idiopathic')
  50. return cl_data.reset_index(drop=True)
  51. class PreprocessUtilz:
  52. @staticmethod
  53. def normalize_col(col: pd.Series) -> float:
  54. """
  55. Performs z-score normalization of a given column (pandas series).
  56. """
  57. return (col - col.mean()) / col.std()
  58. def normalize_group_means(means: np.ndarray | pd.Series, ses: np.ndarray | pd.Series = None,
  59. mode: str = None) -> tuple[np.ndarray | pd.Series]:
  60. """
  61. Normalize the means and standard errors (ses) of a given feature across groups (e.g. clusters).
  62. :param means: a sequence representing the mean of a given feature for each group.
  63. :param ses: a sequence representing the standard error of a given feature for each group.
  64. :param mode: the name of the normalization mode, should be one of 'minmax', 'z-score'.
  65. """
  66. if mode is None:
  67. return means, ses
  68. if mode.lower() == "minmax":
  69. fmin = means.min(axis=0)
  70. rng = (means.max(axis=0) - fmin)
  71. rng = rng.replace(0, 1.0)
  72. means_n = (means - fmin) / rng
  73. ses_n = None if ses is None else ses / rng
  74. return means_n, ses_n
  75. if mode.lower() == "z-score":
  76. mu = means.mean(axis=0)
  77. sigma = means.std(axis=0).replace(0, 1.0)
  78. means_n = (means - mu) / sigma
  79. ses_n = None if ses is None else ses / sigma
  80. return means_n, ses_n
  81. raise ValueError("mode must be None, 'minmax', or 'z-score'")
  82. @staticmethod
  83. def _preprocess_pipeline(pipe, data: np.ndarray):
  84. """
  85. Transforms the input data along all pipeline steps excluding the last one (e.g. clustering model).
  86. :param pipe: a Pipeline object representing the model.
  87. :param data: an input numpy array for the model.
  88. """
  89. return pipe[:-1].transform(data)
  90. @staticmethod
  91. def set_random_seeds(models: list, seed: int = None):
  92. """
  93. Sets a random seed to all steps in a the given pipelines.
  94. :param models: a list of pipeline objects.
  95. :param seed: a random seed to set. Default is None.
  96. """
  97. for m in models:
  98. params_to_set = {}
  99. for name, step in m.steps:
  100. if hasattr(step, 'random_state'):
  101. param_key = f"{name}__random_state"
  102. params_to_set[param_key] = seed
  103. if params_to_set:
  104. m.set_params(**params_to_set)
  105. @staticmethod
  106. def get_ylim(data: pd.DataFrame, feature_name: str, groupby: list[str], offset: bool = True
  107. , error: str = 'se') -> (float, float):
  108. """
  109. Calculates scalar limits for a given feature_name in the data using groupby columns.
  110. :param data: a dataframe with the relevant columns.
  111. :param feature_name: the feature name to get scalar limits for.
  112. :param groupby: a list of column names according to which to group the data and calculate the limits.
  113. :param offset: if True, widen the scalar limits in both directions by a standard deviation offset.
  114. :param error:
  115. :return ylim: a tuple (low, high) of the scalar limits for the given feature.
  116. """
  117. groupby = data.groupby(groupby)[feature_name]
  118. means = groupby.mean()
  119. ylim = means.min(), means.max()
  120. if offset:
  121. if error == 'se':
  122. offset = groupby.sem().max()
  123. else:
  124. offset = groupby.std().max()
  125. ylim = ylim[0] - 2*offset, ylim[1] + 2*offset
  126. return ylim
  127. class TopoPlot:
  128. def __init__(self, data: pd.DataFrame, group_col: str, channel_col: str, feature_names: list[str],
  129. display_names: list[str] = None, montage: str = 'standard_1020', cmap: str = 'viridis',
  130. dims: (float, float) = (3, 4), vlim: (float, float) = None, title: str = None):
  131. """
  132. :param data: a dataframe with the raw feature table.
  133. :param group_col: the name of a column in the feature table to split the figures by.
  134. :param channel_col: the name of a column in the feature table which corresponds to the channel name \ ID.
  135. :param feature_names: a list of feature name to analyze topologically.
  136. :param display_names: the display names for the given features (labels of color bars).
  137. :param montage: a name of an MNE-EEG montage for the topological figure.
  138. :param cmap: a seaborn colormap name.
  139. :param dims: a tuple of dimensions for each figure.
  140. :param title: a customized title for the figure.
  141. """
  142. self.features = data.copy()
  143. self.feature_names = feature_names
  144. if display_names is None:
  145. self.display_names = [f.replace('_', ' ').strip() for f in feature_names]
  146. else:
  147. self.display_names = display_names
  148. self.channel_col = channel_col
  149. self.group_col = group_col
  150. self.groups = self.features[self.group_col].unique()
  151. self.n_groups = len(self.groups)
  152. self.eeg_info = TopoPlot.get_eeg_info(
  153. electrodes=self.features[self.channel_col].unique()
  154. , montage=montage
  155. )
  156. self.cmap = cmap
  157. self.dims = dims
  158. self.vlim = vlim
  159. self.title = title
  160. self.plot_topomap_mat()
  161. @staticmethod
  162. def get_eeg_info(electrodes: np.ndarray | list, montage: str):
  163. """
  164. Creates an MNE info object for downstream MNE topological plots creation.
  165. :param electrodes: a sequence of electrode names for the given configuration.
  166. :param montage: the desired configuration of electrodes on the scalp.
  167. """
  168. ch_names = list(electrodes)
  169. info = mne.create_info(ch_names=ch_names, sfreq=1, ch_types='eeg')
  170. montage = mne.channels.make_standard_montage(montage)
  171. info.set_montage(montage, on_missing='ignore')
  172. return info
  173. def plot_topomap(self, features: pd.DataFrame, feature_name: str, ax, title: str = '', vlim: (float, float) = None):
  174. """
  175. Creates figure with the topological distribution of the given feature name over the scalp using '.montage'.
  176. :param features: a transformed feature table (dataframe) with features are columns.
  177. :param feature_name: the feature name to plot its topological distribution.
  178. :param channel_col:
  179. :param ax: a matplotlib axes to draw the plot within.
  180. :param title: a title for the given axes.
  181. :param vlim: a tuple of floats (min, max) representing the color bar limits.
  182. If not given, estimated from values using PreprocessUtilz.get_ylim.
  183. """
  184. if vlim is None:
  185. vlim = PreprocessUtilz.get_ylim(features
  186. , feature_name
  187. , self.channel_col
  188. , offset=False
  189. , error='se')
  190. values = features.groupby(self.channel_col)[feature_name].mean().values
  191. im, cn = mne.viz.plot_topomap(values
  192. , self.eeg_info
  193. , axes=ax
  194. , cmap=self.cmap
  195. , vlim=vlim
  196. , show=False
  197. )
  198. ax.set_title(title, fontsize=13, y=1.02)
  199. return im
  200. def plot_topomap_row(self, feature_name: str, display_name: str, group_names: bool = True, axes = None):
  201. """
  202. Creates figure with the topological distribution of the given feature name over the scalp using '.montage'.
  203. The figure consists of a 1D row of subplots, one per each group, defined using '.group_col'.
  204. :param feature_name: the feature name to plot its topological distribution.
  205. :param display_name: the display name for the given feature (label of the color bar).
  206. :param group_names: If true, adds groups names as titles to the subplots. Default is True.
  207. :param axes: a 1D matplotlib axes for plot creation. Default is None.
  208. """
  209. if self.vlim:
  210. vlim = self.vlim
  211. else:
  212. vlim = PreprocessUtilz.get_ylim(self.features
  213. , feature_name
  214. , [self.group_col, self.channel_col]
  215. , offset=False
  216. , error='se')
  217. if axes is None:
  218. fig, axes = plt.subplots(1, self.n_groups
  219. , figsize=(self.n_groups * self.dims[0], self.dims[1] * 0.64)
  220. , constrained_layout=True)
  221. else:
  222. fig = np.ravel(axes)[0].figure
  223. for i, group in enumerate(self.groups):
  224. group_mask = self.features[self.group_col] == group
  225. if group_names:
  226. im = self.plot_topomap(self.features.loc[group_mask]
  227. , feature_name
  228. , ax=axes[i]
  229. , title=group
  230. , vlim=vlim
  231. )
  232. else:
  233. im = self.plot_topomap(self.features.loc[group_mask]
  234. , feature_name
  235. , ax=axes[i]
  236. , vlim=vlim
  237. )
  238. # Add a single colorbar to the right
  239. cbar = fig.colorbar(im, ax=axes, orientation='vertical', fraction=0.03, pad=0.04)
  240. cbar.set_label(display_name, fontsize=11)
  241. return fig
  242. def plot_topomap_mat(self):
  243. """
  244. Creates figure with the topological distribution of the given feature name over the scalp using '.montage'.
  245. The figure consists of a 2D matrix of subplots: one column per each group, defined using '.group_col',
  246. and one row for each feature, defined using '.feature_names'.
  247. """
  248. n_features = len(self.feature_names)
  249. if n_features > 1:
  250. fig, axes = plt.subplots(n_features, self.n_groups,
  251. figsize=(self.n_groups * self.dims[0], n_features * self.dims[1] * 0.64))
  252. for i, fname in enumerate(self.feature_names):
  253. self.plot_topomap_row(feature_name=fname,
  254. display_name=self.display_names[i],
  255. group_names=(i == 0),
  256. axes=axes[i])
  257. else: ## only 1 feature
  258. fig = self.plot_topomap_row(feature_name=self.feature_names[0], display_name=self.display_names[0])
  259. if self.title:
  260. fig.suptitle(self.title, fontsize=14, y=0.96)
  261. else:
  262. fig.suptitle(f'Topological Distributions Between Groups', fontsize=14, y=0.96)
  263. plt.show()
  264. class StatsUtilz:
  265. @staticmethod
  266. def anova(data: pd.DataFrame, feature_name: str, group_col: str = 'clusterID', print_line: bool = True) -> dict:
  267. """
  268. One-way ANOVA across clusters for a single numeric feature.
  269. Reports F, p, df1, df2, eta-squared (η²).
  270. :param data: a dataframe with the actual numeric data and splitter columns.
  271. :param feature_name: a name of a column within data on which to perform the analysis.
  272. :param group_col: a name of a column within data for group splitting (e.g. 'clusterID').
  273. :param print_line: if True, prints all statistics in a comprehensive line.
  274. """
  275. groups = []
  276. levels = data[group_col].dropna().unique()
  277. for lev in levels:
  278. groups.append(data.loc[data[group_col] == lev, feature_name].dropna().values)
  279. groups = [g for g in groups if len(g) > 0]
  280. F, p = f_oneway(*groups)
  281. k = len(groups)
  282. n = sum(len(g) for g in groups)
  283. df1 = k - 1
  284. df2 = n - k
  285. eta_sq = (F * df1) / (F * df1 + df2) if (F * df1 + df2) > 0 else np.nan
  286. if print_line:
  287. print(f"{feature_name}: F({df1},{df2}) = {F:.3f}, p = {p:.6f}, η² = {eta_sq:.3f}")
  288. return {
  289. "feature": feature_name,
  290. "k_groups": k,
  291. "n_total": n,
  292. "F": F,
  293. "p": p,
  294. "df1": df1,
  295. "df2": df2,
  296. "eta_sq": eta_sq
  297. }
  298. @staticmethod
  299. def pairwise_posthoc(data: pd.DataFrame, feature_name: str, group_col: str = 'clusterID', print_line: bool = True) -> str:
  300. """
  301. Conducts a tukey posthoc analysis test, assuming a significant one-way ANOVA.
  302. :param data: a dataframe with the actual numeric data and splitter columns.
  303. :param feature_name: a name of a column within data on which to perform the analysis.
  304. :param group_col: a name of a column within data for group splitting (e.g. 'clusterID').
  305. :param print_line: if True, prints all statistics in a comprehensive line.
  306. """
  307. data_c = data[[feature_name, group_col]].dropna().copy()
  308. posthoc = pairwise_tukeyhsd(endog=data_c[feature_name], groups=data_c[group_col], alpha=0.05)
  309. summary = posthoc.summary()
  310. if print_line:
  311. print(summary)
  312. return summary
  313. def chi_squared(data: pd.DataFrame, feature_name: str, group_col: str = 'clusterID', id_col: str = 'Subject',
  314. print_line: bool = True) -> dict:
  315. """
  316. Chi-square test of independence between group_col (e.g., 'clusterID') and a categorical feature.
  317. Builds a contingency table using unique counts of id_col per cell (robust to duplicates).
  318. Reports χ², df, p and Cramer's V.
  319. :param data: a dataframe with the actual numeric data and splitter columns.
  320. :param feature_name: a name of a column within data on which to perform the analysis.
  321. :param group_col: a name of a column within data for group splitting (e.g. 'clusterID').
  322. :param id_col: a column name within data of unique IDs or keys for each sample (e.g. 'Subject').
  323. :param print_line: if True, prints all statistics in a comprehensive line.
  324. """
  325. ct = (data.groupby([group_col, feature_name])[id_col]
  326. .nunique()
  327. .unstack(fill_value=0))
  328. ct = ct.loc[(ct.sum(axis=1) > 5), (ct.sum(axis=0) > 5)]
  329. chi2, p, dof, exp = chi2_contingency(ct.values, correction=False)
  330. n = ct.values.sum()
  331. r, c = ct.shape
  332. k = min(r, c)
  333. cramers_v = np.sqrt(chi2 / (n * (k - 1))) if k > 1 else np.nan
  334. if print_line:
  335. print(f"{feature_name}: χ²({dof}) = {chi2:.3f}, p = {p:.6f}, V = {cramers_v:.3f}")
  336. return {
  337. "feature": feature_name,
  338. "table": ct,
  339. "chi2": chi2,
  340. "df": dof,
  341. "p": p,
  342. "cramers_v": cramers_v,
  343. "n_total": int(n),
  344. "shape": (r, c)
  345. }
  346. def correct_ps(Ps: np.ndarray | list) -> np.ndarray:
  347. """
  348. Performs FDR-BH statistical correction for multiple comparisons.
  349. :param Ps: a sequence of p-values to correct.
  350. """
  351. _, pvals_corrected, _, _ = multipletests(Ps, alpha=0.05, method='fdr_bh')
  352. return pvals_corrected
  353. def calc_stats(data: pd.DataFrame, feature_families: dict):
  354. """
  355. Calculating statistical test score including p-values and effect-size measures for the given features.
  356. In addition, computes corrected p-values for multiple comparisons within each feature family using the FDR-BH method.
  357. """
  358. all_frames = []
  359. effect_sizes = []
  360. for family, features in feature_families.items():
  361. Ps = []
  362. for f in features:
  363. if pd.api.types.is_numeric_dtype(data[f]):
  364. res = StatsUtilz.anova(data, f)
  365. effect_sizes.append(res['eta_sq'])
  366. else:
  367. res = StatsUtilz.chi_squared(data, group_col='clusterID', feature_name=f)
  368. effect_sizes.append(res['cramers_v'])
  369. Ps.append(res['p'])
  370. _, corrected, _, _ = multipletests(Ps, alpha=0.05, method='fdr_bh')
  371. all_frames.append(pd.DataFrame(
  372. {'Variable': features, 'RawP': Ps, 'CorrectedP': corrected}
  373. ))
  374. stats = pd.concat(all_frames)
  375. stats['EffectSize'] = effect_sizes
  376. return stats
  377. class ClusterEval:
  378. @staticmethod
  379. def get_intrinsic_metrics(data: np.ndarray, models: list, model_names: list[str]) -> pd.DataFrame:
  380. """
  381. Get intrinsic clustering metrics to evaluate the model.
  382. The metrics calculated are: silhouette score, Calniski-Harbasz index (CH) and Davies-Bouldin index (DBI).
  383. In addition, for the sake of DBSCAN evaluation, the function computes validity scores - defined by
  384. wether there are more than 1 non-noise labels. For other models, you may ignore that.
  385. :return: a dataframe with the intrinsic metrics calculated for each model.
  386. """
  387. n_models = len(models)
  388. loo = LeaveOneOut()
  389. test_shape = (n_models, data.shape[0])
  390. silhouette_scores = np.zeros(test_shape)
  391. CH_scores = np.zeros(test_shape)
  392. DBI_scores = np.zeros(test_shape)
  393. validity_scores = np.zeros(test_shape)
  394. for i, model in enumerate(models):
  395. for train_idx, j in loo.split(data):
  396. sample = data[train_idx]
  397. labels = model.fit_predict(sample)
  398. non_noise_mask = labels != -1
  399. non_noise_labels = labels[non_noise_mask]
  400. non_noise_data = sample[non_noise_mask]
  401. if np.unique(non_noise_labels).size > 1: # more than 1 cluster
  402. prep_sample = PreprocessUtilz._preprocess_pipeline(model, non_noise_data)
  403. silhouette_scores[i, j] = silhouette_score(prep_sample, non_noise_labels)
  404. CH_scores[i, j] = calinski_harabasz_score(prep_sample, non_noise_labels)
  405. DBI_scores[i, j] = davies_bouldin_score(prep_sample, non_noise_labels)
  406. validity_scores[i, j] = 1
  407. else: # 1 cluster or None
  408. silhouette_scores[i, j] = np.nan
  409. CH_scores[i, j] = np.nan
  410. DBI_scores[i, j] = np.nan
  411. silhouette_means = np.nanmean(silhouette_scores, axis=1)
  412. CH_means = np.nanmean(CH_scores, axis=1)
  413. DBI_means = np.nanmean(DBI_scores, axis=1)
  414. validity_means = np.nanmean(validity_scores, axis=1)
  415. silhouette_stds = np.nanstd(silhouette_scores, axis=1)
  416. CH_stds = np.nanstd(CH_scores, axis=1)
  417. DBI_stds = np.nanstd(DBI_scores, axis=1)
  418. validity_stds = np.nanstd(validity_scores, axis=1)
  419. # Creating a DataFrame with the metrics
  420. metric_names = ['silhouette_score', 'CH_score', 'DBI_score', 'Validity']
  421. if model_names is None:
  422. model_names = list(range(n_models))
  423. intrinsic_metrics = pd.DataFrame({
  424. 'ModelName': len(metric_names) * model_names
  425. , 'Metric': np.concatenate([n_models * [mname] for mname in metric_names])
  426. , 'Mean': np.concatenate([silhouette_means, CH_means, DBI_means, validity_means])
  427. , 'STD': np.concatenate([silhouette_stds, CH_stds, DBI_stds, validity_stds])
  428. })
  429. pivoted = intrinsic_metrics.melt(
  430. id_vars=['ModelName', 'Metric'],
  431. var_name='Statistic',
  432. value_name='Value'
  433. ).pivot_table(
  434. index='Metric',
  435. columns=['ModelName', 'Statistic'],
  436. values='Value'
  437. )
  438. return pivoted
  439. @staticmethod
  440. def test_stability(data: np.ndarray, model_path: str, n_trials: int = 100, random_seed: bool = True) -> np.ndarray:
  441. """
  442. Test within-model stability between clustering predictions using the adjusted rand index (ARI)
  443. in a leave-one-out paradigm, with concurrent validation trials.
  444. :param data: an input numpy array for the model.
  445. :param model_path: a path to .pkl files with the clustering model.
  446. This function assumes that the model is a Pipeline object.
  447. :param n_trials: the number of vaidation trials to perform.
  448. :param random_seed: if True, randomizes a new random seed for each iteration.
  449. If False, sets all random seeds to be None.
  450. """
  451. model_name = model_path.split(os.sep)[-1].split('.')[0]
  452. model_1 = joblib.load(model_path)
  453. model_2 = joblib.load(model_path)
  454. # Resetting existing random seeds in each step for each model
  455. if not random_seed:
  456. PreprocessUtilz.set_random_seeds([model_1, model_2], seed=None)
  457. n = data.shape[0]
  458. loo = LeaveOneOut()
  459. ARIs = np.zeros((n_trials, n))
  460. # Iterating on subjects in a Leave-One-Out paradigm
  461. for train_idx, test_idx in tqdm(loo.split(data)):
  462. sample = data[train_idx]
  463. # Iterating n_trials times
  464. for t in range(n_trials):
  465. # Specifing distinct random seeds for each model
  466. if random_seed:
  467. seed_1 = random.randint(1, 1e6)
  468. PreprocessUtilz.set_random_seeds([model_1], seed=seed_1)
  469. seed_2 = random.randint(1, 1e6)
  470. PreprocessUtilz.set_random_seeds([model_2], seed=seed_2)
  471. ARIs[t][test_idx] = adjusted_rand_score(model_1.fit_predict(sample), model_2.fit_predict(sample))
  472. return ARIs
  473. @staticmethod
  474. def test_agreement(data: np.ndarray, model_paths: list[str], n_trials: int = 100, random_seed: bool = True) -> dict:
  475. """
  476. Test pair-wise model agreement between clustering predictions using the adjusted rand index (ARI)
  477. in a leave-one-out paradigm, with concurrent validation trials.
  478. :param data: an input numpy array for the model.
  479. :param model_paths: a list of paths to .pkl files with the clustering models.
  480. This function assumes that each model is a Pipeline object.
  481. :param n_trials: the number of vaidation trials to perform.
  482. :param random_seed: if True, randomizes a new random seed for each iteration.
  483. If False, sets all random seeds to be None.
  484. """
  485. model_names = [path.split(os.sep)[-1].split('.')[0] for path in model_paths]
  486. models = [joblib.load(path) for path in model_paths]
  487. # Resetting existing random seeds in each step for each model
  488. if not random_seed:
  489. PreprocessUtilz.set_random_seeds(models, seed=None)
  490. n = data.shape[0]
  491. loo = LeaveOneOut()
  492. model_pairs = list(combinations(model_names, 2))
  493. pair_names = ["_".join(pair) for pair in model_pairs]
  494. ARIs = {pair : np.zeros((n_trials, n)) for pair in pair_names}
  495. # Iterating on subjects in a Leave-One-Out paradigm
  496. for train_idx, test_idx in tqdm(loo.split(data)):
  497. sample = data[train_idx]
  498. # Iterating n_trials times
  499. for t in range(n_trials):
  500. if random_seed:
  501. seed = random.randint(1, 1e6)
  502. PreprocessUtilz.set_random_seeds(models, seed=seed)
  503. labels = {name: None for name in model_names}
  504. # Predicting cluster labels for each model
  505. for i, m in enumerate(models):
  506. name = model_names[i]
  507. labels[name] = m.fit_predict(sample)
  508. # Computing pair-wise ARI
  509. for i, (a, b) in enumerate(model_pairs):
  510. ARIs[pair_names[i]][t][test_idx] += adjusted_rand_score(labels[a], labels[b])
  511. return ARIs
  512. @staticmethod
  513. def radar_plot(data: pd.DataFrame, metrics: list[str], group_col: str = 'clusterID', title: str = None,
  514. normalize: str = None, ylabel: str = None, figsize: (int, int) = (5, 6), yticks: np.ndarray = None,
  515. metric_names: list[str] = None, show_err: bool = True, spin = np.pi / 2):
  516. """
  517. A general function to create a radar plot including several feature.
  518. :param data: a dataframe with the feature values.
  519. :param metrics: a list of column names within data correspoding to the relevant features for the plot.
  520. :param group_col: the name of the group column within clusters. Default is 'clusterID'.
  521. :param title: a title for the figure. Default is None.
  522. :param normalize: if True, normalizing the values within each features using Z-score before creating the plot.
  523. :param ylabel: a label for the y-axis. Default is None.
  524. :param figsize: the figure size. Default is (5, 6).
  525. :param yticks: an array of specific ticks for the y-axis. Default is None.
  526. :param metric_names: a list of names correspoding to metrics to display in the plot.
  527. :param show_err: if True, draws the standard error bar for each group. Default is True.
  528. :parm spin: a rotation factor for the plot in radians. Default is np.pi / 2.
  529. """
  530. groupby = data.groupby([group_col])[metrics]
  531. means = groupby.mean().sort_index()
  532. ses = groupby.sem().sort_index()
  533. means, ses = PreprocessUtilz.normalize_group_means(means=means, ses=ses, mode=normalize)
  534. angles = np.linspace(0, 2*np.pi, len(metrics), endpoint=False)
  535. angles = np.concatenate([angles, angles[:1]])
  536. fig = plt.figure(figsize=figsize)
  537. ax = plt.subplot(111, polar=True) # << this line makes it a radar chart
  538. ax.set_xticks(angles[:-1])
  539. ax.set_xticklabels(metrics if metric_names is None else metric_names, fontsize=14)
  540. ax.tick_params(axis="x", pad=15) # push feature labels outward a bit
  541. if yticks is not None:
  542. ax.set_yticks(yticks)
  543. ax.yaxis.grid(True, alpha=0.6)
  544. if ylabel is None:
  545. ylabel = 'Value' if normalize is None else normalize
  546. ax.set_ylabel(ylabel, labelpad=50, fontsize=11)
  547. ax.yaxis.set_label_coords(0.5, -0.2)
  548. ax.yaxis.label.set_rotation(0)
  549. for cid, row in means.iterrows():
  550. vals = row.values
  551. curve = np.concatenate([vals, vals[:1]])
  552. ax.plot(angles, curve, linewidth=2, label=cid)
  553. if show_err:
  554. se = ses.loc[cid].values
  555. lower = np.concatenate([vals - se, [vals[0] - se[0]]])
  556. upper = np.concatenate([vals + se, [vals[0] + se[0]]])
  557. ax.fill_between(angles, lower, upper, alpha=0.15)
  558. ax.legend(loc="upper left", bbox_to_anchor=(1.05, 1.05), title="Cluster")
  559. ax.set_theta_offset(spin)
  560. ax.set_theta_direction(-1)
  561. ax.set_title(title, pad=16, fontsize=14, loc='center')
  562. plt.show()
  563. @staticmethod
  564. def plot_multiple_features(features: pd.DataFrame, clusters: pd.DataFrame, raw_feature_names: list[str], disp_feature_names: list[str] = None,
  565. xlabel: str = 'Feature Name', ylabel: str = 'Value', cluster_name: str = 'K-Means', title: str = 'Feature Values',
  566. plot_type: str = 'line', ylim = None, group_col: str = 'clusterID'):
  567. """
  568. A general function to plot inter-group differences of several features, between behavioral conditions.
  569. :param features: a dataframe with the feature values for each subject.
  570. :param clusters: a dataframe with the cluster assignment for each subject.
  571. :param raw_feature_names: a list of feature names corresponding to colums in features.
  572. :param disp_feature_names: a list of feature names for display in the figure, corresponding to raw_feature_names.
  573. If None, raw_feature_names are used.
  574. :param xlabel: a label for the x-axis. Default is 'Feature Name'.
  575. :param ylabel: a label for the y-axis. Default is 'Value'.
  576. :param cluster_name: the name of the clustering model, or otherwise grouping scheme for the subjects (e.g. K-Means).
  577. :param title: a title prefix for the figure. Full title will be '{title} between {cluster_name} Clusters Across Behavioral States'.
  578. :param plot_type: a name of a plot type, should be one of ['bar', 'line'].
  579. :param ylim: a tuple of limits for the y-axis in the figure. If None, limits are set automatically via seaborn.
  580. :param group_col: the name of the group column within clusters. Default is 'clusterID'.
  581. """
  582. if disp_feature_names is None:
  583. disp_feature_names = raw_feature_names
  584. selected_features = features.loc[features['FeatureName'].isin(raw_feature_names)]
  585. agged_features = selected_features.groupby(['Subject', 'Condition', 'FeatureName'])[['RawValue']].mean().reset_index()
  586. agged_features = pd.merge(agged_features, clusters, how='inner', on='Subject')
  587. agged_features['FeatureName'] = pd.Categorical(
  588. agged_features['FeatureName'],
  589. categories=raw_feature_names,
  590. ordered=True
  591. )
  592. fig, axes = plt.subplots(1, 2, figsize=(14, 5))
  593. if plot_type == 'line':
  594. sns.lineplot(agged_features.loc[agged_features['Condition'] == 'sit'],
  595. x='FeatureName', y='RawValue', hue=group_col, ax=axes[0])
  596. sns.lineplot(agged_features.loc[agged_features['Condition'] == 'walk'],
  597. x='FeatureName', y='RawValue', hue=group_col, ax=axes[1])
  598. elif plot_type == 'bar':
  599. sns.barplot(agged_features.loc[agged_features['Condition'] == 'sit'],
  600. x='FeatureName', y='RawValue', hue=group_col, ax=axes[0], capsize=0.2)
  601. sns.barplot(agged_features.loc[agged_features['Condition'] == 'walk'],
  602. x='FeatureName', y='RawValue', hue=group_col, ax=axes[1], capsize=0.2)
  603. axes[0].set_title('Resting-State')
  604. axes[1].set_title('Active-Walking')
  605. for i in range(2):
  606. axes[i].set_xlabel(xlabel)
  607. axes[i].set_ylabel(ylabel)
  608. axes[i].set_xticklabels(disp_feature_names)
  609. if ylim is not None:
  610. axes[i].set_ylim(ylim)
  611. plt.suptitle(f'{title} between {cluster_name} Clusters Across Behavioral States', fontsize=14, y=1.02)
  612. plt.show()
  613. @staticmethod
  614. def plot_single_feature(features: pd.DataFrame, clusters: pd.DataFrame, feature_name: str,
  615. ylabel: str = 'Value', cluster_name: str = 'K-Means', title: str = 'Feature Values',
  616. plot_type: str = 'line', ylim = None, group_col: str = 'clusterID'):
  617. """
  618. A general function to plot inter-group differences of a single feature, between behavioral conditions.
  619. :param features: a dataframe with the feature values for each subject.
  620. :param clusters: a dataframe with the cluster assignment for each subject.
  621. :param feature_name: a name of a feature corresponding to colums in features.
  622. :param ylabel: a label for the y-axis. Default is 'Value'.
  623. :param cluster_name: the name of the clustering model, or otherwise grouping scheme for the subjects (e.g. K-Means).
  624. :param title: a title prefix for the figure. Full title will be '{title} between {cluster_name} Clusters Across Behavioral States'.
  625. :param plot_type: a name of a plot type, should be one of ['bar', 'line'].
  626. :param ylim: a tuple of limits for the y-axis in the figure. If None, limits are set automatically via seaborn.
  627. :param group_col: the name of the group column within clusters. Default is 'clusterID'.
  628. """
  629. selected_features = features.loc[features['FeatureName'] == feature_name]
  630. agged_features = selected_features.groupby(['Subject', 'Condition'])[['RawValue']].mean().reset_index()
  631. agged_features = pd.merge(agged_features, clusters, how='inner', on='Subject')
  632. if plot_type == 'line':
  633. ax = sns.lineplot(agged_features, x='Condition', y='RawValue', hue=group_col)
  634. elif plot_type == 'bar':
  635. ax = sns.barplot(agged_features, x='Condition', y='RawValue', hue=group_col, capsize=0.2)
  636. plt.ylabel(ylabel)
  637. if ylim is not None:
  638. plt.ylim(ylim)
  639. ax.set_xticklabels(['Resting-State', 'Active-Walking'])
  640. plt.title(f'{title} between {cluster_name} Clusters Across Behavioral States', fontsize=12, y=1.02)
  641. plt.show()
  642. class ClusterReport:
  643. def __init__(self, models: list[GaussianMixture], model_names: list[str], PD_data: np.ndarray, PD_subjects: np.ndarray
  644. , HC_data: np.ndarray, cl_data_path: str, cl_fnames: list[str], k: int = 5, resample_rate: float = 0.8):
  645. """
  646. An object for fast evaluation of Parkinson's disease clustering models using conventional performance indices and clinical assessments.
  647. :param models: a list of unfitted sklearn clustering models which one wish to evaluate.
  648. :param model_names: labels corresponding to the models, for referral to results.
  649. :param PD_data: a list of feature matrices of the Parkinson's Disease (PD) subjects, for each model.
  650. :param PD_subjects: an array of the subjectIDs for the Parkinson's Disease (PD) subjects.
  651. :param HC_data: a list of feature matrices of the Parkinson's Disease (PD) subjects, for each model.
  652. :param cl_data_path: a path to the .xlsx file of the clinical data.
  653. :param cl_fnames: a list of feature names from the clinical data to include in evaluation (e.g. LEDD, UPDRS).
  654. """
  655. self.models = models
  656. self.model_names = model_names
  657. self.n_models = len(models)
  658. self.PD_data = PD_data
  659. self.PD_subjects = PD_subjects
  660. self.HC_data = HC_data
  661. self.cl_data: pd.DataFrame = IOUtilz.read_clinical_features(cl_data_path)
  662. self.cl_fnames = cl_fnames
  663. self.loo = LeaveOneOut()
  664. def set_models(self, models: list, model_names: list[str]):
  665. """
  666. Set the model attributes for object reuse purposes.
  667. """
  668. self.models = models
  669. self.model_names = model_names
  670. self.n_models = len(models)
  671. def set_data(self, PD_data: np.ndarray, HC_data: np.ndarray):
  672. """
  673. Set the data attributes for object reuse purposes.
  674. """
  675. self.PD_data = PD_data
  676. self.HC_data = HC_data
  677. def _fit_models(self):
  678. """
  679. Fit all models to all Parkinson's Disease (PD) data.
  680. """
  681. for i in range(self.n_models):
  682. self.models[i].fit(self.PD_data)
  683. def _get_intrinsic_metrics(self, data: np.ndarray) -> pd.DataFrame:
  684. """
  685. Get intrinsic clustering metrics to evaluate the model.
  686. The metrics calculated are: silhouette score, Calniski-Harbasz index (CH) and Davies-Bouldin index (DBI).
  687. In addition, for the sake of DBSCAN evaluation, the function computes validity scores - defined by
  688. wether there are more than 1 non-noise labels. For other models, you may ignore that.
  689. :return: a dataframe with the intrinsic metrics calculated for each model.
  690. """
  691. return ClusterEval.get_intrinsic_metrics(data=self.PD_data,
  692. models=self.models,
  693. model_names=self.model_names)
  694. def _get_clinical_means(self, fnames: list[str]) -> pd.DataFrame:
  695. """
  696. Get the means of the clinical measures for each cluster in each model.
  697. Assumes models are already fitted to the data.
  698. :param fnames: a list of feature names from the clinical data to include in calculation.
  699. :return: a dataframe with the mean of each clinical measure in fnames, per cluster and model.
  700. """
  701. # Slicing only PD subjects found in subjects
  702. PD_subject_mask = self.cl_data['Subject'].isin(self.PD_subjects)
  703. cl_features = self.cl_data.loc[PD_subject_mask, fnames]
  704. cl_feature_mat = cl_features.values
  705. cl_feature_mat[np.isnan(cl_feature_mat)] = 0
  706. # Slicing only PD subjects found in clinical data
  707. subject_mask = np.where(np.isin(self.PD_subjects, self.cl_data['Subject'].values))[0]
  708. metrics_per_model = []
  709. N = subject_mask.size
  710. for i in range(self.n_models):
  711. data = self.PD_data[subject_mask]
  712. cl_metrics = pd.DataFrame(cl_feature_mat, columns=fnames)
  713. cl_metrics['ClusterID'] = self.models[i].fit_predict(data)
  714. cl_metrics['Model'] = self.model_names[i]
  715. metrics_per_model.append(cl_metrics)
  716. # Adding healthy controls for reference
  717. HC_subject_mask = self.cl_data['Subject'].str.startswith('HC')
  718. cl_features = self.cl_data.loc[HC_subject_mask, fnames]
  719. cl_feature_mat = cl_features.values
  720. cl_feature_mat[np.isnan(cl_feature_mat)] = 0
  721. for i in range(self.n_models):
  722. cl_metrics = pd.DataFrame(cl_feature_mat, columns=fnames)
  723. cl_metrics['ClusterID'] = 'HC'
  724. cl_metrics['Model'] = self.model_names[i]
  725. metrics_per_model.append(cl_metrics)
  726. all_clinical_metrics = pd.concat(metrics_per_model)
  727. return all_clinical_metrics.groupby(['Model', 'ClusterID']).mean().reset_index(drop=False)
  728. def _visualize_metrics(self, metrics: pd.DataFrame):
  729. """
  730. Visualize metrics in table in heatmap-figure forms, grouped by model.
  731. :param metrics: a dataframe with a Model CLusterID columns, and other numerical columns to be visualized.
  732. """
  733. select_cols = [col for col in metrics.columns if col != 'Model']
  734. if self.n_models == 1: # Only one model to evaluate
  735. model_cl_data = metrics[select_cols].set_index('ClusterID')
  736. norm_cl_data = model_cl_data.apply(PreprocessUtilz.normalize_col, axis=0)
  737. plt.figure(figsize=(8, 3))
  738. sns.heatmap(norm_cl_data.T, cmap='coolwarm', cbar_kws ={'label': 'Normalized Value'}, annot=model_cl_data.round(3).T)
  739. plt.title(self.model_names[0].title())
  740. else: # Several models to evaluate
  741. fig, axes = plt.subplots(1, self.n_models, figsize=(self.n_models * 8, 3))
  742. for i in range(self.n_models):
  743. name = self.model_names[i]
  744. model_cl_data = metrics.loc[metrics['Model'] == name, select_cols].set_index('ClusterID')
  745. norm_cl_data = model_cl_data.apply(PreprocessUtilz.normalize_col, axis=0)
  746. sns.heatmap(norm_cl_data.T, ax=axes[i], cmap='coolwarm', cbar_kws ={'label': 'Normalized Value'}, annot=model_cl_data.round(3).T)
  747. axes[i].set_title(name.title())
  748. plt.suptitle('Metrics Between Clusters')
  749. plt.show()
  750. plt.close()
  751. @staticmethod
  752. def _UMAP_project(data) -> np.ndarray:
  753. """
  754. Project the given data into a lower dimension using the UMAP algorithm.
  755. :param data: a dataset to evaluate models on.
  756. """
  757. reducer = umap.UMAP(n_components=2, n_neighbors=5, random_state=SEED)
  758. return reducer.fit_transform(data)
  759. def _UMAP_visualize(self):
  760. """
  761. Creates a scatter plot for each model where UMAP-projected data points are colored by hard-clustering labels.
  762. Assumes models are already fitted to the data.
  763. :param data: a dataset to evaluate models on.
  764. """
  765. plt.figure()
  766. if self.n_models == 1:
  767. prepped = PreprocessUtilz._preprocess_pipeline(self.models[0], self.PD_data)
  768. UMAP_proj = ClusterReport._UMAP_project(prepped)
  769. x = UMAP_proj[:, 0]
  770. y = UMAP_proj[:, 1]
  771. labels = self.models[0].fit_predict(self.PD_data)
  772. scatter = plt.scatter(x=x, y=y, c=labels)
  773. plt.title('UMAP Projected Clusters (PD only)')
  774. plt.xlabel('UMAP-comp1')
  775. plt.ylabel('UMAP-comp2')
  776. legend = plt.legend(*scatter.legend_elements(), title="ClusterID")
  777. plt.gca().add_artist(legend)
  778. else:
  779. fig, axes = plt.subplots(1, self.n_models, figsize=(self.n_models * 6, 5))
  780. for i, model in enumerate(self.models):
  781. prepped = PreprocessUtilz._preprocess_pipeline(model, self.PD_data)
  782. UMAP_proj = ClusterReport._UMAP_project(prepped)
  783. x = UMAP_proj[:, 0]
  784. y = UMAP_proj[:, 1]
  785. labels = model.fit_predict(self.PD_data)
  786. scatter = axes[i].scatter(x=x, y=y, c=labels)
  787. axes[i].set_title(self.model_names[i].title(), fontsize=14)
  788. axes[i].set_xlabel('UMAP-comp1')
  789. axes[i].set_ylabel('UMAP-comp2')
  790. legend = axes[i].legend(*scatter.legend_elements(), title="ClusterID")
  791. axes[i].add_artist(legend)
  792. plt.suptitle('UMAP Projected Clusters (PD only)', fontsize=18)
  793. plt.show()
  794. plt.close()
  795. def report(self, include_clinical: bool = True):
  796. print("======================================== Model ClusterEval Report ========================================\n")
  797. print("--------------------------------------------- General Metrics --------------------------------------------\n")
  798. intrinsic_metrics = self._get_intrinsic_metrics(self.PD_data)
  799. print(intrinsic_metrics, '\n')
  800. print("-------------------------------------- Low Dimensional Visualization -------------------------------------\n")
  801. self._UMAP_visualize()
  802. if include_clinical:
  803. print("-------------------------------------- Clinical Metrics Differences -------------------------------------\n")
  804. clinical_metrics = self._get_clinical_means(self.cl_fnames)
  805. self._visualize_metrics(clinical_metrics)
  806. class FeatureImportance:
  807. def __init__(self, features_metadata: pd.DataFrame, n_trials: int, model_path: str, data: np.ndarray):
  808. """
  809. :param features_metadata:
  810. :param n_trials:
  811. :param model_path:
  812. :param data:
  813. """
  814. self.features_metadata = features_metadata
  815. self.n_trials = n_trials
  816. self.data = data
  817. self.base_model = joblib.load(model_path)
  818. self.dr_name = self.base_model.steps[-2][0]
  819. self.cluster_name = self.base_model.steps[-1][0]
  820. def create_splits(self, split_conditions: bool = True, split_regions: bool = False):
  821. """
  822. Creates data splits for feature importance analysis (via calc_feature_importance).
  823. :param split_conditions: If True, splits data between behavioral conditions. Deafult is True.
  824. :param split_conditions: If True, splits data between anatomical regions. Default is False.
  825. :return splits: a tuple (ex_ids, keep_ids) with the indices of the corresponding columns in the data to exlucde and keep.
  826. :return labels: a corresponding label for each split (feature_name, condition, region).
  827. If data was not split by conditions or regions, then condition | region = 'ALL' accordingly.
  828. """
  829. splits, labels = [], []
  830. features = self.features_metadata['FeatureName'].unique()
  831. conditions = self.features_metadata['Condition'].unique() if split_conditions else np.array(['ALL'])
  832. regions = self.features_metadata['Region'].unique() if split_regions else np.array(['ALL'])
  833. for fname in features:
  834. fmask = (self.features_metadata['FeatureName'] == fname)
  835. for cond in conditions:
  836. cmask = (self.features_metadata['Condition'] == cond)
  837. for reg in regions:
  838. rmask = (self.features_metadata['Region'] == reg)
  839. if split_conditions and split_regions: mask = fmask & cmask & rmask
  840. elif split_conditions: mask = fmask & cmask
  841. elif split_regions: mask = fmask & rmask
  842. else: mask = fmask
  843. ex_idx = np.where(mask)[0]
  844. keep_idx= np.where(~mask)[0]
  845. splits.append((ex_idx, keep_idx))
  846. labels.append((fname, cond, reg)) # ALWAYS 3-tuple
  847. return splits, labels
  848. @staticmethod
  849. def fit_predict(model, X: np.ndarray, fit: bool = True):
  850. """
  851. Fits and predicts a clustering model on the input data (X), and returns the cluster labels and silhouette score.
  852. :param model: a clustering model Pipeline object with DR (-2) and clustering (-1) steps.
  853. :param X: the data matrix to predict.
  854. :param fit: If True, fits the model on X, then predicts the labels on X. If False, only performs label prediction.
  855. """
  856. if fit: labels = model.fit_predict(X)
  857. else: labels = model.predict(X)
  858. embed = model[:-1].transform(X)
  859. non_noise = labels != -1
  860. n_clusters = len(np.unique(labels[non_noise]))
  861. if n_clusters > 1 and embed.shape[0] > n_clusters:
  862. sil = silhouette_score(embed[non_noise], labels[non_noise])
  863. else:
  864. sil = np.nan
  865. return labels, sil
  866. def calc_feature_importance(self, split_conditions: bool = True, split_regions: bool = False):
  867. """
  868. Calculates feature importance on the input data using a permutation importance approach.
  869. :param split_conditions: If True, splits data between behavioral conditions. Deafult is True.
  870. :param split_conditions: If True, splits data between anatomical regions. Default is False.
  871. :return: a dataframe with the silhouette drop and ARI for each permuted data split, within each trial.
  872. """
  873. splits, labels = self.create_splits(split_conditions, split_regions)
  874. shape = (len(splits), self.n_trials)
  875. ARI = np.zeros(shape, dtype=float)
  876. silhouette = np.zeros(shape, dtype=float)
  877. rng = np.random.default_rng(42)
  878. for j in tqdm(range(self.n_trials)):
  879. seed = int(rng.integers(0, 1_000_000))
  880. # ---- FULL baseline once per trial ----
  881. if self.cluster_name == 'DBSCAN': seed_params = {f"{dr_name}__random_state": seed}
  882. else: seed_params = {
  883. f"{self.dr_name}__random_state": seed,
  884. f"{self.cluster_name}__random_state": seed
  885. }
  886. raw_model = clone(self.base_model).set_params(**seed_params)
  887. raw_labels, raw_sil = FeatureImportance.fit_predict(model=raw_model, X=self.data)
  888. # ---- Permutation ----
  889. for i, (ex_idx, keep_idx) in enumerate(splits):
  890. Xp = self.data.copy()
  891. for c in np.atleast_1d(ex_idx):
  892. Xp[:, c] = rng.permutation(Xp[:, c])
  893. perm_labels, perm_sil = FeatureImportance.fit_predict(model=raw_model,
  894. X=Xp,
  895. fit=self.cluster_name == 'DBSCAN')
  896. ARI[i, j] = adjusted_rand_score(raw_labels, perm_labels)
  897. silhouette[i, j] = (raw_sil - perm_sil) if (not np.isnan(raw_sil) and not np.isnan(perm_sil)) else 0.0
  898. labels_arr = np.array(labels, dtype=object) # shape (M, 2)
  899. feat_series = np.repeat(labels_arr[:, 0], self.n_trials) # FeatureName repeated
  900. cond_series = np.repeat(labels_arr[:, 1], self.n_trials) # Condition repeated
  901. reg_series = np.repeat(labels_arr[:, 2], self.n_trials) # region repeated
  902. trials = np.tile(np.arange(1, self.n_trials + 1), len(splits))
  903. return pd.DataFrame({
  904. "FeatureName": feat_series,
  905. "Condition": cond_series,
  906. "Region": reg_series,
  907. "Trial": trials,
  908. "ARI": ARI.reshape(-1),
  909. "SilhouetteDrop": silhouette.reshape(-1)
  910. })
  911. @staticmethod
  912. def plot_feature_importance(importance: pd.DataFrame, order = None, xlabels = None):
  913. """
  914. Plots feature importance data that were pre-calculated.
  915. :param importance: a dataframe with the feature importance results.
  916. :param order: a list representing the ordr of features for display.
  917. :param xlables: an alternative list of xlabels, corresponding to each feature in the given order.
  918. """
  919. fig, axes = plt.subplots(1, 2, figsize=(15, 5))
  920. sns.barplot(importance, hue='Condition', x='FeatureName', y='ARI',
  921. ax=axes[0], capsize=.2, errorbar='se', order=order)
  922. axes[0].set_title('Adjusted Rand Index (ARI) between Raw and Permuted Data')
  923. axes[0].set_ylim(0.7, 0.95)
  924. axes[0].set_xlabel('Feature Name')
  925. if xlabels is not None:
  926. axes[0].set_xticklabels(xlabels)
  927. axes[0].legend(loc='upper right')
  928. sns.barplot(importance, hue='Condition', x='FeatureName', y='SilhouetteDrop',
  929. ax=axes[1], capsize=.2, errorbar='se', order=order)
  930. axes[1].set_title('Drop in Silhouette Score After Permutation')
  931. axes[1].set_xlabel('Feature Name')
  932. axes[1].set_ylabel('$\Delta$ Silhouette')
  933. if xlabels is not None:
  934. axes[1].set_xticklabels(xlabels)
  935. axes[1].legend(loc='upper right')
  936. plt.suptitle('Permutation Feature Importance between Conditions', fontsize=16, y=1.02)
  937. plt.show()

ClusteringUtilz.py at commit e8c6780, under MIT · at the source

Overview

Authors: Daniel Vered1,2, Zoya Katzir1,3, Idan Daniel Grosbard2, Avner Thaler1,2,3, Inbal Maidan1,2,3
ORCID iDs: Avner Thaler
  1. Laboratory of Early Markers of Neurodegeneration, Neurological Institute, Tel Aviv Sourasky Medical Center, Tel Aviv, Israel
  2. Sagol School of Neuroscience, Tel Aviv University, Tel Aviv, Israel
  3. Department of Neurology, Gray Faculty of Medical and Health Sciences, Tel Aviv University, Tel Aviv, Israel
Institutions: Tel Aviv University (Israel); Tel Aviv Sourasky Medical Center (Israel)
Dates: received 27 January 2026; accepted 16 April 2026; published online 6 May 2026; in print August 2026
Type: Research article · Language: English
License: CC BY-NC-ND
Identifiers: DOI 10.1002/mds.70348 · PMID 42089402 · PMCID PMC13518290 · OpenAlex W7160381131
Open access: hybrid, a free copy (OpenAlex)
Status: code verified
Categories: EEG (modality), human (organism), Parkinson's (population)
Methods: Spectral & time-frequency, Connectivity, Statistics, Smoothing, state filtering, decompositions, Complexity
Keywords: Parkinson's disease, electroencephalography (EEG), neurophysiological subtypes, clustering analysis, neural heterogeneity
MeSH: Brain*, Electroencephalography*, Parkinson Disease*, Aged, Cluster Analysis, Clustering Algorithms, Female, Humans, Leucine-Rich Repeat Serine-Threonine Protein Kinase-2, Male, Middle Aged (* major topic)
Topic: Neurological disorders and treatments (Neurology, Medicine), according to OpenAlex
Funding: Israel Science Foundation (1157/20)
Citations: cited by 1 paper (Europe PMC); 48 references in the paper

Abstract

The abstract is not reproduced here: the paper's license (CC BY-NC-ND) does not allow it. Read it in the paper, at the publisher or on Europe PMC.

Repositories

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

ronmon1994/eeg-preprocessing-toolkit

License: none: the authors keep all their rights
State: the link answers, verified on 28 September 2026
Evidence: files inventoried
Commit: ce581702def5bb04ca8b92b9622dcd1f44989690, 28 January 2025
Languages: MATLAB (11)
Size: 12 files, 11 scripts
Software Heritage: not archived
Found in: the text, “EEG Acquisition and Preprocessing”
Holds: README
Not found: license file, CITATION.cff, environment file, tests, continuous integration, documentation
Tools: EEGLAB (9 files), ICLabel (1 file)
Availability: 1 check, the latest on 28 September 2026: the link answers
  • 28 September 2026: the link answers
12 files

danielvered/eeg-based-pd-subtyping

License: MIT
State: the link answers, verified on 28 September 2026
Evidence: files inventoried
Commit: e8c6780629c56360123ce0f0dc44e7af41780667, 3 May 2026
Languages: Python (3), Jupyter (3)
Size: 18 files, 6 scripts
Software Heritage: not archived
Found in: “Data Availability Statement”
Holds: README, license file, environment (requirements.txt), 3 notebooks
Not found: CITATION.cff, tests, continuous integration, documentation
Tools: NumPy (6 files), pandas (5 files), scikit-learn (5 files), Matplotlib (4 files), SciPy (4 files), seaborn (4 files), UMAP (4 files), MNE-Python (3 files), specparam (formerly FOOOF) (1 file), statsmodels (1 file)
Availability: 1 check, the latest on 28 September 2026: the link answers
  • 28 September 2026: the link answers
8 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:

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

Code and data availability statement

The paper has a code and data availability statement. Its license (CC BY-NC-ND) does not allow reproducing it here; in short, from what the harvester recognized in it:

Read it in the paper: doi.org/10.1002/mds.70348.

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

  • Publisher: n/a → Wiley

Version 1, 28 September 2026: the first record

Recorded: type, language, journal, volume, issue, pages, dates, 5 authors, 5 keywords, 11 MeSH terms, 1 funder, 40 references.

Cite

This paper

Vered, D., Katzir, Z., Grosbard, I. D., Thaler, A., & Maidan, I. (2026). Electroencephalography-Based Clustering Reveals Robust Neurophysiological Subtypes in Parkinson's Disease. Movement disorders : official journal of the Movement Disorder Society, 41(8), 2107-2116. https://doi.org/10.1002/mds.70348

BibTeX

@article{vered2026electroencephalograp,
author = {Vered, Daniel and Katzir, Zoya and Grosbard, Idan Daniel and Thaler, Avner and Maidan, Inbal},
title = {{Electroencephalography-Based Clustering Reveals Robust Neurophysiological Subtypes in Parkinson's Disease}},
journal = {Movement disorders : official journal of the Movement Disorder Society},
year = {2026},
month = may,
volume = {41},
number = {8},
pages = {2107--2116},
publisher = {Wiley},
issn = {0885-3185},
doi = {10.1002/mds.70348},
url = {https://doi.org/10.1002/mds.70348},
pmid = {42089402},
pmcid = {PMC13518290}
}

RIS

TY - JOUR
AU - Vered, Daniel
AU - Katzir, Zoya
AU - Grosbard, Idan Daniel
AU - Thaler, Avner
AU - Maidan, Inbal
TI - Electroencephalography-Based Clustering Reveals Robust Neurophysiological Subtypes in Parkinson's Disease
T2 - Movement disorders : official journal of the Movement Disorder Society
J2 - Mov Disord
PY - 2026
DA - 2026/05/06
VL - 41
IS - 8
SP - 2107
EP - 2116
SN - 0885-3185
PB - Wiley
DO - 10.1002/mds.70348
UR - https://doi.org/10.1002/mds.70348
LA - en
ER -

CSL-JSON

{
"id": "10.1002/mds.70348",
"type": "article-journal",
"title": "Electroencephalography-Based Clustering Reveals Robust Neurophysiological Subtypes in Parkinson's Disease",
"container-title": "Movement disorders : official journal of the Movement Disorder Society",
"author": [
{
"family": "Vered",
"given": "Daniel"
},
{
"family": "Katzir",
"given": "Zoya"
},
{
"family": "Grosbard",
"given": "Idan Daniel"
},
{
"family": "Thaler",
"given": "Avner"
},
{
"family": "Maidan",
"given": "Inbal"
}
],
"container-title-short": "Mov Disord",
"volume": "41",
"issue": "8",
"page": "2107-2116",
"DOI": "10.1002/mds.70348",
"PMID": "42089402",
"PMCID": "PMC13518290",
"ISSN": "0885-3185",
"publisher": "Wiley",
"URL": "https://doi.org/10.1002/mds.70348",
"language": "en",
"issued": {
"date-parts": [
[
2026,
5,
6
]
]
}
}

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.3389/fncom.2026.1786996 [code]
Schumann-anchored golden ratio organization of human neural oscillations.
Journal: Frontiers in computational neuroscience
In common: specparam (formerly FOOOF), UMAP, MNE-Python, 7 other tools, EEG, 1 reference
[2] doi:10.1162/imag.a.1269 [code]
From early to contemporary normative modeling: Mapping individual differences in neurophysiological signals.
Journal: Imaging neuroscience (Cambridge, Mass.)
In common: specparam (formerly FOOOF), ICLabel, MNE-Python, 7 other tools, EEG, 1 reference
[3] doi:10.1093/cercor/bhag113 [code]
Long-term reliability and stability of parameterized resting state EEG: evidence from a five-year follow-up.
Journal: Cerebral cortex (New York, N.Y. : 1991)
In common: specparam (formerly FOOOF), ICLabel, MNE-Python, 6 other tools, EEG, 1 reference
[4] doi:10.1162/imag.a.1169 [code]
Diazepam alters the shape of alpha oscillations recorded from human cortex using EEG.
Journal: Imaging neuroscience (Cambridge, Mass.)
In common: specparam (formerly FOOOF), EEGLAB, MNE-Python, 5 other tools, EEG, 2 references
[5] doi:10.1371/journal.pcbi.1014043 [code]
EEG-Pype: An accessible MNE-Python pipeline with graphical user interface for preprocessing and analysis of resting-state electroencephalography data.
Journal: PLoS computational biology
In common: specparam (formerly FOOOF), ICLabel, MNE-Python, 4 other tools, EEG, 2 references
[6] doi:10.1097/j.pain.0000000000004044 [code]
No effect of rhythmic visual stimulation on experimental pain perception.
Journal: Pain
In common: specparam (formerly FOOOF), ICLabel, MNE-Python, 6 other tools, EEG
[7] 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, 6 other tools, EEG, 2 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: ICLabel, EEGLAB, MNE-Python, 6 other tools, EEG, 1 reference
[9] doi:10.1016/j.ebiom.2026.106375 [code]
Brainwaves under medication: revealing class-specific neural signatures of psychotropic medication from 24,000 EEGs.
Journal: EBioMedicine
In common: ICLabel, EEGLAB, MNE-Python, 6 other tools, EEG
[10] doi:10.1371/journal.pcbi.1014154 [code]
Complexity of resting cortical activity predicts neurophysiological responses to theta-burst stimulation but fails to generalize: A rigorous machine-learning approach.
Journal: PLoS computational biology
In common: specparam (formerly FOOOF), MNE-Python, statsmodels, 5 other tools, EEG, 1 reference

Contribute

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

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

Request its removal

To ask OSCR to remove this record, the copies of its authors' scripts or its tracing map, use the removal request page: signed in, you say who you are, what to remove and why, then review and confirm the request. Published rules decide every request (how).

Discussion, reproductions, activity

Discussion: questions and error reports about this paper and its code, from signed-in readers and its authors. It opens with sign-in.

Reproductions: reports from readers who ran the authors' code: what they reproduced, with which environment, commit and data. It opens with sign-in.

Activity: what happens around this paper: new versions of its record, its map's validation, discussions and reproductions. It opens with sign-in.