OSCR

The role of amygdala GABA neurons in controlling stress and reproduction in female mice.

Code ↔ Paper

17 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 17 matches · 4 of them tie a paragraph to a whole file, not to given lines: weak matches, whose lines are not tinted
  1. [1] § Methods › Comparing pre-intervention and intervention cell-cell interaction strength ↔ data_analysis/calculate_riemannian_distances.ipynb, lines 12–127 · score 0.83 · Metropolis Hasting, SPD matrices, Riemannian distance, correlation matrices, symmetric, uniform
  2. [2] § Methods › Comparing pre-intervention and intervention cell-cell interaction strength ↔ data_analysis/analysis_pipeline.ipynb, lines 303–418 · score 0.82 · Metropolis Hasting, SPD matrices, Riemannian distance, correlation matrices, symmetric, uniform
  3. [3] § Results › In silico modeling reveals complementary relationship between MePD GABA and UCN3 populations ↔ MePD_model/simulations/KNDyXMePDU.m, lines 2–61 · score 0.71 · neuronal activity, KNDy model, model parameter, UCN3 stimulation, couple, secretion
  4. [4] § Results › Dynamic reconfiguration of MePD GABA neuronal networks during selective optogenetic stimulation of urocortin-3 (UCN3) neurons and restraint stress ↔ data_analysis/data/calcium_imaging/figures/norm_Riemannian distance_stat_fig_1.ipynb, the whole file · a weak match · score 0.65 · post hoc, Riemannian distance, control animals, Holm, Cohen, G7
  5. [5] § Results › In silico modeling reveals complementary relationship between MePD GABA and UCN3 populations ↔ MePD_model/simulations/KNDyXMePD_stress.m, lines 3–56 · score 0.65 · neuronal activity, KNDy model, model parameter, couple, secretion, MePD
  6. [6] § Methods › Mini-endoscope-based calcium imaging › Restraint stress ↔ data_analysis/analysis_pipeline.ipynb, lines 2370–2403 · score 0.62 · GCaMP, calcium imaging, restraint stress, opsin, UCN3, GABA
  7. [7] § Methods › Modeling ↔ MePD_model/simulations/KNDyXMePDU.m, lines 2–61 · score 0.57 · model parameter, GABA efferent, couple, MePD, glutamate, KNDy
  8. [8] § Methods › Clustering analysis ↔ data_analysis/analysis_pipeline.ipynb, lines 1695–1714 · score 0.56 · linkage criterion, hierarchical clustering, agglomerative, activity
  9. [9] § Results › Selective optogenetic stimulation of UCN3 non-GABA neurons inhibits pulsatile LH secretion in female mice ↔ data_analysis/data/LH_profiling/figures/fig5J.m, the whole file · a weak match · score 0.54 · ConFon, LH IPI, bars, SEM, position, Figure 5
  10. [10] § Methods › Data analysis ↔ data_analysis/analysis_pipeline.ipynb, lines 495–514 · score 0.54 · power spectral density, GABA neurons, pre, max, signal, clustering
  11. [11] § Results › The MePD GABA neurons relay information from UCN3 neurons and inhibit the GnRH pulse generator ↔ data_analysis/data/LH_profiling/figures/fig5D.m, the whole file · a weak match · score 0.53 · ConFoff, LH IPI, vertical, bars, SEM, position
  12. [12] § Results › The MePD GABA neurons relay information from UCN3 neurons and inhibit the GnRH pulse generator ↔ data_analysis/data/LH_profiling/figures/fig5J.m, the whole file · a weak match · score 0.52 · ConFon, LH IPI, bars, SEM, position
  13. [13] § Methods › Mini-endoscope-based calcium imaging › Optogenetic stimulation of MePD UCN3 neurons ↔ data_analysis/analysis_pipeline.ipynb, lines 2370–2403 · score 0.52 · GCaMP, Calcium imaging, opsin, UCN3, GABA, stimulation
  14. [14] § Methods › Modeling ↔ MePD_model/simulations/combinedMePDv1.m, lines 43–73 · score 0.52 · GABA efferent neurons, GABA interneurons, opted, MePD, UCN3, model
  15. [15] § Results › Dynamic reconfiguration of MePD GABA neuronal networks during selective optogenetic stimulation of urocortin-3 (UCN3) neurons and restraint stress ↔ data_analysis/data/calcium_imaging/figures/permutation_test_AUC_data_fig1.ipynb, lines 9–33 · score 0.51 · stim stress, permutation, AUC, Cliff, bootstrap, delta
  16. [16] § Methods › Comparing pre-intervention and intervention cell-cell interaction strength ↔ data_analysis/calculate_riemannian_distances.ipynb, lines 150–172 · score 0.51 · Riemannian distance, correlation matrices, pre
  17. [17] § Results › Dynamic reconfiguration of MePD GABA neuronal networks during selective optogenetic stimulation of urocortin-3 (UCN3) neurons and restraint stress ↔ data_analysis/calculate_riemannian_distances.ipynb, lines 150–172 · score 0.51 · normalized Riemannian distance, correlation matrices, calcium

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 · 2,816 lines · 104 KB · CC0-1.0 · 5 matches

  1. # %% [markdown]
  2. # # [Amygdala GABA Neurons: Gatekeepers of Stress and Reproduction in Female Mice ](https://www.biorxiv.org/content/10.1101/2025.01.06.631361v4). Analysis pipeline + Figures
  3. # %% [markdown]
  4. # # 🧰 Preparation Block for Analysis
  5. # %%
  6. # Check the current Python version for a stable connection
  7. !python --version
  8. # %% [markdown]
  9. # ### 📦 Install Required Packages
  10. # %%
  11. # Install the Shapely library for geometric operations
  12. !pip install shapely
  13. !pip install colabcode
  14. !pip install colorcet
  15. # Install scikit-posthocs for performing statistical post-hoc tests
  16. !pip install scikit_posthocs
  17. # Install statsmodels module for statistical modeling and hypothesis testing
  18. !python -m pip install statsmodels
  19. # %% [markdown]
  20. # ### 📥 Importing Required Packages
  21. # %%
  22. # OS interface for interacting with the file system
  23. import os
  24. # Core data manipulation and visualisation libraries
  25. import numpy as np # Numerical computing (arrays, math functions)
  26. import pandas as pd # Data structures & analysis (DataFrame, CSV I/O)
  27. import matplotlib.pyplot as plt # Plotting library for basic graphs
  28. import colorcet as cc # Colour maps for visualisation
  29. import seaborn as sns # Statistical data visualisation, builds on Matplotlib
  30. from pathlib import Path # Object-oriented filesystem paths
  31. # Advanced plotting tools from Matplotlib
  32. from matplotlib import gridspec # For custom subplot layouts
  33. from matplotlib.cm import ScalarMappable # For colour mapping in plots
  34. # Signal processing & statistical functions from SciPy
  35. from scipy.signal import correlate, periodogram # Signal similarity & frequency analysis
  36. from scipy.cluster.hierarchy import linkage, dendrogram # Hierarchical clustering & visual trees
  37. from scipy.spatial.distance import pdist, squareform # Pairwise distance computations
  38. from scipy.stats import mannwhitneyu, kstest, kruskal # Non-parametric tests
  39. from scipy.stats import wilcoxon # Wilcoxon signed-rank test
  40. from scipy.linalg import logm, sqrtm # Matrix operations for Riemannian geometry
  41. from scipy.stats import t
  42. # Machine learning tools from Scikit-learn
  43. from sklearn.preprocessing import scale, minmax_scale # Feature scaling methods
  44. from sklearn.cluster import KMeans, AgglomerativeClustering # Clustering algorithms
  45. from sklearn.metrics import silhouette_score # Clustering quality metric
  46. import statsmodels.formula.api as smf # Statistical modeling using formulas
  47. # Utilities
  48. from itertools import product # Cartesian product generator (combinatorics)
  49. # Shapes for visualisation
  50. from matplotlib.patches import Ellipse, Polygon # Custom shapes for plots
  51. from shapely.geometry import Polygon as ShapelyPolygon # Geometric operations
  52. # Post-hoc statistical tests
  53. from scikit_posthocs import posthoc_dunn # Dunn’s test for multiple comparisons after Kruskal-Wallis
  54. # Riemannian distance function
  55. from pyriemann.utils.distance import distance_riemann
  56. # ✅ Confirmation
  57. print("Imports done ✅!")
  58. # %% [markdown]
  59. # These two sections below are optional. They are only required for consistency of the style of the figures.
  60. # %%
  61. # Set the default appearance for Seaborn plots to enhance readability and aesthetics
  62. sns.set_theme(
  63. context='notebook', # Context presets size and scaling for notebook display
  64. style='whitegrid', # Background grid style for better visual separation
  65. palette='deep', # colour palette for plots (rich, vivid colours)
  66. font='DejaVu Sans', # Font for labels and titles
  67. font_scale=1.5, # Increase font size for better legibility
  68. rc={ # Override specific matplotlib parameters
  69. 'lines.linewidth': 2, # Thicker lines for better visibility
  70. 'lines.markersize': 8, # Larger markers for clarity
  71. 'xtick.labelsize': 15, # Font size for x-axis tick labels
  72. 'ytick.labelsize': 15 # Font size for y-axis tick labels
  73. }
  74. )
  75. # %%
  76. # Customise Matplotlib settings for consistent plot styling
  77. rc_params = {
  78. 'axes.grid': False, # Turn off background gridlines
  79. 'font.family': 'DejaVu Sans', # Set global font for plot text
  80. 'font.size': 15, # Base font size for all elements
  81. 'lines.linewidth': 2, # Make plot lines thicker and easier to read
  82. 'lines.markersize': 8, # Larger markers for better visibility
  83. 'xtick.major.size': 3, # Length of major ticks on x-axis
  84. 'ytick.major.size': 3, # Length of major ticks on y-axis
  85. 'xtick.labelsize': 15, # Font size for x-axis tick labels
  86. 'ytick.labelsize': 15, # Font size for y-axis tick labels
  87. 'xtick.bottom': True, # Show ticks on bottom of x-axis
  88. 'xtick.top': False, # Hide ticks on top of x-axis
  89. 'ytick.left': True, # Show ticks on left of y-axis
  90. 'ytick.right': False, # Hide ticks on right of y-axis
  91. 'axes.edgecolor': 'grey' # Use grey colour for plot borders
  92. }
  93. # Apply each setting to Matplotlib's runtime configuration
  94. for param, value in rc_params.items():
  95. plt.rcParams[param] = value
  96. # %% [markdown]
  97. # ### 🛠 Utility Functions for Your Analysis
  98. # %% [markdown]
  99. # Several functions have been defined here to avoid repeating the code; however, in parts, I have repeated these steps with appropriate comments for better readability.
  100. # %%
  101. def load_data(stim_csv_fname):
  102. """
  103. Load stimulation data from a CSV file and extract relevant cell trace information.
  104. Parameters:
  105. stim_csv_fname (str): Path to the CSV file containing stimulation data.
  106. Returns:
  107. tuple:
  108. - data_stim (pd.DataFrame): Entire transposed dataset including metadata.
  109. - cells_traces_tot_old (pd.DataFrame): Data subset excluding metadata (typically fluorescence traces).
  110. """
  111. # Load data from CSV and transpose it (rows become columns and vice versa)
  112. data_stim = pd.read_csv(stim_csv_fname).T
  113. # Strip leading/trailing whitespace from index labels to standardise them
  114. data_stim.index = data_stim.index.str.strip()
  115. # Extract cell trace data by skipping the first row and column (assumed to be metadata)
  116. cells_traces_tot_old = data_stim.iloc[1:, 1:]
  117. # Return both the full dataset and the cleaned subset for downstream use
  118. return data_stim, cells_traces_tot_old
  119. # %%
  120. def process_data(original_data, processed_data, stim_onset=1204, stim_offset=2404, stimulus_freq=0.1, kmeans_clusters=2, random_state=11111):
  121. """
  122. Process fluorescence stimulation data to identify and remove noisy/resonating cells
  123. using frequency-based filtering and K-means clustering.
  124. Parameters:
  125. original_data (pd.DataFrame): Raw stimulation data including metadata.
  126. processed_data (pd.DataFrame): Data with metadata removed (fluorescence traces).
  127. stim_onset (int): Index of stimulation onset.
  128. stim_offset (int): Index of stimulation offset.
  129. stimulus_freq (float): Expected stimulation frequency in Hz (default is 0.1 Hz).
  130. kmeans_clusters (int): Number of clusters used in K-means (default is 2).
  131. random_state (int): Seed for reproducible clustering (default is 11111).
  132. Returns:
  133. tuple:
  134. - data_stim_tot (np.ndarray): Cleaned fluorescence data with noisy cells removed.
  135. - n_cells (int): Number of remaining, reliable cells.
  136. """
  137. # Get number of cells (rows) from processed data
  138. total_cells_stim, _ = processed_data.shape
  139. # Extract time data from original_data and calculate sampling frequency
  140. ts_stim_tot = np.asarray(original_data.iloc[0, 1:], dtype=float)
  141. sec_per_frame = ts_stim_tot[1]
  142. fs = 1 / sec_per_frame # Sampling frequency in Hz
  143. # Convert processed data (traces) to NumPy array for efficient computation
  144. data_stim_artifact = np.asarray(processed_data, dtype=float)
  145. # Identify resonating cells via frequency analysis (Periodogram)
  146. labels_removed_PSD = []
  147. for i in range(total_cells_stim):
  148. freq, Pxx_den = periodogram(data_stim_artifact[i, stim_onset:stim_offset], fs)
  149. Pxx_den = Pxx_den / max(Pxx_den) # Normalise power
  150. freq_bin = round(stimulus_freq * (stim_offset - stim_onset) / fs)
  151. if Pxx_den[freq_bin] > 0.37:
  152. labels_removed_PSD.append(i) # Mark as resonating
  153. # Create binary mask of valid vs. resonating cells
  154. labels_artifact = np.array([0 if i in labels_removed_PSD else 1 for i in range(total_cells_stim)])
  155. # Apply K-means clustering to activity during stimulus period
  156. km = KMeans(n_clusters=kmeans_clusters, random_state=random_state, n_init=10, max_iter=100)
  157. clustered = scale(data_stim_artifact[:, stim_onset:stim_offset], axis=0)
  158. km.fit(clustered)
  159. labels_artifact_KM = km.labels_
  160. # Identify which cluster is smaller (assumed artifact) and remove it
  161. cluster_removed = 0 if np.sum(labels_artifact_KM == 0) < np.sum(labels_artifact_KM == 1) else 1
  162. for i in range(len(data_stim_artifact)):
  163. if labels_artifact[i] == 1 and labels_artifact_KM[i] == cluster_removed:
  164. labels_artifact[i] = 0 # Remove cell
  165. # Extract indices of removed cells and drop them
  166. labels_removed = np.where(labels_artifact == 0)[0]
  167. removed_row_labels = processed_data.index[labels_removed]
  168. cells_traces_stim_tot = processed_data.drop(removed_row_labels, axis=0, inplace=False)
  169. # Convert final cleaned DataFrame to NumPy array
  170. data_stim_tot = np.asarray(cells_traces_stim_tot, dtype=float)
  171. # Return cleaned data and count of remaining cells
  172. n_cells = len(cells_traces_stim_tot)
  173. return data_stim_tot, n_cells
  174. # %%
  175. def min_max_norm(data):
  176. # Convert input to NumPy array to ensure consistent operations
  177. data = np.asarray(data)
  178. # Compute per-trace (row-wise) minimum values
  179. min_vals = np.min(data, axis=1, keepdims=True)
  180. # Compute per-trace (row-wise) maximum values
  181. max_vals = np.max(data, axis=1, keepdims=True)
  182. # Scale each trace independently to the range [0, 1]
  183. return (data - min_vals) / (max_vals - min_vals)
  184. # %%
  185. # Convert p-value to custom significance symbols for visualisation
  186. def pval_to_star(pvalue):
  187. """
  188. Converts a p-value into a string representing significance levels using hash marks and daggers.
  189. Parameters:
  190. pvalue (float): The p-value to evaluate.
  191. Returns:
  192. str: A string indicating statistical significance:
  193. '##' → Highly significant (p ≤ 0.001)
  194. '#' → Significant (p ≤ 0.01)
  195. '†' → Marginally significant (p ≤ 0.05)
  196. 'ns' → Not significant
  197. """
  198. if pvalue <= 0.0001:
  199. return '##' # Extremely significant
  200. elif pvalue <= 0.001:
  201. return '##' # Highly significant
  202. elif pvalue <= 0.01:
  203. return '#' # Significant
  204. elif pvalue <= 0.05:
  205. return '†' # Marginally significant
  206. else:
  207. return 'ns' # Not significant
  208. # %%
  209. # Compute a metric based on signed lagged cross-correlation (SLxCorr)
  210. def signed_lagged_corr(x, y, demean=True):
  211. """
  212. Computes the signed lagged cross-correlation between two signals and returns the
  213. correlation value at the lag with the maximum absolute correlation, along with that lag.
  214. Parameters:
  215. x (numpy.ndarray): First input signal (1D).
  216. y (numpy.ndarray): Second input signal (1D).
  217. demean (bool): If True, subtract the mean from each signal before correlation.
  218. Returns:
  219. tuple:
  220. - float: Signed, normalised cross-correlation at the peak absolute correlation.
  221. - int: Lag (in samples) corresponding to that peak. Positive lag means y lags x.
  222. """
  223. # Ensure 1D arrays
  224. x = np.asarray(x).ravel()
  225. y = np.asarray(y).ravel()
  226. # Optionally remove DC offset so correlation focuses on shape, not level
  227. if demean:
  228. x = x - x.mean()
  229. y = y - y.mean()
  230. # Guard against zero-variance inputs
  231. norm = np.linalg.norm(x) * np.linalg.norm(y)
  232. if norm == 0:
  233. raise ValueError("Normalisation factor is zero. Ensure signals have non-zero variance.")
  234. # Full cross-correlation; zero lag is at index len(y) - 1 for correlate(x, y)
  235. cross_corr = correlate(x, y, mode='full')
  236. scc = cross_corr / norm # Normalised cross-correlation
  237. abs_scc = np.abs(scc)
  238. # Peak absolute correlation and its lag
  239. max_idx = int(np.argmax(abs_scc))
  240. lag = max_idx - (len(y) - 1) # Convert index to lag (centered at zero lag)
  241. signed_correlation = float(scc[max_idx]) # Keep the sign at the peak
  242. return signed_correlation, lag
  243. # %%
  244. import warnings
  245. warnings.filterwarnings("ignore", category=RuntimeWarning)
  246. # run the risky operations here
  247. # Generate mean and standard deviation of Riemannian distances for SPD (correlation) matrices of sizes p=5–80
  248. def metropolis_hastings(p, component_index, num_samples=2000, burn_in=200, proposal_std=0.01):
  249. """
  250. Generate random correlation matrix components using Metropolis–Hastings sampling on the unit sphere.
  251. Parameters:
  252. p (int): Dimension of the full correlation matrix.
  253. component_index (int): Index of the component being generated (1-based).
  254. num_samples (int): Number of samples to retain after burn-in.
  255. burn_in (int): Number of initial samples to discard to allow convergence.
  256. proposal_std (float): Standard deviation of the Gaussian proposal noise.
  257. Returns:
  258. numpy.ndarray: Array of shape (num_samples, dim) containing generated unit vectors.
  259. """
  260. dim = p - component_index + 1 # Effective dimension of the current component
  261. samples = np.zeros((burn_in + num_samples + 1, dim))
  262. # Initialise with a random unit vector; ensure first component is non-negative
  263. current = np.random.randn(1, dim)
  264. current[0, 0] = np.abs(current[0, 0])
  265. current = current / np.linalg.norm(current)
  266. # Run Metropolis–Hastings chain
  267. for t in range(burn_in + num_samples + 1):
  268. # Propose a new vector with small Gaussian perturbation
  269. proposal = current + np.random.normal(scale=proposal_std, size=(1, dim))
  270. proposal = proposal / np.linalg.norm(proposal) # Renormalise to unit length
  271. # Compute acceptance ratio (ensuring positivity of first component)
  272. delta = np.random.uniform(0, 1)
  273. acceptance_ratio = (proposal[0, 0] / current[0, 0])**component_index if proposal[0, 0] >= 0 else 0
  274. # Accept or reject proposal
  275. if delta < acceptance_ratio:
  276. current = proposal
  277. samples[t, :] = current.flatten()
  278. # Return only the post-burn-in samples
  279. return samples[burn_in + 1:, :]
  280. def random_correlation_matrix(p):
  281. """
  282. Construct a random correlation matrix of size p × p using Metropolis–Hastings sampling.
  283. Each row of the upper-triangular matrix U is sampled sequentially, and the
  284. final correlation matrix is obtained as C = U Uᵀ, normalised to have unit diagonal.
  285. Parameters:
  286. p (int): Dimension of the correlation matrix.
  287. Returns:
  288. numpy.ndarray: Random correlation matrix of size p × p.
  289. """
  290. U = np.zeros((p, p))
  291. for i in range(p):
  292. # For each row i, sample the corresponding unit vector and use the last draw
  293. U[i, i:] = metropolis_hastings(p, i + 1)[-1, :]
  294. # Compute symmetric product and normalise diagonal entries to 1
  295. C = U @ U.T
  296. D = np.sqrt(np.diag(C))
  297. correlation_matrix = C / np.outer(D, D)
  298. return correlation_matrix
  299. def distance_riemann(A, B):
  300. """
  301. Compute the affine-invariant Riemannian distance between two SPD matrices A and B.
  302. Formula:
  303. d(A, B) = || log( A^{-1/2} B A^{-1/2} ) ||_F
  304. Parameters:
  305. A (numpy.ndarray): First SPD matrix.
  306. B (numpy.ndarray): Second SPD matrix.
  307. Returns:
  308. float: The affine-invariant Riemannian distance between the two matrices.
  309. """
  310. sqrt_A = sqrtm(A)
  311. inv_sqrt_A = np.linalg.inv(sqrt_A)
  312. C = inv_sqrt_A @ B @ inv_sqrt_A
  313. log_C = logm(C)
  314. return np.linalg.norm(log_C, 'fro')
  315. def expected_distance(matrix_size, num_pairs=100):
  316. """
  317. Estimate the expected Riemannian distance (mean ± std) between pairs of
  318. random correlation matrices of a given size.
  319. Parameters:
  320. matrix_size (int): Dimension of the correlation matrices.
  321. num_pairs (int): Number of pairs of matrices to generate.
  322. Returns:
  323. tuple:
  324. - float: Mean of the estimated distances.
  325. - float: Standard deviation of the estimated distances.
  326. """
  327. distances = []
  328. for _ in range(num_pairs):
  329. A = random_correlation_matrix(matrix_size)
  330. B = random_correlation_matrix(matrix_size)
  331. d = distance_riemann(A, B)
  332. distances.append(d)
  333. return np.mean(distances), np.std(distances)
  334. # %%
  335. def compute_AUC(data_tot, stim_onset, stim_offset):
  336. """
  337. Compute the change in area under the curve (AUC) of ΔF/F signals before and during stimulation.
  338. Parameters:
  339. data_tot (numpy.ndarray): 2D array of calcium fluorescence signals
  340. (shape: n_cells × n_timepoints).
  341. stim_onset (int): Frame index marking the start of stimulation.
  342. stim_offset (int): Frame index marking the end of stimulation.
  343. Notes:
  344. - ΔF/F (delta F over F) is computed relative to the mean baseline fluorescence
  345. during the pre-stimulation period.
  346. - AUC is computed for several time windows (30, 60, 90, 120 seconds)
  347. within the stimulation period.
  348. - Sampling rate is assumed to be 10 Hz (hence `time_point * 10`).
  349. """
  350. AUC_time_windows = [30, 60, 90, stim_offset/10] # time windows (in seconds) for which AUC is computed
  351. # --- Pre-stimulation ΔF/F computation ---
  352. # Compute mean baseline for each cell during pre-stimulation period
  353. baseline_pre = np.mean(data_tot[:, :stim_onset], axis=1)
  354. # Compute ΔF/F trace for pre-stimulation
  355. dff_pre = (data_tot[:, :stim_onset] - baseline_pre[:, np.newaxis]) / baseline_pre[:, np.newaxis]
  356. # --- Stimulation ΔF/F computation ---
  357. # Compute mean baseline again (same as above, to ensure comparable scaling)
  358. baseline_stim = np.mean(data_tot[:, :stim_onset], axis=1)
  359. # Compute ΔF/F trace during stimulation
  360. dff_stim = (data_tot[:, stim_onset:stim_offset] - baseline_stim[:, np.newaxis]) / baseline_stim[:, np.newaxis]
  361. # --- Compute AUC differences for each time window ---
  362. for AUC_window in AUC_time_windows:
  363. # Compute average AUC for pre-stimulation (normalised by duration)
  364. area_1 = np.trapezoid(dff_pre) / (stim_onset / 10)
  365. # Compute average AUC during stimulation (up to specified duration)
  366. # Sampling rate assumed 10 Hz → convert seconds to frame index.
  367. area_2 = np.trapezoid(dff_stim[:, :AUC_window * 10]) / AUC_window
  368. # Output median difference in AUC between stimulation and pre-stimulation
  369. print(f'AUC for {AUC_window} s is {np.median(area_2) - np.median(area_1)}.')
  370. # %% [markdown]
  371. # ### 📁 Setting File Paths for Figures and Data
  372. # %%
  373. # --- Fig. 1: Stimulation and Stress Data ---
  374. stim_fig1_csv_fname = './data/calcium_imaging/UCN3_stimulation/G7 20240201 10hz 223sti.csv'
  375. stress_fig1_csv_fname = './data/calcium_imaging/restrain_stress/G28 333restraint 20240630.csv'
  376. # --- Fig. 2: Stimulation and Stress Data ---
  377. stim_fig2_csv_fname = './data/calcium_imaging/UCN3_stimulation/G28 20240709 10hz 223sti.csv'
  378. stress_fig2_csv_fname = './data/calcium_imaging/restrain_stress/G9 20231218 334restraint.csv'
  379. # --- Figs. 1 & 2: P-values and Cell Percentages ---
  380. hier_clustering_cell_perc_fname = './data/cell_percentage_hier_corr 10Hz.csv'
  381. if not Path(stim_fig1_csv_fname).is_file():
  382. print(f"File {stim_fig1_csv_fname} does not exist")
  383. if not Path(stress_fig1_csv_fname).is_file():
  384. print(f"File {stim_fig1_csv_fname} does not exist")
  385. if not Path(stim_fig2_csv_fname).is_file():
  386. print(f"File {stim_fig1_csv_fname} does not exist")
  387. if not Path(stress_fig2_csv_fname).is_file():
  388. print(f"File {stim_fig1_csv_fname} does not exist")
  389. if not Path(hier_clustering_cell_perc_fname).is_file():
  390. print(f"File {hier_clustering_cell_perc_fname} does not exist")
  391. # %%
  392. # --- Time Windows (in index units) ---
  393. stim_onset = 1204
  394. stim_offset = 2404
  395. stress_onset = 1804
  396. stress_offset = 3604
  397. # --- Signal Analysis ---
  398. PSD_thr = 0.37 # Power Spectral Density threshold
  399. # --- Clustering & Bootstrapping ---
  400. max_no_clusters = 4
  401. n_resampling = 100
  402. Ks_1 = 2 # Pre-stimulation clusters
  403. Ks_2 = 2 # Stimulation clusters
  404. # --- GABA Neuron colours ---
  405. eff_green = (63/255, 128/255, 0) # GABA efferent
  406. inter_green = (0, 1, 0) # GABA interneuron
  407. GABA_greens = [eff_green, inter_green]
  408. # %% [markdown]
  409. # # 🧪 Full Workflow: G7 Stimulation (Figures 1 & S1)
  410. # %% [markdown]
  411. # This is an example stimulation trial for figure 1 from animal G7.
  412. # %%
  413. # Load and transpose the data
  414. data_fig1_stim = pd.read_csv(stim_fig1_csv_fname).T
  415. # Clean up row labels
  416. data_fig1_stim.index = data_fig1_stim.index.str.strip()
  417. # Preview the last 5 cells
  418. data_fig1_stim.tail(5)
  419. # %% [markdown]
  420. # ## ✂️ Data Slicing for G7 Stimulation
  421. # %%
  422. # Extract signal data (excluding metadata)
  423. cells_traces_tot_old = data_fig1_stim.iloc[1:, 1:]
  424. # Get shape info
  425. total_cells_fig1_stim, data_fig1_stim_length_tot = cells_traces_tot_old.shape
  426. # Preview bottom rows
  427. cells_traces_tot_old.tail()
  428. # %%
  429. # Extract time axis from first row (excluding first column)
  430. ts_fig1_stim_tot = np.asarray(data_fig1_stim.iloc[0, 1:], dtype=float)
  431. # Time between frames (first timestamp is 0)
  432. sec_per_frame = ts_fig1_stim_tot[1]
  433. # Sampling frequency (Hz)
  434. fs = 1 / sec_per_frame
  435. # %%
  436. # Convert fluorescence traces to NumPy array
  437. data_fig1_stim_artifact = np.asarray(cells_traces_tot_old, dtype=float)
  438. # %% [markdown]
  439. # ## 🔍 Finding UCN3 Cells
  440. # %% [markdown]
  441. # ### 🌀 1. Power Spectral Density (PSD) Filtering
  442. # %%
  443. # Define the stimulus frequency and initialise a list to store labels of removed cells based on PSD threshold
  444. stimulus_freq = 0.1 # Stimulus frequency in Hz
  445. labels_removed_PSD = [] # List to store indices of cells flagged as responsive
  446. # Loop through each cell to calculate the power spectral density (PSD)
  447. for i in range(total_cells_fig1_stim):
  448. # Compute the periodogram (PSD) for the cell's signal during the stimulation period
  449. freq, Pxx_den = periodogram(
  450. data_fig1_stim_artifact[i, stim_onset:stim_offset], fs)
  451. # Normalise the PSD by its maximum value to scale between 0 and 1
  452. Pxx_den = Pxx_den / max(Pxx_den)
  453. # Calculate the index corresponding to the stimulus frequency
  454. # This assumes uniform frequency spacing in the PSD output
  455. stimulus_freq_idx = round(stimulus_freq * (stim_offset - stim_onset) / fs)
  456. # Check if the normalised PSD at the stimulus frequency exceeds the threshold
  457. if Pxx_den[stimulus_freq_idx] > PSD_thr:
  458. # If so, mark the cell as responsive by adding its index to the list
  459. labels_removed_PSD.append(i)
  460. # Print the indices of cells identified as responsive based on PSD threshold
  461. print("Indices of cells removed based on PSD threshold:", labels_removed_PSD)
  462. # %%
  463. # Create binary mask to flag artifact-affected cells
  464. # - 0: cell identified as artifact (high PSD at stimulus frequency)
  465. # - 1: cell retained for further analysis
  466. labels_artifact = np.asarray(
  467. [0 if _ in labels_removed_PSD else 1 for _ in range(total_cells_fig1_stim)]
  468. )
  469. # Optional: Print the binary mask to verify which cells were flagged
  470. # 0 = artifact (failed PSD threshold), 1 = valid (passed PSD threshold)
  471. print(labels_artifact)
  472. # %% [markdown]
  473. # ### 🔢 2. K-Means Clustering
  474. # %%
  475. # Apply scikit-learn K-means clustering to sort activity traces
  476. # Step 1: Normalise the activity data across time (columns) to ensure fair clustering
  477. scaled_data = scale(data_fig1_stim_artifact[:, stim_onset:stim_offset], axis=0)
  478. # Step 2: Set the number of clusters (2 groups: responsive vs non-responsive)
  479. Ks = 2
  480. # Step 3: Initialise and run K-means clustering
  481. # - random_state ensures reproducibility
  482. # - n_init = 10 means the algorithm runs 10 times with different centroid seeds
  483. # - max_iter = 100 limits the number of iterations per run
  484. kmeans = KMeans(n_clusters=Ks, random_state=11111, n_init=10, max_iter=100)
  485. # Step 4: Fit the model to the scaled data and assign cluster labels to each cell
  486. labels_artifact_KM = kmeans.fit_predict(scaled_data)
  487. # Step 5: Sort cell indices based on their assigned cluster labels
  488. # This helps organise or visualise cells by their activity patterns
  489. i_labels_artifact = np.argsort(labels_artifact_KM)
  490. # Step 6: Display the sorted indices for inspection or downstream analysis
  491. print(f"Sorted indices of cells by cluster ID:\n{i_labels_artifact}")
  492. # %%
  493. # Determine which cluster to remove based on relative size
  494. # Step 1: Count the number of cells in each cluster
  495. size_cluster_0 = len(data_fig1_stim_artifact[labels_artifact_KM == 0])
  496. size_cluster_1 = len(data_fig1_stim_artifact[labels_artifact_KM == 1])
  497. # Step 2: Compare cluster sizes to identify the smaller one
  498. # The assumption here is that the smaller cluster likely represents artifacts or noise
  499. if size_cluster_0 < size_cluster_1:
  500. cluster_removed = 0 # Smaller cluster is flagged for removal
  501. cluster_kept = 1 # Larger cluster is retained
  502. print('Cluster #0 (smaller) is removed.')
  503. elif size_cluster_1 != 0:
  504. cluster_removed = 1
  505. cluster_kept = 0
  506. print('Cluster #1 (smaller) is removed.')
  507. else:
  508. # Edge case: one cluster has zero members, so no valid removal decision
  509. print("No valid cluster for removal.")
  510. # %%
  511. # Update artifact labels based on K-means clustering results
  512. # Step 1: Check if the cluster marked for removal contains any cells
  513. if len(data_fig1_stim_artifact[labels_artifact_KM == cluster_removed]) != 0:
  514. # Step 2: Iterate through all cells
  515. for i in range(len(data_fig1_stim_artifact)):
  516. # Only update cells that were previously marked as valid (1)
  517. # and now belong to the cluster flagged for removal
  518. if labels_artifact[i] == 1 and labels_artifact_KM[i] == cluster_removed:
  519. labels_artifact[i] = 0 # Mark as artifact
  520. # Step 3: Explicitly define which cluster was removed and which was kept
  521. cluster_removed = 0
  522. cluster_kept = 1
  523. # Step 4: Extract indices of cells now marked as artifacts
  524. labels_removed = np.where(labels_artifact == cluster_removed)[0]
  525. # Optional: print summary for verification
  526. print(f"Number of (UCN) cells removed: {len(labels_removed)}")
  527. print(f"Indices of removed cells: {labels_removed}")
  528. # %%
  529. # Remove resonated (artifact) cells from fluorescence intensity traces
  530. # Step 1: Get the row labels (e.g., cell IDs) corresponding to removed indices
  531. removed_row_labels = cells_traces_tot_old.index[labels_removed]
  532. # Step 2: Drop the identified artifact cells from the original DataFrame
  533. # - axis=0 means rows are being removed
  534. # - inplace=False ensures the original DataFrame remains unchanged
  535. cells_traces_fig1_stim_tot = cells_traces_tot_old.drop(
  536. removed_row_labels, axis=0, inplace=False)
  537. # Step 3: Count the number of remaining (valid) cells after removal
  538. n_cells_fig1_stim = len(cells_traces_fig1_stim_tot)
  539. # Optional: print summary
  540. print(f"Number of GABA cells: {n_cells_fig1_stim}")
  541. # %% [markdown]
  542. # ### ✂️ Data Slicing
  543. # %%
  544. # Convert filtered fluorescence traces from DataFrame to NumPy array
  545. data_fig1_stim_tot = cells_traces_fig1_stim_tot.to_numpy(dtype=float)
  546. # Slice the data into temporal segments for analysis
  547. # Pre-stimulation period: from start up to stimulus onset
  548. data_fig1_stim_1 = data_fig1_stim_tot[:, :stim_onset]
  549. # Stimulation period: from stimulus onset to stimulus offset
  550. data_fig1_stim_2 = data_fig1_stim_tot[:, stim_onset:stim_offset]
  551. # Optional: Print shapes to verify correct slicing
  552. print(f"Total data shape: {data_fig1_stim_tot.shape}")
  553. print(f"Pre-stimulation data shape: {data_fig1_stim_1.shape}")
  554. print(f"Stimulation data shape: {data_fig1_stim_2.shape}")
  555. # %%
  556. # Rescale pre-stimulation traces using min-max normalisation (row-wise)
  557. normalised_data_fig1_stim_1 = minmax_scale(data_fig1_stim_1, axis=1)
  558. # Optional: Inspect the first few normalised traces to verify the transformation
  559. print("Normalised Pre-stimulation Data (first few rows):")
  560. print(normalised_data_fig1_stim_1[:5])
  561. # %%
  562. # Rescale stimulation-period traces using min-max normalisation (row-wise)
  563. normalised_data_fig1_stim_2 = minmax_scale(data_fig1_stim_2, axis=1)
  564. # Optional: Inspect the first few normalised stimulation traces
  565. print("Normalised Stimulation Data (first few rows):")
  566. print(normalised_data_fig1_stim_2[:5])
  567. # %% [markdown]
  568. # ### 📈 corrcoef: Standard Pearson Correlation Coefficient
  569. # %%
  570. # Compute the correlation matrix for pre-stimulation traces
  571. max_corr_fig1_stim_1 = np.corrcoef(normalised_data_fig1_stim_1, rowvar=True)
  572. # Compute the correlation matrix for stimulation-period traces
  573. max_corr_fig1_stim_2 = np.corrcoef(normalised_data_fig1_stim_2, rowvar=True)
  574. # %% [markdown]
  575. # ## 🖼️ Figure S2: Visualisation of Trace Filtering and Clustering
  576. # %%
  577. # Set up the figure canvas with 4 vertical subplots
  578. plt.figure(figsize=(15, 14))
  579. # ─────────────────────────────────────────────────────────────
  580. # 🔹 Plot 1: Original Traces (before any filtering)
  581. ax1 = plt.subplot(4, 1, 1)
  582. # Normalise raw traces for visualisation
  583. scaled_data = minmax_scale(data_fig1_stim_artifact[:, :stim_offset], axis=1)
  584. # Display as heatmap
  585. plt.imshow(scaled_data, cmap="viridis", interpolation='none')
  586. # Mark stimulus onset with a vertical white line
  587. ax1.plot([1204, 1204], [0, total_cells_fig1_stim-1], '-w')
  588. # Configure plot aesthetics
  589. ax1.set_aspect('auto')
  590. ax1.set_xlabel('Time [sec]')
  591. ax1.set_ylabel('Cell ID')
  592. ax1.invert_yaxis()
  593. ax1.set_title('Original Traces')
  594. ax1.set_xticks([0, 500, 1000, 1500, 2000])
  595. ax1.set_xticklabels([0, 50, 100, 150, 200])
  596. # Add colourbar for ΔF/F₀ intensity
  597. cmap = plt.get_cmap("viridis")
  598. sm = ScalarMappable(cmap=cmap)
  599. sm.set_clim(vmin=0, vmax=1)
  600. cb = plt.colorbar(cmap=cmap, fraction=0.04, pad=0.03)
  601. cb.set_label('$\Delta$ F$/$F$_0$ (scaled)')
  602. cb.set_ticks([0, 0.5, 1])
  603. # ─────────────────────────────────────────────────────────────
  604. # 🔹 Plot 2: Power Spectral Density (PSD) at 0.1 Hz
  605. ax2 = plt.subplot(4, 1, 2)
  606. # Loop through each cell to compute and plot PSD
  607. for i in range(total_cells_fig1_stim):
  608. freq, Pxx_den = periodogram(data_fig1_stim_artifact[i, stim_onset:stim_offset], fs)
  609. Pxx_den = Pxx_den / max(Pxx_den) # Normalise PSD
  610. stimulus_freq_PSD = Pxx_den[round(stimulus_freq * (stim_offset - stim_onset) / fs)]
  611. # Mark cells based on PSD threshold
  612. if stimulus_freq_PSD < PSD_thr:
  613. labels_removed_PSD.append(i)
  614. ax2.scatter(i, stimulus_freq_PSD, marker='.', s=300, c='black') # Below threshold
  615. else:
  616. ax2.scatter(i, stimulus_freq_PSD, marker='.', s=300, c='red') # Above threshold
  617. # Add horizontal threshold line
  618. ax2.plot([-1, total_cells_fig1_stim], [0.37, 0.37], '--', c='gray')
  619. ax2.set_xlim([-1, total_cells_fig1_stim])
  620. ax2.set_ylim([-0.05, 1.1])
  621. ax2.set_xlabel('Cell ID')
  622. ax2.set_ylabel('PSD')
  623. ax2.set_title('Power Spectral Density (PSD) at 0.1 Hz')
  624. ax2.annotate(0.37, (10, 0.4), ha='center')
  625. # ─────────────────────────────────────────────────────────────
  626. # 🔹 Plot 3: Traces Sorted by K-means Clustering
  627. ax3 = plt.subplot(4, 1, 3)
  628. # Display sorted traces based on clustering labels
  629. plt.imshow(minmax_scale(data_fig1_stim_artifact[i_labels_artifact, :stim_offset], axis=1), cmap="viridis", interpolation='none')
  630. # Mark stimulus onset
  631. ax3.plot([1204, 1204], [0, total_cells_fig1_stim-1], '-w')
  632. # Configure plot aesthetics
  633. ax3.set_aspect('auto')
  634. ax3.set_xlabel('Time [sec]')
  635. ax3.set_ylabel('Cell ID')
  636. ax3.invert_yaxis()
  637. ax3.set_title('K-means Sorted Traces')
  638. ax3.set_xticks([0, 500, 1000, 1500, 2000])
  639. ax3.set_xticklabels([0, 50, 100, 150, 200])
  640. # Add colourbar
  641. cmap = plt.get_cmap("viridis")
  642. sm = ScalarMappable(cmap=cmap)
  643. sm.set_clim(vmin=0, vmax=1)
  644. cb = plt.colorbar(cmap=cmap, fraction=0.04, pad=0.03)
  645. cb.set_label('$\Delta$ F$/$F$_0$ (scaled)')
  646. cb.set_ticks([0, 0.5, 1])
  647. # ─────────────────────────────────────────────────────────────
  648. # 🔹 Plot 4: Purely GABA Traces (after filtering)
  649. ax4 = plt.subplot(4, 1, 4)
  650. # Display final cleaned traces
  651. plt.imshow(minmax_scale(data_fig1_stim_tot[:, :stim_offset], axis=1), cmap="viridis", interpolation='none')
  652. # Mark stimulus onset
  653. ax4.plot([1204, 1204], [0, n_cells_fig1_stim-1], '-w')
  654. # Configure plot aesthetics
  655. ax4.set_aspect('auto')
  656. ax4.set_xlabel('Time [sec]')
  657. ax4.set_ylabel('Cell ID')
  658. ax4.invert_yaxis()
  659. ax4.set_title('Purely GABA Traces')
  660. ax4.set_xticks([0, 500, 1000, 1500, 2000])
  661. ax4.set_xticklabels([0, 50, 100, 150, 200])
  662. # Add colourbar
  663. cmap = plt.get_cmap("viridis")
  664. sm = ScalarMappable(cmap=cmap)
  665. sm.set_clim(vmin=0, vmax=1)
  666. cb = plt.colorbar(cmap=cmap, fraction=0.04, pad=0.03)
  667. cb.set_label('$\Delta$ F$/$F$_0$ (scaled)')
  668. cb.set_ticks([0, 0.5, 1])
  669. # ─────────────────────────────────────────────────────────────
  670. # Final layout adjustments and export
  671. plt.tight_layout()
  672. # Optional: Save figure to disk
  673. # plt.savefig("FigS1_revised.svg", dpi=600)
  674. # Display the figure
  675. plt.show()
  676. # %%
  677. # Visualise a few example stimulus traces after normalisation
  678. X = minmax_scale(data_fig1_stim_artifact[:, :stim_offset], axis=1)
  679. plt.figure(figsize=(10, 6))
  680. for i in range(3, 5): # plot a small subset of neurons
  681. plt.plot(X[i, :], linewidth=1)
  682. plt.xlim(0, stim_offset)
  683. plt.xlabel("Time")
  684. plt.ylabel("Normalised activity")
  685. plt.title("Stimulus traces (neurons 3–7)")
  686. plt.show()
  687. # %% [markdown]
  688. # # 🧪 Full Workflow: G28 Stress (Figure 1)
  689. # %% [markdown]
  690. # This is a example restraint stress trial for figure 1 from animal G28.
  691. # %%
  692. # Load and transpose the data
  693. data_fig1_stress = pd.read_csv(stress_fig1_csv_fname).T
  694. # Clean up row labels
  695. data_fig1_stress.index = data_fig1_stress.index.str.strip()
  696. # Preview the last 6 cells
  697. data_fig1_stress.tail(5) # You can change to .tail(2) for a quicker peek
  698. # %% [markdown]
  699. # ### ✂️ Data Slicing for G28 Stress
  700. # %%
  701. # Extract signal data (excluding metadata)
  702. cells_traces_fig1_stress_tot = data_fig1_stress.iloc[1:, 1:]
  703. # Get shape info
  704. n_cells_fig1_stress, _ = cells_traces_fig1_stress_tot.shape
  705. # Preview bottom rows
  706. cells_traces_fig1_stress_tot.tail()
  707. # %%
  708. # Convert DataFrame to NumPy array for numerical processing
  709. data_fig1_stress_tot = cells_traces_fig1_stress_tot.values.astype(float)
  710. # Slice the data before stimulation onset
  711. data_fig1_stress_1 = data_fig1_stress_tot[:, :stress_onset]
  712. # Slice data during stimulation period
  713. data_fig1_stress_2 = data_fig1_stress_tot[:, stress_onset:stress_offset]
  714. # %%
  715. # Rescale pre-stimulation traces using min-max normalisation (row-wise)
  716. normalised_data_fig1_stress_1 = minmax_scale(data_fig1_stress_1, axis=1)
  717. # Rescale stimulation-period traces using min-max normalisation (row-wise)
  718. normalised_data_fig1_stress_2 = minmax_scale(data_fig1_stress_2, axis=1)
  719. # %% [markdown]
  720. # ### 📈 corrcoef: Standard Pearson Correlation Coefficient
  721. # %%
  722. # Compute the correlation matrix for pre-stimulation traces
  723. max_corr_fig1_stress_1 = np.corrcoef(normalised_data_fig1_stress_1, rowvar=True)
  724. # Compute the correlation matrix for stimulation-period traces
  725. max_corr_fig1_stress_2 = np.corrcoef(normalised_data_fig1_stress_2, rowvar=True)
  726. # %% [markdown]
  727. # # 🖼️ Figure 1
  728. # %%
  729. # Riemannian distances between empirical correlation matrices and a null model
  730. # - Each value reflects how far a real correlation matrix is from a distribution of random matrices
  731. # - Random matrices generated via Metropolis-Hastings sampling
  732. # - Distances are normalised by the mean pairwise distance among sampled matrices
  733. # Control condition: 12 samples across 4 animals (G33–G36)
  734. control_dist = [0.6484, 0.6310, 0.6835, 0.3532, 0.4503, 0.4678, 0.4226, 0.4540, 0.5091, 0.4670, 0.5697, 0.5820]
  735. control_animal = ['G33', 'G33', 'G33', 'G34', 'G34', 'G34', 'G35', 'G35', 'G35', 'G36', 'G36', 'G36']
  736. # Stimulated condition: 20 samples across 6 animals (G7–G9, G28–G29, G37)
  737. stim_dist = [0.8543, 0.8024, 0.8918, 0.7506, 0.8326, 0.8286, 0.7508, 0.7120, 0.8152, 0.7978,
  738. 0.5823, 0.7944, 0.6782, 0.4742, 0.3286, 0.4824, 0.7652, 0.7087, 0.6085, 0.6523]
  739. stim_animal = ['G7', 'G7', 'G8', 'G8', 'G8', 'G8', 'G9', 'G9', 'G9', 'G9',
  740. 'G28', 'G28','G28', 'G29', 'G29', 'G29', 'G37', 'G37', 'G37', 'G37']
  741. # Stress condition: 7 samples across 6 animals (G7–G9, G28–G29, G37)
  742. # - Note: G37 contributes two samples from distinct sessions
  743. stress_dist = [1.001113156, 1.002419683, 0.969890523, 0.7640226, 0.790428686, 0.885770891, 0.810616632]
  744. stress_animal = ['G7', 'G8', 'G9', 'G28', 'G29', 'G37', 'G37']
  745. # %%
  746. # AUC values represent integrated response over time windows
  747. # - Each list contains per-sample AUCs for a given condition and time window
  748. # - Negative values may reflect suppression or baseline drift
  749. # - Stress values show large positive outliers, suggesting high activation
  750. # ⏱ 30-second window
  751. AUC_stim_30 = [-3.2941, -0.8233, 1.4743, -1.9491, 2.5396, 1.0696, -0.6789, -0.4772, -0.6625, -2.1106,
  752. -2.6996, 10.156, 1.2475, 4.7087, -1.5431, 3.906, 1.4121, -2.2647, -1.1277, -3.9984]
  753. AUC_stress_30 = [3.9668, 4.511, 2.5152, 60.6365, 23.0652, 22.4978, 41.8574]
  754. # ⏱ 60-second window
  755. AUC_stim_60 = [-3.0155, -1.267, 1.6671, -2.3267, 0.952, -0.4225, -2.2116, 1.5772, -0.1854, -1.7976,
  756. -1.8799, 14.8400, -2.0294, 4.1857, -0.9583, 1.1032, 0.0982, -2.7538, -0.5514, -3.5729]
  757. AUC_stress_60 = [4.7454, 2.3182, 0.4937, 50.6340, 21.3518, 26.5827, 51.8101]
  758. # ⏱ 90-second window
  759. AUC_stim_90 = [-1.2230, -1.1646, 1.5550, -1.1195, 1.0739, -0.7815, -2.6634, 1.4759, -0.1702, -3.1705,
  760. -0.7590, 13.0363, -2.0926, 3.4858, 1.1061, 2.0930, -2.0750, -4.4860, -1.3451, -3.1809]
  761. AUC_stress_90 = [4.2257, 1.6342, -1.6458, 39.6093, 17.7396, 24.2487, 50.9404]
  762. # ⏱ 120-second window
  763. AUC_stim_120 = [-0.6409, -0.7567, 0.7624, -0.5384, 0.7329, -0.8672, 0.0113, 1.1853, 0.6831, -0.7784,
  764. -0.8053, 5.1225, -0.5467, 1.7969, -0.0616, 0.3441, -1.2354, -2.4234, -0.7771, -1.499]
  765. AUC_stress_120 = [1.0498, -1.5431, -0.7729, 17.6906, 11.2251, 6.8057, 20.8275]
  766. # %%
  767. # --- Helper function to build sub-DataFrame ---
  768. def build_df(animal_list, values, condition):
  769. df = pd.DataFrame({
  770. 'Animal_id': animal_list,
  771. 'y': values
  772. })
  773. # Add condition and compute repeat count per animal
  774. df['condition'] = condition
  775. df['repeat'] = df.groupby('Animal_id').cumcount() + 1
  776. return df[['Animal_id', 'condition', 'repeat', 'y']]
  777. # --- Combine all conditions ---
  778. df_control = build_df(control_animal, control_dist, 'control')
  779. df_stim = build_df(stim_animal, stim_dist, 'stim')
  780. df_stress = build_df(stress_animal, stress_dist, 'stress')
  781. # Merge into one DataFrame
  782. df = pd.concat([df_control,df_stim, df_stress], ignore_index=True)
  783. # Sort for readability
  784. df = df.sort_values(['condition', 'Animal_id', 'repeat']).reset_index(drop=True)
  785. print(df)
  786. # %%
  787. # Nested repeats within animal:
  788. # y ~ condition + (1 | Animal_id)
  789. m_nested = smf.mixedlm(
  790. "y ~ C(condition, Treatment(reference='control'))",
  791. df,
  792. groups=df["Animal_id"] # (1 | Animal_id)
  793. ).fit(reml=True, method="lbfgs", maxiter=600, disp=False)
  794. print("\n=== Nested model (animal + animal:repeat) ===")
  795. print(m_nested.summary())
  796. pvalues_variable = m_nested.pvalues
  797. # %%
  798. # Extract p-values for Stim and Stress vs Control
  799. p_control_Stim = m_nested.pvalues.get("C(condition, Treatment(reference='control'))[T.stim]", None)
  800. p_control_Stress = m_nested.pvalues.get("C(condition, Treatment(reference='control'))[T.stim]", None)
  801. print(p_control_Stim, p_control_Stress)
  802. # %%
  803. # Compute p-value for stim vs stress using t-test
  804. # Extract coefficients and standard errors
  805. params = m_nested.params
  806. bse = m_nested.bse
  807. # Coefficients for stim and stress
  808. b_stim = params["C(condition, Treatment(reference='control'))[T.stim]"]
  809. b_stress = params["C(condition, Treatment(reference='control'))[T.stress]"]
  810. # Calculate the difference in coefficients for stress and stim conditions
  811. diff = b_stim - b_stress
  812. # Retrieve the covariance matrix of the fixed effects, which contains the variances and covariances of the estimated coefficients.
  813. cov_matrix = m_nested.cov_params()
  814. # Standard error of the difference
  815. se_diff = np.sqrt(cov_matrix.loc["C(condition, Treatment(reference='control'))[T.stim]", "C(condition, Treatment(reference='control'))[T.stim]"] +
  816. cov_matrix.loc["C(condition, Treatment(reference='control'))[T.stress]", "C(condition, Treatment(reference='control'))[T.stress]"] -
  817. 2 * cov_matrix.loc["C(condition, Treatment(reference='control'))[T.stim]", "C(condition, Treatment(reference='control'))[T.stress]"])
  818. # t-statistic
  819. t_stat = diff / se_diff
  820. # Degrees of freedom (approximation)
  821. df = m_nested.df_resid
  822. # p-value (two-tailed)
  823. p_Stress_Stim = 2 * t.sf(np.abs(t_stat), df)
  824. print(p_Stress_Stim)
  825. # %%
  826. # Diverging colour palette (brown → teal), normalised to [0, 1] for plotting
  827. mycolors = np.array([
  828. [140, 81, 10],
  829. [216, 179, 101],
  830. [246, 232, 195],
  831. [199, 234, 229],
  832. [ 90, 180, 172],
  833. [ 1, 102, 94]
  834. ]) / 255.0
  835. # Reduced colour palette for control conditions
  836. colors_ctrl = np.array([
  837. [166, 97, 26], # #a6611a
  838. [223, 194, 125], # #dfc27d
  839. [128, 205, 193], # #80cdc1
  840. [ 1, 133, 113] # #018571
  841. ]) / 255.0
  842. # %%
  843. # Create a figure with specific dimensions
  844. fig = plt.figure(figsize=(15, 9), dpi=600)
  845. # Set up the grid layout for subplots
  846. gs = gridspec.GridSpec(3, 8, figure=fig)
  847. # 🔹 Plot 1: Stimulus Data (Baseline)
  848. ax = fig.add_subplot(gs[0:4])
  849. # Normalise stimulus data: z-score across rows (cells), then min-max across columns (time)
  850. stim_data = minmax_scale(data_fig1_stim_tot[:, :stim_offset], axis=1)
  851. plt.imshow(stim_data, cmap="viridis", interpolation='none')
  852. # Set axis limits and ticks for clarity
  853. ax.set_xlim(0, stim_offset)
  854. ax.set_ylim(0, n_cells_fig1_stim-1)
  855. ax.set_xticks(np.arange(0, 2500, 500))
  856. ax.set_xticklabels(np.arange(0, 250, 50))
  857. ax.set_yticks([_ for _ in list(range(n_cells_fig1_stim)) if _ % 10 == 0])
  858. ax.set_yticklabels([_ for _ in list(range(n_cells_fig1_stim)) if _ % 10 == 0])
  859. ax.set_ylabel('cell ID')
  860. ax.set_xlabel('time [sec]')
  861. ax.set_aspect('auto')
  862. # Add colourbar for stimulus data
  863. cmap = plt.get_cmap("viridis")
  864. sm = ScalarMappable(cmap=cmap)
  865. sm.set_clim(vmin=0, vmax=1)
  866. cb = plt.colorbar(cmap=cmap, fraction=0.04, pad=0.03)
  867. cb.set_label('$\Delta$ F$/$F$_0$ (scaled)')
  868. # 🔹 Plot 2: Stress Data (Stress Onset)
  869. ax = fig.add_subplot(gs[4:8])
  870. # Normalise stress data: z-score across rows, then min-max across columns
  871. stress_data = minmax_scale(data_fig1_stress_tot[:, :stress_offset], axis=1)
  872. plt.imshow(stress_data, cmap="viridis", interpolation='none')
  873. # Set axis limits and ticks for clarity
  874. ax.set_xlim(0, stress_offset)
  875. ax.set_ylim(0, n_cells_fig1_stress-1)
  876. ax.set_xticks(np.arange(0, 4000, 500))
  877. ax.set_xticklabels(np.arange(0, 400, 50))
  878. ax.set_yticks([_ for _ in list(range(n_cells_fig1_stress)) if _ % 10 == 0])
  879. ax.set_yticklabels([_ for _ in list(range(n_cells_fig1_stress)) if _ % 10 == 0])
  880. ax.set_ylabel('cell ID')
  881. ax.set_xlabel('time [sec]')
  882. ax.set_aspect('auto')
  883. # Add colourbar for stress data
  884. cmap = plt.get_cmap("viridis")
  885. sm = ScalarMappable(cmap=cmap)
  886. sm.set_clim(vmin=0, vmax=1)
  887. cb = plt.colorbar(cmap=cmap, fraction=0.04, pad=0.03)
  888. cb.set_label('$\Delta$ F$/$F$_0$ (scaled)')
  889. # 🔹 Plot 3: Heatmap for Max Correlation (Baseline)
  890. ax = fig.add_subplot(gs[8:10])
  891. # Heatmap of pairwise correlations during baseline (stim)
  892. sns.heatmap(max_corr_fig1_stim_1, cbar_kws={'label': 'Corr'}, cmap="viridis", vmin=-1, vmax=1)
  893. ax.set_title('Baseline')
  894. ax.set(xlabel='cell ID', ylabel='cell ID')
  895. ax.set_xticks([_ for _ in list(range(n_cells_fig1_stress)) if _ % 10 == 0])
  896. ax.set_xticklabels([_ for _ in list(range(n_cells_fig1_stress)) if _ % 10 == 0])
  897. ax.set_yticks([_ for _ in list(range(n_cells_fig1_stress)) if _ % 10 == 0])
  898. ax.set_yticklabels([_ for _ in list(range(n_cells_fig1_stress)) if _ % 10 == 0])
  899. ax.invert_yaxis()
  900. # Add outline around the whole plot
  901. for spine in ax.spines.values():
  902. spine.set_visible(True)
  903. spine.set_linewidth(1.5)
  904. spine.set_edgecolor('gray')
  905. # Add grey outline around the colourbar
  906. cbar = ax.collections[0].colorbar
  907. cbar.outline.set_visible(True)
  908. cbar.outline.set_edgecolor('grey')
  909. cbar.outline.set_linewidth(1.0)
  910. # 🔹 Plot 4: Heatmap for Max Correlation (Stimulation)
  911. ax = fig.add_subplot(gs[10:12])
  912. # Heatmap of pairwise correlations during stimulation
  913. sns.heatmap(max_corr_fig1_stim_2, cbar_kws={'label': 'Corr'}, cmap="viridis", vmin=-1, vmax=1)
  914. ax.set_title('Stimulation')
  915. ax.set(xlabel='cell ID')
  916. ax.set_xticks([_ for _ in list(range(n_cells_fig1_stress)) if _ % 10 == 0])
  917. ax.set_xticklabels([_ for _ in list(range(n_cells_fig1_stress)) if _ % 10 == 0])
  918. ax.set_yticks([_ for _ in list(range(n_cells_fig1_stress)) if _ % 10 == 0])
  919. ax.set_yticklabels([_ for _ in list(range(n_cells_fig1_stress)) if _ % 10 == 0])
  920. ax.invert_yaxis()
  921. # Add outline around the whole plot
  922. for spine in ax.spines.values():
  923. spine.set_visible(True)
  924. spine.set_linewidth(1.5)
  925. spine.set_edgecolor('gray')
  926. # Add grey outline around the colourbar
  927. cbar = ax.collections[0].colorbar
  928. cbar.outline.set_visible(True)
  929. cbar.outline.set_edgecolor('grey')
  930. cbar.outline.set_linewidth(1.0)
  931. # 🔹 Plot 5: Heatmap for Max Correlation (Stress Baseline)
  932. ax = fig.add_subplot(gs[12:14])
  933. # Heatmap of pairwise correlations during stress baseline
  934. sns.heatmap(max_corr_fig1_stress_1, cbar_kws={'label': 'Corr'}, cmap="viridis", vmin=-1, vmax=1)
  935. ax.set_title('Baseline')
  936. ax.set(xlabel='cell ID', ylabel='cell ID')
  937. ax.set_xticks([_ for _ in list(range(n_cells_fig1_stress)) if _ % 10 == 0])
  938. ax.set_xticklabels([_ for _ in list(range(n_cells_fig1_stress)) if _ % 10 == 0])
  939. ax.set_yticks([_ for _ in list(range(n_cells_fig1_stress)) if _ % 10 == 0])
  940. ax.set_yticklabels([_ for _ in list(range(n_cells_fig1_stress)) if _ % 10 == 0])
  941. ax.invert_yaxis()
  942. # Add outline around the whole plot
  943. for spine in ax.spines.values():
  944. spine.set_visible(True)
  945. spine.set_linewidth(1.5)
  946. spine.set_edgecolor('gray')
  947. # Add grey outline around the colourbar
  948. cbar = ax.collections[0].colorbar
  949. cbar.outline.set_visible(True)
  950. cbar.outline.set_edgecolor('grey')
  951. cbar.outline.set_linewidth(1.0)
  952. # 🔹 Plot 6: Heatmap for Max Correlation (Stress)
  953. ax = fig.add_subplot(gs[14:16])
  954. # Heatmap of pairwise correlations during stress period
  955. sns.heatmap(max_corr_fig1_stress_2, cbar_kws={'label': 'Corr'}, cmap="viridis", vmin=-1, vmax=1)
  956. ax.set_title('Stress')
  957. ax.set(xlabel='cell ID')
  958. ax.set_xticks([_ for _ in list(range(n_cells_fig1_stress)) if _ % 10 == 0])
  959. ax.set_xticklabels([_ for _ in list(range(n_cells_fig1_stress)) if _ % 10 == 0])
  960. ax.set_yticks([_ for _ in list(range(n_cells_fig1_stress)) if _ % 10 == 0])
  961. ax.set_yticklabels([_ for _ in list(range(n_cells_fig1_stress)) if _ % 10 == 0])
  962. ax.invert_yaxis()
  963. # Add outline around the whole plot
  964. for spine in ax.spines.values():
  965. spine.set_visible(True)
  966. spine.set_linewidth(1.5)
  967. spine.set_edgecolor('gray')
  968. # Add grey outline around the colourbar
  969. cbar = ax.collections[0].colorbar
  970. cbar.outline.set_visible(True)
  971. cbar.outline.set_edgecolor('grey')
  972. cbar.outline.set_linewidth(1.0)
  973. # 🔹 Plot 7: Swarm plot for stimulation p-values
  974. colour_list = ['blue', 'red', 'green', 'orange', 'purple', 'brown']
  975. ax = fig.add_subplot(gs[16:19])
  976. # Violin + swarm plot for control condition
  977. cc1 = control_dist
  978. control_animals = pd.unique(control_animal)
  979. control_palette = dict(zip(
  980. control_animals,
  981. colors_ctrl[:len(control_animals)]
  982. ))
  983. df = pd.DataFrame(dict(x=np.repeat([0], len(cc1)), y=cc1, c=control_animal))
  984. sns.violinplot(x="x", y="y", data=df, order=['0','1','2'], fill=False, color='black')
  985. sns.swarmplot(
  986. x="x",
  987. y="y",
  988. hue="c",
  989. data=df,
  990. palette=control_palette,
  991. size=7,
  992. marker='s',
  993. legend=False
  994. )
  995. # Violin + swarm plot for stimulation condition
  996. animals = pd.unique(stim_animal)
  997. palette = dict(zip(animals, mycolors))
  998. cc2 = stim_dist
  999. df = pd.DataFrame(dict(x=np.repeat([1], len(cc2)), y=cc2, c=stim_animal))
  1000. sns.violinplot(x="x", y="y", data=df, order=['0','1','2'], fill=False, color='black')
  1001. sns.swarmplot(
  1002. x="x",
  1003. y="y",
  1004. hue="c",
  1005. data=df,
  1006. palette=palette,
  1007. order=[1], # x is numeric and always 1
  1008. size=7,
  1009. legend=False
  1010. )
  1011. # Violin + swarm plot for stress condition
  1012. cc3 = stress_dist
  1013. df = pd.DataFrame(dict(
  1014. x=np.repeat(2, len(cc3)),
  1015. y=cc3,
  1016. c=stress_animal
  1017. ))
  1018. sns.violinplot(x="x", y="y", data=df, order=['0','1','2'], fill=False, color='black')
  1019. sns.swarmplot(
  1020. x="x",
  1021. y="y",
  1022. hue="c",
  1023. data=df,
  1024. palette=palette,
  1025. order=[2],
  1026. size=7,
  1027. legend=False
  1028. )
  1029. # Add statistical annotations between groups
  1030. # Control vs Stim
  1031. ax.plot([0.1, 0.9], [1.2, 1.2], 'k-', lw=1)
  1032. ax.plot([0.1, 0.1], [1.2, 1.18], 'k-', lw=1)
  1033. ax.plot([0.9, 0.9], [1.2, 1.18], 'k-', lw=1)
  1034. _, kruskal_p_value = kruskal(control_dist, stim_dist)
  1035. ax.annotate(pval_to_star(p_control_Stim), (1/2, 1.21), ha='center')
  1036. # Stim vs Stress
  1037. ax.plot([1.1, 1.9], [1.2, 1.2], 'k-', lw=1)
  1038. ax.plot([1.1, 1.1], [1.2, 1.18], 'k-', lw=1)
  1039. ax.plot([1.9, 1.9], [1.2, 1.18], 'k-', lw=1)
  1040. _, kruskal_p_value = kruskal(stress_dist, stim_dist)
  1041. ax.annotate(pval_to_star(p_Stress_Stim), (3/2, 1.21), ha='center')
  1042. # Control vs Stress
  1043. ax.plot([0.1, 1.9], [1.35, 1.35], 'k-', lw=1)
  1044. ax.plot([0.1, 0.1], [1.35, 1.33], 'k-', lw=1)
  1045. ax.plot([1.9, 1.9], [1.35, 1.33], 'k-', lw=1)
  1046. _, kruskal_p_value = kruskal(control_dist, stress_dist)
  1047. ax.annotate(pval_to_star(p_control_Stress), (1, 1.36), ha='center')
  1048. # Final axis settings
  1049. ax.set_ylim(0.0, 1.5)
  1050. ax.set_xlabel('')
  1051. ax.set_ylabel('Distance')
  1052. ax.set_xticks(range(3), labels=['Control', 'Stimulation', 'Stress'])
  1053. # 🔹 Plot 8: Swarm plot for stress p-values
  1054. ax = fig.add_subplot(gs[19:23])
  1055. # Time windows used for AUC calculation
  1056. time_points = [30, 60, 90, 120]
  1057. # Grouped AUC data for stim and stress conditions
  1058. stim_data = [AUC_stim_30, AUC_stim_60, AUC_stim_90, AUC_stim_120]
  1059. stress_data = [AUC_stress_30, AUC_stress_60, AUC_stress_90, AUC_stress_120]
  1060. # Compute median and SEM for each time window
  1061. stim_medians = [np.median(d) for d in stim_data]
  1062. stim_sem = [np.std(d, ddof=1) / np.sqrt(len(d)) for d in stim_data]
  1063. stress_medians = [np.median(d) for d in stress_data]
  1064. stress_sem = [np.std(d, ddof=1) / np.sqrt(len(d)) for d in stress_data]
  1065. # Create a DataFrame for plotting
  1066. df = pd.DataFrame({
  1067. 'Time': time_points,
  1068. 'Stim_Median': stim_medians,
  1069. 'Stim_SEM': stim_sem,
  1070. 'Stress_Median': stress_medians,
  1071. 'Stress_SEM': stress_sem
  1072. })
  1073. ## Create the plot for AUC data
  1074. # -----------------------------
  1075. # Positions
  1076. # -----------------------------
  1077. x = np.arange(len(time_points))
  1078. offset = 0.18
  1079. stim_pos = x - offset
  1080. stress_pos = x + offset
  1081. # -----------------------------
  1082. # Plot
  1083. # -----------------------------
  1084. bp_stim = plt.boxplot(
  1085. stim_data,
  1086. positions=stim_pos,
  1087. widths=0.3,
  1088. patch_artist=True,
  1089. medianprops=dict(color='tab:blue', linewidth=2),
  1090. flierprops=dict(
  1091. marker='o',
  1092. markersize=5,
  1093. markerfacecolor='tab:blue',
  1094. markeredgecolor='tab:blue',
  1095. alpha=0.6
  1096. )
  1097. )
  1098. bp_stress = plt.boxplot(
  1099. stress_data,
  1100. positions=stress_pos,
  1101. widths=0.3,
  1102. patch_artist=True,
  1103. medianprops=dict(color='tab:orange', linewidth=2),
  1104. flierprops=dict(
  1105. marker='o',
  1106. markersize=5,
  1107. markerfacecolor='tab:orange',
  1108. markeredgecolor='tab:orange',
  1109. alpha=0.6
  1110. )
  1111. )
  1112. # -----------------------------
  1113. # Box transparency & colors
  1114. # -----------------------------
  1115. for box in bp_stim['boxes']:
  1116. box.set(facecolor='tab:blue', alpha=0.45)
  1117. for box in bp_stress['boxes']:
  1118. box.set(facecolor='tab:orange', alpha=0.45)
  1119. # -----------------------------
  1120. # Connect medians
  1121. # -----------------------------
  1122. stim_medians = [np.median(d) for d in stim_data]
  1123. stress_medians = [np.median(d) for d in stress_data]
  1124. ax.plot(stim_pos, stim_medians, 'o', color='tab:blue', linewidth=2, label='Stim')
  1125. ax.plot(stress_pos, stress_medians, 'o', color='tab:orange', linewidth=2, label='Stress')
  1126. # Plot median ± SEM for stimulation
  1127. #ax.errorbar(df['Time'], df['Stim_Median'], yerr=df['Stim_SEM'],
  1128. # label='Stim', marker='o', capsize=5, linestyle='-')
  1129. # Plot median ± SEM for stress
  1130. #ax.errorbar(df['Time'], df['Stress_Median'], yerr=df['Stress_SEM'],
  1131. # label='Stress', marker='s', capsize=5, linestyle='--')
  1132. # Annotate significance for each time window using permulation test (see data/calcium_imaging/figures/fig1_stim_stress_permutation_test.py)
  1133. mw_p_value = 0.0132
  1134. ax.annotate(pval_to_star(mw_p_value), (0, 70), ha='center')
  1135. mw_p_value = 0.0121
  1136. ax.annotate(pval_to_star(mw_p_value), (1, 70), ha='center')
  1137. mw_p_value = 0.0421
  1138. ax.annotate(pval_to_star(mw_p_value), (2, 70), ha='center')
  1139. mw_p_value = 0.09709
  1140. ax.annotate(pval_to_star(mw_p_value), (3, 70), ha='center')
  1141. # Final axis settings
  1142. ax.set_ylim(-10, 80)
  1143. ax.set_xlabel('Time [sec]')
  1144. ax.set_ylabel('Time-Averaged\n Activity')
  1145. ax.set_xticks([0,1,2,3], labels=time_points)
  1146. ax.legend(
  1147. )
  1148. # Adjust layout to prevent overlap and ensure clean spacing
  1149. plt.tight_layout()
  1150. # Display the figure
  1151. # fig.savefig("Fig1_revised.svg", format="svg", bbox_inches="tight", dpi=600)
  1152. plt.show()
  1153. # %% [markdown]
  1154. # # 🧪 Full Workflow: G28 Stimulation (Figure 2)
  1155. # %% [markdown]
  1156. # An example of data analysis pipline for stimulation experimental setup.
  1157. # %%
  1158. # Load and transpose the data
  1159. data_fig2_stim = pd.read_csv(stim_fig2_csv_fname).T
  1160. # Clean up row labels
  1161. data_fig2_stim.index = data_fig2_stim.index.str.strip()
  1162. # Preview the last 6 cells
  1163. data_fig2_stim.tail(6)
  1164. # %% [markdown]
  1165. # ## ✂️ Data Slicing for G28 Stimulation
  1166. # %%
  1167. # Extract signal data (excluding metadata)
  1168. cells_traces_tot_old = data_fig2_stim.iloc[1:, 1:]
  1169. # Get shape info
  1170. total_cells_stim, data_stim_length_tot = cells_traces_tot_old.shape
  1171. # Preview bottom rows
  1172. cells_traces_tot_old.tail()
  1173. # %%
  1174. # Extract time axis from first row (excluding first column)
  1175. ts_stim_tot = np.asarray(data_fig2_stim.iloc[0, 1:], dtype=float)
  1176. # Time between frames (first timestamp is 0)
  1177. sec_per_frame = ts_stim_tot[1]
  1178. # Sampling frequency (Hz)
  1179. fs = 1 / sec_per_frame
  1180. # %%
  1181. # Convert fluorescence traces to NumPy array
  1182. data_stim_artifact = np.asarray(cells_traces_tot_old, dtype=float)
  1183. # %% [markdown]
  1184. # ## 🔍 Finding UCN3 Cells
  1185. # %% [markdown]
  1186. # ### 🌀 1. Power Spectral Density (PSD) Filtering
  1187. # %%
  1188. # Define the stimulus frequency and initialise a list to store labels of removed cells based on PSD threshold
  1189. stimulus_freq = 0.1 # Stimulus frequency in Hz
  1190. labels_removed_PSD = [] # List to store indices of cells with high PSD at stimulus frequency
  1191. # Loop through each cell trace to assess frequency-domain response
  1192. for i in range(total_cells_stim):
  1193. # Extract stimulation-period trace for cell i
  1194. trace_segment = data_stim_artifact[i, stim_onset:stim_offset]
  1195. # Compute power spectral density using periodogram
  1196. freq, Pxx_den = periodogram(trace_segment, fs=fs)
  1197. # Normalise PSD to its peak value for comparability
  1198. Pxx_den /= np.max(Pxx_den)
  1199. # Identify index corresponding to stimulus frequency
  1200. stimulus_freq_idx = round(stimulus_freq * (stim_offset - stim_onset) / fs)
  1201. # Thresholding: retain cells with strong PSD at stimulus frequency
  1202. if Pxx_den[stimulus_freq_idx] > PSD_thr:
  1203. labels_removed_PSD.append(i)
  1204. # Output: indices of cells flagged for removal based on PSD criterion
  1205. print("Indices of cells removed based on PSD threshold:", labels_removed_PSD)
  1206. # %%
  1207. # Create binary mask to flag artifact-affected cells
  1208. # - 0: cell identified as artifact (high PSD at stimulus frequency)
  1209. # - 1: cell retained for further analysis
  1210. labels_artifact = np.asarray([
  1211. 0 if cell in labels_removed_PSD else 1
  1212. for cell in range(total_cells_stim)
  1213. ])
  1214. # Optional: Print the binary mask to verify which cells were flagged
  1215. # 0 = artifact (failed PSD threshold), 1 = valid (passed PSD threshold)
  1216. print(labels_artifact)
  1217. # %% [markdown]
  1218. # ### 🔢 2. K-Means Clustering
  1219. # %%
  1220. # Apply scikit-learn K-means clustering to sort activity traces
  1221. # Step 1: Normalise the activity data across time (columns) to ensure fair clustering
  1222. scaled_data = scale(data_stim_artifact[:, stim_onset:stim_offset], axis=0)
  1223. # Step 2: Set the number of clusters (2 groups: responsive vs non-responsive)
  1224. Ks = 2
  1225. # Step 3: Initialise and run K-means clustering
  1226. # - random_state ensures reproducibility
  1227. # - n_init = 10 means the algorithm runs 10 times with different centroid seeds
  1228. # - max_iter = 100 limits the number of iterations per run
  1229. kmeans = KMeans(n_clusters=Ks, random_state=11111, n_init=10, max_iter=100)
  1230. # Step 4: Fit the model to the scaled data and assign cluster labels to each cell
  1231. labels_artifact_KM = kmeans.fit_predict(scaled_data)
  1232. # Step 5: Sort cell indices based on their assigned cluster labels
  1233. # This helps organise or visualise cells by their activity patterns
  1234. i_labels_artifact = np.argsort(labels_artifact_KM)
  1235. # Step 6: Display the sorted indices for inspection or downstream analysis
  1236. print(f"Sorted indices of cells by cluster ID:\n{i_labels_artifact}")
  1237. # %%
  1238. # Determine which cluster to remove based on relative size
  1239. # Step 1: Count the number of cells in each cluster
  1240. size_cluster_0 = np.sum(labels_artifact_KM == 0)
  1241. size_cluster_1 = np.sum(labels_artifact_KM == 1)
  1242. # Step 2: Compare cluster sizes to identify the smaller one
  1243. # The assumption here is that the smaller cluster likely represents artifacts or noise
  1244. if size_cluster_0 < size_cluster_1:
  1245. cluster_removed = 0 # Smaller cluster is flagged for removal
  1246. cluster_kept = 1 # Larger cluster is retained
  1247. print("Cluster #0 (smaller) is removed.")
  1248. elif size_cluster_1 < size_cluster_0:
  1249. cluster_removed = 1
  1250. cluster_kept = 0
  1251. print("Cluster #1 (smaller) is removed.")
  1252. else:
  1253. # Edge case: one cluster has zero members, so no valid removal decision
  1254. print("Clusters are equal in size — no removal applied.")
  1255. # %%
  1256. # Update artifact labels based on K-means clustering results
  1257. # Step 1: Check if the cluster marked for removal contains any cells
  1258. if np.any(labels_artifact_KM == cluster_removed):
  1259. # Step 2: Iterate through all cells
  1260. for i in range(len(data_stim_artifact)):
  1261. # Only update cells that were previously marked as valid (1)
  1262. # and now belong to the cluster flagged for removal
  1263. if labels_artifact[i] == 1 and labels_artifact_KM[i] == cluster_removed:
  1264. labels_artifact[i] = 0 # Mark as artifact
  1265. # Step 3: Explicitly define which cluster was removed and which was kept
  1266. cluster_removed = 0
  1267. cluster_kept = 1
  1268. # Step 4: Extract indices of cells now marked as artifacts
  1269. labels_removed = np.where(labels_artifact == cluster_removed)[0]
  1270. # Optional: print summary for verification
  1271. print(f"Number of (UCN) cells removed: {len(labels_removed)}")
  1272. print(f"Indices of removed cells: {labels_removed}")
  1273. # %%
  1274. # Remove resonated (artifact) cells from fluorescence intensity traces
  1275. # Step 1: Get the row labels (e.g., cell IDs) corresponding to removed indices
  1276. removed_row_labels = cells_traces_tot_old.index[labels_removed]
  1277. # Step 2: Drop the identified artifact cells from the original DataFrame
  1278. # - axis=0 means rows are being removed
  1279. # - inplace=False ensures the original DataFrame remains unchanged
  1280. cells_traces_stim_tot = cells_traces_tot_old.drop(
  1281. index=removed_row_labels, axis=0, inplace=False
  1282. )
  1283. # Step 3: Count the number of remaining (valid) cells after removal
  1284. n_cells_stim = len(cells_traces_stim_tot)
  1285. # Optional: print summary
  1286. print(f"Number of GABA cells retained after artifact removal: {n_cells_stim}")
  1287. # %% [markdown]
  1288. # ## Minmax Scaling over each trace
  1289. # %%
  1290. # Convert fluorescence traces to NumPy array (float)
  1291. data_stim_tot = np.asarray(cells_traces_stim_tot, dtype=float)
  1292. # Apply Min-Max scaling to each trace individually
  1293. # - Rescales each cell's activity to [0, 1] across its full time course
  1294. normalised_data_stim_tot = minmax_scale(data_stim_tot, axis=1)
  1295. # %% [markdown]
  1296. # ## ⚡️ **G28 Stimulation**
  1297. #
  1298. # Hereafter, I only focus on the data while the stimulation is applied at 2 minutes after starting recording with 0.1 Hz (5 s on followed by 5 s off).
  1299. # %% [markdown]
  1300. # ### ✂️ Data Slicing
  1301. # %%
  1302. # Slice fluorescence data to isolate stimulation period
  1303. cells_traces_stim_2 = cells_traces_stim_tot.iloc[:, stim_onset:stim_offset]
  1304. # Min-max normalised fluorescence during stimulation
  1305. normalised_data_stim_2 = normalised_data_stim_tot[:, stim_onset:stim_offset]
  1306. # Update stimulation-period length
  1307. data_stim_length_2 = cells_traces_stim_2.shape[1]
  1308. # Optional: preview first few traces during stimulation
  1309. cells_traces_stim_2.head()
  1310. # %%
  1311. # Create time axis for stimulation period
  1312. # - Ensures alignment with sliced fluorescence data
  1313. ts_stim_2 = ts_stim_tot[stim_onset:stim_offset]
  1314. # %%
  1315. # Convert stimulation-period fluorescence data to NumPy array (float)
  1316. # - Enables numerical operations like clustering, filtering, or plotting
  1317. data_stim_2 = np.asarray(cells_traces_stim_2, dtype=float)
  1318. # %% [markdown]
  1319. # ### Clustering
  1320. # %% [markdown]
  1321. # #### 🌲 Hierarchical (corrcoef)
  1322. # %%
  1323. # Compute pairwise correlation-based distances
  1324. # - Distance metric: 1 - Pearson correlation coefficient
  1325. # - pdist returns a condensed distance matrix (n*(n-1)/2 elements)
  1326. dist_corr_stim_2 = pdist(normalised_data_stim_2,
  1327. lambda x, y: 1 - np.corrcoef(x, y)[0, 1])
  1328. # %%
  1329. # Convert condensed distance matrix to square form
  1330. # - Required for AgglomerativeClustering with 'precomputed' metric
  1331. dist_matrix = squareform(dist_corr_stim_2)
  1332. # Apply hierarchical clustering to sort activity traces
  1333. # - AgglomerativeClustering performs hierarchical clustering.
  1334. # - `n_clusters=Ks_2` specifies the number of clusters.
  1335. # - `metric='precomputed'` indicates that the distance matrix has already been computed.
  1336. # - `linkage='average'` uses the average linkage criterion for merging clusters.
  1337. # - `compute_distances=True` ensures that pairwise distances are retained.
  1338. ac = AgglomerativeClustering(
  1339. n_clusters=Ks_2,
  1340. metric='precomputed',
  1341. linkage='average',
  1342. compute_distances=True
  1343. ).fit(dist_matrix)
  1344. # Retrieve the cluster labels assigned to each trace
  1345. labels_hier_stim_2 = ac.labels_
  1346. # %%
  1347. # Compute pairwise Pearson correlation matrix for stimulation data
  1348. # - Avoids redundant diagonal comparisons
  1349. # - Uses z-scored traces for consistency
  1350. max_corr_stim_2 = np.zeros((n_cells_stim, n_cells_stim))
  1351. for i in range(n_cells_stim):
  1352. for j in range(i + 1, n_cells_stim): # Upper triangle only
  1353. trace_i = scale(normalised_data_stim_2[i, :])
  1354. trace_j = scale(normalised_data_stim_2[j, :])
  1355. corr = np.corrcoef(trace_i, trace_j)[0, 1]
  1356. max_corr_stim_2[i, j] = corr
  1357. max_corr_stim_2[j, i] = corr # Symmetric assignment
  1358. # %%
  1359. from sklearn.metrics import silhouette_score
  1360. # Evaluate clustering quality using silhouette score
  1361. # - Measures how well each trace fits within its assigned cluster
  1362. # - Higher scores indicate better-defined clusters
  1363. sil_score = silhouette_score(
  1364. squareform(dist_corr_stim_2), # Full distance matrix
  1365. labels_hier_stim_2, # Cluster labels
  1366. metric="precomputed" # Use provided distances
  1367. )
  1368. # Display the silhouette score
  1369. print(f'Silhouette score: {sil_score:.3f}')
  1370. # %% [markdown]
  1371. # #### 🔢 K-means (corrcoef)
  1372. # %%
  1373. # Apply scikit-learn K-means clustering to the SLxCorr distance matrix
  1374. # Fix the random seed for reproducibility and set the number of clusters (Ks_2)
  1375. km = KMeans(n_clusters=Ks_2, random_state=11112, n_init=10, max_iter=100)
  1376. # Fit the model on the squared form of the SLxCorr distance matrix
  1377. km.fit(squareform(dist_corr_stim_2))
  1378. # Get the labels for the clusters assigned to each cell
  1379. labels_corr_stim_2 = km.labels_
  1380. # %%
  1381. # Plotting the unsorted and sorted (SLxCorr, K-means) distance matrices
  1382. plt.figure(figsize=(16, 6))
  1383. # Subplot 1: Unsorted SLxCorr distance matrix
  1384. plt.subplot(1, 2, 1)
  1385. sns.heatmap(2 * minmax_scale(squareform(dist_corr_stim_2),axis=1), cmap='BrBG')
  1386. plt.title('Unsorted SLxCorr Distance Matrix')
  1387. # Subplot 2: Sorted SLxCorr distance matrix based on K-means labels
  1388. plt.subplot(1, 2, 2)
  1389. # Sorting the SLxCorr distance matrix based on the K-means cluster labels
  1390. idx = np.argsort(labels_corr_stim_2) # Sort indices by K-means labels
  1391. sorted_cov_dist = squareform(dist_corr_stim_2)[idx, :][:, idx] # Apply sorting
  1392. ax = sns.heatmap(2 * minmax_scale(sorted_cov_dist,axis=1),
  1393. cmap='BrBG', cbar_kws={'label': 'Dissimilarity'})
  1394. ax.set(xlabel='Cell ID', ylabel='Cell ID')
  1395. plt.title('Sorted SLxCorr Distance Matrix (K-means)')
  1396. # Show the plot
  1397. plt.show()
  1398. # %% [markdown]
  1399. # # 🧪 Full Workflow: G9 Stress (Figure 2)
  1400. # %% [markdown]
  1401. # An example of data analysis pipline for restraint stress experimental setup.
  1402. # %%
  1403. # Load stress dataset from CSV and transpose it
  1404. # - Transposition makes each row represent a cell (e.g., for time-series or feature vectors)
  1405. data_fig2_stress = pd.read_csv(stress_fig2_csv_fname).T
  1406. # Clean up cell ID labels by stripping whitespace
  1407. # - Ensures consistent indexing and avoids matching issues
  1408. data_fig2_stress.index = data_fig2_stress.index.str.strip()
  1409. # Display the last few rows of the transposed dataset
  1410. # - Useful for inspecting cell-level data or verifying formatting
  1411. data_fig2_stress.tail()
  1412. # %% [markdown]
  1413. # ## ✂️ Data Slicing
  1414. # %%
  1415. # Extract fluorescence intensity traces from deconvoluted stress data
  1416. # - Skip first row (likely metadata or time vector)
  1417. # - Skip first column (likely cell labels or non-numeric info)
  1418. cells_traces_stress_tot = data_fig2_stress.iloc[1:, 1:]
  1419. # Determine dataset dimensions
  1420. # - n_cells_stress: number of cells (rows)
  1421. # - data_stress_length_tot: number of time points or features (columns)
  1422. n_cells_stress, data_stress_length_tot = cells_traces_stress_tot.shape
  1423. # Preview the last few rows of the fluorescence intensity matrix
  1424. # - Each row = one cell's trace
  1425. # - Each column = one time point or feature
  1426. cells_traces_stress_tot.tail()
  1427. # %%
  1428. # Extract time axis from the first row (excluding first column)
  1429. # - Assumes first row contains timestamps
  1430. # - Converts to NumPy array of floats for numerical operations
  1431. ts_stress_tot = np.asarray(data_fig2_stress.iloc[0, 1:], dtype=float)
  1432. # Determine time per frame (seconds)
  1433. # - Assumes uniform sampling; uses second timestamp as Δt
  1434. sec_per_frame = ts_stress_tot[1]
  1435. # Compute sampling frequency (Hz)
  1436. # - fs = frames per second = 1 / time per frame
  1437. fs = 1 / sec_per_frame
  1438. # %% [markdown]
  1439. # ## Minmax Scaling over each trace
  1440. # %%
  1441. # Convert fluorescence intensity data to NumPy array (float)
  1442. # - Enables fast numerical operations and compatibility with signal processing tools
  1443. # - Each row = one cell's activity trace
  1444. # - Each column = one time point
  1445. data_stress_tot = np.asarray(cells_traces_stress_tot, dtype=float)
  1446. # Normalise each cell's fluorescence trace to [0, 1]
  1447. # - axis=1 ensures scaling is done per row (i.e., per cell)
  1448. # - Preserves temporal dynamics while removing amplitude bias
  1449. normalised_data_stress_tot = minmax_scale(data_stress_tot, axis=1)
  1450. # %% [markdown]
  1451. # ## 🔥 **G7 Stress**
  1452. #
  1453. # Hereafter, I only focus on the data while the stimulation is applied at 2 minutes after starting recording with 0.1 Hz (5 s on followed by 5 s off).
  1454. # %% [markdown]
  1455. # ### ✂️ Data Slicing
  1456. # %%
  1457. # Slice fluorescence intensity traces during stimulation period
  1458. # - Columns correspond to time points; slicing isolates stress window
  1459. cells_traces_stress_2 = cells_traces_stress_tot.iloc[:, stress_onset:stress_offset]
  1460. # Slice normalized traces for the same stimulation window
  1461. # - Ensures amplitude-independent comparison during stress
  1462. normalised_data_stress_2 = normalised_data_stress_tot[:, stress_onset:stress_offset]
  1463. # Get number of time points during stimulation
  1464. data_stress_length_2 = cells_traces_stress_2.shape[1]
  1465. # Preview top rows of sliced raw intensity data
  1466. cells_traces_stress_2.head()
  1467. # %%
  1468. # Extract time axis corresponding to stimulation period
  1469. ts_stress_2 = ts_stress_tot[stress_onset:stress_offset]
  1470. # %%
  1471. # Convert sliced fluorescence intensity data to NumPy array (float)
  1472. data_stress_2 = np.asarray(cells_traces_stress_2.iloc[:, :], dtype=float)
  1473. # %% [markdown]
  1474. # ### Clustering
  1475. # %% [markdown]
  1476. # #### 🌲 Hierarchical (corrcoef)
  1477. # %%
  1478. # Compute pairwise Pearson correlation-based distances
  1479. # - Each trace is standardized (zero mean, unit variance) across time
  1480. # - Distance = 1 - Pearson correlation coefficient
  1481. # → High correlation → small distance
  1482. dist_corr_stress_2 = pdist(
  1483. scale(normalised_data_stress_2, axis=1),
  1484. lambda x, y: 1 - np.corrcoef(x, y)[0, 1]
  1485. )
  1486. # Output is a condensed distance matrix
  1487. # - Suitable for clustering, silhouette scoring, or dendrograms
  1488. # %%
  1489. # Apply hierarchical clustering to standardized activity traces
  1490. # - Uses precomputed Pearson correlation-based distances
  1491. # - Average linkage merges clusters based on mean pairwise distance
  1492. ac = AgglomerativeClustering(
  1493. n_clusters=Ks_2, # 🔢 Desired number of clusters
  1494. metric='precomputed', # 📏 Use custom distance matrix
  1495. linkage='average', # 🔗 Average linkage for merging
  1496. compute_distances=True # 📐 Retain distance info for dendrograms
  1497. ).fit(squareform(dist_corr_stress_2)) # 🔄 Convert condensed to square distance matrix
  1498. # Extract cluster labels for each cell trace
  1499. labels_hier_stress_2 = ac.labels_
  1500. # ✅ `labels_hier_stress_2` contains the cluster assignment for each trace
  1501. # %%
  1502. # Initialize symmetric matrix to store pairwise Pearson correlations
  1503. # - Shape: (n_cells_stress, n_cells_stress)
  1504. # - Diagonal remains zero (self-comparisons skipped)
  1505. max_corr_stress_2 = np.zeros((n_cells_stress, n_cells_stress))
  1506. # Compute pairwise correlations (upper triangle only)
  1507. # - Each trace is standardised (zero mean, unit variance)
  1508. # - Matrix is filled symmetrically to avoid redundant computation
  1509. for first in range(n_cells_stress):
  1510. for second in range(first + 1, n_cells_stress):
  1511. corr = np.corrcoef(
  1512. scale(normalised_data_stress_2[first, :]),
  1513. scale(normalised_data_stress_2[second, :])
  1514. )[0, 1]
  1515. max_corr_stress_2[first, second] = corr
  1516. max_corr_stress_2[second, first] = corr # 🔁 Symmetric fill
  1517. # %%
  1518. # Evaluate clustering quality using silhouette score
  1519. # - Measures how similar each cell is to its own cluster vs other clusters
  1520. # - Uses precomputed Pearson correlation-based distances
  1521. sil_score = silhouette_score(
  1522. squareform(dist_corr_stress_2), # 🔄 Full pairwise distance matrix
  1523. labels_hier_stress_2, # 🏷️ Cluster labels
  1524. metric="precomputed" # 📏 Use custom distance metric
  1525. )
  1526. # Display silhouette score (range: -1 to 1)
  1527. # - Higher = better-defined clusters
  1528. print(f'Silhouette score: {sil_score:.3f}')
  1529. # %% [markdown]
  1530. # #### 🔢 K-means (corrcoef)
  1531. # %%
  1532. # Note: KMeans typically expects feature vectors, not distance matrices.
  1533. # - Applying it to a distance matrix (especially non-Euclidean) may distort clustering.
  1534. # - Consider using MDS or t-SNE to embed the distance matrix into feature space first.
  1535. # Apply K-means clustering to the SLxCorr distance matrix
  1536. # - `random_state` ensures reproducibility
  1537. # - `n_init=10` runs the algorithm multiple times to avoid poor local minima
  1538. # - `max_iter=100` limits iterations per run
  1539. km = KMeans(n_clusters=Ks_2, random_state=11112, n_init=10, max_iter=100)
  1540. # Fit the model on the square distance matrix
  1541. # - Assumes rows are interpretable as feature vectors (approximate)
  1542. km.fit(squareform(dist_corr_stress_2))
  1543. # Extract cluster labels for each cell
  1544. labels_corr_stress_2 = km.labels_
  1545. # %% [markdown]
  1546. # # 🖼️ Figure 2
  1547. # %%
  1548. # Load hierarchical clustering cell percentage data
  1549. # - Each row = one trial
  1550. # - Each column = one cluster category (e.g., Cluster 0, Cluster 1, ...)
  1551. cell_percentage_hier = pd.read_csv(hier_clustering_cell_perc_fname)
  1552. # Preview the bottom rows to inspect structure and completeness
  1553. cell_percentage_hier.tail()
  1554. # %%
  1555. # Extract cluster-1 cell percentages for each stimulated animal
  1556. # NaN values (empty clusters) are removed
  1557. cluster1_percentage = [
  1558. cell_percentage_hier[k].dropna().values
  1559. for k in ['G7 (#1)', 'G8 (#1)', 'G9 (#1)', 'G28 (#1)', 'G29 (#1)', 'G37 (#1)']
  1560. ]
  1561. # Extract cluster-2 cell percentages for each stimulated animal
  1562. # NaN values (empty clusters) are removed
  1563. cluster2_percentage = [
  1564. cell_percentage_hier[k].dropna().values
  1565. for k in ['G7 (#2)', 'G8 (#2)', 'G9 (#2)', 'G28 (#2)', 'G29 (#2)', 'G37 (#2)']
  1566. ]
  1567. flat_cluster1stim = np.concatenate(cluster1_percentage)
  1568. flat_cluster2stim = np.concatenate(cluster2_percentage)
  1569. # Extract cluster percentages for each restraint stress animal
  1570. flat_cluster1stress = cell_percentage_hier['Stress (#1)'].dropna().values
  1571. flat_cluster2stress = cell_percentage_hier['Stress (#2)'].dropna().values
  1572. # Animal labels corresponding to the flattened cluster entries
  1573. stim_animal = [
  1574. 'G7', 'G7',
  1575. 'G8', 'G8', 'G8', 'G8',
  1576. 'G9', 'G9', 'G9', 'G9',
  1577. 'G28', 'G28', 'G28',
  1578. 'G29', 'G29', 'G29',
  1579. 'G37', 'G37', 'G37', 'G37'
  1580. ]
  1581. stress_animal = ['G7', 'G8', 'G9', 'G28', 'G29', 'G37', 'G37']
  1582. def build_df_two_clusters(animal_list, y1, y2, condition):
  1583. y1 = np.asarray(y1, dtype=float)
  1584. y2 = np.asarray(y2, dtype=float)
  1585. animal_list = np.asarray(animal_list)
  1586. if len(animal_list) != len(y1) or len(animal_list) != len(y2):
  1587. raise ValueError(
  1588. f"Length mismatch: animals={len(animal_list)}, y1={len(y1)}, y2={len(y2)}"
  1589. )
  1590. df = pd.DataFrame({
  1591. "Animal_id": animal_list,
  1592. "y1": y1, # cluster 1 size
  1593. "y2": y2 # cluster 2 size
  1594. })
  1595. df["condition"] = condition
  1596. df["repeat"] = df.groupby("Animal_id").cumcount() + 1
  1597. return df[["Animal_id", "condition", "repeat", "y1", "y2"]]
  1598. # --- Build condition-specific dataframes ---
  1599. df_stim = build_df_two_clusters(stim_animal, flat_cluster1stim, flat_cluster2stim, "stim")
  1600. df_stress = build_df_two_clusters(stress_animal, flat_cluster1stress, flat_cluster2stress, "stress")
  1601. # --- Merge into one dataframe ---
  1602. df = pd.concat([df_stim, df_stress], ignore_index=True)
  1603. df = df.sort_values(["condition", "Animal_id", "repeat"]).reset_index(drop=True)
  1604. print(df)
  1605. # %%
  1606. # df has columns: Animal_id, condition, repeat, y1, y2
  1607. df_long = (
  1608. df[["Animal_id", "condition", "repeat", "y1", "y2"]]
  1609. .melt(
  1610. id_vars=["Animal_id", "condition", "repeat"],
  1611. value_vars=["y1", "y2"],
  1612. var_name="cluster",
  1613. value_name="y"
  1614. )
  1615. )
  1616. # clean cluster labels
  1617. df_long["cluster"] = df_long["cluster"].str.strip().map({"y1": "cluster1", "y2": "cluster2"})
  1618. # drop any rows where y is missing (should usually be none)
  1619. df_long = df_long.dropna(subset=["y"]).reset_index(drop=True)
  1620. print(df_long.head(10))
  1621. print(df_long.columns)
  1622. # %%
  1623. # Fit mixed-effects model with interaction between cluster and condition
  1624. m_nested = smf.mixedlm(
  1625. "y ~ cluster * condition",
  1626. df_long,
  1627. groups=df_long["Animal_id"],
  1628. ).fit(reml=False, method="nm", maxiter=2000, disp=True)
  1629. print(m_nested.summary())
  1630. # %%
  1631. # Compute marginal R²: variance explained by fixed effects only
  1632. var_fixed = np.var(m_nested.fittedvalues)
  1633. var_random = m_nested.cov_re.iloc[0, 0]
  1634. var_resid = m_nested.scale
  1635. R2_marginal = var_fixed / (var_fixed + var_random + var_resid)
  1636. print(R2_marginal)
  1637. # Extract p value for the fixed effect of cluster identity (cluster2 vs reference)
  1638. pvals_all_clusters = m_nested.pvalues.get("cluster[T.cluster2]")
  1639. print(pvals_all_clusters)
  1640. # %%
  1641. # Subset to stimulation only
  1642. df_stim_only = df_long[df_long["condition"] == "stim"].copy()
  1643. # Fit mixed-effects model: cluster effect within stim
  1644. m_stim = smf.mixedlm(
  1645. "y ~ cluster",
  1646. df_stim_only,
  1647. groups=df_stim_only["Animal_id"],
  1648. re_formula="~1",
  1649. ).fit(reml=False, method="nm", maxiter=2000, disp=True)
  1650. print(m_stim.summary())
  1651. # %%
  1652. # Compute marginal R² for the stimulation model (fixed effects only)
  1653. var_fixed = np.var(m_stim.fittedvalues)
  1654. var_random = m_stim.cov_re.iloc[0, 0]
  1655. var_resid = m_stim.scale
  1656. R2_marginal = var_fixed / (var_fixed + var_random + var_resid)
  1657. print(R2_marginal)
  1658. # Extract p value for the cluster effect under stimulation
  1659. pvals_stim_clusters = m_stim.pvalues.get("cluster[T.cluster2]")
  1660. print(pvals_stim_clusters)
  1661. # %%
  1662. # Subset to stress only
  1663. df_stress_only = df_long[df_long["condition"] == "stress"].copy()
  1664. # Fit mixed-effects model: cluster effect within stress
  1665. m_stress = smf.mixedlm(
  1666. "y ~ cluster",
  1667. df_stress_only,
  1668. groups=df_stress_only["Animal_id"],
  1669. re_formula="~1",
  1670. ).fit(reml=False, method="nm", maxiter=2000, disp=True)
  1671. print(m_stress.summary())
  1672. # %%
  1673. # Compute marginal R² for the stress model (fixed effects only)
  1674. var_fixed = np.var(m_stress.fittedvalues)
  1675. var_random = m_stress.cov_re.iloc[0, 0]
  1676. var_resid = m_stress.scale
  1677. R2_marginal = var_fixed / (var_fixed + var_random + var_resid)
  1678. print(R2_marginal)
  1679. # Extract p value for the cluster effect under stress
  1680. pvals_stress_clusters = m_stress.pvalues.get("cluster[T.cluster2]")
  1681. print(pvals_stress_clusters)
  1682. # %%
  1683. # Extract cluster category labels from column headers
  1684. categories_percent_hier = cell_percentage_hier.columns.tolist()
  1685. # Extract total number of GABAergic cells across trials
  1686. cluster1_cells_allanimals_stim = cell_percentage_hier['Stimulation (#1)']
  1687. cluster2_cells_allanimals_stim = cell_percentage_hier['Stimulation (#2)']
  1688. cluster1_cells_allanimals_stress = cell_percentage_hier['Stress (#1)']
  1689. cluster2_cells_allanimals_stress = cell_percentage_hier['Stress (#2)']
  1690. # Remove all the NaNs
  1691. cluster1_cells_allanimals_stim = cluster1_cells_allanimals_stim[~np.isnan(cluster1_cells_allanimals_stim)]
  1692. cluster2_cells_allanimals_stim = cluster2_cells_allanimals_stim[~np.isnan(cluster2_cells_allanimals_stim)]
  1693. cluster1_cells_allanimals_stress = cluster1_cells_allanimals_stress[~np.isnan(cluster1_cells_allanimals_stress)]
  1694. cluster2_cells_allanimals_stress = cluster2_cells_allanimals_stress[~np.isnan(cluster2_cells_allanimals_stress)]
  1695. # %%
  1696. # Set up figure layout
  1697. fig = plt.figure(figsize=(10, 15)) # Define overall figure size
  1698. gs = gridspec.GridSpec(8, 3, figure=fig) # Create an 8x3 grid for subplot placement
  1699. # Sort cell indices by hierarchical clustering labels (stimulus condition)
  1700. idx = np.argsort(labels_hier_stim_2)
  1701. # 🔳 Plot 1: Heatmap of pairwise correlations (stimulus condition)
  1702. ax = fig.add_subplot(gs[0:2, 0]) # Top-left subplot
  1703. ax = sns.heatmap(max_corr_stim_2[idx, :][:, idx], # Correlation matrix sorted by cluster
  1704. cbar_kws={'label': 'Corr', 'location': 'top'}, # Colourbar settings
  1705. cmap="viridis", vmin=-1, vmax=1) # Colourmap and value range
  1706. ax.set(xlabel='cell ID', ylabel='cell ID') # Axis labels
  1707. ax.set_xticks([_ for _ in range(n_cells_stim) if _ % 10 == 0]) # X-axis ticks every 10 cells
  1708. ax.set_xticklabels([_ for _ in range(n_cells_stim) if _ % 10 == 0]) # X-axis tick labels
  1709. ax.set_yticks([_ for _ in range(n_cells_stim) if _ % 10 == 0]) # Y-axis ticks every 10 cells
  1710. ax.set_yticklabels([_ for _ in range(n_cells_stim) if _ % 10 == 0]) # Y-axis tick labels
  1711. ax.invert_yaxis() # Show first cell at top
  1712. # Add plot outline
  1713. for spine in ax.spines.values():
  1714. spine.set_visible(True)
  1715. spine.set_linewidth(1.5)
  1716. spine.set_edgecolor('gray')
  1717. # Add colourbar outline
  1718. cbar = ax.collections[0].colorbar
  1719. cbar.outline.set_visible(True)
  1720. cbar.outline.set_edgecolor('grey')
  1721. cbar.outline.set_linewidth(1.0)
  1722. # 🔳 Plot 2: Heatmap of normalised fluorescence traces (stimulus condition)
  1723. ax = plt.subplot(gs[0:2, 1:3]) # Top-right subplot
  1724. im = ax.imshow(minmax_scale(scale(data_stim_2[idx, :], axis=1), axis=1), aspect='auto',
  1725. cmap="viridis", interpolation='none', vmin=0, vmax=1, origin='upper') # Heatmap of scaled traces
  1726. ax.set_xlabel('')
  1727. ax.set_xticks([])
  1728. ax.set_yticks(range(0, 40, 30))
  1729. ax.set_yticklabels([])
  1730. ax.invert_yaxis()
  1731. # Add colourbar for fluorescence heatmap
  1732. cbar = plt.colorbar(im, ax=ax, orientation='horizontal', location='top', aspect=50, pad=0.05)
  1733. cbar.set_label('$\Delta$ F$/$F$_0$ (scaled)')
  1734. # 🔳 Plot 3: Average traces per cluster (stimulus condition)
  1735. ax = plt.subplot(gs[2, 1:3]) # Middle-right subplot
  1736. for Ki in range(Ks_2): # Loop through clusters
  1737. js = np.where(labels_hier_stim_2 == Ki) # Get cell indices for cluster Ki
  1738. avg = np.mean(np.squeeze(data_stim_2[js, :]), axis=0) # Average trace
  1739. avg = minmax_scale(avg) # Normalise trace
  1740. ax.plot(avg, c=GABA_greens[Ki-1]) # Plot with cluster colour
  1741. ax.set_xlim(0, 120)
  1742. ax.set_xlabel('time [sec]')
  1743. ax.set_xticks(range(0, 1400, 200), labels=range(0, 140, 20))
  1744. ax.set_ylabel('$\Delta F/F_0$')
  1745. ax.set_yticks([0, 0.5, 1])
  1746. # Sort cell indices by hierarchical clustering labels (stress condition)
  1747. idx = np.argsort(labels_hier_stress_2)
  1748. # 🔳 Plot 4: Heatmap of pairwise correlations (stress condition)
  1749. ax = fig.add_subplot(gs[3:5, 0]) # Middle-left subplot
  1750. ax = sns.heatmap(max_corr_stress_2[idx, :][:, idx],
  1751. cbar_kws={'label': 'Corr', 'location': 'top'},
  1752. cmap="viridis", vmin=-1, vmax=1)
  1753. ax.set(xlabel='cell ID', ylabel='cell ID')
  1754. ax.set_xticks([_ for _ in range(n_cells_stress) if _ % 20 == 0])
  1755. ax.set_xticklabels([_ for _ in range(n_cells_stress) if _ % 20 == 0])
  1756. ax.set_yticks([_ for _ in range(n_cells_stress) if _ % 20 == 0])
  1757. ax.set_yticklabels([_ for _ in range(n_cells_stress) if _ % 20 == 0])
  1758. ax.invert_yaxis()
  1759. # Add plot outline
  1760. for spine in ax.spines.values():
  1761. spine.set_visible(True)
  1762. spine.set_linewidth(1.5)
  1763. spine.set_edgecolor('gray')
  1764. # Add colourbar outline
  1765. cbar = ax.collections[0].colorbar
  1766. cbar.outline.set_visible(True)
  1767. cbar.outline.set_edgecolor('grey')
  1768. cbar.outline.set_linewidth(1.0)
  1769. # 🔳 Plot 5: Heatmap of normalised fluorescence traces (stress condition)
  1770. ax = plt.subplot(gs[3:5, 1:3]) # Middle-right subplot
  1771. im = ax.imshow(minmax_scale(scale(data_stress_2[idx, :], axis=1), axis=1), aspect='auto',
  1772. cmap="viridis", interpolation='none', vmin=0, vmax=1, origin='upper')
  1773. ax.set_xlabel('')
  1774. ax.set_xticks([])
  1775. ax.set_yticks(range(0, 40, 30))
  1776. ax.set_yticklabels([])
  1777. ax.invert_yaxis()
  1778. # Add colourbar for fluorescence heatmap
  1779. cbar = plt.colorbar(im, ax=ax, orientation='horizontal', location='top', aspect=50, pad=0.05)
  1780. cbar.set_label('$\Delta$ F$/$F$_0$ (scaled)')
  1781. # 🔳 Plot 6: Average traces per cluster (stress condition)
  1782. ax = plt.subplot(gs[5, 1:3]) # Bottom-right subplot
  1783. for Ki in range(Ks_2):
  1784. js = np.where(labels_hier_stress_2 == Ki)
  1785. avg = np.mean(np.squeeze(data_stress_2[js, :]), axis=0)
  1786. avg = minmax_scale(avg)
  1787. ax.plot(avg, c=GABA_greens[Ki-1])
  1788. ax.set_xlim(0, 120)
  1789. ax.set_xlabel('time [sec]')
  1790. ax.set_xticks(range(0, 1500, 300), labels=range(0, 150, 30))
  1791. ax.set_ylabel('$\Delta F/F_0$')
  1792. ax.set_yticks([0, 0.5, 1])
  1793. # 🔳 Plot 7: Violin plot of cell percentages (stimulation condition, cluster group A)
  1794. ax = plt.subplot(gs[6:8, 0]) # Bottom-left subplot
  1795. perc1 = cluster1_cells_allanimals_stim
  1796. df = pd.DataFrame(dict(x=np.repeat([0], len(perc1)), y=perc1))
  1797. sns.violinplot(x="x", y="y", data=df, order=np.arange(2), inner='box', linecolor='k', color=eff_green)
  1798. # perc2 = cell_percentage_hier[categories_percent_hier[8]] / cell_percentage_hier[categories_percent_hier[1]] * 100
  1799. perc2 =cluster2_cells_allanimals_stim
  1800. df = pd.DataFrame(dict(x=np.repeat([1], len(perc2)), y=perc2))
  1801. sns.violinplot(x="x", y="y", data=df, order=np.arange(2), inner='box', linecolor='k', color=inter_green)
  1802. ax.set_title(categories_percent_hier[7][:-5])
  1803. ax.set_ylim(-20, 140)
  1804. ax.set_xlabel('')
  1805. ax.set_ylabel('% of cells')
  1806. ax.set_xticks(range(2), labels=['cluster 1', 'cluster 2'])
  1807. ax.set_yticks(range(0, 150, 50), labels=range(0, 150, 50))
  1808. # Statistical tests between cluster groups
  1809. ax.plot([0, 1], [123, 123], 'k-', lw=1)
  1810. ax.plot([0, 0], [70, 123], 'k-', lw=1)
  1811. ax.plot([1, 1], [118, 123], 'k-', lw=1)
  1812. MW_p_value, _ = mannwhitneyu(perc1.dropna(), perc2.dropna())
  1813. KS_p_value = pvals_stim_clusters
  1814. ax.annotate(pval_to_star(KS_p_value), (0.5, 125), ha='center')
  1815. # 🔳 Plot 8: Violin plot of cell percentages (stress condition, cluster group B)
  1816. ax = plt.subplot(gs[6:8, 1]) # Bottom-center subplot
  1817. perc1 = cluster1_cells_allanimals_stress
  1818. df = pd.DataFrame(dict(x=np.repeat([0], len(perc1)), y=perc1))
  1819. sns.violinplot(x="x", y="y", data=df, order=np.arange(2), inner='box', linecolor='k', color=eff_green)
  1820. # Extract and normalise cell percentages for cluster 2
  1821. perc2 = cluster2_cells_allanimals_stress
  1822. df = pd.DataFrame(dict(x=np.repeat([1], len(perc2)), y=perc2))
  1823. sns.violinplot(x="x", y="y", data=df, order=np.arange(2), inner='box', linecolor='k', color=inter_green)
  1824. # Configure plot appearance
  1825. ax.set_title(categories_percent_hier[11][:-5]) # Remove suffix from category name
  1826. ax.set_ylim(-20, 140) # Set y-axis limits
  1827. ax.set_xlabel('')
  1828. ax.set_ylabel('% of cells')
  1829. ax.set_xticks(range(2), labels=['cluster 1', 'cluster 2'])
  1830. ax.set_yticks(range(0, 150, 50), labels=range(0, 150, 50))
  1831. # Statistical tests between cluster groups
  1832. ax.plot([0, 1], [123, 123], 'k-', lw=1) # Horizontal line
  1833. ax.plot([0, 0], [70, 123], 'k-', lw=1) # Left vertical line
  1834. ax.plot([1, 1], [118, 123], 'k-', lw=1) # Right vertical line
  1835. MW_p_value, _ = mannwhitneyu(perc1.dropna(), perc2.dropna()) # Mann-Whitney U test
  1836. KS_p_value = pvals_stress_clusters # Kolmogorov-Smirnov test
  1837. ax.annotate(pval_to_star(KS_p_value), (0.5, 125), ha='center') # Annotate significance
  1838. # Optional: Add final layout adjustments and save figure
  1839. fig.tight_layout()
  1840. # plt.savefig('cluster_summary_figure.svg', dpi=700) # Uncomment to save
  1841. plt.show()
  1842. # %% [markdown]
  1843. # # 🖼️ Figure S3 (cluster size)
  1844. # %%
  1845. # Create figure for cluster percentage comparison across all animals
  1846. fig = plt.figure(figsize=(8, 5))
  1847. ax = plt.subplot(1, 1, 1)
  1848. ax.tick_params(axis='both', length=0)
  1849. # Extract cell percentages for the two clusters
  1850. perc1 = cell_percentage_hier['All Animals (#1)']
  1851. perc2 = cell_percentage_hier['All Animals (#2)']
  1852. # Violin plot for cluster 1
  1853. df1 = pd.DataFrame(dict(x=np.repeat([0], len(perc1)), y=perc1))
  1854. sns.violinplot(x="x", y="y", data=df1, order=np.arange(2), color=eff_green)
  1855. # Violin plot for cluster 2
  1856. df2 = pd.DataFrame(dict(x=np.repeat([1], len(perc2)), y=perc2))
  1857. sns.violinplot(x="x", y="y", data=df2, order=np.arange(2), color=inter_green)
  1858. # Axis formatting and labels
  1859. ax.set_title(categories_percent_hier[4*0 + 3][:-5])
  1860. ax.set_ylim(-25, 150)
  1861. ax.set_xlabel('')
  1862. ax.set_ylabel('% of cells')
  1863. ax.set_xticks([0, 1], labels=['cluster 1', 'cluster 2'])
  1864. # Annotate p value for cluster comparison
  1865. ax.annotate(f'p={pvals_all_clusters:0.3e}', (0.5, 125), ha='center')
  1866. fig.tight_layout()
  1867. # plt.savefig('cluster_summary_figure_supplement.svg', dpi=700)
  1868. plt.show()
  1869. # %% [markdown]
  1870. # # 🖼️ Figure S4 (corrcoef)
  1871. # %%
  1872. # List of all relevant CSV file paths for UCN-opsin & GABA-GCaMP experiments
  1873. all_file_paths = [
  1874. './data/calcium_imaging/UCN3_stimulation/G7 20231110 10hz 222sti.csv', #1
  1875. './data/calcium_imaging/UCN3_stimulation/G7 20240201 10hz 223sti.csv', #2
  1876. './data/calcium_imaging/UCN3_stimulation/G8 20240220 10hz 223sti.csv', #3
  1877. './data/calcium_imaging/UCN3_stimulation/G8 20240301 10hz 223sti.csv', #4
  1878. './data/calcium_imaging/UCN3_stimulation/G8 20240202 10hz 223sti.csv', #5
  1879. './data/calcium_imaging/UCN3_stimulation/G8 20231218 10hz 222sti.csv', #6
  1880. './data/calcium_imaging/UCN3_stimulation/G9 20231218 10hz 222sti.csv', #7
  1881. './data/calcium_imaging/UCN3_stimulation/G9 20240202 10hz 223sti.csv', #8
  1882. './data/calcium_imaging/UCN3_stimulation/G9 20240307 10hz 223sti.csv', #9
  1883. './data/calcium_imaging/UCN3_stimulation/G9 20240228 10hz 223sti.csv', #10
  1884. './data/calcium_imaging/UCN3_stimulation/G28 20240709 10hz 223sti.csv', #11
  1885. './data/calcium_imaging/UCN3_stimulation/G28 20240701 10hz 223sti.csv', #12
  1886. './data/calcium_imaging/UCN3_stimulation/G28 20240704 10hz 223sti.csv', #13
  1887. './data/calcium_imaging/UCN3_stimulation/G29 20240703 10hz 223sti.csv', #14
  1888. './data/calcium_imaging/UCN3_stimulation/G29 20240711 10hz 223sti.csv', #15
  1889. './data/calcium_imaging/UCN3_stimulation/G29 20240705 10hz 223sti.csv', #16
  1890. './data/calcium_imaging/UCN3_stimulation/G37 10hz 223sti 20240720 A.csv', #17
  1891. './data/calcium_imaging/UCN3_stimulation/G37 10hz 223sti 20240720 B.csv', #18
  1892. './data/calcium_imaging/UCN3_stimulation/G37 10hz 223sti 20240721 A.csv', #19
  1893. './data/calcium_imaging/UCN3_stimulation/G37 10hz 223sti 20240721 B.csv', #20
  1894. './data/calcium_imaging/UCN3_stimulation/G7 20231218 334restraint.csv', #21
  1895. './data/calcium_imaging/UCN3_stimulation/G8 20231218 343restraint.csv', #22
  1896. './data/calcium_imaging/UCN3_stimulation/G9 20231218 334restraint.csv', #23
  1897. './data/calcium_imaging/restrain_stress/G28 333restraint 20240630.csv', #24
  1898. './data/calcium_imaging/restrain_stress/G29 333restraint 20240701.csv', #25
  1899. './data/calcium_imaging/restrain_stress/G37 333restraint 20240719.csv', #26
  1900. './data/calcium_imaging/restrain_stress/G37 333restraint 20240725.csv', #27
  1901. ]
  1902. # %%
  1903. # Selected stimulation files for bootstrapping analysis
  1904. # Each entry corresponds to a specific animal and session
  1905. boot_file_paths = [
  1906. all_file_paths[1], # G7 — 2024-02-01 — 223sti
  1907. all_file_paths[5], # G8 — 2024-03-01 — 223sti
  1908. all_file_paths[8], # G9 — 2024-03-07 — 223sti
  1909. all_file_paths[12], # G28 — 2024-07-09 — 223sti
  1910. all_file_paths[15], # G29 — 2024-07-11 — 223sti
  1911. all_file_paths[17] # G37 — 2024-07-20 A — 223sti
  1912. ]
  1913. # %%
  1914. # Create a 3x3 grid layout for plotting silhouette score distributions
  1915. plt.figure(figsize=(15, 15)) # Set overall figure size
  1916. # Define subplot axes for each animal/group
  1917. ax1 = plt.subplot(3, 3, 1)
  1918. ax2 = plt.subplot(3, 3, 2)
  1919. ax3 = plt.subplot(3, 3, 3)
  1920. ax4 = plt.subplot(3, 3, 4)
  1921. ax5 = plt.subplot(3, 3, 5)
  1922. ax6 = plt.subplot(3, 3, 6)
  1923. # Iterate over bootstrapped stimulation datasets
  1924. for ind, stim_csv_fname in enumerate(boot_file_paths):
  1925. # Load stimulation data and raw traces
  1926. data_stim, cells_traces_tot_old = load_data(stim_csv_fname)
  1927. # Filter for UCN3+ GABAergic neurons
  1928. data_stim_tot, n_cells = process_data(data_stim, cells_traces_tot_old)
  1929. # Normalise each trace to its own min/max (row-wise)
  1930. normalised_data_stim_tot = minmax_scale(data_stim_tot, axis=1)
  1931. # Slice stimulation period from normalised traces
  1932. normalised_data_stim_2 = normalised_data_stim_tot[:, stim_onset:stim_offset]
  1933. # Compute pairwise correlation-based distance matrix
  1934. # Using 1 - Pearson correlation as a dissimilarity metric
  1935. dist_cov_2 = pdist(scale(normalised_data_stim_2, axis=1),
  1936. lambda x, y: 1 - np.corrcoef(x, y)[0, 1])
  1937. # Initialise array to store silhouette scores across bootstraps
  1938. sil_score_2 = np.zeros((max_no_clusters - 1, n_resampling))
  1939. # Loop over cluster numbers and bootstrap iterations
  1940. for i, Ks_2 in enumerate(range(2, max_no_clusters + 1)):
  1941. for j in range(n_resampling):
  1942. # Resample 75% of cells for bootstrapping
  1943. sampled_ids = np.random.choice(range(n_cells), int(0.75 * n_cells), replace=False)
  1944. sampled_data = squareform(dist_cov_2)[sampled_ids, :][:, sampled_ids]
  1945. # Apply hierarchical clustering with average linkage
  1946. ac = AgglomerativeClustering(n_clusters=Ks_2, metric='precomputed',
  1947. linkage='average', compute_distances=True).fit(sampled_data)
  1948. # Compute silhouette score using precomputed distances
  1949. sil_score_2[i, j] = silhouette_score(sampled_data, ac.labels_, metric='precomputed')
  1950. # Select subplot axis based on file name prefix
  1951. file_name = os.path.split(stim_csv_fname)[-1]
  1952. ax = {
  1953. 'G7': ax1, 'G8': ax2, 'G9': ax3,
  1954. 'G28': ax4, 'G29': ax5, 'G37': ax6
  1955. }.get(file_name.split(' ')[0], None)
  1956. if ax:
  1957. # Plot silhouette score distributions as violin plots
  1958. for k, colour in enumerate(['red', 'green', 'blue']): # Cluster sizes: 2, 3, 4
  1959. df = pd.DataFrame({'x': np.repeat([k], len(sil_score_2[k, :])), 'y': sil_score_2[k, :]})
  1960. sns.violinplot(x="x", y="y", data=df, color=colour, linecolor='k', ax=ax)
  1961. # Perform Dunn's test for post-hoc comparisons
  1962. p_vals = posthoc_dunn(sil_score_2, p_adjust='fdr_bh')
  1963. # Annotate significance between cluster numbers
  1964. ax.plot([0, 1], [0.79, 0.79], 'k-') # 2 vs 3
  1965. ax.plot([1, 2], [0.7, 0.7], 'k-') # 3 vs 4
  1966. ax.plot([0, 2], [0.89, 0.89], 'k-') # 2 vs 4
  1967. ax.annotate(pval_to_star(p_vals[1][2]), (0.5, 0.8), ha='center')
  1968. ax.annotate(pval_to_star(p_vals[2][3]), (1.5, 0.71), ha='center')
  1969. ax.annotate(pval_to_star(p_vals[1][3]), (1, 0.9), ha='center')
  1970. # Configure subplot titles and axis labels
  1971. for idx, title in enumerate(['G7', 'G8', 'G9', 'G28', 'G29', 'G37'], start=1):
  1972. ax = plt.subplot(3, 3, idx)
  1973. ax.set_title(title) # Animal/group ID
  1974. ax.set_ylim(0, 1) # Score range
  1975. ax.set_ylabel('Silhouette score')
  1976. ax.set_xlabel('Number of Clusters')
  1977. ax.set_xticklabels([2, 3, 4, '', 2, 3, 4]) # Cluster labels
  1978. # Final layout and export
  1979. plt.tight_layout() # Optimise spacing
  1980. # plt.savefig('./gdrive/My Drive/FigS3_corr_revised.svg', dpi=300) # Save figure
  1981. plt.show() # Display plot
  1982. # %% [markdown]
  1983. # # 🖼️ Figure S5 (SLxCorr)
  1984. # %%
  1985. # Create a 3x3 grid layout for plotting silhouette score distributions
  1986. plt.figure(figsize=(15, 15)) # Set overall figure size
  1987. # Define subplot axes for each animal/group
  1988. ax1 = plt.subplot(3, 3, 1)
  1989. ax2 = plt.subplot(3, 3, 2)
  1990. ax3 = plt.subplot(3, 3, 3)
  1991. ax4 = plt.subplot(3, 3, 4)
  1992. ax5 = plt.subplot(3, 3, 5)
  1993. ax6 = plt.subplot(3, 3, 6)
  1994. # Iterate over bootstrapped stimulation datasets
  1995. for ind, stim_csv_fname in enumerate(boot_file_paths):
  1996. # Load stimulation data and raw traces
  1997. data_stim, cells_traces_tot_old = load_data(stim_csv_fname)
  1998. # Filter for UCN3+ GABAergic neurons
  1999. data_stim_tot, n_cells = process_data(data_stim, cells_traces_tot_old)
  2000. # Normalise each trace to its own min/max (row-wise)
  2001. normalised_data_stim_tot = minmax_scale(data_stim_tot, axis=1)
  2002. # Slice stimulation period from normalised traces
  2003. normalised_data_stim_2 = normalised_data_stim_tot[:, stim_onset:stim_offset]
  2004. # Compute pairwise signed lagged correlation distance matrix
  2005. dist_corr_2 = pdist(scale(normalised_data_stim_2, axis=1),
  2006. lambda x, y: 1 - signed_lagged_corr(x, y)[0])
  2007. # Initialise array to store silhouette scores across bootstraps
  2008. sil_score_2 = np.zeros((max_no_clusters - 1, n_resampling))
  2009. # Loop over cluster numbers and bootstrap iterations
  2010. for i, Ks_2 in enumerate(range(2, max_no_clusters + 1)):
  2011. for j in range(n_resampling):
  2012. # Resample 75% of cells for bootstrapping
  2013. sampled_ids = np.random.choice(range(n_cells), int(0.75 * n_cells), replace=False)
  2014. sampled_data = squareform(dist_corr_2)[sampled_ids, :][:, sampled_ids]
  2015. # Apply hierarchical clustering with average linkage
  2016. ac = AgglomerativeClustering(n_clusters=Ks_2, metric='precomputed',
  2017. linkage='average', compute_distances=True).fit(sampled_data)
  2018. # Compute silhouette score using precomputed distances
  2019. sil_score_2[i, j] = silhouette_score(sampled_data, ac.labels_, metric='precomputed')
  2020. # Select subplot axis based on file name prefix
  2021. file_name = os.path.split(stim_csv_fname)[-1]
  2022. ax = {
  2023. 'G7': ax1, 'G8': ax2, 'G9': ax3,
  2024. 'G28': ax4, 'G29': ax5, 'G37': ax6
  2025. }.get(file_name.split(' ')[0], None)
  2026. if ax:
  2027. # Plot silhouette score distributions as violin plots
  2028. for k, colour in enumerate(['red', 'green', 'blue']): # Cluster sizes: 2, 3, 4
  2029. df = pd.DataFrame({'x': np.repeat([k], len(sil_score_2[k, :])), 'y': sil_score_2[k, :]})
  2030. sns.violinplot(x="x", y="y", data=df, color=colour, linecolor='k', ax=ax)
  2031. # Perform Dunn's test for post-hoc comparisons
  2032. p_vals = posthoc_dunn(sil_score_2, p_adjust='fdr_bh')
  2033. # Annotate significance between cluster numbers
  2034. ax.plot([0, 1], [0.79, 0.79], 'k-') # 2 vs 3
  2035. ax.plot([1, 2], [0.7, 0.7], 'k-') # 3 vs 4
  2036. ax.plot([0, 2], [0.89, 0.89], 'k-') # 2 vs 4
  2037. ax.annotate(pval_to_star(p_vals[1][2]), (0.5, 0.8), ha='center')
  2038. ax.annotate(pval_to_star(p_vals[2][3]), (1.5, 0.71), ha='center')
  2039. ax.annotate(pval_to_star(p_vals[1][3]), (1, 0.9), ha='center')
  2040. # Configure subplot titles and axis labels
  2041. for idx, title in enumerate(['G7', 'G8', 'G9', 'G28', 'G29', 'G37'], start=1):
  2042. ax = plt.subplot(3, 3, idx)
  2043. ax.set_title(title) # Animal/group ID
  2044. ax.set_ylim(0, 1) # Score range
  2045. ax.set_ylabel('Silhouette score')
  2046. ax.set_xlabel('Number of Clusters')
  2047. ax.set_xticklabels([2, 3, 4, '', 2, 3, 4]) # Cluster labels
  2048. # Final layout and export
  2049. plt.tight_layout() # Optimise spacing
  2050. plt.show() # Display plot
  2051. # %% [markdown]
  2052. # # 🖼️ Figure S6 (kmeans)
  2053. # %%
  2054. # Create a 3x3 grid for plotting silhouette score distributions
  2055. plt.figure(figsize=(15, 15)) # Set overall figure size
  2056. # Define subplot axes for each animal/group
  2057. ax1 = plt.subplot(3, 3, 1)
  2058. ax2 = plt.subplot(3, 3, 2)
  2059. ax3 = plt.subplot(3, 3, 3)
  2060. ax4 = plt.subplot(3, 3, 4)
  2061. ax5 = plt.subplot(3, 3, 5)
  2062. ax6 = plt.subplot(3, 3, 6)
  2063. # Iterate over bootstrapped stimulation datasets
  2064. for ind, stim_csv_fname in enumerate(boot_file_paths):
  2065. # Load stimulation data and raw traces
  2066. data_stim, cells_traces_tot_old = load_data(stim_csv_fname)
  2067. # Filter for UCN3+ GABAergic neurons
  2068. data_stim_tot, n_cells = process_data(data_stim, cells_traces_tot_old)
  2069. # Normalise each trace to its own min/max (row-wise)
  2070. normalised_data_stim_tot = minmax_scale(data_stim_tot, axis=1)
  2071. # Slice stimulation period from normalised traces
  2072. normalised_data_stim_2 = normalised_data_stim_tot[:, stim_onset:stim_offset]
  2073. # Compute pairwise correlation-based distance matrix
  2074. dist_corr_2 = pdist(scale(normalised_data_stim_2, axis=1),
  2075. lambda x, y: 1 - np.corrcoef(x, y)[0, 1])
  2076. # Initialise array to store silhouette scores across bootstraps
  2077. sil_score_2 = np.zeros((max_no_clusters - 1, n_resampling))
  2078. # Loop over cluster numbers and bootstrap iterations
  2079. for i, Ks_2 in enumerate(range(2, max_no_clusters + 1)):
  2080. for j in range(n_resampling):
  2081. # Resample 75% of cells for bootstrapping
  2082. sampled_ids = np.random.choice(range(n_cells), int(0.75 * n_cells), replace=False)
  2083. sampled_data = squareform(dist_corr_2)[sampled_ids, :][:, sampled_ids]
  2084. # Apply K-means clustering to resampled data
  2085. km = KMeans(n_clusters=Ks_2, n_init=10, max_iter=100).fit(sampled_data)
  2086. # Compute silhouette score using precomputed distances
  2087. sil_score_2[i, j] = silhouette_score(sampled_data, km.labels_, metric='precomputed')
  2088. # Select subplot axis based on file name prefix
  2089. file_name = os.path.split(stim_csv_fname)[-1]
  2090. ax = {
  2091. 'G7': ax1, 'G8': ax2, 'G9': ax3,
  2092. 'G28': ax4, 'G29': ax5, 'G37': ax6
  2093. }.get(file_name.split(' ')[0], None)
  2094. if ax:
  2095. # Plot silhouette score distributions as violin plots
  2096. for k, colour in enumerate(['red', 'green', 'blue']):
  2097. df = pd.DataFrame({'x': np.repeat([k], len(sil_score_2[k, :])), 'y': sil_score_2[k, :]})
  2098. sns.violinplot(x="x", y="y", data=df, color=colour, linecolor='k', ax=ax)
  2099. # Perform Dunn's test for post-hoc comparisons
  2100. p_vals = posthoc_dunn(sil_score_2, p_adjust='fdr_bh')
  2101. # Annotate significance between cluster numbers
  2102. ax.plot([0, 1], [0.79, 0.79], 'k-') # 2 vs 3
  2103. ax.plot([1, 2], [0.7, 0.7], 'k-') # 3 vs 4
  2104. ax.plot([0, 2], [0.89, 0.89], 'k-') # 2 vs 4
  2105. ax.annotate(pval_to_star(p_vals[1][2]), (0.5, 0.8), ha='center')
  2106. ax.annotate(pval_to_star(p_vals[2][3]), (1.5, 0.71), ha='center')
  2107. ax.annotate(pval_to_star(p_vals[1][3]), (1, 0.9), ha='center')
  2108. # Configure subplot titles and axis labels
  2109. for idx, title in enumerate(['G7', 'G8', 'G9', 'G28', 'G29', 'G37'], start=1):
  2110. ax = plt.subplot(3, 3, idx)
  2111. ax.set_title(title) # Animal/group ID
  2112. ax.set_ylim(0, 1) # Score range
  2113. ax.set_ylabel('Silhouette score')
  2114. ax.set_xlabel('Number of Clusters')
  2115. ax.set_xticklabels([2, 3, 4, '', 2, 3, 4]) # Cluster labels
  2116. # Final layout and export
  2117. plt.tight_layout()
  2118. # plt.savefig('./gdrive/My Drive/FigS5_kmeans_revised.svg', dpi=300)
  2119. plt.show()
  2120. # %% [markdown]
  2121. # # 🖼️ Figure S7 (stim. frequency)
  2122. # %%
  2123. # Distance metrics for control condition at 5 Hz stimulation
  2124. # Each value likely represents a summary statistic (e.g., mean trace distance) per cell or ROI
  2125. control_dist_5Hz = [0.3880, 0.5961, 0.6274, 0.2502, 0.3371, 0.3458, 0.3078, 0.4944, 0.3302, 0.3775, 0.5336, 0.4705]
  2126. # Distance metrics for stimulated condition at 5 Hz
  2127. # Larger sample size suggests more cells or trials were analyzed under stimulation
  2128. stim_dist_5Hz = [0.3697, 0.3987, 0.3547, 0.4152, 0.3759, 0.3717, 0.3927, 0.4157, 0.3807, 0.3770,
  2129. 0.3625, 0.2734, 0.3256, 0.4289, 0.3702, 0.3674, 0.4469, 0.4589, 0.4747, 0.3439]
  2130. # Distance metrics for control condition at 20 Hz stimulation
  2131. # Values may reflect baseline or spontaneous activity patterns at higher frequency context
  2132. control_dist_20Hz = [0.6933, 0.5293, 0.5910, 0.4005, 0.3390, 0.4714, 0.5060, 0.4065, 0.4393, 0.5510, 0.4441, 0.4634]
  2133. # Distance metrics for stimulated condition at 20 Hz
  2134. # Used to assess how stimulation alters trace similarity or variability at higher frequency
  2135. stim_dist_20Hz = [0.3857, 0.4040, 0.3860, 0.3939, 0.4034, 0.3658, 0.4081, 0.4177, 0.4064, 0.3816,
  2136. 0.2990, 0.4429, 0.3006, 0.4532, 0.3107, 0.4206, 0.4544, 0.4467, 0.4376, 0.4959]
  2137. # %%
  2138. # =========================
  2139. # Helpers
  2140. # =========================
  2141. def make_palette(animals, colors):
  2142. uniq = pd.unique(animals)
  2143. if len(uniq) != len(colors):
  2144. raise ValueError("Number of animals and colors must match")
  2145. return dict(zip(uniq, colors))
  2146. palette_ctrl = make_palette(control_animal, colors_ctrl)
  2147. palette_stim = make_palette(stim_animal, mycolors)
  2148. x_order = [0, 1, 3, 4, 6, 7, 8, 9 ]
  2149. def violin_swarm(xpos, values, animals, palette, marker):
  2150. df = pd.DataFrame({
  2151. "x": np.repeat(xpos, len(values)),
  2152. "y": values,
  2153. "c": animals
  2154. })
  2155. sns.violinplot(
  2156. x="x", y="y", data=df,
  2157. order=x_order,
  2158. fill=False, color="k",
  2159. linewidth=1.5,
  2160. ax=ax
  2161. )
  2162. sns.swarmplot(
  2163. x="x", y="y", hue="c", data=df,
  2164. order=x_order,
  2165. palette=palette,
  2166. size=10,
  2167. marker=marker,
  2168. legend=False,
  2169. ax=ax
  2170. )
  2171. # =========================
  2172. # Figure
  2173. # =========================
  2174. fig = plt.figure(figsize=(10, 10))
  2175. ax = plt.subplot(1, 1, 1)
  2176. # --- 5 Hz ---
  2177. violin_swarm(0, control_dist_5Hz, control_animal, palette_ctrl, "s")
  2178. violin_swarm(1, stim_dist_5Hz, stim_animal, palette_stim, "o")
  2179. # --- 10 Hz ---
  2180. violin_swarm(4, control_dist, control_animal, palette_ctrl, "s")
  2181. violin_swarm(6, stim_dist, stim_animal, palette_stim, "o")
  2182. # --- 20 Hz ---
  2183. violin_swarm(8, control_dist_20Hz, control_animal, palette_ctrl, "s")
  2184. violin_swarm(9, stim_dist_20Hz, stim_animal, palette_stim, "o")
  2185. # =========================
  2186. # Axes
  2187. # =========================
  2188. ax.set_ylim(0.1, 1.2)
  2189. ax.set_ylabel("Normalised Riemannian Distance")
  2190. ax.set_xlabel("")
  2191. ax.set_xticks(
  2192. [0, 1, 3, 4, 6, 7],
  2193. labels=[
  2194. "5Hz\n Control", "5Hz\n Stim.",
  2195. "10Hz\n Control", "10Hz\n Stim.",
  2196. "20Hz\n Control", "20Hz\n Stim."
  2197. ]
  2198. )
  2199. # =========================
  2200. # Statistics
  2201. # =========================
  2202. # 5 Hz
  2203. ax.plot([0, 1], [0.85, 0.85], "k-", lw=1)
  2204. ax.plot([0, 0], [0.85, 0.84], "k-", lw=1)
  2205. ax.plot([1, 1], [0.85, 0.84], "k-", lw=1)
  2206. _, p = kruskal(control_dist_5Hz, stim_dist_5Hz)
  2207. ax.annotate(pval_to_star(p), (0.5, 0.86), ha="center")
  2208. # 10 Hz
  2209. ax.plot([3, 4], [1.1, 1.1], "k-", lw=1)
  2210. ax.plot([3, 3], [1.1, 1.09], "k-", lw=1)
  2211. ax.plot([4, 4], [1.1, 1.09], "k-", lw=1)
  2212. _, p = kruskal(control_dist, stim_dist)
  2213. ax.annotate(pval_to_star(p), (3.5, 1.11), ha="center")
  2214. # 20 Hz
  2215. ax.plot([6, 7], [0.85, 0.85], "k-", lw=1)
  2216. ax.plot([6, 6], [0.85, 0.84], "k-", lw=1)
  2217. ax.plot([7, 7], [0.85, 0.84], "k-", lw=1)
  2218. _, p = kruskal(control_dist_20Hz, stim_dist_20Hz)
  2219. ax.annotate(pval_to_star(p), (6.5, 0.86), ha="center")
  2220. plt.tight_layout()
  2221. # plt.savefig("Fig_dist_violin_swarm.svg", dpi=600, bbox_inches="tight")
  2222. plt.show()
  2223. # %% [markdown]
  2224. # # 📚 Used Libraries
  2225. # %%
  2226. # List all currently imported libraries in the global namespace
  2227. import types
  2228. def list_imported_modules():
  2229. return sorted(
  2230. val.__name__ for name, val in globals().items()
  2231. if isinstance(val, types.ModuleType)
  2232. )
  2233. # Display the list
  2234. set(list_imported_modules())

analysis_pipeline.ipynb at commit 538ba57, under CC0-1.0 · at the source

Overview

Authors: Junru Yu1,2, Saeed Farjami3,4,5, Kateryna Nechyporenko3,4, Xiao Feng Li1, Hafsa Yaseen1,6, Yanyan Lin1, Jinbin Ye1, Owen Hollings1, Ross de Burgh1, Baban Singh1, Kevin T O’Byrne1, Krasimira Tsaneva-Atanasova3,4,7, Margaritis Voliotis3,4
  1. Department of Women and Children’s Health, School of Life Course and Population Sciences, King’s College London, Guy’s Campus, London, UK
  2. Department of Rehabilitation Medicine, The First Affiliated Hospital of Wenzhou Medical University, Wenzhou, Zhejiang China
  3. Department of Mathematics and Statistics, University of Exeter, Exeter, UK
  4. Living Systems Institute, University of Exeter, Exeter, UK
  5. The Pirbright Institute, Pirbright, Surrey, UK
  6. Biological Sciences, University of Missouri, Columbia, MO USA
  7. EPSRC Hub for Quantitative Modelling in Healthcare, University of Exeter, Exeter, UK
Institutions: King's College London (United Kingdom); First Affiliated Hospital of Wenzhou Medical University (China); The Pirbright Institute (United Kingdom); University of Exeter (United Kingdom); University of Missouri (United States)
Journal: Nature communications, volume 17, issue 1, article 5690
Dates: received 9 February 2025; accepted 25 February 2026; published online 10 March 2026
Type: Research article · Language: English
License: CC BY
Identifiers: DOI 10.1038/s41467-026-70364-9 · PMID 41807404 · PMCID PMC13319269 · OpenAlex W7134887411
Open access: gold, a free copy (OpenAlex)
Status: code verified
Categories: mouse (organism), systems (subfield)
Methods: Connectivity, Statistics, Smoothing, state filtering, decompositions, Machine learning, fMRI & imaging, Single-unit activity, calcium imaging
Keywords: Endocrine reproductive disorders, Neural circuits, Network models
MeSH: Amygdala*, GABAergic Neurons*, Reproduction*, Stress, Physiological*, Stress, Psychological*, Animals, Female, gamma-Aminobutyric Acid, Gonadotropin-Releasing Hormone, Hypothalamic-Pituitary-Gonadal Axis, Luteinizing Hormone, Mice, Mice, Inbred C57BL, Optogenetics, Urocortins (* major topic)
Topic: Neuroendocrine regulation and behavior (Social Psychology, Psychology), according to OpenAlex
Funding: Biotechnology and Biological Sciences Research Council (BB/W005883/1, BB/S019979/1, BB/W005913/1); RCUK | Engineering and Physical Sciences Research Council (EPSRC) (EP/T017856/)
Citations: cited by 1 paper (Europe PMC); 45 references in the paper
Research resources: i) coating antibody RRID:AB_2665514, ii) anti-LH antibody RRID:AB_2665533, RRID:AB_772206

Abstract

Stress can disrupt menstrual cycles, impair fertility and cause reproductive disfunction. The posterodorsal medial amygdala (MePD) integrates stress signals and regulates the gonadotropin-releasing hormone (GnRH) pulse generator through a dense network of GABA and Urocortin-3 (UCN3) neurons, yet the mechanisms underlying the circuitry remain poorly understood. Here, we combine in vivo mini-endoscopic calcium imaging, optogenetics, clustering analysis, and computational modeling to investigate the MePD circuitry in female mice. We uncover two anti-correlated GABA subpopulations in the MePD that are involved in the response to restraint stress and UCN3 neuron stimulation. Computational modeling suggests that mutual inhibition between these GABA groups drives their anti-correlated activity and predicts how these interactions shape downstream responses to stimulation of GABA and UCN3 neurons. In vivo optogenetics confirms that GABA neurons are critical for transmitting UCN3 signals to regulate luteinizing hormone (LH) pulse frequency. Together, our findings reveal amygdala GABAergic circuit mechanisms that mediate stress effects on reproductive health, linking emotional processing and neuroendocrine control.

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

mv-kr/MePD_GABA

License: CC0-1.0
State: the link answers, verified on 30 September 2026
Evidence: files inventoried
Commit: 538ba57379a0669c2cdbd2f5c34e6d5d9f37a1e9, 8 January 2026
Languages: MATLAB (15), Jupyter (7)
Size: 270 files, 22 scripts
Software Heritage: not archived
Found in: “Code availability”
Holds: README, license file, 7 notebooks
Not found: CITATION.cff, environment file, tests, continuous integration, documentation
Tools: NumPy (7 files), pandas (6 files), SciPy (5 files), Matplotlib (4 files), statsmodels (4 files), Signal Processing Toolbox (2 files), seaborn (2 files), Pingouin (1 file), pyRiemann (1 file), scikit-learn (1 file), scikit-posthocs (1 file)
Availability: 1 check, the latest on 30 September 2026: the link answers
  • 30 September 2026: the link answers
24 files

Code availability

The code for reproducing the data analysis (Python™), mathematical modeling (MATLAB™), and figure generation for both the main text and Supplementary information, along with the datasets used in the analysis, is publicly available at Figshare45(10.24378/exe.31018072) or in Github repository (https://github.com/mv-kr/MePD_GABA).

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

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

Data

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

Data availability

The main data supporting the results in this study are available within the paper and its Supplementary Information. Any additional requests for information can be directed to and will be fulfilled by the corresponding authors. Source data are provided with this paper. Source data are available at Figshare44 (10.24378/exe.31021447). Source data are provided with this paper for reproducing all Figures in the manuscript and Supplementary Information. 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, 30 September 2026: the first record

Recorded: type, language, journal, volume, issue, pages, dates, 13 authors, 3 keywords, 15 MeSH terms, 2 funders, 39 references, 3 RRIDs.

Cite

This paper

Yu, J., Farjami, S., Nechyporenko, K., Li, X. F., Yaseen, H., Lin, Y., Ye, J., Hollings, O., de Burgh, R., Singh, B., O’Byrne, K. T., Tsaneva-Atanasova, K., & Voliotis, M. (2026). The role of amygdala GABA neurons in controlling stress and reproduction in female mice. Nature communications, 17(1), 5690. https://doi.org/10.1038/s41467-026-70364-9

BibTeX

@article{yu2026role,
author = {Yu, Junru and Farjami, Saeed and Nechyporenko, Kateryna and Li, Xiao Feng and Yaseen, Hafsa and Lin, Yanyan and Ye, Jinbin and Hollings, Owen and de Burgh, Ross and Singh, Baban and O’Byrne, Kevin T and Tsaneva-Atanasova, Krasimira and Voliotis, Margaritis},
title = {{The role of amygdala GABA neurons in controlling stress and reproduction in female mice}},
journal = {Nature communications},
year = {2026},
month = mar,
volume = {17},
number = {1},
pages = {5690},
publisher = {Nature Publishing Group},
issn = {2041-1723},
doi = {10.1038/s41467-026-70364-9},
url = {https://doi.org/10.1038/s41467-026-70364-9},
pmid = {41807404},
pmcid = {PMC13319269}
}

RIS

TY - JOUR
AU - Yu, Junru
AU - Farjami, Saeed
AU - Nechyporenko, Kateryna
AU - Li, Xiao Feng
AU - Yaseen, Hafsa
AU - Lin, Yanyan
AU - Ye, Jinbin
AU - Hollings, Owen
AU - de Burgh, Ross
AU - Singh, Baban
AU - O’Byrne, Kevin T
AU - Tsaneva-Atanasova, Krasimira
AU - Voliotis, Margaritis
TI - The role of amygdala GABA neurons in controlling stress and reproduction in female mice
T2 - Nature communications
J2 - Nat Commun
PY - 2026
DA - 2026/03/10
VL - 17
IS - 1
SP - 5690
SN - 2041-1723
PB - Nature Publishing Group
DO - 10.1038/s41467-026-70364-9
UR - https://doi.org/10.1038/s41467-026-70364-9
LA - en
ER -

CSL-JSON

{
"id": "10.1038/s41467-026-70364-9",
"type": "article-journal",
"title": "The role of amygdala GABA neurons in controlling stress and reproduction in female mice",
"container-title": "Nature communications",
"author": [
{
"family": "Yu",
"given": "Junru"
},
{
"family": "Farjami",
"given": "Saeed"
},
{
"family": "Nechyporenko",
"given": "Kateryna"
},
{
"family": "Li",
"given": "Xiao Feng"
},
{
"family": "Yaseen",
"given": "Hafsa"
},
{
"family": "Lin",
"given": "Yanyan"
},
{
"family": "Ye",
"given": "Jinbin"
},
{
"family": "Hollings",
"given": "Owen"
},
{
"family": "de Burgh",
"given": "Ross"
},
{
"family": "Singh",
"given": "Baban"
},
{
"family": "O’Byrne",
"given": "Kevin T"
},
{
"family": "Tsaneva-Atanasova",
"given": "Krasimira"
},
{
"family": "Voliotis",
"given": "Margaritis"
}
],
"container-title-short": "Nat Commun",
"volume": "17",
"issue": "1",
"page": "5690",
"DOI": "10.1038/s41467-026-70364-9",
"PMID": "41807404",
"PMCID": "PMC13319269",
"ISSN": "2041-1723",
"publisher": "Nature Publishing Group",
"URL": "https://doi.org/10.1038/s41467-026-70364-9",
"language": "en",
"issued": {
"date-parts": [
[
2026,
3,
10
]
]
}
}

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.1016/j.isci.2026.117375 [code]
Motor priming is associated with widespread recruitment into neural ensembles and more rapid ensemble transitions.
Journal: iScience
In common: scikit-posthocs, Pingouin, Signal Processing Toolbox, 7 other tools, systems
[2] doi:10.7554/elife.100605 [code]
Age-related changes in ‘cortical’ 1/f dynamics are linked to cardiac activity
Journal: n/a
In common: pyRiemann, Pingouin, statsmodels, 6 other tools
[3] doi:10.1038/s41593-026-02362-5 [code]
Replay of procedural memory is independent of the hippocampus.
Journal: Nature neuroscience
In common: scikit-posthocs, Pingouin, statsmodels, 6 other tools, mouse
[4] doi:10.1038/s41467-026-74823-1 [code]
Cerebellar activity is triggered by reach endpoint during learning of a complex locomotor task.
Journal: Nature communications
In common: scikit-posthocs, Pingouin, statsmodels, 6 other tools, mouse
[5] doi:10.1038/s41467-026-75662-w [code]
Distinct Roles of Deep and Superficial Cortical Layers in Tone Prediction, Comparison, and Adaptation in Human Auditory Cortices.
Journal: Nature communications
In common: scikit-posthocs, Pingouin, statsmodels, 6 other tools
[6] doi:10.1186/s13321-026-01177-7 [code]
A pipeline for developing AI-driven models to predict molecular initiating events: a case study on neural tube defects.
Journal: Journal of cheminformatics
In common: scikit-posthocs, Pingouin, statsmodels, 6 other tools
[7] doi:10.1038/s41593-026-02232-0 [code]
Entorhinal cortex represents task-relevant remote locations independently of CA1.
Journal: Nature neuroscience
In common: Pingouin, Signal Processing Toolbox, statsmodels, 6 other tools, systems, mouse
[8] doi:10.1002/advs.202518450 [code]
A Brain-Wide Atlas of Astrocytic Oxytocin Receptors Reveals a Glial Basis for Nucleus Accumbens Modulation of Affiliative Behavior.
Journal: Advanced science (Weinheim, Baden-Wurttemberg, Germany)
In common: scikit-posthocs, Pingouin, statsmodels, 5 other tools, mouse
[9] doi:10.1038/s41586-026-10501-y [code]
Long-term editing of brain circuits using an engineered electrical synapse.
Journal: Nature
In common: Pingouin, Signal Processing Toolbox, statsmodels, 6 other tools, mouse
[10] doi:10.1371/journal.pbio.3003684 [code]
The retrieval of previously learned motor memories is facilitated by the reinstatement of default mode network manifold structures.
Journal: PLoS biology
In common: pyRiemann, Pingouin, seaborn, 5 other tools

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.