OSCR

In-scanner thoughts contribute to resting-state functional connectivity.

Code ↔ Paper

15 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 15 matches · 3 of them tie a paragraph to a whole file, not to given lines: weak matches, whose lines are not tinted
  1. [1] § Methods › In-scanner experience reports analyses › Clustering ↔ notebooks/S10_SNYCQ_TSNE_Clustering.ipynb, lines 415–442 · score 0.94 · Gaussian Mixture Modeling, Python library scikit, soft clustering nature, spherical clusters, provides membership probabilities, neuroimaging literature
  2. [2] § Methods › In-scanner experience reports analyses › Dimensionality reduction: T-SNE ↔ notebooks/S10_SNYCQ_TSNE_Clustering.ipynb, lines 277–310 · score 0.91 · draw biplot arrows, NSE embedding space, SNE dimensions relate, linear function, maximal change, SNE coordinates
  3. [3] § Methods › In-scanner experience reports analyses › Dimensionality reduction: T-SNE ↔ notebooks/S10_SNYCQ_TSNE_Clustering.ipynb, lines 218–253 · score 0.85 · explored hyper parameter, embedding preserves local, SNE algorithm, t-SNE, trustworthiness, perplexity
  4. [4] § Methods › fMRI Data Pre-processing ↔ bash/S06_NuissanceRegression.sh, lines 86–173 · score 0.79 · nuisance regression, noise regressors, 0.15 Hz, derivative, filtering, AFNI
  5. [5] § Methods › In-scanner experience reports analyses › Data scaling ↔ notebooks/S10_SNYCQ_TSNE_Clustering.ipynb, lines 66–75 · score 0.79 · RobustScaler, inter quantile range, excessive influence, scikit learn, median, outliers
  6. [6] § Methods › In-scanner experience reports analyses › Clustering ↔ notebooks/S10_SNYCQ_TSNE_Clustering.ipynb, lines 415–442 · score 0.79 · low dimensional space, clustering technique, clustering algorithms, inner experience, separate scans, robustness
  7. [7] § Methods › In-scanner experience reports analyses › Outlier detection ↔ notebooks/S10_SNYCQ_TSNE_Clustering.ipynb, lines 85–101 · score 0.65 · squared Mahalanobis distance, Outlier detection, quantile, scan
  8. [8] § Methods › fMRI Data Pre-processing ↔ bash/S04_TransformToMNI.pass01.sh, the whole file · a weak match · score 0.63 · MNI space, motion correction, Pre processing, AFNI, scans
  9. [9] § Methods › Literature search › Connectome-based predictive modeling studies ↔ notebooks/S16_CPM.Development.ipynb, lines 257–315 · score 0.60 · feature selection, cross validation, Fold, CPM, predicted, models
  10. [10] § Methods › Functional connectivity analyses - statistical differences across scan sets › Parcellation ↔ bash/S08_ExtractROIts.sh, the whole file · a weak match · score 0.57 · dNetCorr, pre processing, FC matrices, ROIs, AFNI, atlas
  11. [11] § Methods › Functional connectivity analyses – connectome-based predictive modeling ↔ notebooks/S16_CPM.Development.ipynb, lines 257–315 · score 0.56 · cross validation, gather, training, fold, CPM, metric
  12. [12] § Methods › fMRI Data Pre-processing ↔ bash/S04_TransformToMNI.pass01.sh, the whole file · a weak match · score 0.55 · ANTs, pre processing, MNI152, scan
  13. [13] § Methods › In-scanner experience reports analyses › Clustering ↔ notebooks/S10_SNYCQ_TSNE_Clustering.ipynb, lines 479–515 · score 0.53 · Adjusted Rand, subsamples, ARI, bootstrapping, clusters, SI
  14. [14] § Results › Self-report annotations of resting-state scans ↔ notebooks/S10_SNYCQ_TSNE_Clustering.ipynb, lines 479–515 · score 0.52 · adjusted Rand, subsampling, ARI, bootstrapping, fit, GMM
  15. [15] § Methods › Functional connectivity analyses - statistical differences across scan sets › Parcellation ↔ notebooks/S07_PrepareAtlas.ipynb, lines 1–32 · score 0.52 · Limbic network, subcortical regions, pallidum, AAL, putamen, ROIs

Paper

Loaded from Europe PMC by your browser, not stored by OSCR: doi.org · Europe PMC

The paper is loaded when this pane is shown.

The authors' code

Jupyter notebook · 1,101 lines · 40 KB · CC0-1.0 · 8 matches

  1. # %% [markdown]
  2. # # Description: SNYCQ Initial Exploration, Clustering and TSNE
  3. #
  4. # This notebook contains the following analytical steps associated with the in-scanner experience data
  5. #
  6. # 1. Data scaling: this is accomplished using skicit-learn [```RobustScaler```](https://scikit-learn.org/stable/modules/generated/sklearn.preprocessing.RobustScaler.html)
  7. #
  8. # 2. Outlier detection
  9. #
  10. # 3. Creates figure looking a potential correlations between sNYCQ items
  11. #
  12. # 4. Dimensionality reduction with T-SNE
  13. #
  14. # 5. Clustering analysis in original 11D space
  15. # %%
  16. from utils.basics import get_sbj_scan_list, RESOURCES_DINFO_DIR, RESOURCES_SNYCQ_DIR, ORIG_DEMO_PATH
  17. from utils.plotting import show_correlations_with_statistics
  18. import os.path as osp
  19. from scipy.stats import ttest_ind, mannwhitneyu, wilcoxon
  20. import hvplot.pandas
  21. import holoviews as hv
  22. import seaborn as sns
  23. from tqdm.notebook import tqdm
  24. import warnings
  25. warnings.simplefilter(action='ignore', category=FutureWarning)
  26. # %%
  27. import numpy as np
  28. import pandas as pd
  29. import matplotlib.pyplot as plt
  30. import plotly.graph_objects as go
  31. import panel as pn
  32. from scipy.stats import pearsonr
  33. from sklearn.preprocessing import RobustScaler
  34. from sklearn.covariance import MinCovDet
  35. from sklearn.mixture import GaussianMixture
  36. from sklearn.cluster import KMeans
  37. from sklearn.metrics import silhouette_score, adjusted_rand_score
  38. from sklearn.manifold import TSNE, trustworthiness
  39. from sklearn.linear_model import LinearRegression
  40. # %% [markdown]
  41. # Configurations for outlier detection, ambigous clusters and random seed
  42. # %%
  43. OUTLIER_Q = 0.997 # robust cutoff on squared Mahalanobis distances
  44. AMBIG_THRESH = 0.8 # ambiguous if max(prob) < 0.8
  45. RANDOM_STATE = 42
  46. # %% [markdown]
  47. # ***
  48. # # 1. Load In-scanner Experience Data (SNYCQ)
  49. #
  50. # We load this data only for the scans that have passed our QA for the imaging data
  51. # %%
  52. SBJs, SCANs, SNYCQ_wVigilance = get_sbj_scan_list(when='post_motion', return_snycq=True)
  53. SNYCQ = SNYCQ_wVigilance.drop('Vigilance',axis=1)
  54. Nscans, Nquestions = SNYCQ.shape
  55. print(SNYCQ.shape)
  56. SNYCQ_items = SNYCQ.columns
  57. # %% [markdown]
  58. # ***
  59. # # 2. Data Scaling
  60. # Answers to all questions, except wakefulness, were scaled using scikit-learn ```RobustScaler```. This scaler object removes the median and scales the data by the inter-quantile range. This form of scaling was performed to avoid excessive influence of outliers in the scaling process.
  61. # %%
  62. X_raw_df = SNYCQ[SNYCQ_items].replace([np.inf, -np.inf], np.nan).dropna(axis=0)
  63. idx = X_raw_df.index
  64. X_scaled = RobustScaler().fit_transform(X_raw_df.values)
  65. X_scaled_df = pd.DataFrame(X_scaled,index=idx, columns=X_raw_df.columns)
  66. # %% [markdown]
  67. # We look at the distributions of the sNYCQ data before and after scaling
  68. # %%
  69. plot_dist = X_raw_df.hvplot.hist(title='SNYC-Q pre scaling') + X_scaled_df.hvplot.hist(title='SNYC-Q post scaling', shared_axes=False)
  70. hv.save(plot_dist, osp.join('figures', 'FigureXX-SNYCQ_histograms_pre_post_scaling.html'))
  71. plot_dist
  72. # %% [markdown]
  73. # ![Distributions of SNYQC items before and after scaling](./figures/FigureXX-SNYCQ_histograms_pre_post_scaling.png)
  74. # %% [markdown]
  75. # ***
  76. # # 3. Outlier Detection
  77. #
  78. # We relied on the Mahalanobis distance of each scan to the sample's mean in order to detect outlier scans. We set the threshold to the top 3% quantile
  79. # %%
  80. mcd = MinCovDet().fit(X_scaled_df.values)
  81. # squared Mahalanobis distances for the training set
  82. md2 = mcd.mahalanobis(X_scaled_df.values) if hasattr(mcd, "mahalanobis") else mcd.dist_
  83. # Threshold
  84. thr = np.quantile(md2, OUTLIER_Q)
  85. keep = md2 < thr
  86. # %%
  87. md2_df = pd.DataFrame(md2, index=idx)
  88. md2_df.hvplot(hover_cols=['Subject','Run'], title='Mahalanobis ditance',ylabel='Mahalanobis distance') *hv.HLine(thr).opts(line_width=0.5, line_dash='dashed', line_color='k')
  89. # %% [markdown]
  90. # Now that we have identified two outlier scans, we create a new version of the data where those scans have been removed (e.g., ```X_$$$$_kept```)
  91. # %%
  92. idx_kept = idx[keep]
  93. X_scaled_kept_df = X_scaled_df.loc[idx_kept] # Scaled data for scans not marked as outliers
  94. X_raw_kept_df = X_raw_df.loc[idx_kept] # Original data for scans not marked as outliers
  95. print(f"[Outliers] Flagged {(~keep).sum()} / {len(md2)}; keeping {keep.sum()}")
  96. # %% [markdown]
  97. # # 4. Correlation between SNYCQ items
  98. #
  99. # To explore the structure of in-scanner experience reports, we first computed the Pearson’s correlation between the 11 in-scanner experience items.
  100. # %%
  101. # Compute correlation matrix
  102. X_raw_kept_corr_df = X_raw_kept_df.corr()
  103. # %%
  104. # Estimate P-value matrix (Pearson)
  105. cols = X_raw_kept_df.columns
  106. pval_df = pd.DataFrame(np.zeros((len(cols), len(cols))),
  107. index=cols, columns=cols)
  108. for i, c1 in enumerate(cols):
  109. for j, c2 in enumerate(cols):
  110. if i <= j:
  111. r, p = pearsonr(X_raw_kept_df[c1], X_raw_kept_df[c2])
  112. pval_df.loc[c1, c2] = p
  113. pval_df.loc[c2, c1] = p
  114. # %%
  115. # Get clustering order from seaborn
  116. clustergrid = sns.clustermap(X_raw_kept_corr_df)
  117. plt.close()
  118. row_order = clustergrid.dendrogram_row.reordered_ind
  119. ordered = X_raw_kept_corr_df.index[row_order]
  120. corr_ord = X_raw_kept_corr_df.round(2).loc[ordered, ordered]
  121. pval_ord = pval_df.loc[ordered, ordered]
  122. # %%
  123. # Make sure names exist (used by show_results)
  124. corr_ord.index.name = 'index'
  125. corr_ord.columns.name = 'col'
  126. corr_ord.name = 'corr'
  127. pval_ord.index.name = 'index'
  128. pval_ord.columns.name = 'col'
  129. pval_ord.name = 'pval'
  130. # %%
  131. # Calcualte the number of unique entries to do Bonferroni correction
  132. n_comps = corr_ord.shape[0]*(corr_ord.shape[0]-1)/2
  133. # %%
  134. # Plot: correlation heatmap with bold black outline for pBonf < 0.05
  135. plot = show_correlations_with_statistics(
  136. data_val=corr_ord,
  137. data_pval=pval_ord,
  138. pval_thr=0.05/n_comps,
  139. clabel='Pearson correlation',
  140. height=700,
  141. width=800,
  142. cmap='RdBu_r',
  143. fontscale=1.5,
  144. clim=(-0.7, 0.7)
  145. )
  146. # %% [markdown]
  147. # ![Figure 01 - Panel B](./figures/Figure01_B-SNYCQcorr.png)
  148. #
  149. # ### Saving Publication ready format data and figure panel
  150. #
  151. # We will save now the panel as HTML in a way that we can then export to PDF via the "Print..." option of the browser
  152. # %%
  153. import holoviews as hv
  154. from bokeh.io import save
  155. from bokeh.models.plots import Plot
  156. from bokeh.resources import INLINE
  157. hv.extension("bokeh")
  158. def svg_backend(hv_plot, element):
  159. hv_plot.state.output_backend = "svg"
  160. svg_plot = plot.opts(hooks=[svg_backend])
  161. bokeh_obj = hv.render(svg_plot, backend="bokeh")
  162. # Extra safety for layouts / overlays
  163. if isinstance(bokeh_obj, Plot):
  164. bokeh_obj.output_backend = "svg"
  165. for p in bokeh_obj.select({"type": Plot}):
  166. p.output_backend = "svg"
  167. save(
  168. bokeh_obj,
  169. filename="./figures/Figure01_B-SNYCQcorr.html",
  170. resources=INLINE,
  171. title="Figure01_B",
  172. )
  173. # %%
  174. corr_ord.to_csv('./source_data_files/figure_01_b.csv',float_format='%.2f')
  175. print('Saving source data for Figure 01B: SNYCQ correlation matrix with significant correlations outlined in black [./source_data_files/figure_01_b.csv]')
  176. # %% [markdown]
  177. # ***
  178. # # 4. Dimensionality Reduction with T-SNE
  179. # ## 4.1. Hyper-parameter Optimization
  180. # Two key hyper-parameters of the T-SNE algorithm are dimensionality and perplexity. We relied on [trustworthiness](https://scikit-learn.org/stable/modules/generated/sklearn.manifold.trustworthiness.html)—an estimate of how well a given embedding preserves local distances—to select these two hyper-parameters on a data-driven manner. The explored hyper-parameter space was:
  181. #
  182. # | Hyper-parameter | Space Explored |
  183. # |:----------------|:---------------|
  184. # |dimensionality| {1,2,3}|
  185. # | perplexity | {5,9,10,13,15,18,20,30,40,50} |
  186. # %%
  187. def choose_tsne_perplexity(X, random_state=RANDOM_STATE,n_components=[1,2,3]):
  188. n = X.shape[0]
  189. # Theoretical upper bound: perplexity < (n - 1); practical upper ~ n/3
  190. max_perp = max(5, min(60, (n - 1) // 3))
  191. base = min(max_perp, max(5, int(round(n / 50)))) # ~ n/50 baseline, clamped [5, 60]
  192. # Candidate set around 'base' plus some classics
  193. cands = sorted(set([
  194. 5, 10, 15, 20, 30, 40, 50,
  195. base, int(base*1.5), int(base*2)
  196. ]))
  197. cands = [p for p in cands if 5 <= p <= max_perp and p < (n - 1)]
  198. scores = []
  199. for nc in tqdm(n_components, position=0, desc='Num Components'):
  200. for p in tqdm(cands, position=1, desc='Perplexity', leave=False):
  201. tsne_tmp = TSNE(n_components=nc, perplexity=p, learning_rate='auto', init='pca',
  202. n_iter_without_progress=600, early_exaggeration=12.0, random_state=random_state)
  203. Ytmp = tsne_tmp.fit_transform(X)
  204. tw = trustworthiness(X, Ytmp, n_neighbors=10, metric='euclidean')
  205. scores.append((nc,p, tw))
  206. # pick the perplexity with max trustworthiness; break ties by preferring mid-range
  207. scores.sort(key=lambda t: (-t[2], abs(t[0] - 30)))
  208. return scores[0][0], scores[0][1], pd.DataFrame(scores, columns=["num_components","perplexity", "trustworthiness"])
  209. best_nc, best_perp, tw_table = choose_tsne_perplexity(X_scaled_kept_df.values, RANDOM_STATE)
  210. # %% [markdown]
  211. # Now we show what were the values that maximized the trustworthiness of the T-SNE embeddings
  212. # %%
  213. print(f"[t-SNE tuning] Selected perplexity = {best_perp}")
  214. print(f"[t-SNE tuning] Selected dimensionaity = {best_nc}")
  215. print(f"[t-SNE tuning] Trustworthiness = {tw_table['trustworthiness'].max()}")
  216. # %% [markdown]
  217. # ## 4.2. Compute T-SNE with optimal dimensionality and perplexity
  218. # %%
  219. tsne = TSNE(
  220. n_components=best_nc, perplexity=best_perp, learning_rate='auto', init='pca',
  221. n_iter_without_progress=1000, early_exaggeration=12.0, random_state=RANDOM_STATE
  222. )
  223. Y = tsne.fit_transform(X_scaled_kept_df.values)
  224. if best_nc == 2:
  225. emb = pd.DataFrame(Y, index=idx_kept, columns=["TSNE1", "TSNE2"])
  226. else:
  227. emb = pd.DataFrame(Y, index=idx_kept, columns=["TSNE1", "TSNE2","TSNE3"])
  228. # %% [markdown]
  229. # ## 4.3. Compute Biplot Arrows inidicating directions of maximal variance for each SNYCQ item
  230. #
  231. # To aid with the interpretation of how T-SNE dimensions relate to the original items in the SNYC survey, we modeled each of the SNYCQ items as a linear function of the three T-SNE coordinates. We then used these coefficients to draw biplot arrows depicting the directions of maximal change for each SNYCQ item in the 3D T-NSE embedding space.
  232. # %%
  233. def compute_biplot_arrows(emb_df, features_df, feature_names):
  234. """
  235. For each feature f: fit f ~ a*X + b*Y on the 2D embedding; return direction (a,b) and R^2.
  236. features_df should be standardized/robust-scaled (we use Xr_kept).
  237. """
  238. Xy = emb_df.values # shape (n,2)
  239. n_components = Xy.shape[1]
  240. arrows = []
  241. reg = LinearRegression()
  242. for j, f in enumerate(feature_names):
  243. y = features_df.iloc[:, j].values
  244. reg.fit(Xy, y)
  245. if n_components == 2:
  246. a, b = reg.coef_
  247. r2 = reg.score(Xy, y)
  248. arrows.append((f, a, b, r2))
  249. elif n_components == 3:
  250. a,b,c = reg.coef_
  251. r2 = reg.score(Xy,y)
  252. arrows.append((f,a,b,c,r2))
  253. else:
  254. print('++ ERROR: This function only works with 2D and 3D embeddings')
  255. return None
  256. if n_components==2:
  257. arrows_df = pd.DataFrame(arrows, columns=["feature", "beta_x", "beta_y", "R2"]).sort_values("R2", ascending=False)
  258. elif n_components == 3:
  259. arrows_df = pd.DataFrame(arrows, columns=["feature", "beta_x", "beta_y", "beta_z","R2"]).sort_values("R2", ascending=False)
  260. return arrows_df
  261. # %%
  262. tsne_arrows = compute_biplot_arrows(emb,X_raw_kept_df,SNYCQ_items)
  263. # Scaling so that they are clearly visible in the plot
  264. tsne_arrows['beta_x'] = tsne_arrows['beta_x'] * 5
  265. tsne_arrows['beta_y'] = tsne_arrows['beta_y'] * 5
  266. tsne_arrows['beta_z'] = tsne_arrows['beta_z'] * 5
  267. # %% [markdown]
  268. # ## 4.4. Plot the T-SNE embedding colored by one SNYCQ item
  269. # %%
  270. # Create a new DF with both th original values and the dimensions in T-SNE (for plotting purposes)
  271. emb_plus = pd.concat([emb, X_raw_kept_df], axis=1)
  272. # %%
  273. tsne_camera_object = dict(
  274. center=dict(x=6.661338147750939e-16, y=-1.942890293094024e-16, z=8.881784197001252e-16),
  275. eye=dict(x=0.4505864572659788, y=1.5176226342505497, z=-1.6428294069570146),
  276. up=dict(x=-0.7264163680411282, y=0.05191238226958195, z=0.6852914451596732)
  277. )
  278. # Create the scatter trace
  279. scatter_trace = go.Scatter3d(
  280. x=emb_plus['TSNE1'],
  281. y=emb_plus['TSNE2'],
  282. z=emb_plus['TSNE3'],
  283. mode='markers',
  284. marker=dict(
  285. size=5, # Fixed small size
  286. color=emb_plus['People'], # Color mapped to 'People'
  287. colorscale='viridis', # Viridis colormap
  288. opacity=0.8,
  289. colorbar=dict(title='People') # Optional colorbar
  290. ),
  291. text=[
  292. "<br>".join(f"{col}: {row[col]}" for col in emb_plus.columns if col not in ['TSNE1', 'TSNE2', 'TSNE3'])
  293. for _, row in emb_plus.iterrows()
  294. ],
  295. hoverinfo='text'
  296. )
  297. # === 2. Arrows from (0, 0, 0) to (beta_x, beta_y, beta_z) ===
  298. arrow_traces = []
  299. for i, row in tsne_arrows.iterrows():
  300. # Line (arrow body)
  301. if row['feature'] == 'People':
  302. arrow_color = 'red'
  303. else:
  304. arrow_color = 'black'
  305. arrow_trace = go.Scatter3d(
  306. x=[0, row['beta_x']],
  307. y=[0, row['beta_y']],
  308. z=[0, row['beta_z']],
  309. mode='lines',
  310. line=dict(
  311. color=arrow_color,
  312. width=max(1, row['R2'] * 10), # Scale width by R2
  313. ),
  314. showlegend=False
  315. )
  316. # Text label at arrow tip
  317. label_trace = go.Scatter3d(
  318. x=[row['beta_x']],
  319. y=[row['beta_y']],
  320. z=[row['beta_z']],
  321. mode='text',
  322. text=[row['feature']],
  323. textposition='top center',
  324. showlegend=False
  325. )
  326. arrow_traces.extend([arrow_trace, label_trace])
  327. # === 3. Combine all traces ===
  328. fig = go.Figure(data=[scatter_trace] + arrow_traces)
  329. # === 4. Update layout ===
  330. fig.update_layout(
  331. scene=dict(
  332. xaxis_title='TSNE1',
  333. yaxis_title='TSNE2',
  334. zaxis_title='TSNE3',
  335. aspectmode='data',
  336. camera=tsne_camera_object
  337. ),
  338. title='3D t-SNE Plot with Feature Arrows',
  339. margin=dict(l=0, r=0, b=0, t=40),
  340. height=700,
  341. showlegend=False
  342. )
  343. fig.show()
  344. # %%
  345. fig.write_html(osp.join('figures', 'FigureXX-SNYCQtsne_colorby_People.html'))
  346. # %% [markdown]
  347. # ![TSNE Map colored by the People question](./figures/FigureXX-SNYCQtsne_colorby_People.png)
  348. # %% [markdown]
  349. # ***
  350. # # 5. Clustering Analyses
  351. #
  352. # ## 5.1 Apply K-means and Gaussian Mixture Modeling to the data (K=2)
  353. #
  354. # To separate scans into two sets with well-differentiated inner-experience, we decided to apply two different clustering algorithms to the in-experience data (11 questions) following robust scaling.
  355. #
  356. # >NOTE: To be clear, clustering was performed in the original 11D space, not the low dimensional space generated by T-SNE.
  357. #
  358. # Working with two different clustering methods allows to evaluate the robustness of results against clustering technique.
  359. #
  360. # The two selected methods were [K-Means](https://scikit-learn.org/stable/modules/generated/sklearn.cluster.KMeans.html) and [Gaussian Mixture Modeling](https://scikit-learn.org/stable/modules/generated/sklearn.mixture.GaussianMixture.html); both as implemented in the python library scikit-learn.
  361. #
  362. # K-Means was chosen as a representative hard-clustering method often used in the neuroimaging literature.
  363. #
  364. # Gaussian Mixture Modeling was chosen because of its soft-clustering nature (i.e., it provides membership probabilities) and because it allows non-spherical clusters. In both instances, we set k=2.
  365. #
  366. # The next cell computes the GMM clustering for K = 2
  367. # %%
  368. K = 2
  369. gm = GaussianMixture(n_components=K, covariance_type="spherical", random_state=RANDOM_STATE).fit(X_scaled_kept_df.values)
  370. proba = gm.predict_proba(X_scaled_kept_df.values)
  371. labels_gmm = proba.argmax(axis=1)
  372. # %% [markdown]
  373. # Now we do the same using KMeans for comparison
  374. # %%
  375. km = KMeans(n_clusters=K, random_state=RANDOM_STATE, n_init=10).fit(X_scaled_kept_df.values)
  376. labels_km = km.predict(X_scaled_kept_df.values)
  377. # %% [markdown]
  378. # ## 5.2. Cluster method comparison with ARI and Silhouette Index
  379. #
  380. # We now check for the consistency of the clustering results across both methods using the Asjusted Rand Index (ARI), and also for their quality, separately, using the Silhouette Index (SI)
  381. #
  382. # 1. Compute the SI for each clustering result
  383. # %%
  384. sil_gmm = silhouette_score(X_scaled_kept_df.values, labels_gmm)
  385. sil_km = silhouette_score(X_scaled_kept_df.values, labels_km)
  386. # %% [markdown]
  387. # 2. Compute the ARI comparing both methods
  388. # %%
  389. ari_km_gmm = adjusted_rand_score(labels_km, labels_gmm)
  390. # %% [markdown]
  391. # 3. Print the computed statistics
  392. # %%
  393. print(f"[GMM k=2 sph] silhouette={sil_gmm:.3f}")
  394. print(f"[K-M k=2 ] silhouette={sil_km:.3f}")
  395. print(f"[Agreement] ARI(KMeans vs GMM) = {ari_km_gmm:.3f}")
  396. print(f"[Sizes] GMM clusters: {np.bincount(labels_gmm)}")
  397. # %% [markdown]
  398. # Based on the SI, we decided to move forward with the GMM solustion, which we explore in further detail.
  399. # %% [markdown]
  400. # ## 5.3. Bootstraing Analysis for GMM
  401. # %%
  402. def subsample_labels(model, X, frac=0.8, seed=0):
  403. rng = np.random.RandomState(seed)
  404. n = X.shape[0]
  405. take = np.sort(rng.choice(n, int(frac*n), replace=False)) # sort for convenience
  406. # clone model with same hyperparams
  407. m2 = type(model)(**model.get_params())
  408. if hasattr(m2, "random_state"):
  409. m2.random_state = seed
  410. # (KMeans has fit_predict; GMM needs fit + predict)
  411. if hasattr(m2, "fit_predict"):
  412. labels = m2.fit_predict(X[take])
  413. else:
  414. m2.fit(X[take]); labels = m2.predict(X[take])
  415. return take, labels
  416. def bootstrap_ari(model, X, n_runs=20, frac=0.8, base_seed=0):
  417. aris = []
  418. for s in range(n_runs):
  419. i1, l1 = subsample_labels(model, X, frac=frac, seed=base_seed + 2*s)
  420. i2, l2 = subsample_labels(model, X, frac=frac, seed=base_seed + 2*s + 1)
  421. # align to the same samples, same order
  422. common = np.intersect1d(i1, i2)
  423. # positions of the common indices in each subsample
  424. pos1 = np.searchsorted(i1, common)
  425. pos2 = np.searchsorted(i2, common)
  426. aris.append(adjusted_rand_score(l1[pos1], l2[pos2]))
  427. return float(np.mean(aris)), float(np.std(aris))
  428. gm_mean_ari, gm_sd_ari = bootstrap_ari(gm, X_scaled_kept_df.values, n_runs=20, frac=0.8, base_seed=100)
  429. print("Bootstrap ARI (GMM) mean±sd: %.2f +/- %.2f" %(gm_mean_ari, gm_sd_ari))
  430. # %% [markdown]
  431. # ***
  432. # ## 5.4. Detection of scans with ambigous cluster membership
  433. #
  434. # One additional bonus of GMM over K-means is that GMM not being a hard clustering algorithm, it outputs cluster membershup probabilities. We will use these to detect scans with ambiguous membership. Such scans will not be included in the population differences analyses later on.
  435. # %%
  436. proba_df = pd.DataFrame(proba, index=idx_kept, columns=['P(c1)','P(c2)'])
  437. proba_df.hvplot.hist('P(c1)', title='Probability Distribution for Membership in Cluster 1')
  438. # %%
  439. # store probabilities & an ambiguity flag
  440. p1 = proba[:, 1]
  441. ambiguity = np.maximum(1 - p1, p1) < (AMBIG_THRESH)
  442. # %% [markdown]
  443. # Save final cluster/sets labels in a new pandas dataframe: ```group_info_df```
  444. # %%
  445. # Store that information with clear labels in a pandas Dataframe
  446. group_info_df = pd.DataFrame(index=idx_kept, columns=['Set Label', 'Group Probability'])
  447. for i,scan in enumerate(idx_kept):
  448. if ambiguity[i]:
  449. group_info_df.loc[scan,"Set Label"] = "Ambiguous"
  450. group_info_df.loc[scan,"Group Probability"] = np.max([p1[i],1-p1[i]])
  451. else:
  452. if p1[i] > 1 - p1[i]:
  453. group_info_df.loc[scan,"Set Label"] = "Set B"
  454. group_info_df.loc[scan,"Group Probability"] = p1[i]
  455. else:
  456. group_info_df.loc[scan,"Set Label"] = "Set A"
  457. group_info_df.loc[scan,"Group Probability"] = 1 - p1[i]
  458. group_info_df['Set Label'].value_counts()
  459. # %% [markdown]
  460. # Add cluster membership information to the TSNE embedding information, and save two versions to disk: one with the original SNYCQ items, one with their scaled values.
  461. # %%
  462. emb_plus = pd.concat([emb_plus, group_info_df], axis=1)
  463. emb_plus.to_csv(osp.join(RESOURCES_SNYCQ_DIR, 'SNYCQ_tsne_embeddings_plus.csv'))
  464. print('++ INFO: Saved t-SNE embeddings with original features and group info to CSV: %s' % osp.join(RESOURCES_SNYCQ_DIR, 'SNYCQ_tsne_embeddings_plus.csv'))
  465. emb_plus_scaled = pd.concat([emb, X_scaled_kept_df, group_info_df], axis=1)
  466. emb_plus_scaled.to_csv(osp.join(RESOURCES_SNYCQ_DIR, 'SNYCQ_tsne_embeddings_plus_scaled.csv'))
  467. print('++ INFO: Saved t-SNE embeddings with scaled features and group info to CSV: %s' % osp.join(RESOURCES_SNYCQ_DIR, 'SNYCQ_tsne_embeddings_plus_scaled.csv'))
  468. # %% [markdown]
  469. # ## 5.5. Plot the T-SNE embedding again, but this time with scans colored according to set membership
  470. # %%
  471. tsne_camera_object = dict(
  472. center=dict(x=0.0, y=0.0, z=0.0),
  473. eye=dict(x=1.25, y=-1.25, z=1.25),
  474. up=dict(x=0.0, y=0.0, z=0.0)
  475. )
  476. scatter_traces = []
  477. group_colors = {
  478. "Set A": "#1f77b4", # light blue
  479. "Set B": "#ff7f0e", # orange
  480. "Ambiguous": "#ffffff" # white fill
  481. }
  482. for group, color in group_colors.items():
  483. group_data = emb_plus[emb_plus["Set Label"] == group]
  484. scatter_traces.append(
  485. go.Scatter3d(
  486. x=group_data["TSNE1"],
  487. y=group_data["TSNE2"],
  488. z=group_data["TSNE3"],
  489. mode='markers',
  490. name=group,
  491. marker=dict(
  492. size=5,
  493. color=color,
  494. opacity=0.9,
  495. line=dict(
  496. color='black' if group == "Ambiguous" else color,
  497. width=2 if group == "Ambiguous" else 0
  498. )
  499. ),
  500. text=[
  501. "<br>".join(
  502. f"{col}: {row[col]}"
  503. for col in emb_plus.columns if col not in ['TSNE1', 'TSNE2', 'TSNE3']
  504. )
  505. for _, row in group_data.iterrows()
  506. ],
  507. hoverinfo='text'
  508. )
  509. )
  510. # === 3. Combine all traces ===
  511. fig = go.Figure(data=scatter_traces + arrow_traces)
  512. fig.update_layout(
  513. scene=dict(
  514. xaxis_title='TSNE1',
  515. yaxis_title='TSNE2',
  516. zaxis_title='TSNE3',
  517. aspectmode='data',
  518. camera=tsne_camera_object
  519. ),
  520. legend=dict(
  521. title='Group:',
  522. itemsizing='constant',
  523. x=0.55, # move horizontally (1.0 is far right, 0.5 is middle)
  524. y=0.9, # move vertically (1.0 is top)
  525. xanchor='left',
  526. yanchor='top',
  527. bgcolor='rgba(255,255,255,0.7)', # optional background for readability
  528. bordercolor='rgba(0,0,0,0.2)',
  529. borderwidth=1
  530. ),
  531. title='3D t-SNE Plot with Feature Arrows',
  532. margin=dict(l=0, r=0, b=0, t=40),
  533. height=700,
  534. showlegend=True
  535. )
  536. fig.show()
  537. # %%
  538. fig.write_html(osp.join('figures', 'Figure01_K_SNYCQtsneWclusters.html'))
  539. # %% [markdown]
  540. # ![TSNE with clusters](./figures/Figure01_K-SNYCQtnseWclusters.png)
  541. # %% [markdown]
  542. # ### Save Publication Ready Figure Panel and Data source
  543. # %%
  544. fig.write_image(osp.join('figures', 'Figure01_K_SNYCQtsneWclusters.svg'))
  545. # %%
  546. emb_plus[['TSNE1', 'TSNE2', 'TSNE3','Set Label']].to_csv('./source_data_files/figure_01_k.csv', float_format='%.3f', index=False)
  547. # %% [markdown]
  548. # ***
  549. # ## 6. Explore how Sets A and B differ in terms of SNYCQ items, vigilance, head motion and basic demographics
  550. #
  551. # We will seek for potential differences across groups in the following variables:
  552. #
  553. # * All entries in the SNYCQ, including wakefulness
  554. # * Mean head motion
  555. # * Age distribution
  556. # * Gender distribution
  557. #
  558. # We will do this using Cohen's d, MannWhitney tests and Wilconxon tests
  559. # %% [markdown]
  560. # ## 6.1. Examination of differences in age distribution
  561. # %%
  562. # Load Demographic Data
  563. demographics = pd.read_csv(ORIG_DEMO_PATH, index_col=0,sep='\t')
  564. demographics = demographics.loc[list(SBJs)]
  565. # Load Demographic Data
  566. #demographics = pd.read_csv(osp.join(RESOURCES_SNYCQ_DIR,'participants_post_motion_QA.csv'), index_col=0)
  567. # Extract information about age
  568. age_per_scan = pd.DataFrame(index=idx_kept, columns=['Set Label','Age (5-year bins)'])
  569. for sbj,run in tqdm(idx_kept):
  570. age_range = demographics.loc[sbj,'age (5-year bins)']
  571. group_label = emb_plus.loc[(sbj,run),'Set Label']
  572. age_per_scan.loc[(sbj,run),'Age (5-year bins)'] = age_range
  573. age_per_scan.loc[(sbj,run),'Set Label'] = group_label
  574. # Remove entries for ambiguous scans
  575. age_per_scan = age_per_scan[age_per_scan['Set Label']!='Ambiguous']
  576. # Get counts of scas in each age range
  577. age_counts_per_group = age_per_scan.groupby('Set Label').value_counts()
  578. age_counts_per_group = age_counts_per_group.infer_objects()
  579. # Prepare Dataframe for plotting with hvplot
  580. age_counts_per_group = age_counts_per_group.reset_index()
  581. age_counts_per_group.columns = ['Group','Age Range','# Scans']
  582. age_counts_per_group.replace({'Set A':'A', 'Set B':'B'}, inplace=True)
  583. age_counts_per_group['color'] = '#ffffff'
  584. age_counts_per_group.loc[age_counts_per_group['Group']=='A','color'] = group_colors['Set A']
  585. age_counts_per_group.loc[age_counts_per_group['Group']=='B','color'] = group_colors['Set B']
  586. age_counts_per_group = age_counts_per_group.infer_objects()
  587. age_counts_per_group = age_counts_per_group.sort_values(by='Age Range', ascending=True)
  588. # %%
  589. A = age_counts_per_group.set_index(['Group','Age Range']).loc['A',:]['# Scans']
  590. B = age_counts_per_group.set_index(['Group','Age Range']).loc['B',:]['# Scans']
  591. W, w_p = wilcoxon(A,B, alternative='two-sided', method='exact')
  592. print('++ AGE ACROSS SETS: Wilcoxon = %.2f (p = %.2f)' % (W,w_p))
  593. # %%
  594. # Generate graph that will get later added to a Grid with information about all variables
  595. age_bar_plot = age_counts_per_group.hvplot.bar(x='Age Range',by='Group', alpha=0.5, xlabel='Age',cmap=["#1f77b4","#ff7f0e"]).opts(toolbar=None, xrotation=90, width=250, height=200, fontscale=1)
  596. # %% [markdown]
  597. # ![Age per set](./figures/Figure01_I-AgePerSet.png)
  598. # %% [markdown]
  599. # ### Save publication ready and source data for Figure 01I
  600. # %%
  601. age_counts_per_group.to_csv('./source_data_files/figure_01_i.csv', index=False)
  602. # %%
  603. svg_plot = age_bar_plot.opts(hooks=[svg_backend])
  604. bokeh_obj = hv.render(svg_plot, backend="bokeh")
  605. # Extra safety for layouts / overlays
  606. if isinstance(bokeh_obj, Plot):
  607. bokeh_obj.output_backend = "svg"
  608. for p in bokeh_obj.select({"type": Plot}):
  609. p.output_backend = "svg"
  610. save(
  611. bokeh_obj,
  612. filename="./figures/Figure01_I-AgePerSet.html",
  613. resources=INLINE,
  614. title="Figure01_I",
  615. )
  616. # %% [markdown]
  617. # ## 6.2. Examination of differneces in gender distribution
  618. # %%
  619. # Extract information about age
  620. sex_per_scan = pd.DataFrame(index=idx_kept, columns=['Set Label','Sex'])
  621. for sbj,run in tqdm(idx_kept):
  622. sex = demographics.loc[sbj,'gender']
  623. if sex == 'M':
  624. sex = 'Male'
  625. else:
  626. sex = 'Female'
  627. group_label = emb_plus.loc[(sbj,run),'Set Label']
  628. sex_per_scan.loc[(sbj,run),'Sex'] = sex
  629. sex_per_scan.loc[(sbj,run),'Set Label'] = group_label
  630. # Remove entries for ambiguous scans
  631. sex_per_scan = sex_per_scan[sex_per_scan['Set Label']!='Ambiguous']
  632. # Get counts of scas in each age range
  633. sex_counts_per_group = sex_per_scan.groupby('Set Label').value_counts()
  634. sex_counts_per_group = sex_counts_per_group.infer_objects()
  635. sex_counts_per_group
  636. # %%
  637. sex_bar_plot = sex_counts_per_group.hvplot.bar(stacked=True, xlabel='', legend='top_left', title='', ylabel='# Scans', color=['white','gray']).opts(toolbar=None, width=250, height=200, fontscale=1)
  638. #hv.save(sex_bar_plot, osp.join('figures', 'Figure01_J-SexPerSet.html'))
  639. # %% [markdown]
  640. # ![Sex per Set](./figures/Figure01_J-SexPerSet.png)
  641. #
  642. # ### Save Publication Ready and Data Source
  643. # %%
  644. svg_plot = sex_bar_plot.opts(hooks=[svg_backend])
  645. bokeh_obj = hv.render(svg_plot, backend="bokeh")
  646. # Extra safety for layouts / overlays
  647. if isinstance(bokeh_obj, Plot):
  648. bokeh_obj.output_backend = "svg"
  649. for p in bokeh_obj.select({"type": Plot}):
  650. p.output_backend = "svg"
  651. save(
  652. bokeh_obj,
  653. filename="./figures/Figure01_J-SexPerSet.html",
  654. resources=INLINE,
  655. title="Figure01_J",
  656. )
  657. # %%
  658. sex_counts_per_group.to_csv('./source_data_files/figure_01_j.csv')
  659. # %% [markdown]
  660. # ## 6.3. Examination of diffrences in head motion
  661. # %%
  662. # Load motion information for each scan
  663. mot_info = pd.read_csv(osp.join(RESOURCES_DINFO_DIR,'motion_confounds.csv'),index_col=['Subject','Run'])
  664. scans_in_A = emb_plus[emb_plus['Set Label'] == 'Set A'].index
  665. scans_in_B = emb_plus[emb_plus['Set Label'] == 'Set B'].index
  666. mot_A = mot_info.loc[scans_in_A,'Mean Rel Motion'].values
  667. mot_B = mot_info.loc[scans_in_B,'Mean Rel Motion'].values
  668. U, u_p = mannwhitneyu(mot_A,mot_B,alternative='two-sided')
  669. print('++ AGE ACROSS SETS: Mann-Whiteney U = %.2f (p = %.2f)' % (U,u_p))
  670. # %%
  671. mot_A = mot_info.loc[scans_in_A,'Mean Rel Motion']
  672. mot_B = mot_info.loc[scans_in_B,'Mean Rel Motion']
  673. overlay = mot_A.hvplot.hist(label='Set A', c=group_colors['Set A'], title='', width=250, height=200, alpha=0.5, shared_axes=False, bins=20, normed=True).opts(toolbar=None) * \
  674. mot_B.hvplot.hist(label='Set B', c=group_colors['Set B'], alpha=0.5, shared_axes=False, bins=20, normed=True) * \
  675. mot_A.hvplot.kde(label='Set A', c=group_colors['Set A'], alpha=0.5, shared_axes=False) * \
  676. mot_B.hvplot.kde(label='Set B', c=group_colors['Set B'], alpha=0.5, shared_axes=False)
  677. # %% [markdown]
  678. # ![Motion per set](./figures/Figure01_H-MotionPerSet.png)
  679. #
  680. # ### Save Publication ready figure and Source Data
  681. # %%
  682. svg_plot = overlay.opts(hooks=[svg_backend])
  683. bokeh_obj = hv.render(svg_plot, backend="bokeh")
  684. # Extra safety for layouts / overlays
  685. if isinstance(bokeh_obj, Plot):
  686. bokeh_obj.output_backend = "svg"
  687. for p in bokeh_obj.select({"type": Plot}):
  688. p.output_backend = "svg"
  689. save(
  690. bokeh_obj,
  691. filename="./figures/Figure01_H-MotionPerSet.html",
  692. resources=INLINE,
  693. title="Figure01_H",
  694. )
  695. # %%
  696. pd.concat([mot_A.reset_index(drop=True), mot_B.reset_index(drop=True)], axis=1, keys=['Set A','Set B']).to_csv('./source_data_files/figure_01_h.csv', index=False)
  697. # %% [markdown]
  698. # ## 6.4 Examination of Vigilance
  699. # %%
  700. vigilance = SNYCQ_wVigilance['Vigilance']
  701. vigilance.name = 'Wakefulness'
  702. scans_in_A = emb_plus[emb_plus['Set Label'] == 'Set A'].index
  703. scans_in_B = emb_plus[emb_plus['Set Label'] == 'Set B'].index
  704. vigilance_A = vigilance.loc[scans_in_A].values
  705. vigilance_B = vigilance.loc[scans_in_B].values
  706. U, u_p = mannwhitneyu(vigilance_A,vigilance_B,alternative='two-sided')
  707. print('++ VIGILANCE ACROSS SETS: Mann-Whiteney U = %.2f (p = %.2f)' % (U,u_p))
  708. # %%
  709. vigilance_A = vigilance.loc[scans_in_A]
  710. vigilance_B = vigilance.loc[scans_in_B]
  711. overlay = vigilance_A.hvplot.hist(label='Set A', c=group_colors['Set A'], title='', width=250, height=200, alpha=0.5, shared_axes=False, bins=[0,10,20,30,40,50,60,70,80,90,100], normed=True, legend=False).opts(toolbar=None) * \
  712. vigilance_B.hvplot.hist(label='Set B', c=group_colors['Set B'], alpha=0.5, shared_axes=False, bins=[0,10,20,30,40,50,60,70,80,90,100], normed=True) * \
  713. vigilance_A.hvplot.kde(label='Set A', c=group_colors['Set A'], alpha=0.5, shared_axes=False) * \
  714. vigilance_B.hvplot.kde(label='Set B', c=group_colors['Set B'], alpha=0.5, shared_axes=False)
  715. #hv.save(overlay, osp.join('figures', 'Figure01_G-VigilancePerSet.html'))
  716. # %% [markdown]
  717. # ![Wakefulness per set](./figures/Figure01_G-VigilancePerSet.png)
  718. # ### Save Publication Ready Panel and Source Data
  719. # %%
  720. svg_plot = overlay.opts(hooks=[svg_backend])
  721. bokeh_obj = hv.render(svg_plot, backend="bokeh")
  722. # Extra safety for layouts / overlays
  723. if isinstance(bokeh_obj, Plot):
  724. bokeh_obj.output_backend = "svg"
  725. for p in bokeh_obj.select({"type": Plot}):
  726. p.output_backend = "svg"
  727. save(
  728. bokeh_obj,
  729. filename="./figures/Figure01_G-VigilancePerSet.html",
  730. resources=INLINE,
  731. title="Figure01_G",
  732. )
  733. # %%
  734. pd.concat([vigilance_A.reset_index(drop=True), vigilance_B.reset_index(drop=True)], axis=1, keys=['Set A','Set B']).to_csv('./source_data_files/figure_01_g.csv', index=False)
  735. # %% [markdown]
  736. # ## 6.5. Examination of differences in SNYCQ items
  737. # %%
  738. def cohens_d(x0, x1):
  739. m0, m1 = np.nanmean(x0), np.nanmean(x1)
  740. s0, s1 = np.nanstd(x0, ddof=1), np.nanstd(x1, ddof=1)
  741. n0, n1 = np.sum(~np.isnan(x0)), np.sum(~np.isnan(x1))
  742. sp = np.sqrt(((n0-1)*s0**2 + (n1-1)*s1**2) / (n0 + n1 - 2))
  743. return (m1 - m0) / sp if sp > 0 else np.nan
  744. def calculate_stats_per_set(df, items, label_col='Set Label', method="bootstrap",
  745. B=2000, # bootstrap reps
  746. ci=95,random_state=123):
  747. rng = np.random.default_rng(random_state)
  748. alpha_low = (100 - ci) / 2.0
  749. alpha_high = 100 - alpha_low
  750. out = []
  751. for f in items:
  752. x0 = df.loc[df[label_col] == "Set A", f].dropna().values
  753. x1 = df.loc[df[label_col] == "Set B", f].dropna().values
  754. m0 = float(np.mean(x0)) if x0.size else np.nan
  755. m1 = float(np.mean(x1)) if x1.size else np.nan
  756. d = cohens_d(x0,x1)
  757. u, p = mannwhitneyu(x0,x1,alternative='two-sided')
  758. if method == "bootstrap":
  759. # nonparametric bootstrap of the mean per cluster
  760. if x0.size >= 2:
  761. boots0 = [np.mean(x0[rng.integers(0, x0.size, x0.size)]) for _ in range(B)]
  762. lo0, hi0 = np.percentile(boots0, [alpha_low, alpha_high])
  763. else:
  764. lo0 = hi0 = np.nan
  765. if x1.size >= 2:
  766. boots1 = [np.mean(x1[rng.integers(0, x1.size, x1.size)]) for _ in range(B)]
  767. lo1, hi1 = np.percentile(boots1, [alpha_low, alpha_high])
  768. else:
  769. lo1 = hi1 = np.nan
  770. elif method == "analytic":
  771. # mean ± z * SE; SE = s / sqrt(n)
  772. z = 1.96 if ci == 95 else None
  773. if z is None:
  774. from scipy.stats import norm
  775. z = float(norm.ppf(0.5 + ci/200.0))
  776. if x0.size >= 2:
  777. se0 = float(np.std(x0, ddof=1) / np.sqrt(x0.size))
  778. lo0, hi0 = m0 - z*se0, m0 + z*se0
  779. else:
  780. lo0 = hi0 = np.nan
  781. if x1.size >= 2:
  782. se1 = float(np.std(x1, ddof=1) / np.sqrt(x1.size))
  783. lo1, hi1 = m1 - z*se1, m1 + z*se1
  784. else:
  785. lo1 = hi1 = np.nan
  786. else:
  787. raise ValueError("method must be 'bootstrap' or 'analytic'.")
  788. out.append((f, d, u,p, m0, lo0, hi0, m1, lo1, hi1))
  789. out_df = pd.DataFrame(out, columns=["Item", "d(Set B - Set A)", "MW (U)","MW (p)","Set A (mean)", "lo (Set A)", "hi (Set A)", "Set B (mean)", "lo (Set B)", "hi (Set B)"]).set_index("Item")
  790. return out_df
  791. # %%
  792. non_ambiguous_scans = emb_plus[emb_plus['Set Label']!='Ambiguous'].index
  793. data_items = SNYCQ_wVigilance.loc[non_ambiguous_scans,:]
  794. data_labels = emb_plus.loc[non_ambiguous_scans,'Set Label']
  795. data = pd.concat([data_items,data_labels],axis=1)
  796. stats_per_set = calculate_stats_per_set(data,[c for c in data_items.columns if c !='Vigilance'],'Set Label')
  797. table_02 = stats_per_set.sort_values(by="d(Set B - Set A)", ascending=False).round(2)[['Set A (mean)','Set B (mean)','d(Set B - Set A)','MW (U)','MW (p)']]
  798. table_02
  799. # %%
  800. table_02.to_csv('./source_data_files/table_02.csv', float_format='%.2f')
  801. # %%
  802. layout = pn.GridBox(ncols=4)
  803. items_in_descending_d = stats_per_set.sort_values(by="d(Set B - Set A)", ascending=False).index
  804. set_a_scans = emb_plus[emb_plus['Set Label']=='Set A'].index
  805. set_b_scans = emb_plus[emb_plus['Set Label']=='Set B'].index
  806. for item in items_in_descending_d:
  807. bins = [0,10,20,30,40,50,60,70,80,90,100]
  808. this_pval = stats_per_set.loc[item,"MW (p)"]
  809. if this_pval >= 0.01:
  810. title = "Cohen d=%.2f | n.s." % stats_per_set.loc[item]["d(Set B - Set A)"]
  811. else:
  812. title = "Cohen d=%.2f | p < 0.01" % stats_per_set.loc[item]["d(Set B - Set A)"]
  813. overlay = SNYCQ.loc[set_a_scans,item].hvplot.hist(label='Set A', c=group_colors['Set A'], title=title, width=225, height=200, alpha=0.5, shared_axes=False, bins=bins, normed=True) * \
  814. SNYCQ.loc[set_b_scans,item].hvplot.hist(label='Set B', c=group_colors['Set B'], alpha=0.5, shared_axes=False, bins=bins, normed=True) * \
  815. SNYCQ.loc[set_a_scans,item].hvplot.kde(label='Set A', c=group_colors['Set A'], title=title, width=300, height=200, alpha=0.5, shared_axes=False) * \
  816. SNYCQ.loc[set_b_scans,item].hvplot.kde(label='Set B', c=group_colors['Set B'], alpha=0.5, shared_axes=False)
  817. overlay = overlay.opts(show_legend=False,shared_axes=False, toolbar=None)
  818. layout.append(overlay)
  819. layout.save( osp.join('figures', 'Supplementary_Figure02.html'))
  820. # %%
  821. SNYCQ.loc[set_a_scans].to_csv('./source_data_files/suppfig_02_parta.csv',index=False)
  822. SNYCQ.loc[set_b_scans].to_csv('./source_data_files/suppfig_02_partb.csv',index=False)
  823. # %% [markdown]
  824. # ![Supplementary Figure 02](./figures/Supplementary_Figure02.png)
  825. # %% [markdown]
  826. # Provide the same information in more concise manner in the form of a radar plot
  827. # %%
  828. def plot_radar_means_with_ci(
  829. stats_df, # output of cluster_means_ci (indexed by feature)
  830. order=None, # optional ordering of features
  831. title="Per-cluster item means (with 95% CI)",
  832. ci_label="95% CI"
  833. ):
  834. """
  835. Draws radar with Cluster 0 & 1 mean lines and shaded CI bands.
  836. """
  837. # order features
  838. if order is None:
  839. features = list(stats_df.index)
  840. else:
  841. features = list(order)
  842. stats_df = stats_df.loc[features]
  843. # angles for axes + close the loop
  844. angles = np.linspace(0, 2*np.pi, len(features), endpoint=False).tolist()
  845. angles += angles[:1]
  846. # extract arrays and close loops
  847. m0 = stats_df["Set A (mean)"].to_numpy().tolist(); m0 += m0[:1]
  848. m1 = stats_df["Set B (mean)"].to_numpy().tolist(); m1 += m1[:1]
  849. lo0 = stats_df["lo (Set A)"].to_numpy().tolist(); lo0 += lo0[:1]
  850. hi0 = stats_df["hi (Set A)"].to_numpy().tolist(); hi0 += hi0[:1]
  851. lo1 = stats_df["lo (Set B)"].to_numpy().tolist(); lo1 += lo1[:1]
  852. hi1 = stats_df["hi (Set B)"].to_numpy().tolist(); hi1 += hi1[:1]
  853. # limits based on CI envelopes
  854. all_vals = np.array((lo0 + hi0 + lo1 + hi1), dtype=float)
  855. finite_vals = all_vals[np.isfinite(all_vals)]
  856. if finite_vals.size:
  857. rmin, rmax = float(np.nanmin(finite_vals)), float(np.nanmax(finite_vals))
  858. else:
  859. rmin, rmax = 0.0, 1.0
  860. pad = max(1.0, 0.05 * (rmax - rmin))
  861. # plot
  862. fig = plt.figure(figsize=(7, 7))
  863. ax = plt.subplot(111, polar=True)
  864. # mean lines
  865. l0, = ax.plot(angles, m0, linewidth=2, label="Set A")
  866. l1, = ax.plot(angles, m1, linewidth=2, label="Set B")
  867. # shaded CI polygons (build as upper path + reversed lower path)
  868. # cluster 0
  869. ang_np = np.array(angles)
  870. poly0_ang = np.concatenate([ang_np, ang_np[::-1]])
  871. poly0_rad = np.concatenate([np.array(hi0), np.array(lo0)[::-1]])
  872. s0 = ax.fill(poly0_ang, poly0_rad, alpha=0.15, label=f"Set A {ci_label}")
  873. # cluster 1
  874. poly1_ang = np.concatenate([ang_np, ang_np[::-1]])
  875. poly1_rad = np.concatenate([np.array(hi1), np.array(lo1)[::-1]])
  876. s1 = ax.fill(poly1_ang, poly1_rad, alpha=0.15, label=f"Set B {ci_label}")
  877. # axes & labels
  878. ax.set_xticks(angles[:-1])
  879. ax.set_xticklabels(features)
  880. ax.set_ylim(rmin - pad, rmax + pad)
  881. ax.set_title(title)
  882. ax.legend(loc="upper right", bbox_to_anchor=(0.1, 1.0))
  883. plt.tight_layout()
  884. plt.show()
  885. return fig
  886. # %%
  887. # Plot (keep your preferred order of spokes)
  888. plot = plot_radar_means_with_ci(stats_per_set, order=list(items_in_descending_d),
  889. title="SNYCQ per-cluster means with 95% CI")
  890. # %% [markdown]
  891. # ### Save Publication Ready Panel and Source Data for the Radar Plot
  892. # %%
  893. stats_per_set.to_csv('./source_data_files/figure_01_f.csv', float_format='%.2f')
  894. # %%
  895. plot.savefig('./figures/Figure01_F.svg', format='svg', bbox_inches='tight')
  896. # %% [markdown]
  897. # ***
  898. #
  899. # # Distribution of SNYCQ values (Supplementary Figure 1)
  900. # %%
  901. layout = None
  902. for q in SNYCQ.columns:
  903. plot = SNYCQ[q].reset_index(drop=True).hvplot.hist(bins=np.linspace(0,100,20), width=250, height=200, normed=True, ylabel='Density', fontsize=12) * SNYCQ[q].reset_index(drop=True).hvplot.kde()
  904. if layout is None:
  905. layout = plot
  906. else:
  907. layout = layout + plot
  908. layout = layout.cols(3).opts(toolbar=None)
  909. hv.save(layout, osp.join('figures', 'Supplementary_Figure01.html'))
  910. # %% [markdown]
  911. # ![Supplementary Figure 01](./figures/Supplementary_Figure01.png)
  912. # %%
  913. SNYCQ.to_csv('./source_data_files/suppfig_01.csv',index=False)
  914. # %% [markdown]

S10_SNYCQ_TSNE_Clustering.ipynb at commit 2aa1bb1, under CC0-1.0 · at the source

Overview

Authors: Javier Gonzalez-Castillo1, Megan A. Spurney1,2, Ka Chun Lam3, Isabel S. Gephart1, Francisco Pereira3, Daniel A. Handwerker1, Julia W. Y. Kam4,5, Peter A. Bandettini1,6
  1. Section on Functional Imaging Methods, NIMH, NIH,Bethesda, MA USA
  2. Department of Psychology, Northwestern University,Evanston, IL USA
  3. Machine Learning Team, NIMH, NIH,Bethesda, MA USA
  4. Department of Psychology, University of Calgary,Calgary, AL Canada
  5. Hotchkiss Brain Institute, University of Calgary,Calgary, AL Canada
  6. Functional MRI Core, NIMH, NIH,Bethesda, MA USA
Institutions: National Institutes of Health (United States); Northwestern University (United States); University of Calgary (Canada)
Journal: Nature communications, volume 17, issue 1, article 8058
Dates: received 8 December 2025; accepted 16 June 2026; published online 27 June 2026
Type: Research article · Language: English
License: CC BY
Identifiers: DOI 10.1038/s41467-026-74953-6 · PMID 42365014 · PMCID PMC13454257 · OpenAlex W7166358421
Open access: gold, a free copy (OpenAlex)
Status: code verified
Categories: fMRI (modality), human (organism), clinical / translational (subfield)
Methods: Connectivity, Statistics, Smoothing, state filtering, decompositions, Machine learning, fMRI & imaging
Keywords: Consciousness, Cognitive neuroscience, Biomarkers
MeSH: Brain*, Magnetic Resonance Imaging*, Rest*, Thinking*, Adult, Brain Mapping, Female, Humans, Male, Retrospective Studies (* major topic)
Topic: Functional Brain Connectivity Studies (Cognitive Neuroscience, Neuroscience), according to OpenAlex
Funding: National Institute of Mental Health (ZIAMH002783, ZICMH002968, ZICMH002884)
Citations: not cited yet (Europe PMC); 99 references in the paper

Abstract

Resting-state fMRI (rsfMRI) scans—acquired in the absence of experimentally controlled stimuli or task demands—are widely used to identify aberrant patterns of functional connectivity (FC) in clinical populations. To minimize interpretational uncertainty, researchers routinely control for across-cohort disparities in age, gender, comorbidities, and head motion. Yet, studies rarely consider the possibility that systematic differences in inner experience (i.e., how subjects think and feel during the scan) directly affect FC measures. Here, using an rsfMRI dataset comprising 469 scans with retrospective experiential annotations, we show that summary descriptors of in-scanner experience are reproducible across visits and subject-specific, consistent with trait-like characteristics. We further show that widespread significant differences in FC are observed between scans that are associated with different reported experiential profiles, and that FC can predict specific experiential dimensions with performance comparable to that reported for demographic, cognitive, and clinical variables. Together, these findings highlight the key role that in-scanner experience should play when interpreting FC in the context of rsfMRI. Given that the available experiential measures are retrospective summaries, these results speak to stable experiential tendencies rather than potential moment-to-moment, state-dependent relationships between ongoing experience and concurrent brain activity.

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

nimh-sfim/fc_introspection

License: CC0-1.0
State: the link answers, verified on 27 September 2026
Evidence: files inventoried
Commit: 2aa1bb1b653111e0486001780837e9734d90eaf3, 1 June 2026
Languages: Python (36), Jupyter (24), Shell (9), MATLAB (3)
Size: 326 files, 72 scripts
Software Heritage: not archived
Found in: “Code availability”
Holds: README, license file, 24 notebooks
Not found: CITATION.cff, environment file, tests, continuous integration, documentation
Tools: pandas (33 files), NumPy (17 files), AFNI (11 files), Matplotlib (10 files), seaborn (10 files), scikit-learn (5 files), SciPy (5 files), ANTs (4 files), xarray (4 files), FreeSurfer (2 files), FSL (2 files), Plotly (2 files)
Availability: 1 check, the latest on 27 September 2026: the link answers
  • 27 September 2026: the link answers
47 files

Code availability

Code used in this study is available at https://github.com/nimh-sfim/fc_introspection (release tagged as v4.0.0).

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

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;
  • 45 scripts, each with its path and the digest of its content;
  • 15 matches between paragraphs of the paper and lines of the code (method lexical-v1);
  • neither the text of the paper nor the code itself.

Its JSON (tracing-map.json) is deposited on Zenodo with its DOI once the map is validated.

Data

Datasets cited

Data availability

This study is based on publicly available data as described in Mendes et al35 Imaging data can be accessed at ftp://ftp.gwdg.de/pub/misc/MPILeipzig_Mind-Brain-Body. Behavioral data is available at https://dataverse.harvard.edu/dataset.xhtml?persistentId=doi:10.7910/DVN/VMJ6NV. Additional access locations can be found in the Mendes et al. publication. In addition, Source data are provided with this paper.

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

Versions

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

Version 1, 27 September 2026: the first record

Recorded: type, language, journal, volume, issue, pages, dates, 8 authors, 3 keywords, 10 MeSH terms, 1 funder, 93 references.

Cite

This paper

Gonzalez-Castillo, J., Spurney, M. A., Lam, K. C., Gephart, I. S., Pereira, F., Handwerker, D. A., Kam, J. W. Y., & Bandettini, P. A. (2026). In-scanner thoughts contribute to resting-state functional connectivity. Nature communications, 17(1), 8058. https://doi.org/10.1038/s41467-026-74953-6

BibTeX

@article{gonzalezcastillo2026scanner,
author = {Gonzalez-Castillo, Javier and Spurney, Megan A. and Lam, Ka Chun and Gephart, Isabel S. and Pereira, Francisco and Handwerker, Daniel A. and Kam, Julia W. Y. and Bandettini, Peter A.},
title = {{In-scanner thoughts contribute to resting-state functional connectivity}},
journal = {Nature communications},
year = {2026},
month = jun,
volume = {17},
number = {1},
pages = {8058},
publisher = {Nature Publishing Group},
issn = {2041-1723},
doi = {10.1038/s41467-026-74953-6},
url = {https://doi.org/10.1038/s41467-026-74953-6},
pmid = {42365014},
pmcid = {PMC13454257}
}

RIS

TY - JOUR
AU - Gonzalez-Castillo, Javier
AU - Spurney, Megan A.
AU - Lam, Ka Chun
AU - Gephart, Isabel S.
AU - Pereira, Francisco
AU - Handwerker, Daniel A.
AU - Kam, Julia W. Y.
AU - Bandettini, Peter A.
TI - In-scanner thoughts contribute to resting-state functional connectivity
T2 - Nature communications
J2 - Nat Commun
PY - 2026
DA - 2026/06/27
VL - 17
IS - 1
SP - 8058
SN - 2041-1723
PB - Nature Publishing Group
DO - 10.1038/s41467-026-74953-6
UR - https://doi.org/10.1038/s41467-026-74953-6
LA - en
ER -

CSL-JSON

{
"id": "10.1038/s41467-026-74953-6",
"type": "article-journal",
"title": "In-scanner thoughts contribute to resting-state functional connectivity",
"container-title": "Nature communications",
"author": [
{
"family": "Gonzalez-Castillo",
"given": "Javier"
},
{
"family": "Spurney",
"given": "Megan A."
},
{
"family": "Lam",
"given": "Ka Chun"
},
{
"family": "Gephart",
"given": "Isabel S."
},
{
"family": "Pereira",
"given": "Francisco"
},
{
"family": "Handwerker",
"given": "Daniel A."
},
{
"family": "Kam",
"given": "Julia W. Y."
},
{
"family": "Bandettini",
"given": "Peter A."
}
],
"container-title-short": "Nat Commun",
"volume": "17",
"issue": "1",
"page": "8058",
"DOI": "10.1038/s41467-026-74953-6",
"PMID": "42365014",
"PMCID": "PMC13454257",
"ISSN": "2041-1723",
"publisher": "Nature Publishing Group",
"URL": "https://doi.org/10.1038/s41467-026-74953-6",
"language": "en",
"issued": {
"date-parts": [
[
2026,
6,
27
]
]
}
}

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.7554/elife.104053 [code]
Brain cognition gaps reveal associations with dopamine and factors related to brain health through artificial intelligence prediction of functional connectome.
Journal: eLife
In common: scikit-learn, pandas, SciPy, 1 other tool, 10 references
[2] doi:10.1002/epi.70323 [code]
Individual-specific resting-state networks predict language dominance in drug-resistant epilepsy.
Journal: Epilepsia
In common: AFNI, ANTs, FreeSurfer, 5 other tools, fMRI, 4 references
[3] doi:10.21203/rs.3.rs-9326213/v1 [code]
Multi-task fMRI outperforms resting-state fMRI for revealing task-invariant organization of the human brain
Journal: Research Square (preprint)
In common: ANTs, FreeSurfer, FSL, 7 other tools, fMRI, 3 references
[4] doi:10.64898/2026.03.09.710558 [code]
Multi-task fMRI outperforms resting-state fMRI for revealing task-invariant organization of the human brain
Journal: bioRxiv (preprint)
In common: ANTs, FreeSurfer, FSL, 7 other tools, fMRI, 3 references
[5] doi:10.1162/imag.a.1222 [code]
Network-based near-scalp personalized brain stimulation targets.
Journal: Imaging neuroscience (Cambridge, Mass.)
In common: AFNI, ANTs, FreeSurfer, 5 other tools, 3 references
[6] doi:10.1038/s41467-026-73668-y [code]
Convergent and divergent brain-cognition development in early adolescence.
Journal: Nature communications
In common: AFNI, ANTs, FreeSurfer, 7 other tools, fMRI, 2 references
[7] doi:10.1016/j.neuron.2026.04.011 [code]
Precision fMRI reveals densely interdigitated network patches with conserved motifs in the lateral prefrontal cortex.
Journal: Neuron
In common: AFNI, ANTs, FreeSurfer, 5 other tools, fMRI, 3 references
[8] doi:10.1162/imag.a.1336 [code]
Brain network dynamics in the wake-sleep transition reorganize according to task engagement.
Journal: Imaging neuroscience (Cambridge, Mass.)
In common: 7 references, author Javier Gonzalez-Castillo
[9] doi:10.1038/s41398-026-04025-2 [code]
Brain energetic landscapes shape state dysregulation in major depressive disorder: a morphological network controllability perspective.
Journal: Translational psychiatry
In common: AFNI, ANTs, FreeSurfer, 7 other tools, 2 references
[10] doi:10.1016/j.xcrm.2026.102943 [code]
Parent-of-origin effects in Alzheimer's liability dissociate neurocognitive and cardiovascular traits in at-risk individuals.
Journal: Cell reports. Medicine
In common: AFNI, ANTs, FreeSurfer, 8 other tools, clinical / translational

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.