OSCR

Spatiotemporal asymmetries on brain energy landscape uncover system entrapment related to depression severity.

Code ↔ Paper

10 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 10 matches
  1. [1] § Results › Asymmetric state switching relates to anhedonia and rumination ↔ tutorial.ipynb, lines 491–514 · score 0.76 · medication status, RRS Brooding, RRS Depression, RRS Reflection, MASQ, QIDS
  2. [2] § Results › Asymmetric state switching relates to anhedonia and rumination ↔ tutorial.ipynb, lines 491–514 · score 0.71 · MASQ GD scores, MASQ AD scores, MASQ AA, QIDS, depression, MDD
  3. [3] § Results › Canonical RSNs described by spontaneous coactivation patterns ↔ tutorial.ipynb, lines 377–404 · score 0.67 · medoid silhouette coefficients, correlation distance, cluster variance, iterations
  4. [4] § Methods › Data acquisition ↔ meica.libs/nibabel/parrec.py, lines 29–67 · score 0.67 · phase encoding, AP, gradient, EPI, repetition, resolution
  5. [5] § Methods › Clustering of fMRI volumes ↔ tutorial.ipynb, lines 377–404 · score 0.66 · medoid silhouette coefficients, pairwise correlation, iteration, variance, clusters
  6. [6] § Methods › Network control theory and dynamics on networks ↔ tutorial.ipynb, lines 241–288 · score 0.60 · state trajectories, control signals, structural connectivity, energies, matrix, transition
  7. [7] § Results › Structural connectivity modulates empirical state transitions ↔ tutorial.ipynb, lines 241–288 · score 0.59 · state trajectories, control signal, Control energy, structural connectomes, transitions
  8. [8] § Methods › Time series extraction and connectome construction ↔ tutorial.ipynb, lines 601–635 · score 0.52 · nearest neighbor interpolation, parcellation, affine, mapped, space, brain
  9. [9] § Methods › Statistical inference ↔ tutorial.ipynb, lines 1099–1160 · score 0.52 · clinical score, transition probability, sex, medication, age
  10. [10] § Results › Brain state dynamics are associated with depression severity ↔ tutorial.ipynb, lines 853–892 · score 0.51 · MASQ AD, clinical scores, medication, age, HC, QIDS

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,306 lines · 60 KB · GPL-3.0 · 9 matches

  1. # %%
  2. import numpy as np
  3. import pandas as pd
  4. import statsmodels.api as sm
  5. from statsmodels.stats.multitest import fdrcorrection
  6. from scipy.spatial.distance import squareform, pdist, cdist, cosine
  7. from scipy.stats import zscore, ttest_ind, ttest_rel
  8. import scipy.integrate as spint
  9. if not hasattr(spint, 'simps'):
  10. spint.simps = spint.simpson
  11. from sklearn_extra.cluster import KMedoids
  12. from itertools import groupby
  13. import nibabel as nib
  14. from nilearn.image import resample_img, resample_to_img
  15. from nilearn.plotting import plot_stat_map
  16. from nctpy.energies import get_control_inputs, integrate_u
  17. from nctpy.utils import normalize_state, matrix_normalization
  18. import matplotlib.pyplot as plt
  19. from mpl_toolkits.axes_grid1 import make_axes_locatable
  20. import matplotlib.cm as cm
  21. from matplotlib.patches import RegularPolygon
  22. from matplotlib.transforms import Affine2D
  23. from matplotlib.path import Path
  24. from matplotlib.projections import register_projection
  25. from matplotlib.projections.polar import PolarAxes
  26. from matplotlib.spines import Spine
  27. import seaborn as sns
  28. # %%
  29. def r2(x,y):
  30. return(cdist(np.array([x]), np.array([y]),'correlation')[0][0])
  31. def medoid_silhouette(diss, ind):
  32. medoid_silhouette_values = np.zeros(diss.shape[0])
  33. for ind1 in range(diss.shape[0]):
  34. silh = sorted([diss[ind1, ind2] for ind2 in ind])
  35. num = silh[0]#closest cluster center
  36. if silh[1] == 0:
  37. medoid_silhouette_values[ind1] = 1
  38. else:
  39. medoid_silhouette_values[ind1] = 1-num/silh[1]
  40. return(medoid_silhouette_values)
  41. def silhouette_plot(values, clusters, K, it, ax):
  42. y_lower = 10
  43. for k in range(K):
  44. # Aggregate the silhouette scores for samples belonging to
  45. # cluster i, and sort them
  46. ith_cluster_silhouette_values = values[K,it][clusters[K,it] == k]
  47. ith_cluster_silhouette_values.sort()
  48. size_cluster_i = ith_cluster_silhouette_values.shape[0]
  49. y_upper = y_lower + size_cluster_i
  50. color = cm.nipy_spectral(float(k) / K)
  51. ax.fill_betweenx(np.arange(y_lower, y_upper),0,ith_cluster_silhouette_values,facecolor=color,edgecolor=color,alpha=0.7)
  52. # Label the silhouette plots with their cluster numbers at the middle
  53. ax.text(-0.05, y_lower + 0.5 * size_cluster_i, str(k))
  54. # Compute the new y_lower for next plot
  55. y_lower = y_upper + 10 # 10 for the 0 samples
  56. # The vertical line for average silhouette score of all the values
  57. ax.set_title('Silhouette Plot', fontsize = 16)
  58. ax.set_xlabel('Silhouette Coefficient Values', fontsize = 14)
  59. ax.set_ylabel('Cluster label', fontsize = 14)
  60. ax.axvline(x=values[K,it].mean(), color="black", linestyle="--", label = 'Mean')
  61. ax.set_yticks([]) # Clear the yaxis labels / ticks
  62. ax.set_xticks([-0.1, 0, 0.2, 0.4, 0.6, 0.8, 1])
  63. ax.legend()
  64. def fractional_occs(labels, K):#calculate fractional occupancy for a given subject's state sequence and number of clusters
  65. uniq, cts = np.unique(labels, return_counts=True)
  66. frq = np.zeros(K)
  67. for _, k in enumerate(uniq):
  68. frq[k] = cts[_]/len(labels)#percentage
  69. return(frq)
  70. def dwell_times(labels, K, t_r, thresh = 1):
  71. #calculate dwell times for a given subject's state sequence and number of clusters. thresh variable adjusts how many repetitions counts as dwelling i.e., if thresh ==1, at least 2 consecutive repetitions counts as dwelling
  72. count_dups = [(_,sum(1 for _ in group)) for _, group in groupby(labels)]
  73. dwell_t = [[] for _ in range(K)]
  74. for st, ct in count_dups:
  75. if ct > thresh:
  76. dwell_t[st].append(ct)
  77. dwelltime = []
  78. for dwell in dwell_t:
  79. if dwell:
  80. dwelltime.append(np.mean(dwell) * t_r)
  81. else:
  82. dwelltime.append(0 * t_r)
  83. return(dwelltime)
  84. def get_yeo_networks(states, yeo8, resampled_aparcaseg, size, K):
  85. all_cos = []
  86. for k in range(K):
  87. yeo_array = yeo8.get_fdata()[:,:,:,0]
  88. aparc_array = resampled_aparcaseg.get_fdata()
  89. cos_sim = []
  90. for y in range(1,len(np.unique(yeo_array))):
  91. yeo_networkx = np.where(yeo_array == y)
  92. aparc_network = aparc_array[yeo_networkx]
  93. uniq,cts = np.unique(aparc_network[np.nonzero(aparc_network)], return_counts=True)
  94. roi_counts = {int(roi): count for roi, count in zip(uniq, cts)}
  95. yeo_vector = np.zeros(size)
  96. for i,roi in enumerate(np.unique(aparc_array)[1:]): #ignore 0
  97. if int(roi) in roi_counts:
  98. total_roi = np.sum(aparc_array == int(roi))
  99. yeo_vector[i] = roi_counts[int(roi)]/total_roi if total_roi > 0 else 0
  100. cos_sim.append(float(1-cosine(yeo_vector,states[k])))
  101. all_cos.append(cos_sim)
  102. return(all_cos)
  103. def radar_factory(num_vars, frame='circle'):
  104. """
  105. Create a radar chart with `num_vars` Axes.
  106. This function creates a RadarAxes projection and registers it.
  107. Parameters
  108. ----------
  109. num_vars : int
  110. Number of variables for radar chart.
  111. frame : {'circle', 'polygon'}
  112. Shape of frame surrounding Axes.
  113. """
  114. # calculate evenly-spaced axis angles
  115. theta = np.linspace(0, 2*np.pi, num_vars, endpoint=False)
  116. class RadarTransform(PolarAxes.PolarTransform):
  117. def transform_path_non_affine(self, path):
  118. # Paths with non-unit interpolation steps correspond to gridlines,
  119. # in which case we force interpolation (to defeat PolarTransform's
  120. # autoconversion to circular arcs).
  121. if path._interpolation_steps > 1:
  122. path = path.interpolated(num_vars)
  123. return Path(self.transform(path.vertices), path.codes)
  124. class RadarAxes(PolarAxes):
  125. name = 'radar'
  126. PolarTransform = RadarTransform
  127. def __init__(self, *args, **kwargs):
  128. super().__init__(*args, **kwargs)
  129. # rotate plot such that the first axis is at the top
  130. self.set_theta_zero_location('N')
  131. def fill(self, *args, closed=True, **kwargs):
  132. """Override fill so that line is closed by default"""
  133. return super().fill(closed=closed, *args, **kwargs)
  134. def plot(self, *args, **kwargs):
  135. """Override plot so that line is closed by default"""
  136. lines = super().plot(*args, **kwargs)
  137. for line in lines:
  138. self._close_line(line)
  139. def _close_line(self, line):
  140. x, y = line.get_data()
  141. # FIXME: markers at x[0], y[0] get doubled-up
  142. if x[0] != x[-1]:
  143. x = np.append(x, x[0])
  144. y = np.append(y, y[0])
  145. line.set_data(x, y)
  146. def set_varlabels(self, labels, fs):
  147. self.set_thetagrids(np.degrees(theta), labels, fontsize = fs)
  148. def _gen_axes_patch(self):
  149. # The Axes patch must be centered at (0.5, 0.5) and of radius 0.5
  150. # in axes coordinates.
  151. if frame == 'circle':
  152. return Circle((0.5, 0.5), 0.5)
  153. elif frame == 'polygon':
  154. return RegularPolygon((0.5, 0.5), num_vars, radius=0.5, edgecolor="k")
  155. else:
  156. raise ValueError("Unknown value for 'frame': %s" % frame)
  157. def _gen_axes_spines(self):
  158. if frame == 'circle':
  159. return super()._gen_axes_spines()
  160. elif frame == 'polygon':
  161. # spine_type must be 'left'/'right'/'top'/'bottom'/'circle'.
  162. spine = Spine(axes=self,
  163. spine_type='circle',
  164. path=Path.unit_regular_polygon(num_vars))
  165. # unit_regular_polygon gives a polygon of radius 1 centered at
  166. # (0, 0) but we want a polygon of radius 0.5 centered at (0.5,
  167. # 0.5) in axes coordinates.
  168. spine.set_transform(Affine2D().scale(0.5).translate(.5, .5)+ self.transAxes)
  169. return {'polar': spine}
  170. else:
  171. raise ValueError("Unknown value for 'frame': %s" % frame)
  172. register_projection(RadarAxes)
  173. return theta
  174. def organize_stats(tempdf):
  175. # Remove commas from string values in the DataFrame (e.g., "1,234" → "1234")
  176. # This helps ensure numeric columns are clean for later processing.
  177. df = tempdf.map(lambda x: x.replace(',', '') if isinstance(x, str) else x)
  178. # Split the first column's values into multiple columns based on one or more spaces
  179. # (regex '\s+' matches one or more whitespace characters)
  180. df_split = df[df.keys()[0]].str.split(r'\s+', expand=True)
  181. # Assign descriptive names to the new columns
  182. # The last column is unnamed because it might contain an extra blank split
  183. df_split.columns = ["ROI", "Name", "Type", "Volume-mm3", ""]
  184. # Drop the unnecessary empty column that resulted from splitting
  185. df_cleaned = df_split.drop(columns=[''])
  186. # Return the cleaned DataFrame
  187. return df_cleaned
  188. # Function to generate a single random adjacency matrix
  189. def random_adjacency(n):
  190. mat = np.random.rand(n, n) # random values [0,1)
  191. mat = (mat + mat.T)/2 # make symmetric
  192. np.fill_diagonal(mat, 0) # set diagonal to 0
  193. return mat
  194. def NCT_multi(healthyID, mddID, DTI_matrix, states, K, size, time_h = 1, rho = 1, system = 'continuous'):
  195. s_trajectories = {idx:np.zeros((K,K,1001,size)) for idx in healthyID+mddID}
  196. c_signals= {idx:np.zeros((K,K,1001,size)) for idx in healthyID+mddID}
  197. n_energies = {idx:np.zeros((K,K,size)) for idx in healthyID+mddID}
  198. t_energies = {idx:np.zeros((K,K)) for idx in healthyID+mddID}
  199. s_trajectories_pers = {idx:np.zeros((K,K,1001,size)) for idx in healthyID+mddID}
  200. c_signals_pers = {idx:np.zeros((K,K,1001,size)) for idx in healthyID+mddID}
  201. n_energies_pers = {idx:np.zeros((K,K,size)) for idx in healthyID+mddID}
  202. t_energies_pers = {idx:np.zeros((K,K)) for idx in healthyID+mddID}
  203. control_s, trajectory_c = np.eye(size), np.eye(size)
  204. # normalize structural connectivity
  205. for idx in healthyID+mddID:
  206. norm_adjs_struct = matrix_normalization(A=DTI_matrix[idx], system=system, c=1)
  207. for k1 in range(K):
  208. for k2 in range(K):
  209. if k1 != k2:
  210. #get the state trajectory, x(t), and the control signals, u(t)
  211. s_trajectories[idx][k2,k1], c_signals[idx][k2,k1], numerical_errors = get_control_inputs(A_norm = norm_adjs_struct, T = time_h, B = control_s, x0 = states[k1], xf = states[k2], system = system, rho = rho, S = trajectory_c)
  212. # print errors
  213. thr = 1e-8
  214. if (numerical_errors[0] >= thr) or (numerical_errors[1] >= thr):
  215. # the first numerical error corresponds to the inversion error # the second numerical error corresponds to the reconstruction error
  216. print("Subject: %s, Transition: %dto%d"%(idx,k1,k2),
  217. "Inversion error = {:.2E} (<{:.2E}={:})".format(numerical_errors[0], thr, numerical_errors[0] < thr),
  218. "Reconstruction error = {:.2E} (<{:.2E}={:})".format(numerical_errors[1], thr, numerical_errors[1] < thr))
  219. # integrate control signals to get control energy
  220. n_energies[idx][k2,k1] = integrate_u(c_signals[idx][k2,k1])
  221. t_energies[idx][k2,k1] = np.sum(n_energies[idx][k2,k1]) ##from state k1 to k2
  222. else:#get persistence energies in a seperate array
  223. #get the state trajectory, x(t), and the control signals, u(t)
  224. s_trajectories_pers[idx][k2,k1], c_signals_pers[idx][k2,k1], numerical_errors = get_control_inputs(A_norm = norm_adjs_struct, T = time_h, B = control_s, x0 = states[k1], xf = states[k2], system = system, rho = rho, S = trajectory_c)
  225. # print errors
  226. thr = 1e-8
  227. if (numerical_errors[0] >= thr) or (numerical_errors[1] >= thr):
  228. # the first numerical error corresponds to the inversion error # the second numerical error corresponds to the reconstruction error
  229. print("Subject: %s, Transition: %dto%d"%(idx,k1,k2),
  230. "Inversion error = {:.2E} (<{:.2E}={:})".format(numerical_errors[0], thr, numerical_errors[0] < thr),
  231. "Reconstruction error = {:.2E} (<{:.2E}={:})".format(numerical_errors[1], thr, numerical_errors[1] < thr))
  232. # integrate control signals to get control energy
  233. n_energies_pers[idx][k2,k1] = integrate_u(c_signals_pers[idx][k2,k1])
  234. t_energies_pers[idx][k2,k1] = np.sum(n_energies_pers[idx][k2,k1]) ##from state k1 to k2
  235. return(t_energies, n_energies, s_trajectories, c_signals, t_energies_pers, n_energies_pers, s_trajectories_pers, c_signals_pers)
  236. # %%
  237. working_path = '/Path/to/Brain_states/'
  238. # Adjust your Parameters
  239. size = 85 # number of regions
  240. duration = 200 # time points per subject
  241. n_subjects = 20 # total subjects
  242. n_healthy = n_subjects // 2
  243. n_mdd = n_subjects // 2
  244. # Simulate data as dicts: each subject -> size x duration matrix
  245. np.random.seed(42) # for reproducibility
  246. healthyFMRI = {str(500+i): np.random.randn(size, duration) for i in range(n_healthy)}
  247. mddFMRI = {str(600+i): np.random.randn(size, duration) for i in range(n_mdd)}
  248. # %%
  249. # Get IDs
  250. healthyID = list(healthyFMRI.keys())
  251. mddID = list(mddFMRI.keys())
  252. # Preallocate arrays
  253. X_hcz1 = np.zeros((size, sum([healthyFMRI[idx].shape[1] for idx in healthyID])))
  254. X_mddz1 = np.zeros((size, sum([mddFMRI[idx].shape[1] for idx in mddID])))
  255. # Fill in healthy
  256. dur = 0
  257. for idx in healthyID:
  258. mat = zscore(healthyFMRI[idx], axis=1, nan_policy='raise')
  259. X_hcz1[:, dur:dur + mat.shape[1]] = mat
  260. dur += mat.shape[1]
  261. # Fill in mdd
  262. dur = 0
  263. for idx in mddID:
  264. mat = zscore(mddFMRI[idx], axis=1, nan_policy='raise')
  265. X_mddz1[:, dur:dur + mat.shape[1]] = mat
  266. dur += mat.shape[1]
  267. # Concatenate
  268. X_prez1 = np.concatenate((X_hcz1, X_mddz1), axis=1)
  269. X_prez1[np.isnan(X_prez1)] = 0
  270. # %%
  271. #Display point cloud
  272. fig, ax = plt.subplots(1,1,figsize = (21,4))
  273. im = ax.imshow(X_prez1, aspect = 'auto', interpolation = 'none', cmap = 'coolwarm')
  274. divider = make_axes_locatable(ax)
  275. cax = divider.append_axes('right', size='5%', pad=0.05)
  276. fig.colorbar(im, cax=cax)
  277. plt.tight_layout()
  278. # %%
  279. # Define range of cluster numbers to test (K = 2 to 10)
  280. Ks = np.arange(2, 10)
  281. # Number of iterations (repeated clustering runs for stability analysis)
  282. niter = 10
  283. clusters, indices, inertias = {}, {}, {}
  284. # Compute the pairwise dissimilarity matrix between all time points
  285. # - X_prez1.T: shape (n_timepoints, n_regions)
  286. # - 'correlation' metric → distance = 1 - correlation coefficient
  287. # - squareform: convert condensed distance vector into a square matrix
  288. diss = squareform(pdist(X_prez1.T, metric='correlation'))
  289. # Run clustering multiple times for each K
  290. for it in range(niter):
  291. for K in Ks:
  292. # Initialize and fit K-Medoids clustering with:
  293. # - precomputed distance matrix
  294. # - 'k-medoids++' initialization
  295. # - 'alternate' approximation method which is fast or 'pam' method which is more robust but much slower
  296. km = KMedoids(init="k-medoids++",n_clusters=K,metric='precomputed',method='alternate').fit(diss)
  297. # Store the results for this iteration and K
  298. clusters[K, it] = km.labels_
  299. indices[K, it] = km.medoid_indices_
  300. inertias[K, it] = km.inertia_
  301. # Dictionary to store the actual medoid time series
  302. medoids = {}
  303. for it in range(niter):
  304. for _, K in enumerate(Ks):
  305. # Extract medoid time series from X_prez1.T using saved medoid indices
  306. medoids[K, it] = X_prez1.T[indices[K, it]]
  307. # %%
  308. bt_vr, wt_vr = {}, {} # Initialize dictionaries to store between- and within-cluster variances
  309. for it in range(niter): # Loop over iterations
  310. between_var, within_var = [], [] # Lists to store variances for each K
  311. for K in Ks: # Loop over different numbers of clusters
  312. btw_var, wtn_var = [], [] # Lists to store variance per cluster
  313. for k1 in range(K): # Loop over each cluster
  314. # Calculate BETWEEN-cluster variance for cluster k1
  315. var = [r2(medoids[K, it][k2], medoids[K, it][k1]) for k2 in range(K)] # Pairwise r2 with all other medoids
  316. btw_var.append(np.array(var).mean()) # Average r2 across other clusters
  317. # Calculate WITHIN-cluster variance for cluster k1
  318. var = [r2(X_prez1.T[tr_label], medoids[K, it][k1]) for tr_label in np.where(clusters[K, it] == k1)[0]] # r2 with members
  319. wtn_var.append(np.array(var).mean()) # Average r2 within the cluster
  320. between_var.append(np.array(btw_var).mean()) # Average BETWEEN variance across clusters
  321. within_var.append(np.array(wtn_var).mean()) # Average WITHIN variance across clusters
  322. bt_vr[it] = between_var # Store results for this iteration
  323. wt_vr[it] = within_var
  324. # Calculate medoid silhouette coefficients
  325. medoids_silhouette1_values = {} # Initialize dictionary
  326. diss = squareform(pdist(X_prez1.T, metric='correlation')) # Compute pairwise correlation distance matrix
  327. for it in range(niter): # Loop over iterations
  328. for K in Ks: # Loop over cluster sizes
  329. medoids_silhouette1_values[K, it] = medoid_silhouette(diss, indices[K, it]) # Compute silhouette for medoids
  330. # %%
  331. explained_var, var_gain = {}, {}
  332. fig,ax = plt.subplots(1,6, figsize = (36,4))
  333. for it in range(niter):
  334. ax[0].plot(Ks, wt_vr[it], marker = '*')
  335. ax[1].plot(Ks, bt_vr[it], marker = '*')
  336. explained_var[it] = bt_vr[it]/(np.array(bt_vr[it])+np.array(wt_vr[it]))
  337. ax[2].plot(Ks, explained_var[it], marker = '>')
  338. var_gain[it] = np.diff(bt_vr[it]/(np.array(bt_vr[it])+np.array(wt_vr[it])))
  339. ax[3].plot(Ks[1:], var_gain[it], marker = 'x')
  340. ax[4].plot(Ks,[medoids_silhouette1_values[K,it].mean() for K in Ks])
  341. ax[0].plot(Ks, np.array(list(wt_vr.values())).mean(axis = 0), c = 'black', lw = 2, ls = '--', label = 'Mean')
  342. ax[1].plot(Ks, np.array(list(bt_vr.values())).mean(axis = 0), c = 'black', lw = 2, ls = '--', label = 'Mean')
  343. ax[2].plot(Ks, np.array(list(explained_var.values())).mean(axis = 0), c = 'black', lw = 2, ls = '--', label = 'Mean')
  344. ax[3].plot(Ks[1:],np.array(list(var_gain.values())).mean(axis = 0), c = 'black', lw = 2, ls = '--', label = 'Mean')
  345. ax[4].plot(Ks,np.array([[medoids_silhouette1_values[K,it].mean() for K in Ks] for it in range(niter)]).mean(axis = 0), c = 'black', lw = 2, ls = '--', label = 'Mean')
  346. K=4
  347. it = np.argmax([inertias[K,it] for it in range(niter)])
  348. silhouette_plot(medoids_silhouette1_values, clusters, K, it, ax[5])
  349. for i in range(5):
  350. ax[i].set_xticks(Ks)
  351. ax[i].legend()
  352. ax[i].set_xlabel('# of Clusters (K)', fontsize = 12)
  353. ax[0].set_title('Within Cluster Variance', fontsize = 16)
  354. ax[1].set_title('Between Cluster Variance', fontsize = 16)
  355. ax[2].set_title('Explained Variance', fontsize = 16)
  356. ax[3].set_title('Variance Gain', fontsize = 16) # variance gain from increasing from k-1 to k
  357. ax[4].set_title('Mean Silhouette Scores', fontsize = 16)
  358. ax[2].set_ylabel(r'$R^{2}$', fontsize = 12)
  359. ax[3].set_ylabel(r'$R^{2}$ Gain', fontsize = 12)
  360. ax[4].set_ylabel('Silhouette Coefficient', fontsize = 14)
  361. plt.tight_layout()
  362. # %%
  363. spatial_corr_matrices = {}
  364. fig,ax = plt.subplots(1,1,figsize = (12,4))
  365. for _K, K in enumerate(Ks):
  366. it = np.argmax([inertias[K,it] for it in range(niter)])
  367. spatial_corr_matrices[K] = 1-squareform(pdist(medoids[K,it],metric='correlation'))
  368. upper = np.triu(spatial_corr_matrices[K],1)
  369. upper[np.nonzero(upper)]
  370. ax.scatter([K]*len(upper[np.nonzero(upper)]),upper[np.nonzero(upper)], s = 25, marker = 'x')
  371. ax.set_xlabel('# of Clusters (K)', fontsize = 14)
  372. ax.set_ylabel('Correlation', fontsize = 14)
  373. ax.set_title('Spatial correlation between each pair of states', fontsize = 16)
  374. ax.errorbar(Ks, [(np.triu(spatial_corr_matrices[K],1)[np.nonzero(np.triu(spatial_corr_matrices[K],1))]).mean() for K in Ks], [(np.triu(spatial_corr_matrices[K],1)[np.nonzero(np.triu(spatial_corr_matrices[K],1))]).std() for K in Ks], c = 'black', lw = 1.5,ls = 'dashed', label = 'Mean')
  375. ax.set_xticks(Ks)
  376. ax.legend()
  377. # %%
  378. state_fraq_occ, dwellTIME = {}, {} # Initialize dictionaries to store fractional occupancy and dwell times
  379. tr = 2 # Repetition time (TR) in seconds
  380. for K in Ks: # Loop over number of clusters
  381. it = np.argmax([inertias[K, it] for it in range(niter)]) # Select iteration with maximum inertia
  382. dur = 0 # Initialize cumulative duration index
  383. for i, idx in enumerate(healthyID + mddID): # Loop over all subjects
  384. if idx in healthyID:
  385. size, duration = healthyFMRI[idx].shape # Get subject data shape
  386. else:
  387. size, duration = mddFMRI[idx].shape
  388. subj_labels = clusters[K, it][dur:dur + duration] # Extract cluster labels for this subject
  389. dur = dur + duration # Update cumulative duration
  390. # Calculate fractional occupancy and dwell times for each state
  391. frq = fractional_occs(subj_labels, K) # Fraction of time spent in each state
  392. dwell_time = dwell_times(subj_labels, K, tr) # Average dwell time in seconds
  393. state_fraq_occ[idx, K] = frq * 100 # Store fractional occupancy as percentage
  394. dwellTIME[idx, K] = dwell_time # Store dwell time in seconds
  395. # %%
  396. # Import clinical scores CSV (these ara randomly generated)
  397. clinical = pd.read_csv(working_path + 'Clinical_Scores.csv')
  398. # List of clinical measures of interest
  399. clinical_measures = ['QIDS', 'MASQ_AA', 'MASQ_AD', 'MASQ_GD', 'RRS_DR', 'RRS_BR', 'RRS_RF']
  400. # Create dictionaries mapping subject ID → demographic/medication info
  401. SEX = {idx: 0 if clinical[clinical.study_ID == int(idx)].SUB_gender.iloc[0] == "F" else 1 for idx in healthyID + mddID} # 0=F, 1=M
  402. AGE = {idx: clinical[clinical.study_ID == int(idx)]['SUB_Age '].iloc[0] for idx in healthyID + mddID} # Age in years
  403. MEDS = {idx: 0 if clinical[clinical.study_ID == int(idx)].Medication.iloc[0] == 'no' else 1 for idx in healthyID + mddID} # Medication status (0=no, 1=yes)
  404. # Create dictionaries mapping subject ID → clinical scores
  405. QIDS = {idx: clinical[clinical.study_ID == int(idx)].QIDS_score.iloc[0] for idx in healthyID + mddID}
  406. MASQ_aa = {idx: clinical[clinical.study_ID == int(idx)].MASQ_aa_score.iloc[0] for idx in healthyID + mddID}
  407. MASQ_ad = {idx: clinical[clinical.study_ID == int(idx)].MASQ_ad_score.iloc[0] for idx in healthyID + mddID}
  408. MASQ_gd = {idx: clinical[clinical.study_ID == int(idx)].MASQ_gd_score.iloc[0] for idx in healthyID + mddID}
  409. RRS_dr = {idx: clinical[clinical.study_ID == int(idx)].RRS_Depression_Related.iloc[0] for idx in healthyID + mddID}
  410. RRS_b = {idx: clinical[clinical.study_ID == int(idx)].RRS_Brooding.iloc[0] for idx in healthyID + mddID}
  411. RRS_r = {idx: clinical[clinical.study_ID == int(idx)].RRS_Reflection.iloc[0] for idx in healthyID + mddID}
  412. # %%
  413. # Initialize lists to collect subject-level, state-level, and clinical data
  414. totst_, KS_, ids_, type_, sex_, age_, meds_ = [], [], [], [], [], [], []
  415. dwellt_, fraqoc_ = [], []
  416. qids_, masqaa_, masqad_, masqgd_, rrsdr_, rrsb_, rrsr_ = [], [], [], [], [], [], []
  417. # Loop over all subjects (healthy + MDD)
  418. for i, idx in enumerate(healthyID + mddID):
  419. # Assign group label
  420. typ = 'HC' if idx in healthyID else 'MDD'
  421. # Loop over all tested K values and states within each K
  422. for K in Ks:
  423. for k in range(K):
  424. # Basic subject and state info
  425. totst_.append(K)
  426. KS_.append(k)
  427. ids_.append(idx)
  428. type_.append(typ)
  429. sex_.append(SEX[idx])
  430. age_.append(AGE[idx])
  431. meds_.append(MEDS[idx])
  432. # State-level metrics
  433. dwellt_.append(dwellTIME[idx, K][k])
  434. fraqoc_.append(state_fraq_occ[idx, K][k])
  435. # Clinical scores
  436. qids_.append(QIDS[idx])
  437. masqaa_.append(MASQ_aa[idx])
  438. masqad_.append(MASQ_ad[idx])
  439. masqgd_.append(MASQ_gd[idx])
  440. rrsdr_.append(RRS_dr[idx])
  441. rrsb_.append(RRS_b[idx])
  442. rrsr_.append(RRS_r[idx])
  443. # Create DataFrame with all collected information
  444. df = pd.DataFrame({'SubjectID': ids_, 'Morbidity': type_, 'Sex': sex_, 'Age': age_, 'Medication': meds_,
  445. 'totalstates': totst_, 'StateID': KS_,'DWELLTIME': dwellt_, 'FRAQOCC': fraqoc_,
  446. 'QIDS': qids_, 'MASQ_AA': masqaa_, 'MASQ_AD': masqad_, 'MASQ_GD': masqgd_,'RRS_DR': rrsdr_, 'RRS_BR': rrsb_, 'RRS_RF': rrsr_})
  447. # %%
  448. K = 4
  449. it = np.argmax([inertias[K,it] for it in range(niter)])
  450. my_pal = {"HC": "darkgrey", "MDD": "deepskyblue"}
  451. fig, ax = plt.subplots(1,1, figsize = (10,4))
  452. sns.violinplot(data = df[df.totalstates == K], x = 'StateID', y = 'DWELLTIME', hue = 'Morbidity', ax = ax, palette=my_pal)
  453. ax.set_title('Dwell Time', fontsize = 16)
  454. ax.set_ylabel('Seconds')
  455. dwell_p = ['t:%.3f, p:%.3f'%(ttest_ind(df[(df.Morbidity == 'HC')&(df.StateID == k)&(df.totalstates == K)].DWELLTIME.to_numpy(),df[(df.Morbidity == 'MDD')&(df.StateID == k)&(df.totalstates == K)].DWELLTIME.to_numpy())) for k in range(K)]
  456. print('K = %d'%K)
  457. print('Dwell TIME:', dwell_p, f'\n'
  458. '-----------------------------------')
  459. # %%
  460. K = 4
  461. it = np.argmax([inertias[K,it] for it in range(niter)])
  462. my_pal = {"HC": "darkgrey", "MDD": "deepskyblue"}
  463. fig, ax = plt.subplots(1,1, figsize = (10,4))
  464. sns.violinplot(data = df[df.totalstates == K], x = 'StateID', y = 'FRAQOCC', hue = 'Morbidity', ax = ax, palette=my_pal)
  465. ax.set_title('Fractional Occupancy', fontsize = 16)
  466. ax.set_ylabel('Percentage')
  467. frac_p = ['t:%.3f, p:%.3f'%(ttest_ind(df[(df.Morbidity == 'HC')&(df.StateID == k)&(df.totalstates == K)].FRAQOCC.to_numpy(),df[(df.Morbidity == 'MDD')&(df.StateID == k)&(df.totalstates == K)].FRAQOCC.to_numpy(), equal_var=False)) for k in range(K)]
  468. print('K = %d'%K)
  469. print('Frac Occupancy:', frac_p, f'\n'
  470. '-----------------------------------')
  471. # %%
  472. #ADJUST THESE PATHS, see the github repo for details
  473. brain = nib.load(working_path + 'BIDS_dir/derivatives/cmp-v3.1.0/sub-001/ses-1/anat/sub-001_ses-1_desc-cmp_T1w.nii.gz') #T1w anatomical image
  474. aparcaseg = nib.load(working_path + 'BIDS_dir/derivatives/cmp-v3.1.0/sub-001/ses-1/anat/sub-001_ses-1_atlas-L2018_res-scale1_dseg.nii.gz') # Whole-brain parcellation
  475. yeo7 = nib.load(working_path + 'BIDS_dir/code/Yeo_JNeurophysiol11_MNI152/Yeo2011_7Networks_MNI152_FreeSurferConformed1mm.nii.gz')
  476. yeo7rois = pd.read_csv(working_path + 'BIDS_dir/code/Yeo_JNeurophysiol11_MNI152/7NetworksOrderedNames.csv')
  477. df_stats = pd.read_csv(working_path+ 'BIDS_dir/derivatives/cmp-v3.1.0/sub-001/ses-1/anat/sub-001_ses-1_atlas-L2018_res-scale1_stats.tsv', delimiter= '\t')
  478. df_info = organize_stats(df_stats)
  479. # %%
  480. df_info
  481. # %%
  482. K = 4
  483. it = np.argmax([inertias[K,it] for it in range(niter)])
  484. fig,ax = plt.subplots(1,3,figsize = (16,4))
  485. # Resample aparcaseg (Lausanne2018 parcellation) to match the voxel size and shape of the 'brain' image. Use nearest-neighbor interpolation since these are label maps, not continuous data
  486. resampled_aparcaseg = resample_img(aparcaseg,target_affine=brain.affine,target_shape=brain.shape,interpolation='nearest',force_resample=True,copy_header=True)
  487. # Plot the resampled aparcaseg map on top of the brain anatomy
  488. plot_stat_map(resampled_aparcaseg, threshold=0.01, bg_img=brain, axes=ax[0])
  489. ax[0].set_title('Native brain parcellation (Lausanne2018, N = 85)', fontsize=10)
  490. # Resample the Yeo7 parcellation to match the 'brain' space
  491. resampled_yeo7 = resample_img(yeo7,target_affine=brain.affine,target_shape=brain.shape,interpolation='nearest',force_resample=True,copy_header=True)
  492. # Plot the resampled Yeo7 map with a categorical colormap
  493. plot_stat_map(resampled_yeo7, cmap='Paired', axes=ax[1], bg_img=brain, transparency=0.4)
  494. ax[1].set_title('Native brain parcellation (Yeo7, N = 7)', fontsize=10)
  495. # List of label IDs corresponding to subcortical regions in aparcaseg
  496. subcortical_inds = df_info[(df_info.Type == 'subcortical')].ROI.to_numpy()
  497. # Find voxel indices for each subcortical region in the resampled aparcaseg
  498. subcortical_args = [np.where(resampled_aparcaseg.get_fdata() == int(si)) for si in subcortical_inds]
  499. # Copy the Yeo7 data array so we can modify it
  500. yeo_array = resampled_yeo7.get_fdata().copy()
  501. # Replace all subcortical voxels with a new label value (8), creating Yeo8
  502. for i, si in enumerate(subcortical_inds):
  503. yeo_array[subcortical_args[i]] = 8 # Assign index 8 to all subcortical voxels
  504. # Convert modified array into a NIfTI image and resample to the brain space
  505. yeo8 = resample_to_img(nib.Nifti1Image(yeo_array, affine=resampled_yeo7.affine),brain,force_resample=True,copy_header=True)
  506. # Add the new "SUB" (subcortical) label to the Yeo ROIs dataframe
  507. yeo7rois.loc[len(yeo7rois)] = [8, 'SUB']
  508. # Plot the new Yeo8 parcellation (Yeo7 + subcortical regions)
  509. plot_stat_map(yeo8, cmap='Paired', axes=ax[2], bg_img=brain, transparency=0.4)
  510. ax[2].set_title('Native brain parcellation (Yeo7+Subcortical, N = 8)', fontsize=10)
  511. # %%
  512. K = 4
  513. it = np.argmax([inertias[K,it] for it in range(niter)])
  514. fig,ax = plt.subplots(1,K,figsize = (20,4))
  515. # Loop through each state (cluster) from 0 to K-1
  516. for k in range(K):
  517. # Get the aparcaseg (parcellation) data array
  518. aparcaseg_data = resampled_aparcaseg.get_fdata()
  519. # Initialize a mask for the current state with all zeros
  520. state_mask = np.zeros(aparcaseg_data.shape)
  521. # Loop through each ROI in aparcaseg (skip the first unique value, usually background = 0)
  522. for i, roi in enumerate(np.unique(aparcaseg_data)[1:]):
  523. # Assign the medoid value for this ROI in the current state k
  524. # medoids[K, it][k][i] contains the feature/activation value for ROI i in state k
  525. state_mask[np.where(aparcaseg_data == roi)] = medoids[K, it][k][i]
  526. # Convert the state mask array into a NIfTI image, preserving spatial info
  527. img = nib.Nifti1Image(state_mask, affine=resampled_aparcaseg.affine)
  528. # Plot the state map on top of the brain anatomy
  529. plot_stat_map(img, threshold=0.01, bg_img=brain, axes=ax[k])
  530. ax[k].set_title('State %s' % k, fontsize=12)
  531. # %%
  532. K = 4
  533. it = np.argmax([inertias[K, it] for it in range(niter)])
  534. # Positive amplitude states: keep only positive z-scores, set all others to 0
  535. medoid1 = medoids[K, it].copy()
  536. medoid1[medoid1 <= 0] = 0
  537. pos_amplitude = medoid1.copy()
  538. # Negative amplitude states: keep only negative z-scores, set all others to 0. Then take the absolute value so magnitudes are positive for plotting
  539. medoid2 = medoids[K, it].copy()
  540. medoid2[medoid2 > 0] = 0
  541. neg_amplitude = abs(medoid2.copy())
  542. # Dictionary to store results for each state
  543. all_data8 = {}
  544. # Map positive components to Yeo8 networks
  545. yeo8_pos = get_yeo_networks(pos_amplitude, yeo8, resampled_aparcaseg, size, K)
  546. # Map negative components to Yeo8 networks
  547. yeo8_neg = get_yeo_networks(neg_amplitude, yeo8, resampled_aparcaseg, size, K)
  548. for k in range(K):
  549. all_data8[k] = [yeo8_pos[k], yeo8_neg[k]]
  550. # %%
  551. theta = radar_factory(8, frame = 'polygon')
  552. fig, ax = plt.subplots(figsize=(60, 10), nrows=1, ncols=K,subplot_kw=dict(projection='radar'))
  553. fig.subplots_adjust(wspace=0.25, hspace=0.20, top=0.85, bottom=0.05)
  554. colors = ['b', 'r']
  555. # Plot the four cases from the example data on separate Axes
  556. for k in range(K):
  557. ax[k].set_rgrids([0.2, 0.4, 0.6, 0.8,1], fontsize = 30)
  558. ax[k].set_ylim(0, 1)
  559. for d, color in zip(all_data8[k], colors):
  560. ax[k].plot(theta, d, color='black')
  561. ax[k].fill(theta, d, facecolor=color, alpha=0.25, label='_nolegend_')
  562. ax[k].set_varlabels(yeo7rois[' Network Name'].to_list(), 30)
  563. ax[k].set_title('State %d'%k, weight='bold', fontsize = 30, position=(0.5, 1.1), horizontalalignment='center', verticalalignment='center')
  564. # add legend relative to top-left plot
  565. labels = ('High amplitude', 'Low amplitude')
  566. legend = ax[0].legend(labels, loc=(0.9, .95),labelspacing=0.6, fontsize=40)
  567. # %%
  568. K = 4
  569. it = np.argmax([inertias[K, it] for it in range(niter)])
  570. # Dictionaries for subject-level transition & persistence probabilities
  571. transition_probs, persistence_probs = {}, {}
  572. transition_hc, transition_mdd = np.zeros((len(healthyID), K, K)), np.zeros((len(mddID), K, K))
  573. persistence_hc, persistence_mdd = np.zeros((len(healthyID), K)), np.zeros((len(mddID), K))
  574. # Index to track the time offset when slicing from the concatenated cluster sequence
  575. dur = 0
  576. for i, idx in enumerate(healthyID + mddID):
  577. # Get the fMRI data dimensions for the current subject
  578. if idx in healthyID:
  579. size, duration = healthyFMRI[idx].shape
  580. else:
  581. size, duration = mddFMRI[idx].shape
  582. trnstion_prb = np.zeros((K, K)) # Transition probability matrix
  583. prsstnce_prb = np.zeros((K)) # Persistence counts per state
  584. # Extract the sequence of visited states for this subject. `clusters[K, it]` contains the state assignment for each timepoint in all subjects. We slice from dur to dur+duration to get only this subject's sequence
  585. # new_seq: state sequence without consecutive duplicates (unique transitions)
  586. # new_seq_dup: (state, length_of_consecutive_run) for each run
  587. new_seq = [_ for _, group in groupby(clusters[K, it][dur:dur + duration])]
  588. new_seq_dup = [(_, sum(1 for _ in group)) for _, group in groupby(clusters[K, it][dur:dur + duration])]
  589. # Count how many times each state appears in the deduplicated sequence
  590. uniq, cts = np.unique(new_seq, return_counts=True)
  591. # Update time offset so the next subject's sequence starts correctly
  592. dur += duration
  593. for j, (currst, ct) in enumerate(new_seq_dup):
  594. try:
  595. # Next state after the current run
  596. nextst = new_seq[j + 1]
  597. # Increase transition probability from currst → nextst.
  598. trnstion_prb[nextst, currst] += (cts / cts.sum())[currst]
  599. # Increase persistence probability for currst. (ct - 1) = number of consecutive timepoints staying in the same state
  600. prsstnce_prb[currst] += (ct - 1)
  601. except:
  602. # Last run in the sequence — no next state to transition to
  603. pass
  604. # Normalize transition and persistence probabilities
  605. transition_probs[idx] = trnstion_prb / trnstion_prb.sum()
  606. persistence_probs[idx] = prsstnce_prb / prsstnce_prb.sum()
  607. if idx in healthyID:
  608. transition_hc[i] = trnstion_prb / trnstion_prb.sum()
  609. persistence_hc[i] = prsstnce_prb / prsstnce_prb.sum()
  610. else:
  611. transition_mdd[int(i - len(healthyID))] = trnstion_prb / trnstion_prb.sum()
  612. persistence_mdd[int(i - len(healthyID))] = prsstnce_prb / prsstnce_prb.sum()
  613. # %%
  614. fig,ax = plt.subplots(1,2,figsize = (12,5))
  615. im = ax[0].imshow(transition_hc.mean(axis = 0)-transition_mdd.mean(axis = 0), aspect = 'auto', interpolation = 'none', cmap = 'coolwarm', vmin = -0.02, vmax = 0.02)
  616. divider = make_axes_locatable(ax[0])
  617. cax = divider.append_axes('right', size='5%', pad=0.05)
  618. fig.colorbar(im, cax=cax)
  619. ax[0].set_title('HC-MDD (Transition Prob.)', fontsize = 18)
  620. ax[0].set_xticks([k for k in range(K)])
  621. ax[0].set_yticks([k for k in range(K)])
  622. ax[0].set_xlabel('Current State', fontsize = 15)
  623. ax[0].set_ylabel('Next State', fontsize = 15)
  624. im = ax[1].imshow(np.diag(persistence_hc.mean(axis = 0)-persistence_mdd.mean(axis = 0)), aspect = 'auto', interpolation = 'none', cmap = 'coolwarm', vmin = -0.03, vmax = 0.03)
  625. divider = make_axes_locatable(ax[1])
  626. cax = divider.append_axes('right', size='5%', pad=0.05)
  627. fig.colorbar(im, cax=cax)
  628. ax[1].set_title('HC-MDD (Persistence Prob.)', fontsize = 18)
  629. ax[1].set_xticks([k for k in range(K)])
  630. ax[1].set_yticks([k for k in range(K)])
  631. ax[1].set_xlabel('Current State', fontsize = 15)
  632. ax[1].set_ylabel('Next State', fontsize = 15)
  633. plt.tight_layout()
  634. # %%
  635. fig,ax = plt.subplots(1,2,figsize = (12,2))
  636. im = ax[0].imshow((transition_hc.mean(axis = 0).sum(axis=0)-transition_mdd.mean(axis = 0).sum(axis=0)).reshape(1,4)/3, aspect = 'auto', interpolation = 'none', cmap = 'coolwarm')
  637. divider = make_axes_locatable(ax[0])
  638. cax = divider.append_axes('right', size='5%', pad=0.05)
  639. fig.colorbar(im, cax=cax)
  640. ax[0].set_title('HC-MDD (Exit Prob.)', fontsize = 18)
  641. ax[0].set_xticks([k for k in range(K)])
  642. im = ax[1].imshow((transition_hc.mean(axis = 0).sum(axis=1)-transition_mdd.mean(axis = 0).sum(axis=1)).reshape(1,4)/3, aspect = 'auto', interpolation = 'none', cmap = 'coolwarm')
  643. divider = make_axes_locatable(ax[1])
  644. cax = divider.append_axes('right', size='5%', pad=0.05)
  645. fig.colorbar(im, cax=cax)
  646. ax[1].set_title('HC-MDD (Enter Prob.)', fontsize = 18)
  647. ax[1].set_xticks([k for k in range(K)])
  648. plt.tight_layout()
  649. # %%
  650. # Initialize lists to collect data for each state-to-state transition and clinical score
  651. totst_, KS_, ids_, type_, sex_, age_, meds_ = [], [], [], [], [], [], []
  652. st_prob_, st_trans_ = [], []
  653. clnc_mes_, clnc_score_ = [], []
  654. # Loop over all subjects (healthy + MDD)
  655. for i, idx in enumerate(healthyID + mddID):
  656. # Assign group label and select corresponding transition matrix
  657. if idx in healthyID:
  658. typ = 'HC'
  659. trnst = transition_hc[i]
  660. else:
  661. typ = 'MDD'
  662. trnst = transition_mdd[int(i - len(healthyID))]
  663. # Loop over all clinical scores and their names
  664. for score, mes in zip([QIDS, MASQ_aa, MASQ_ad, MASQ_gd, RRS_dr, RRS_b, RRS_r],['QIDS', 'MASQ_AA', 'MASQ_AD', 'MASQ_GD', 'RRS_DR', 'RRS_BR', 'RRS_RF']):
  665. # Loop over all state-to-state transitions (excluding self-transitions)
  666. for x in range(trnst.shape[0]):
  667. for y in range(trnst.shape[0]):
  668. if x != y:
  669. # Subject and demographic info
  670. ids_.append(idx)
  671. type_.append(typ)
  672. sex_.append(SEX[idx])
  673. age_.append(AGE[idx])
  674. meds_.append(MEDS[idx])
  675. # Transition info
  676. st_trans_.append('%dto%d' % (x, y))
  677. st_prob_.append(trnst[y, x])
  678. # Clinical measure info
  679. clnc_mes_.append(mes)
  680. clnc_score_.append(score[idx])
  681. # Create DataFrame with one row per subject × transition × clinical score
  682. dfff = pd.DataFrame({'SubjectID': ids_, 'Morbidity': type_, 'Sex': sex_, 'Age': age_, 'Medication': meds_,
  683. 'STATETRANS': st_trans_, 'STATEPROB': st_prob_,'CLINICALMES': clnc_mes_, 'CLINICALSCORE': clnc_score_})
  684. # %%
  685. dfff
  686. # %%
  687. my_pal = {"HC": "darkgrey", "MDD": "deepskyblue"}
  688. st_trans = ['0to3', '3to0', '1to2', '2to1', '2to0', '3to1']
  689. fig,ax = plt.subplots(1,1,figsize = (16,5))
  690. sns.boxenplot(data = dfff[(dfff.STATETRANS.isin(st_trans))&(dfff.CLINICALMES == 'QIDS')], x = 'STATETRANS', y ='STATEPROB', hue = 'Morbidity' ,ax = ax, palette=my_pal)
  691. # %%
  692. # Initialize lists to store subject, state, and clinical data
  693. totst_, KS_, ids_, type_, sex_, age_, meds_ = [], [], [], [], [], [], []
  694. st_prob_, st_trans_ = [], []
  695. clnc_mes_, clnc_score_ = [], []
  696. # Loop over all subjects (healthy + MDD)
  697. for i, idx in enumerate(healthyID + mddID):
  698. # Assign group label and select corresponding transition matrix
  699. if idx in healthyID:
  700. typ = 'HC'
  701. trnst = transition_hc[i]
  702. else:
  703. typ = 'MDD'
  704. trnst = transition_mdd[int(i - len(healthyID))]
  705. # Loop over all clinical scores and their names
  706. for score, mes in zip([QIDS, MASQ_aa, MASQ_ad, MASQ_gd, RRS_dr, RRS_b, RRS_r],['QIDS','MASQ_AA','MASQ_AD','MASQ_GD', 'RRS_DR', 'RRS_BR', 'RRS_RF']):
  707. # Loop over each state
  708. for x in range(trnst.shape[0]):
  709. # Compute both EXIT (outgoing) and ENTER (incoming) probabilities
  710. for tty, transtype in enumerate(['EXIT','ENTER']):
  711. # Subject & demographic info
  712. ids_.append(idx)
  713. type_.append(typ)
  714. sex_.append(SEX[idx])
  715. age_.append(AGE[idx])
  716. meds_.append(MEDS[idx])
  717. # Transition type and probability
  718. st_trans_.append('%s %d' % (transtype, x))
  719. st_prob_.append(trnst.sum(axis=tty)[x] / 3) # normalized by number of states?
  720. # Clinical measure info
  721. clnc_mes_.append(mes)
  722. clnc_score_.append(score[idx])
  723. # Create a DataFrame with all subjects × states × transition types × clinical measures
  724. dfff_total = pd.DataFrame({'SubjectID': ids_, 'Morbidity': type_, 'Sex': sex_, 'Age': age_, 'Medication': meds_,
  725. 'STATETRANS': st_trans_, 'STATEPROB': st_prob_,
  726. 'CLINICALMES': clnc_mes_, 'CLINICALSCORE': clnc_score_})
  727. # %%
  728. fig,ax = plt.subplots(1,1,figsize = (24,5))
  729. sns.boxenplot(data = dfff_total, x = 'STATETRANS', y ='STATEPROB', hue = 'Morbidity' ,ax = ax, palette=my_pal)
  730. # %%
  731. fig,ax = plt.subplots(1,1,figsize = (10,4))
  732. sns.violinplot(data = dfff_total[(dfff_total.STATETRANS.isin(['EXIT 2', 'ENTER 2']))&(dfff_total.CLINICALMES=='QIDS')], x = 'STATETRANS', y ='STATEPROB', hue = 'Morbidity' ,ax = ax, palette=my_pal, density_norm='width')
  733. # %%
  734. # %%
  735. K = 4 # Number of states/clusters
  736. it = np.argmax([inertias[K, it] for it in range(niter)]) # Select iteration with maximal inertia
  737. k = 2 # Focus on state k=2
  738. my_pal = {"HC": "darkgrey", "MDD": "deepskyblue"} # Colors for plotting
  739. pvalshc = [] # Store p-values for HC
  740. pvalsmdd = [] # Store p-values for MDD
  741. fig, ax = plt.subplots(1, 4, figsize=(20, 4)) # Create figure with 1 row x 4 columns
  742. for i, mes in enumerate(['QIDS', 'MASQ_AD', 'MASQ_AA', 'MASQ_GD']): # Loop over clinical measures
  743. data = df[(df.totalstates == K) & (df.StateID == k) & (df.Morbidity == 'HC')] # Filter HC data for state k
  744. sns.regplot(data=data, x='DWELLTIME', y=mes, ax=ax[i], label='HC', color="darkgrey") # Plot regression for HC
  745. mask = ~np.isnan(data['DWELLTIME'].to_numpy()) & ~np.isnan(data[mes].to_numpy()) # Remove NaNs in case not every subject has their clinical assesment completed
  746. X = data[[mes, 'Sex', 'Age', 'Medication']][mask] # Design matrix: clinical + covariates
  747. X = sm.add_constant(X) # Add intercept
  748. y = data['DWELLTIME'][mask] # Dependent variable
  749. model = sm.OLS(y, X).fit() # Fit OLS regression
  750. print(model.summary()) # Print model summary
  751. pvalshc.append(model.pvalues.iloc[1]) # Save p-value for clinical measure coefficient
  752. data = df[(df.totalstates == K) & (df.StateID == k) & (df.Morbidity == 'MDD')] # Filter MDD data for state k
  753. sns.regplot(data=data, x='DWELLTIME', y=mes, ax=ax[i], label='MDD', color="deepskyblue") # Plot regression for MDD
  754. mask = ~np.isnan(data['DWELLTIME'].to_numpy()) & ~np.isnan(data[mes].to_numpy()) # Remove NaNs in case not every subject has their clinical assesment completed
  755. X = data[[mes, 'Sex', 'Age', 'Medication']][mask] # Design matrix
  756. X = sm.add_constant(X) # Add intercept
  757. y = data['DWELLTIME'][mask] # Dependent variable
  758. model = sm.OLS(y, X).fit() # Fit OLS regression
  759. print(model.summary()) # Print model summary
  760. pvalsmdd.append(model.pvalues.iloc[1]) # Save p-value for clinical measure coefficient
  761. ax[i].set_title(mes, fontsize=20) # Add subplot title with clinical measure
  762. plt.tight_layout() # Adjust spacing between subplots
  763. # %%
  764. fdrcorrection(pvalsmdd)
  765. # %%
  766. pvalshc = [] # Store p-values for HC
  767. pvalsmdd = [] # Store p-values for MDD
  768. st_trans = '3to0' # State transition of interest
  769. K = 4 # Number of states/clusters
  770. it = np.argmax([inertias[K, it] for it in range(niter)]) # Select iteration with maximal inertia
  771. fig, ax = plt.subplots(1, 7, figsize=(40, 4)) # Create figure with 1 row x 7 columns
  772. for i, mes in enumerate(clinical_measures): # Loop over all clinical measures
  773. # --- Healthy Controls ---
  774. data = dfff[(dfff['Morbidity'] == "HC") & (dfff['CLINICALMES'] == mes) & (dfff['STATETRANS'] == st_trans)] # Filter HC data
  775. sns.regplot(data=data, x='STATEPROB', y='CLINICALSCORE', ax=ax[i], label='HC', color="darkgrey") # Plot regression
  776. mask = ~np.isnan(data['STATEPROB'].to_numpy()) & ~np.isnan(data['CLINICALSCORE'].to_numpy()) # Remove NaNs
  777. X = data[['CLINICALSCORE', 'Sex', 'Age', 'Medication']][mask] # Design matrix with covariates
  778. X = sm.add_constant(X) # Add intercept
  779. y = data['STATEPROB'][mask] # Dependent variable
  780. model = sm.OLS(y, X).fit() # Fit OLS regression
  781. pvalshc.append(model.pvalues.iloc[1]) # Save p-value of clinical measure coefficient
  782. # --- MDD Subjects ---
  783. data = dfff[(dfff['Morbidity'] == "MDD") & (dfff['CLINICALMES'] == mes) & (dfff['STATETRANS'] == st_trans)] # Filter MDD data
  784. sns.regplot(data=data, x='STATEPROB', y='CLINICALSCORE', ax=ax[i], label='MDD', color="deepskyblue") # Plot regression
  785. mask = ~np.isnan(data['STATEPROB'].to_numpy()) & ~np.isnan(data['CLINICALSCORE'].to_numpy()) # Remove NaNs
  786. X = data[['CLINICALSCORE', 'Sex', 'Age', 'Medication']][mask] # Design matrix
  787. X = sm.add_constant(X) # Add intercept
  788. y = data['STATEPROB'][mask] # Dependent variable
  789. model = sm.OLS(y, X).fit() # Fit OLS regression
  790. pvalsmdd.append(model.pvalues.iloc[1]) # Save p-value of clinical measure coefficient
  791. ax[i].set_title(mes, fontsize=20) # Set subplot title
  792. ax[i].set_xlabel('Transition Prob. from %s' % st_trans, fontsize=15) # Label x-axis
  793. print(model.summary()) # Print model summary
  794. # %%
  795. fdrcorrection(pvalshc),pvalshc
  796. # %% [markdown]
  797. # ## Network Control Theory
  798. # %%
  799. np.random.seed(42) # reproducibility
  800. # Generate adjacency matrices for healthy subjects
  801. healthyDTI = {f"{500+i}": random_adjacency(size) for i in range(n_healthy)}
  802. # Generate adjacency matrices for MDD subjects
  803. mddDTI = {f"{600+i}": random_adjacency(size) for i in range(n_mdd)}
  804. allDTI = all_adj = {**healthyDTI, **mddDTI}
  805. # %%
  806. K = 4 # Number of states/clusters
  807. it = np.argmax([inertias[K, it] for it in range(niter)]) # Select iteration with maximal inertia
  808. norm_states = {} # Dictionary to store normalized states
  809. for k in range(K):
  810. norm_states[k] = normalize_state(medoids[K, it][k]) # Normalize the k-th state (z-score or other scaling)
  811. # Compute network control theory metrics for all subjects
  812. # Returns total/control energies, node-level energies, state trajectories, and control signals for both groups
  813. totalenergies, node_energies, state_trajectory, control_signals, totalenergies_pers, node_energies_pers, state_trajectory_pers, control_signals_pers = NCT_multi(healthyID, mddID, allDTI, norm_states, K, size) # Run NCT analysis
  814. # %%
  815. ids_, type_, sex_, age_, meds_ = [], [], [], [], [] # Initialize subject and demographic lists
  816. roi_, st_trans_, ne_ = [], [], [] # Initialize ROI, state transition, and node energy lists
  817. clnc_mes_, clnc_score_ = [], [] # Initialize clinical measure lists
  818. for idx in healthyID + mddID: # Loop over all subjects
  819. if idx in healthyID: group = 'HC' # Assign group label
  820. elif idx in mddID: group = 'MDD'
  821. # Loop over all clinical measures and their names
  822. for score, mes in zip([QIDS, MASQ_aa, MASQ_ad, MASQ_gd, RRS_dr, RRS_b, RRS_r],['QIDS', 'MASQ_AA', 'MASQ_AD', 'MASQ_GD', 'RRS_DR', 'RRS_BR', 'RRS_RF']):
  823. # Loop over all state-to-state transitions
  824. for k1 in range(K):
  825. for k2 in range(K):
  826. if k1 != k2: # Between-state transitions
  827. for n in range(size): # Loop over all ROIs
  828. ids_.append(idx) # Subject ID
  829. sex_.append(SEX[idx]) # Sex
  830. age_.append(AGE[idx]) # Age
  831. meds_.append(MEDS[idx]) # Medication
  832. type_.append(group) # Group
  833. st_trans_.append('%dto%d' % (k1, k2)) # State transition label
  834. roi_.append(n) # ROI index
  835. ne_.append(node_energies[idx][k2, k1, n]) # Node-level energy
  836. clnc_mes_.append(mes) # Clinical measure name
  837. clnc_score_.append(score[idx]) # Clinical score
  838. else: # Persistence (within-state) transitions
  839. for n in range(size):
  840. ids_.append(idx)
  841. sex_.append(SEX[idx])
  842. age_.append(AGE[idx])
  843. meds_.append(MEDS[idx])
  844. type_.append(group)
  845. st_trans_.append('%dto%d' % (k1, k2))
  846. roi_.append(n)
  847. ne_.append(node_energies_pers[idx][k2, k1, n]) # Node energy for persistence
  848. clnc_mes_.append(mes)
  849. clnc_score_.append(score[idx])
  850. # EXIT and ENTER total energies for each state
  851. for t, total in enumerate(['EXIT', 'ENTER']):
  852. for n in range(size):
  853. ids_.append(idx)
  854. sex_.append(SEX[idx])
  855. age_.append(AGE[idx])
  856. meds_.append(MEDS[idx])
  857. type_.append(group)
  858. st_trans_.append('%s%d' % (total, k1)) # Label EXIT or ENTER
  859. roi_.append(n)
  860. ne_.append(node_energies[idx].sum(axis=t)[k1, n] / 3) # Average node energy across states?
  861. clnc_mes_.append(mes)
  862. clnc_score_.append(score[idx])
  863. # Create DataFrame with all node-level energies and associated info
  864. dfff_nct = pd.DataFrame({'SubjectID': ids_, 'Morbidity': type_, 'Sex': sex_, 'Age': age_, 'Medication': meds_,
  865. 'STATETRANS': st_trans_, 'ROI': roi_, 'NODEENERGIES': ne_,
  866. 'CLINICALMES': clnc_mes_, 'CLINICALSCORE': clnc_score_})
  867. # %%
  868. dfff_nct
  869. # %% [markdown]
  870. # ## Compare control energies between groups for given ROIS
  871. # %%
  872. ROIs = [np.random.randint(40) for _ in range(2)]
  873. fig, ax = plt.subplots(2,8, figsize=(24,6))
  874. for s,st_trans in enumerate(['3to0', '2to1', 'EXIT0', 'ENTER0', 'EXIT2', 'ENTER2', 'EXIT3', 'ENTER3']):
  875. for n,roi in enumerate(ROIs):
  876. sns.violinplot(data = dfff_nct[(dfff_nct.ROI == roi)&(dfff_nct.CLINICALMES=='QIDS')&(dfff_nct.STATETRANS == st_trans)], y = 'NODEENERGIES', ax = ax[n][s], hue = 'Morbidity', palette=my_pal, split = True)
  877. ax[0][s].set_title(f"Transition %s "%st_trans, fontsize = 16)
  878. ax[n][s].tick_params(axis='both', which='major', labelsize=10)
  879. ax[n][0].set_ylabel(f'Node Energies\n\n %s'%df_info[df_info.ROI=='%s'%roi].Name.iloc[0], fontsize = 12)
  880. plt.tight_layout()
  881. # %%
  882. ids_, type_, sex_, age_, meds_ = [], [], [], [], [] # Initialize subject and demographic lists
  883. st_trans_, te_, tp_ = [], [], [] # Initialize state transition, total energy, and transition probability lists
  884. clnc_mes_, clnc_score_ = [], [] # Initialize clinical measure lists
  885. for idx in healthyID + mddID: # Loop over all subjects
  886. if idx in healthyID: group = 'HC' # Assign group label
  887. elif idx in mddID: group = 'MDD'
  888. # Loop over selected clinical measures
  889. for score, mes in zip([QIDS, MASQ_aa, MASQ_ad, MASQ_gd], ['QIDS', 'MASQ_AA', 'MASQ_AD', 'MASQ_GD']):
  890. # Loop over all state-to-state transitions
  891. for k1 in range(K):
  892. for k2 in range(K):
  893. if k1 != k2: # Between-state transitions
  894. ids_.append(idx) # Subject ID
  895. sex_.append(SEX[idx]) # Sex
  896. age_.append(AGE[idx]) # Age
  897. meds_.append(MEDS[idx]) # Medication
  898. type_.append(group) # Group label
  899. st_trans_.append('%dto%d' % (k1, k2)) # State transition label
  900. te_.append(totalenergies[idx][k2, k1]) # Total energy for transition
  901. tp_.append(transition_probs[idx][k2, k1]) # Transition probability
  902. clnc_mes_.append(mes) # Clinical measure name
  903. clnc_score_.append(score[idx]) # Clinical score
  904. else: # Persistence (within-state)
  905. ids_.append(idx)
  906. sex_.append(SEX[idx])
  907. age_.append(AGE[idx])
  908. meds_.append(MEDS[idx])
  909. type_.append(group)
  910. st_trans_.append('%dto%d' % (k1, k2))
  911. te_.append(totalenergies_pers[idx][k2, k1]) # Energy for persistence
  912. tp_.append(persistence_probs[idx][k1]) # Persistence probability
  913. clnc_mes_.append(mes)
  914. clnc_score_.append(score[idx])
  915. # EXIT and ENTER total energies/probabilities for each state
  916. for k1 in range(K):
  917. for t, total in enumerate(['EXIT', 'ENTER']):
  918. ids_.append(idx)
  919. sex_.append(SEX[idx])
  920. age_.append(AGE[idx])
  921. meds_.append(MEDS[idx])
  922. type_.append(group)
  923. st_trans_.append('%s%d' % (total, k1)) # Label EXIT or ENTER
  924. te_.append(totalenergies[idx].sum(axis=t)[k1] / 3) # Average total energy
  925. tp_.append(transition_probs[idx].sum(axis=t)[k1] / 3) # Average probability
  926. clnc_mes_.append(mes)
  927. clnc_score_.append(score[idx])
  928. # Create DataFrame with energies, probabilities, and clinical measures
  929. dfff_energies = pd.DataFrame({'SubjectID': ids_, 'Morbidity': type_, 'Sex': sex_, 'Age': age_, 'Medication': meds_,
  930. 'STATETRANS': st_trans_, 'STATEENERGY': te_, 'STATEPROB': tp_,
  931. 'CLINICALMES': clnc_mes_, 'CLINICALSCORE': clnc_score_})
  932. # %%
  933. fig,ax = plt.subplots(1,2,figsize = (12,5))
  934. arrhc = np.array([totalenergies[idx] for idx in healthyID])
  935. arrmdd = np.array([totalenergies[idx] for idx in mddID])
  936. im = ax[0].imshow(arrhc.mean(axis = 0) - arrmdd.mean(axis = 0), aspect = 'auto', interpolation = 'none', cmap = 'coolwarm', vmin = -5, vmax = 5)
  937. divider = make_axes_locatable(ax[0])
  938. cax = divider.append_axes('right', size='5%', pad=0.05)
  939. fig.colorbar(im, cax=cax)
  940. ax[0].set_title('HC-MDD (Transition Energy)', fontsize = 18)
  941. ax[0].set_xticks([k for k in range(K)])
  942. ax[0].set_yticks([k for k in range(K)])
  943. ax[0].set_xlabel('Current State', fontsize = 15)
  944. ax[0].set_ylabel('Next State', fontsize = 15)
  945. arrhc_pers = np.array([totalenergies_pers[idx] for idx in healthyID])
  946. arrmdd_pers = np.array([totalenergies_pers[idx] for idx in mddID])
  947. im = ax[1].imshow(arrhc_pers.mean(axis = 0) - arrmdd_pers.mean(axis = 0), aspect = 'auto', interpolation = 'none', cmap = 'coolwarm', vmin = -5, vmax = 5)
  948. divider = make_axes_locatable(ax[1])
  949. cax = divider.append_axes('right', size='5%', pad=0.05)
  950. fig.colorbar(im, cax=cax)
  951. ax[1].set_title('HC-MDD (Persistence Energy)', fontsize = 18)
  952. ax[1].set_xticks([k for k in range(K)])
  953. ax[1].set_yticks([k for k in range(K)])
  954. ax[1].set_xlabel('Current State', fontsize = 15)
  955. ax[1].set_ylabel('Next State', fontsize = 15)
  956. plt.tight_layout()
  957. # %%
  958. fig,ax = plt.subplots(1,2,figsize = (12,2))
  959. arrhc = np.array([totalenergies[idx] for idx in healthyID])
  960. arrmdd = np.array([totalenergies[idx] for idx in mddID])
  961. im = ax[0].imshow((arrhc.mean(axis = 0).sum(axis=0)/3 - arrmdd.mean(axis = 0).sum(axis=0)/3).reshape(1,4), aspect = 'auto', interpolation = 'none', cmap = 'coolwarm', vmin = -5, vmax = 5)
  962. divider = make_axes_locatable(ax[0])
  963. cax = divider.append_axes('right', size='5%', pad=0.05)
  964. fig.colorbar(im, cax=cax)
  965. ax[0].set_title('HC-MDD (Exit Energy)', fontsize = 18)
  966. ax[0].set_xticks([k for k in range(K)])
  967. im = ax[1].imshow((arrhc.mean(axis = 0).sum(axis=1)/3 - arrmdd.mean(axis = 0).sum(axis=1)/3).reshape(1,4), aspect = 'auto', interpolation = 'none', cmap = 'coolwarm', vmin = -5, vmax = 5)
  968. divider = make_axes_locatable(ax[1])
  969. cax = divider.append_axes('right', size='5%', pad=0.05)
  970. fig.colorbar(im, cax=cax)
  971. ax[1].set_title('HC-MDD (Enter Energy)', fontsize = 18)
  972. ax[1].set_xticks([k for k in range(K)])
  973. plt.tight_layout()
  974. # %%
  975. st_trans = ['0to1','0to2', '0to3', '1to2', '2to1', '2to0', '0to2', '1to3', '3to1', '1to0', '0to1', '3to0','3to2', '2to3']
  976. my_pal = {"HC": "darkgrey", "MDD": "deepskyblue"}
  977. fig,ax = plt.subplots(1,1,figsize= (24,5))
  978. sns.boxenplot(data = dfff_energies[(dfff_energies.STATETRANS.isin(st_trans))&(dfff_energies.CLINICALMES =='QIDS')], x = 'STATETRANS', y ='STATEENERGY', ax = ax, hue = 'Morbidity', palette=my_pal)
  979. ax.tick_params(axis='both', which='major', labelsize=16)
  980. # %%
  981. my_pal = {"HC": "darkgrey", "MDD": "deepskyblue"}
  982. st_trans1 = ['EXIT0', 'EXIT1', 'EXIT2', 'EXIT3','ENTER0','ENTER1', 'ENTER2','ENTER3']
  983. fig,ax = plt.subplots(1,1,figsize= (24,5))
  984. sns.boxplot(data = dfff_energies[(dfff_energies.STATETRANS.isin(st_trans1))&(dfff_energies.CLINICALMES =='QIDS')], x = 'STATETRANS', y ='STATEENERGY', ax = ax, palette=my_pal, hue = 'Morbidity')
  985. # %%
  986. ids_, type_ = [], [] # Initialize lists for subject IDs and group labels
  987. st_trans_, te_, states_ = [], [], [] # Initialize lists for transition type, total energy, and state ID
  988. for idx in healthyID + mddID: # Loop over all subjects
  989. if idx in healthyID: group = 'HC' # Assign group label
  990. elif idx in mddID: group = 'MDD'
  991. else: print('WTF') # Safety check
  992. for k1 in range(K): # Loop over each state
  993. for t, total in enumerate(['EXIT', 'ENTER']): # Loop over EXIT and ENTER transitions
  994. ids_.append(idx) # Append subject ID
  995. type_.append(group) # Append group label
  996. states_.append(k1) # Append state ID
  997. st_trans_.append('%s' % (total)) # Append transition type
  998. te_.append(totalenergies[idx].sum(axis=t)[k1] / 3) # Compute and append average total energy for this transition
  999. # Create DataFrame with EXIT/ENTER total energies
  1000. dfff_energies_exitenter = pd.DataFrame({'SubjectID': ids_, 'Morbidity': type_, 'STATEID': states_,'STATETRANS': st_trans_, 'STATEENERGY': te_})
  1001. my_pal23 = {"EXIT": "darksalmon", "ENTER": "lightseagreen"} # Colors for violin plot
  1002. sns.violinplot(data=dfff_energies_exitenter, x='STATEID', y='STATEENERGY',palette=my_pal23, hue='STATETRANS', cut=True, split=True, density_norm='area') # Plot violin plot of EXIT vs ENTER energies by state
  1003. st_trans = ['EXIT%d', 'ENTER%d'] # Template strings for per-state energy comparisons
  1004. for k in range(4): # Loop over each state
  1005. print(k) # Print state index
  1006. # Compute mean difference between EXIT and ENTER energies for QIDS
  1007. print(dfff_energies[(dfff_energies.STATETRANS == 'EXIT%d' % k) & (dfff_energies.CLINICALMES == 'QIDS')].STATEENERGY.to_numpy().mean() - dfff_energies[(dfff_energies.STATETRANS == 'ENTER%d' % k) & (dfff_energies.CLINICALMES == 'QIDS')].STATEENERGY.to_numpy().mean())
  1008. # Paired t-test between EXIT and ENTER energies for this state
  1009. print(ttest_rel(dfff_energies[(dfff_energies.STATETRANS == 'EXIT%d' % k) & (dfff_energies.CLINICALMES == 'QIDS')].STATEENERGY.to_numpy(),dfff_energies[(dfff_energies.STATETRANS == 'ENTER%d' % k) & (dfff_energies.CLINICALMES == 'QIDS')].STATEENERGY.to_numpy()))
  1010. # %%
  1011. my_pal = {"HC": "darkgrey", "MDD": "deepskyblue"} # Color palette for groups
  1012. fig, ax = plt.subplots(1, 4, figsize=(22, 5)) # Create figure with 1 row x 4 columns
  1013. for st, st_trans in enumerate(['3to0', '2to1', 'EXIT2', "EXIT3"]): # Loop over selected state transitions
  1014. ax[st].set_title('%s' % st_trans, fontsize=16) # Initial subplot title
  1015. for group in ['HC', 'MDD']: # Loop over groups
  1016. # Filter data for current transition, clinical measure, and group
  1017. data = dfff_energies[(dfff_energies.STATETRANS == st_trans) & (dfff_energies.CLINICALMES == 'QIDS') & (dfff_energies.Morbidity == group)]
  1018. # Plot regression: Transition probability vs transition energy
  1019. sns.regplot(data=data, x='STATEENERGY', y='STATEPROB', ax=ax[st], label=group, color=my_pal[group])
  1020. # Prepare design matrix for linear regression
  1021. X = data[['STATEENERGY']] # Independent variable
  1022. X = sm.add_constant(X) # Add intercept
  1023. y = data['STATEPROB'] # Dependent variable
  1024. linear_model = sm.OLS(y, X).fit() # Fit OLS regression
  1025. print(linear_model.summary()) # Print regression summary
  1026. # Update subplot title with more descriptive text
  1027. ax[st].set_title('Trans. Prob. vs Trans. Energy for %s' % (st_trans), fontsize=20)
  1028. plt.tight_layout() # Adjust spacing between subplots
  1029. # %%
  1030. # %%
  1031. # %%
  1032. # %%
  1033. # %%
  1034. # %%
  1035. # %%

tutorial.ipynb at commit 8f93894, under GPL-3.0 · at the source

Overview

Authors: B Ülgen Kilic1,2, Jenna Jubeir1,3, Priti Balchandani3,4,5,6, James W Murrough1,3,7, Laurel S Morris1,8,9, Yael Jacob1,3,4,5
  1. Department of Psychiatry, The Dennis S. Charney, MD, Depression and Anxiety Discovery Center, Icahn School of Medicine at Mount Sinai, New York, NY USA
  2. Department of Psychiatry, Center for Computational Psychiatry, Icahn School of Medicine at Mount Sinai, New York, NY USA
  3. Nash Family Department of Neuroscience & Friedman Brain Institute, Icahn School of Medicine at Mount Sinai, New York, NY USA
  4. Department of Radiology, BioMedical Engineering and Imaging Institute, Icahn School of Medicine at Mount Sinai, New York, NY USA
  5. Center for Engineering and Precision Medicine, Icahn School of Medicine at Mount Sinai & Rensselaer Polytechnic Institute, New York, NY USA
  6. Department of Diagnostic, Molecular and Interventional Radiology, Icahn School of Medicine at Mount Sinai, New York, NY USA
  7. VISN 2 Mental Illness Research, Education and Clinical Center (MIRECC), James J. Peters VA Medical Center, Bronx, NY USA
  8. Nuffield Department of Clinical Neurosciences, University of Oxford, Oxford, UK
  9. Department of Experimental Psychology, University of Oxford, Oxford, UK
Journal: Nature communications, volume 17, issue 1, article 5662
Dates: received 6 August 2025; accepted 3 April 2026; published online 23 April 2026
Type: Research article · Language: English
License: CC BY-NC-ND
Identifiers: DOI 10.1038/s41467-026-71961-4 · PMID 42026043 · PMCID PMC13315732 · OpenAlex W7155399019
Open access: gold, a free copy (OpenAlex)
Status: code verified
Categories: structural MRI / diffusion (modality), human (organism), depression (population), computational (subfield)
Methods: Connectivity, Statistics, Machine learning, Smoothing, state filtering, decompositions, fMRI & imaging
Keywords: Dynamical systems, Depression, Control theory, Network models
MeSH: Brain*, Major Depressive Disorder*, Brain Mapping, Diffusion Magnetic Resonance Imaging, Female, Humans, Magnetic Resonance Imaging, Male, Severity of Illness Index (* major topic)
Topic: Functional Brain Connectivity Studies (Cognitive Neuroscience, Neuroscience), according to OpenAlex
Funding: NIMH NIH HHS (K01 MH131855, R01 MH109544, K01 MH120433); NIH HHS (S10 OD026880, S10 OD030463); U.S. Department of Health &amp; Human Services | National Institutes of Health (R01 MH109544); U.S. Department of Health &amp; Human Services | NIH | National Institute of Mental Health (K01MH12043); NCATS NIH HHS (UL1 TR004419); U.S. Department of Health & Human Services | NIH | National Institute of Mental Health (NIMH) (K01MH12043)
Citations: cited by 1 paper (Europe PMC); 88 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 10 matches between paragraphs and lines of code.

prantikk/me-ica

License: none: the authors keep all their rights
State: the link answers, verified on 29 September 2026
Evidence: files inventoried
Commit: 8cc47cfed0203b3d6d187935ad3c2823b3e36a88, 20 January 2018
Languages: Python (240)
Size: 266 files, 240 scripts
Software Heritage: archived
Found in: the text, “MRI data preprocessing”
Holds: README, tests
Not found: license file, CITATION.cff, environment file, continuous integration, documentation
Tools: NumPy (77 files), SciPy (10 files), NiBabel (4 files), AFNI (3 files), pydicom (2 files), scikit-learn (2 files), Pillow (1 file)
Availability: 1 check, the latest on 29 September 2026: the link answers
  • 29 September 2026: the link answers
241 files

ulgenklc/Brain_states

License: GPL-3.0
State: the link answers, verified on 29 September 2026
Evidence: files inventoried
Commit: 8f93894ad16451c3f0668a8d7b1d6bd38b38c529, 16 May 2026
Languages: Jupyter (1)
Size: 63 files, 1 script
Software Heritage: not archived
Found in: “Code availability”
Holds: README, license file, environment (environment.yml), 1 notebook
Not found: CITATION.cff, tests, continuous integration, documentation
Tools: Matplotlib (1 file), NiBabel (1 file), Nilearn (1 file), NumPy (1 file), pandas (1 file), SciPy (1 file), seaborn (1 file), statsmodels (1 file)
Availability: 1 check, the latest on 29 September 2026: the link answers
  • 29 September 2026: the link answers
3 files

Code availability statement

The paper has a code 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.1038/s41467-026-71961-4.

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

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

Data

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

Data availability statement

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

  • it says that the data are available on request

Read it in the paper: doi.org/10.1038/s41467-026-71961-4.

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

Recorded: type, language, journal, volume, issue, pages, dates, 6 authors, 4 keywords, 9 MeSH terms, 6 funders, 83 references.

Cite

This paper

Kilic, B. Ü., Jubeir, J., Balchandani, P., Murrough, J. W., Morris, L. S., & Jacob, Y. (2026). Spatiotemporal asymmetries on brain energy landscape uncover system entrapment related to depression severity. Nature communications, 17(1), 5662. https://doi.org/10.1038/s41467-026-71961-4

BibTeX

@article{kilic2026spatiotemporal,
author = {Kilic, B Ülgen and Jubeir, Jenna and Balchandani, Priti and Murrough, James W and Morris, Laurel S and Jacob, Yael},
title = {{Spatiotemporal asymmetries on brain energy landscape uncover system entrapment related to depression severity}},
journal = {Nature communications},
year = {2026},
month = apr,
volume = {17},
number = {1},
pages = {5662},
publisher = {Nature Publishing Group},
issn = {2041-1723},
doi = {10.1038/s41467-026-71961-4},
url = {https://doi.org/10.1038/s41467-026-71961-4},
pmid = {42026043},
pmcid = {PMC13315732}
}

RIS

TY - JOUR
AU - Kilic, B Ülgen
AU - Jubeir, Jenna
AU - Balchandani, Priti
AU - Murrough, James W
AU - Morris, Laurel S
AU - Jacob, Yael
TI - Spatiotemporal asymmetries on brain energy landscape uncover system entrapment related to depression severity
T2 - Nature communications
J2 - Nat Commun
PY - 2026
DA - 2026/04/23
VL - 17
IS - 1
SP - 5662
SN - 2041-1723
PB - Nature Publishing Group
DO - 10.1038/s41467-026-71961-4
UR - https://doi.org/10.1038/s41467-026-71961-4
LA - en
ER -

CSL-JSON

{
"id": "10.1038/s41467-026-71961-4",
"type": "article-journal",
"title": "Spatiotemporal asymmetries on brain energy landscape uncover system entrapment related to depression severity",
"container-title": "Nature communications",
"author": [
{
"family": "Kilic",
"given": "B Ülgen"
},
{
"family": "Jubeir",
"given": "Jenna"
},
{
"family": "Balchandani",
"given": "Priti"
},
{
"family": "Murrough",
"given": "James W"
},
{
"family": "Morris",
"given": "Laurel S"
},
{
"family": "Jacob",
"given": "Yael"
}
],
"container-title-short": "Nat Commun",
"volume": "17",
"issue": "1",
"page": "5662",
"DOI": "10.1038/s41467-026-71961-4",
"PMID": "42026043",
"PMCID": "PMC13315732",
"ISSN": "2041-1723",
"publisher": "Nature Publishing Group",
"URL": "https://doi.org/10.1038/s41467-026-71961-4",
"language": "en",
"issued": {
"date-parts": [
[
2026,
4,
23
]
]
}
}

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

Similar papers

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

[1] doi:10.1038/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, Nilearn, NiBabel, 6 other tools, depression, 13 references
[2] doi:10.1162/imag.a.1282 [code]
Metabolic syndrome severity and the energetic cost of brain network transitions: A normative modeling study of accelerated brain aging.
Journal: Imaging neuroscience (Cambridge, Mass.)
In common: Nilearn, NiBabel, statsmodels, 6 other tools, computational, structural MRI / diffusion, 9 references
[3] doi:10.1002/hbm.70600 [code]
A Data-Driven Closed-Loop Control Approach to Drive Neural State Transitions for Mechanistic Insight.
Journal: Human brain mapping
In common: Nilearn, NiBabel, statsmodels, 5 other tools, computational, depression, 6 references
[4] doi:10.1002/hbm.70485 [code]
Exploring the Role of the Rich Club in Network Control of Neurocognitive States.
Journal: Human brain mapping
In common: Nilearn, NiBabel, scikit-learn, 3 other tools, 8 references
[5] doi:10.1038/s41467-026-76011-7 [code]
Human cortex organizes dynamic co-fluctuations along the sensorimotor-association axis.
Journal: Nature communications
In common: AFNI, Pillow, NiBabel, 3 other tools, 8 references
[6] doi:10.1038/s41467-026-72931-6 [code]
Three parsimonious spatiotemporal patterns in cerebellum reveal individual traits in function and behavior.
Journal: Nature communications
In common: Nilearn, NiBabel, statsmodels, 6 other tools, 4 references
[7] doi:10.1038/s41467-026-75585-6 [code]
Brain network dynamics reflect psychiatric illness status and transdiagnostic symptom profiles across health and disease.
Journal: Nature communications
In common: seaborn, scikit-learn, pandas, 3 other tools, depression, 6 references
[8] doi:10.1038/s41467-026-71151-2 [code]
Common and distinct neural correlates of social interaction processing and theory of mind in narratives.
Journal: Nature communications
In common: AFNI, Nilearn, Pillow, 8 other tools, 2 references
[9] doi:10.1038/s41467-026-75745-8 [code]
A language network in the individualized functional connectomes of 1199 human brains doing arbitrary tasks.
Journal: Nature communications
In common: Nilearn, Pillow, NiBabel, 6 other tools, 4 references
[10] doi:10.1038/s41531-026-01354-3 [code]
Neuromodulation-induced normalization of cortical metastable dynamics signatures in Parkinson's disease.
Journal: NPJ Parkinson's disease
In common: Nilearn, NiBabel, statsmodels, 6 other tools, 4 references

Contribute

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

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

Request its removal

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

Discussion, reproductions, activity

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

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

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