The role of amygdala GABA neurons in controlling stress and reproduction in female mice.
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] § 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] § 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] § 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] § 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] § 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] § 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] § Methods › Modeling ↔ MePD_model/simulations/KNDyXMePDU.m, lines 2–61 · score 0.57 · model parameter, GABA efferent, couple, MePD, glutamate, KNDy
- [8] § Methods › Clustering analysis ↔ data_analysis/analysis_pipeline.ipynb, lines 1695–1714 · score 0.56 · linkage criterion, hierarchical clustering, agglomerative, activity
- [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] § Methods › Data analysis ↔ data_analysis/analysis_pipeline.ipynb, lines 495–514 · score 0.54 · power spectral density, GABA neurons, pre, max, signal, clustering
- [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] § 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] § 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] § Methods › Modeling ↔ MePD_model/simulations/combinedMePDv1.m, lines 43–73 · score 0.52 · GABA efferent neurons, GABA interneurons, opted, MePD, UCN3, model
- [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] § 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] § 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
- # %% [markdown]
- # # [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
- # %% [markdown]
- # # 🧰 Preparation Block for Analysis
- # %%
- # Check the current Python version for a stable connection
- !python --version
- # %% [markdown]
- # ### 📦 Install Required Packages
- # %%
- # Install the Shapely library for geometric operations
- !pip install shapely
- !pip install colabcode
- !pip install colorcet
- # Install scikit-posthocs for performing statistical post-hoc tests
- !pip install scikit_posthocs
- # Install statsmodels module for statistical modeling and hypothesis testing
- !python -m pip install statsmodels
- # %% [markdown]
- # ### 📥 Importing Required Packages
- # %%
- # OS interface for interacting with the file system
- import os
- # Core data manipulation and visualisation libraries
- import numpy as np # Numerical computing (arrays, math functions)
- import pandas as pd # Data structures & analysis (DataFrame, CSV I/O)
- import matplotlib.pyplot as plt # Plotting library for basic graphs
- import colorcet as cc # Colour maps for visualisation
- import seaborn as sns # Statistical data visualisation, builds on Matplotlib
- from pathlib import Path # Object-oriented filesystem paths
- # Advanced plotting tools from Matplotlib
- from matplotlib import gridspec # For custom subplot layouts
- from matplotlib.cm import ScalarMappable # For colour mapping in plots
- # Signal processing & statistical functions from SciPy
- from scipy.signal import correlate, periodogram # Signal similarity & frequency analysis
- from scipy.cluster.hierarchy import linkage, dendrogram # Hierarchical clustering & visual trees
- from scipy.spatial.distance import pdist, squareform # Pairwise distance computations
- from scipy.stats import mannwhitneyu, kstest, kruskal # Non-parametric tests
- from scipy.stats import wilcoxon # Wilcoxon signed-rank test
- from scipy.linalg import logm, sqrtm # Matrix operations for Riemannian geometry
- from scipy.stats import t
- # Machine learning tools from Scikit-learn
- from sklearn.preprocessing import scale, minmax_scale # Feature scaling methods
- from sklearn.cluster import KMeans, AgglomerativeClustering # Clustering algorithms
- from sklearn.metrics import silhouette_score # Clustering quality metric
- import statsmodels.formula.api as smf # Statistical modeling using formulas
- # Utilities
- from itertools import product # Cartesian product generator (combinatorics)
- # Shapes for visualisation
- from matplotlib.patches import Ellipse, Polygon # Custom shapes for plots
- from shapely.geometry import Polygon as ShapelyPolygon # Geometric operations
- # Post-hoc statistical tests
- from scikit_posthocs import posthoc_dunn # Dunn’s test for multiple comparisons after Kruskal-Wallis
- # Riemannian distance function
- from pyriemann.utils.distance import distance_riemann
- # ✅ Confirmation
- print("Imports done ✅!")
- # %% [markdown]
- # These two sections below are optional. They are only required for consistency of the style of the figures.
- # %%
- # Set the default appearance for Seaborn plots to enhance readability and aesthetics
- sns.set_theme(
- context='notebook', # Context presets size and scaling for notebook display
- style='whitegrid', # Background grid style for better visual separation
- palette='deep', # colour palette for plots (rich, vivid colours)
- font='DejaVu Sans', # Font for labels and titles
- font_scale=1.5, # Increase font size for better legibility
- rc={ # Override specific matplotlib parameters
- 'lines.linewidth': 2, # Thicker lines for better visibility
- 'lines.markersize': 8, # Larger markers for clarity
- 'xtick.labelsize': 15, # Font size for x-axis tick labels
- 'ytick.labelsize': 15 # Font size for y-axis tick labels
- }
- )
- # %%
- # Customise Matplotlib settings for consistent plot styling
- rc_params = {
- 'axes.grid': False, # Turn off background gridlines
- 'font.family': 'DejaVu Sans', # Set global font for plot text
- 'font.size': 15, # Base font size for all elements
- 'lines.linewidth': 2, # Make plot lines thicker and easier to read
- 'lines.markersize': 8, # Larger markers for better visibility
- 'xtick.major.size': 3, # Length of major ticks on x-axis
- 'ytick.major.size': 3, # Length of major ticks on y-axis
- 'xtick.labelsize': 15, # Font size for x-axis tick labels
- 'ytick.labelsize': 15, # Font size for y-axis tick labels
- 'xtick.bottom': True, # Show ticks on bottom of x-axis
- 'xtick.top': False, # Hide ticks on top of x-axis
- 'ytick.left': True, # Show ticks on left of y-axis
- 'ytick.right': False, # Hide ticks on right of y-axis
- 'axes.edgecolor': 'grey' # Use grey colour for plot borders
- }
- # Apply each setting to Matplotlib's runtime configuration
- for param, value in rc_params.items():
- plt.rcParams[param] = value
- # %% [markdown]
- # ### 🛠 Utility Functions for Your Analysis
- # %% [markdown]
- # 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.
- # %%
- def load_data(stim_csv_fname):
- """
- Load stimulation data from a CSV file and extract relevant cell trace information.
- Parameters:
- stim_csv_fname (str): Path to the CSV file containing stimulation data.
- Returns:
- tuple:
- - data_stim (pd.DataFrame): Entire transposed dataset including metadata.
- - cells_traces_tot_old (pd.DataFrame): Data subset excluding metadata (typically fluorescence traces).
- """
- # Load data from CSV and transpose it (rows become columns and vice versa)
- data_stim = pd.read_csv(stim_csv_fname).T
- # Strip leading/trailing whitespace from index labels to standardise them
- data_stim.index = data_stim.index.str.strip()
- # Extract cell trace data by skipping the first row and column (assumed to be metadata)
- cells_traces_tot_old = data_stim.iloc[1:, 1:]
- # Return both the full dataset and the cleaned subset for downstream use
- return data_stim, cells_traces_tot_old
- # %%
- def process_data(original_data, processed_data, stim_onset=1204, stim_offset=2404, stimulus_freq=0.1, kmeans_clusters=2, random_state=11111):
- """
- Process fluorescence stimulation data to identify and remove noisy/resonating cells
- using frequency-based filtering and K-means clustering.
- Parameters:
- original_data (pd.DataFrame): Raw stimulation data including metadata.
- processed_data (pd.DataFrame): Data with metadata removed (fluorescence traces).
- stim_onset (int): Index of stimulation onset.
- stim_offset (int): Index of stimulation offset.
- stimulus_freq (float): Expected stimulation frequency in Hz (default is 0.1 Hz).
- kmeans_clusters (int): Number of clusters used in K-means (default is 2).
- random_state (int): Seed for reproducible clustering (default is 11111).
- Returns:
- tuple:
- - data_stim_tot (np.ndarray): Cleaned fluorescence data with noisy cells removed.
- - n_cells (int): Number of remaining, reliable cells.
- """
- # Get number of cells (rows) from processed data
- total_cells_stim, _ = processed_data.shape
- # Extract time data from original_data and calculate sampling frequency
- ts_stim_tot = np.asarray(original_data.iloc[0, 1:], dtype=float)
- sec_per_frame = ts_stim_tot[1]
- fs = 1 / sec_per_frame # Sampling frequency in Hz
- # Convert processed data (traces) to NumPy array for efficient computation
- data_stim_artifact = np.asarray(processed_data, dtype=float)
- # Identify resonating cells via frequency analysis (Periodogram)
- labels_removed_PSD = []
- for i in range(total_cells_stim):
- freq, Pxx_den = periodogram(data_stim_artifact[i, stim_onset:stim_offset], fs)
- Pxx_den = Pxx_den / max(Pxx_den) # Normalise power
- freq_bin = round(stimulus_freq * (stim_offset - stim_onset) / fs)
- if Pxx_den[freq_bin] > 0.37:
- labels_removed_PSD.append(i) # Mark as resonating
- # Create binary mask of valid vs. resonating cells
- labels_artifact = np.array([0 if i in labels_removed_PSD else 1 for i in range(total_cells_stim)])
- # Apply K-means clustering to activity during stimulus period
- km = KMeans(n_clusters=kmeans_clusters, random_state=random_state, n_init=10, max_iter=100)
- clustered = scale(data_stim_artifact[:, stim_onset:stim_offset], axis=0)
- km.fit(clustered)
- labels_artifact_KM = km.labels_
- # Identify which cluster is smaller (assumed artifact) and remove it
- cluster_removed = 0 if np.sum(labels_artifact_KM == 0) < np.sum(labels_artifact_KM == 1) else 1
- for i in range(len(data_stim_artifact)):
- if labels_artifact[i] == 1 and labels_artifact_KM[i] == cluster_removed:
- labels_artifact[i] = 0 # Remove cell
- # Extract indices of removed cells and drop them
- labels_removed = np.where(labels_artifact == 0)[0]
- removed_row_labels = processed_data.index[labels_removed]
- cells_traces_stim_tot = processed_data.drop(removed_row_labels, axis=0, inplace=False)
- # Convert final cleaned DataFrame to NumPy array
- data_stim_tot = np.asarray(cells_traces_stim_tot, dtype=float)
- # Return cleaned data and count of remaining cells
- n_cells = len(cells_traces_stim_tot)
- return data_stim_tot, n_cells
- # %%
- def min_max_norm(data):
- # Convert input to NumPy array to ensure consistent operations
- data = np.asarray(data)
- # Compute per-trace (row-wise) minimum values
- min_vals = np.min(data, axis=1, keepdims=True)
- # Compute per-trace (row-wise) maximum values
- max_vals = np.max(data, axis=1, keepdims=True)
- # Scale each trace independently to the range [0, 1]
- return (data - min_vals) / (max_vals - min_vals)
- # %%
- # Convert p-value to custom significance symbols for visualisation
- def pval_to_star(pvalue):
- """
- Converts a p-value into a string representing significance levels using hash marks and daggers.
- Parameters:
- pvalue (float): The p-value to evaluate.
- Returns:
- str: A string indicating statistical significance:
- '##' → Highly significant (p ≤ 0.001)
- '#' → Significant (p ≤ 0.01)
- '†' → Marginally significant (p ≤ 0.05)
- 'ns' → Not significant
- """
- if pvalue <= 0.0001:
- return '##' # Extremely significant
- elif pvalue <= 0.001:
- return '##' # Highly significant
- elif pvalue <= 0.01:
- return '#' # Significant
- elif pvalue <= 0.05:
- return '†' # Marginally significant
- else:
- return 'ns' # Not significant
- # %%
- # Compute a metric based on signed lagged cross-correlation (SLxCorr)
- def signed_lagged_corr(x, y, demean=True):
- """
- Computes the signed lagged cross-correlation between two signals and returns the
- correlation value at the lag with the maximum absolute correlation, along with that lag.
- Parameters:
- x (numpy.ndarray): First input signal (1D).
- y (numpy.ndarray): Second input signal (1D).
- demean (bool): If True, subtract the mean from each signal before correlation.
- Returns:
- tuple:
- - float: Signed, normalised cross-correlation at the peak absolute correlation.
- - int: Lag (in samples) corresponding to that peak. Positive lag means y lags x.
- """
- # Ensure 1D arrays
- x = np.asarray(x).ravel()
- y = np.asarray(y).ravel()
- # Optionally remove DC offset so correlation focuses on shape, not level
- if demean:
- x = x - x.mean()
- y = y - y.mean()
- # Guard against zero-variance inputs
- norm = np.linalg.norm(x) * np.linalg.norm(y)
- if norm == 0:
- raise ValueError("Normalisation factor is zero. Ensure signals have non-zero variance.")
- # Full cross-correlation; zero lag is at index len(y) - 1 for correlate(x, y)
- cross_corr = correlate(x, y, mode='full')
- scc = cross_corr / norm # Normalised cross-correlation
- abs_scc = np.abs(scc)
- # Peak absolute correlation and its lag
- max_idx = int(np.argmax(abs_scc))
- lag = max_idx - (len(y) - 1) # Convert index to lag (centered at zero lag)
- signed_correlation = float(scc[max_idx]) # Keep the sign at the peak
- return signed_correlation, lag
- # %%
- import warnings
- warnings.filterwarnings("ignore", category=RuntimeWarning)
- # run the risky operations here
- # Generate mean and standard deviation of Riemannian distances for SPD (correlation) matrices of sizes p=5–80
- def metropolis_hastings(p, component_index, num_samples=2000, burn_in=200, proposal_std=0.01):
- """
- Generate random correlation matrix components using Metropolis–Hastings sampling on the unit sphere.
- Parameters:
- p (int): Dimension of the full correlation matrix.
- component_index (int): Index of the component being generated (1-based).
- num_samples (int): Number of samples to retain after burn-in.
- burn_in (int): Number of initial samples to discard to allow convergence.
- proposal_std (float): Standard deviation of the Gaussian proposal noise.
- Returns:
- numpy.ndarray: Array of shape (num_samples, dim) containing generated unit vectors.
- """
- dim = p - component_index + 1 # Effective dimension of the current component
- samples = np.zeros((burn_in + num_samples + 1, dim))
- # Initialise with a random unit vector; ensure first component is non-negative
- current = np.random.randn(1, dim)
- current[0, 0] = np.abs(current[0, 0])
- current = current / np.linalg.norm(current)
- # Run Metropolis–Hastings chain
- for t in range(burn_in + num_samples + 1):
- # Propose a new vector with small Gaussian perturbation
- proposal = current + np.random.normal(scale=proposal_std, size=(1, dim))
- proposal = proposal / np.linalg.norm(proposal) # Renormalise to unit length
- # Compute acceptance ratio (ensuring positivity of first component)
- delta = np.random.uniform(0, 1)
- acceptance_ratio = (proposal[0, 0] / current[0, 0])**component_index if proposal[0, 0] >= 0 else 0
- # Accept or reject proposal
- if delta < acceptance_ratio:
- current = proposal
- samples[t, :] = current.flatten()
- # Return only the post-burn-in samples
- return samples[burn_in + 1:, :]
- def random_correlation_matrix(p):
- """
- Construct a random correlation matrix of size p × p using Metropolis–Hastings sampling.
- Each row of the upper-triangular matrix U is sampled sequentially, and the
- final correlation matrix is obtained as C = U Uᵀ, normalised to have unit diagonal.
- Parameters:
- p (int): Dimension of the correlation matrix.
- Returns:
- numpy.ndarray: Random correlation matrix of size p × p.
- """
- U = np.zeros((p, p))
- for i in range(p):
- # For each row i, sample the corresponding unit vector and use the last draw
- U[i, i:] = metropolis_hastings(p, i + 1)[-1, :]
- # Compute symmetric product and normalise diagonal entries to 1
- C = U @ U.T
- D = np.sqrt(np.diag(C))
- correlation_matrix = C / np.outer(D, D)
- return correlation_matrix
- def distance_riemann(A, B):
- """
- Compute the affine-invariant Riemannian distance between two SPD matrices A and B.
- Formula:
- d(A, B) = || log( A^{-1/2} B A^{-1/2} ) ||_F
- Parameters:
- A (numpy.ndarray): First SPD matrix.
- B (numpy.ndarray): Second SPD matrix.
- Returns:
- float: The affine-invariant Riemannian distance between the two matrices.
- """
- sqrt_A = sqrtm(A)
- inv_sqrt_A = np.linalg.inv(sqrt_A)
- C = inv_sqrt_A @ B @ inv_sqrt_A
- log_C = logm(C)
- return np.linalg.norm(log_C, 'fro')
- def expected_distance(matrix_size, num_pairs=100):
- """
- Estimate the expected Riemannian distance (mean ± std) between pairs of
- random correlation matrices of a given size.
- Parameters:
- matrix_size (int): Dimension of the correlation matrices.
- num_pairs (int): Number of pairs of matrices to generate.
- Returns:
- tuple:
- - float: Mean of the estimated distances.
- - float: Standard deviation of the estimated distances.
- """
- distances = []
- for _ in range(num_pairs):
- A = random_correlation_matrix(matrix_size)
- B = random_correlation_matrix(matrix_size)
- d = distance_riemann(A, B)
- distances.append(d)
- return np.mean(distances), np.std(distances)
- # %%
- def compute_AUC(data_tot, stim_onset, stim_offset):
- """
- Compute the change in area under the curve (AUC) of ΔF/F signals before and during stimulation.
- Parameters:
- data_tot (numpy.ndarray): 2D array of calcium fluorescence signals
- (shape: n_cells × n_timepoints).
- stim_onset (int): Frame index marking the start of stimulation.
- stim_offset (int): Frame index marking the end of stimulation.
- Notes:
- - ΔF/F (delta F over F) is computed relative to the mean baseline fluorescence
- during the pre-stimulation period.
- - AUC is computed for several time windows (30, 60, 90, 120 seconds)
- within the stimulation period.
- - Sampling rate is assumed to be 10 Hz (hence `time_point * 10`).
- """
- AUC_time_windows = [30, 60, 90, stim_offset/10] # time windows (in seconds) for which AUC is computed
- # --- Pre-stimulation ΔF/F computation ---
- # Compute mean baseline for each cell during pre-stimulation period
- baseline_pre = np.mean(data_tot[:, :stim_onset], axis=1)
- # Compute ΔF/F trace for pre-stimulation
- dff_pre = (data_tot[:, :stim_onset] - baseline_pre[:, np.newaxis]) / baseline_pre[:, np.newaxis]
- # --- Stimulation ΔF/F computation ---
- # Compute mean baseline again (same as above, to ensure comparable scaling)
- baseline_stim = np.mean(data_tot[:, :stim_onset], axis=1)
- # Compute ΔF/F trace during stimulation
- dff_stim = (data_tot[:, stim_onset:stim_offset] - baseline_stim[:, np.newaxis]) / baseline_stim[:, np.newaxis]
- # --- Compute AUC differences for each time window ---
- for AUC_window in AUC_time_windows:
- # Compute average AUC for pre-stimulation (normalised by duration)
- area_1 = np.trapezoid(dff_pre) / (stim_onset / 10)
- # Compute average AUC during stimulation (up to specified duration)
- # Sampling rate assumed 10 Hz → convert seconds to frame index.
- area_2 = np.trapezoid(dff_stim[:, :AUC_window * 10]) / AUC_window
- # Output median difference in AUC between stimulation and pre-stimulation
- print(f'AUC for {AUC_window} s is {np.median(area_2) - np.median(area_1)}.')
- # %% [markdown]
- # ### 📁 Setting File Paths for Figures and Data
- # %%
- # --- Fig. 1: Stimulation and Stress Data ---
- stim_fig1_csv_fname = './data/calcium_imaging/UCN3_stimulation/G7 20240201 10hz 223sti.csv'
- stress_fig1_csv_fname = './data/calcium_imaging/restrain_stress/G28 333restraint 20240630.csv'
- # --- Fig. 2: Stimulation and Stress Data ---
- stim_fig2_csv_fname = './data/calcium_imaging/UCN3_stimulation/G28 20240709 10hz 223sti.csv'
- stress_fig2_csv_fname = './data/calcium_imaging/restrain_stress/G9 20231218 334restraint.csv'
- # --- Figs. 1 & 2: P-values and Cell Percentages ---
- hier_clustering_cell_perc_fname = './data/cell_percentage_hier_corr 10Hz.csv'
- if not Path(stim_fig1_csv_fname).is_file():
- print(f"File {stim_fig1_csv_fname} does not exist")
- if not Path(stress_fig1_csv_fname).is_file():
- print(f"File {stim_fig1_csv_fname} does not exist")
- if not Path(stim_fig2_csv_fname).is_file():
- print(f"File {stim_fig1_csv_fname} does not exist")
- if not Path(stress_fig2_csv_fname).is_file():
- print(f"File {stim_fig1_csv_fname} does not exist")
- if not Path(hier_clustering_cell_perc_fname).is_file():
- print(f"File {hier_clustering_cell_perc_fname} does not exist")
- # %%
- # --- Time Windows (in index units) ---
- stim_onset = 1204
- stim_offset = 2404
- stress_onset = 1804
- stress_offset = 3604
- # --- Signal Analysis ---
- PSD_thr = 0.37 # Power Spectral Density threshold
- # --- Clustering & Bootstrapping ---
- max_no_clusters = 4
- n_resampling = 100
- Ks_1 = 2 # Pre-stimulation clusters
- Ks_2 = 2 # Stimulation clusters
- # --- GABA Neuron colours ---
- eff_green = (63/255, 128/255, 0) # GABA efferent
- inter_green = (0, 1, 0) # GABA interneuron
- GABA_greens = [eff_green, inter_green]
- # %% [markdown]
- # # 🧪 Full Workflow: G7 Stimulation (Figures 1 & S1)
- # %% [markdown]
- # This is an example stimulation trial for figure 1 from animal G7.
- # %%
- # Load and transpose the data
- data_fig1_stim = pd.read_csv(stim_fig1_csv_fname).T
- # Clean up row labels
- data_fig1_stim.index = data_fig1_stim.index.str.strip()
- # Preview the last 5 cells
- data_fig1_stim.tail(5)
- # %% [markdown]
- # ## ✂️ Data Slicing for G7 Stimulation
- # %%
- # Extract signal data (excluding metadata)
- cells_traces_tot_old = data_fig1_stim.iloc[1:, 1:]
- # Get shape info
- total_cells_fig1_stim, data_fig1_stim_length_tot = cells_traces_tot_old.shape
- # Preview bottom rows
- cells_traces_tot_old.tail()
- # %%
- # Extract time axis from first row (excluding first column)
- ts_fig1_stim_tot = np.asarray(data_fig1_stim.iloc[0, 1:], dtype=float)
- # Time between frames (first timestamp is 0)
- sec_per_frame = ts_fig1_stim_tot[1]
- # Sampling frequency (Hz)
- fs = 1 / sec_per_frame
- # %%
- # Convert fluorescence traces to NumPy array
- data_fig1_stim_artifact = np.asarray(cells_traces_tot_old, dtype=float)
- # %% [markdown]
- # ## 🔍 Finding UCN3 Cells
- # %% [markdown]
- # ### 🌀 1. Power Spectral Density (PSD) Filtering
- # %%
- # Define the stimulus frequency and initialise a list to store labels of removed cells based on PSD threshold
- stimulus_freq = 0.1 # Stimulus frequency in Hz
- labels_removed_PSD = [] # List to store indices of cells flagged as responsive
- # Loop through each cell to calculate the power spectral density (PSD)
- for i in range(total_cells_fig1_stim):
- # Compute the periodogram (PSD) for the cell's signal during the stimulation period
- freq, Pxx_den = periodogram(
- data_fig1_stim_artifact[i, stim_onset:stim_offset], fs)
- # Normalise the PSD by its maximum value to scale between 0 and 1
- Pxx_den = Pxx_den / max(Pxx_den)
- # Calculate the index corresponding to the stimulus frequency
- # This assumes uniform frequency spacing in the PSD output
- stimulus_freq_idx = round(stimulus_freq * (stim_offset - stim_onset) / fs)
- # Check if the normalised PSD at the stimulus frequency exceeds the threshold
- if Pxx_den[stimulus_freq_idx] > PSD_thr:
- # If so, mark the cell as responsive by adding its index to the list
- labels_removed_PSD.append(i)
- # Print the indices of cells identified as responsive based on PSD threshold
- print("Indices of cells removed based on PSD threshold:", labels_removed_PSD)
- # %%
- # Create binary mask to flag artifact-affected cells
- # - 0: cell identified as artifact (high PSD at stimulus frequency)
- # - 1: cell retained for further analysis
- labels_artifact = np.asarray(
- [0 if _ in labels_removed_PSD else 1 for _ in range(total_cells_fig1_stim)]
- )
- # Optional: Print the binary mask to verify which cells were flagged
- # 0 = artifact (failed PSD threshold), 1 = valid (passed PSD threshold)
- print(labels_artifact)
- # %% [markdown]
- # ### 🔢 2. K-Means Clustering
- # %%
- # Apply scikit-learn K-means clustering to sort activity traces
- # Step 1: Normalise the activity data across time (columns) to ensure fair clustering
- scaled_data = scale(data_fig1_stim_artifact[:, stim_onset:stim_offset], axis=0)
- # Step 2: Set the number of clusters (2 groups: responsive vs non-responsive)
- Ks = 2
- # Step 3: Initialise and run K-means clustering
- # - random_state ensures reproducibility
- # - n_init = 10 means the algorithm runs 10 times with different centroid seeds
- # - max_iter = 100 limits the number of iterations per run
- kmeans = KMeans(n_clusters=Ks, random_state=11111, n_init=10, max_iter=100)
- # Step 4: Fit the model to the scaled data and assign cluster labels to each cell
- labels_artifact_KM = kmeans.fit_predict(scaled_data)
- # Step 5: Sort cell indices based on their assigned cluster labels
- # This helps organise or visualise cells by their activity patterns
- i_labels_artifact = np.argsort(labels_artifact_KM)
- # Step 6: Display the sorted indices for inspection or downstream analysis
- print(f"Sorted indices of cells by cluster ID:\n{i_labels_artifact}")
- # %%
- # Determine which cluster to remove based on relative size
- # Step 1: Count the number of cells in each cluster
- size_cluster_0 = len(data_fig1_stim_artifact[labels_artifact_KM == 0])
- size_cluster_1 = len(data_fig1_stim_artifact[labels_artifact_KM == 1])
- # Step 2: Compare cluster sizes to identify the smaller one
- # The assumption here is that the smaller cluster likely represents artifacts or noise
- if size_cluster_0 < size_cluster_1:
- cluster_removed = 0 # Smaller cluster is flagged for removal
- cluster_kept = 1 # Larger cluster is retained
- print('Cluster #0 (smaller) is removed.')
- elif size_cluster_1 != 0:
- cluster_removed = 1
- cluster_kept = 0
- print('Cluster #1 (smaller) is removed.')
- else:
- # Edge case: one cluster has zero members, so no valid removal decision
- print("No valid cluster for removal.")
- # %%
- # Update artifact labels based on K-means clustering results
- # Step 1: Check if the cluster marked for removal contains any cells
- if len(data_fig1_stim_artifact[labels_artifact_KM == cluster_removed]) != 0:
- # Step 2: Iterate through all cells
- for i in range(len(data_fig1_stim_artifact)):
- # Only update cells that were previously marked as valid (1)
- # and now belong to the cluster flagged for removal
- if labels_artifact[i] == 1 and labels_artifact_KM[i] == cluster_removed:
- labels_artifact[i] = 0 # Mark as artifact
- # Step 3: Explicitly define which cluster was removed and which was kept
- cluster_removed = 0
- cluster_kept = 1
- # Step 4: Extract indices of cells now marked as artifacts
- labels_removed = np.where(labels_artifact == cluster_removed)[0]
- # Optional: print summary for verification
- print(f"Number of (UCN) cells removed: {len(labels_removed)}")
- print(f"Indices of removed cells: {labels_removed}")
- # %%
- # Remove resonated (artifact) cells from fluorescence intensity traces
- # Step 1: Get the row labels (e.g., cell IDs) corresponding to removed indices
- removed_row_labels = cells_traces_tot_old.index[labels_removed]
- # Step 2: Drop the identified artifact cells from the original DataFrame
- # - axis=0 means rows are being removed
- # - inplace=False ensures the original DataFrame remains unchanged
- cells_traces_fig1_stim_tot = cells_traces_tot_old.drop(
- removed_row_labels, axis=0, inplace=False)
- # Step 3: Count the number of remaining (valid) cells after removal
- n_cells_fig1_stim = len(cells_traces_fig1_stim_tot)
- # Optional: print summary
- print(f"Number of GABA cells: {n_cells_fig1_stim}")
- # %% [markdown]
- # ### ✂️ Data Slicing
- # %%
- # Convert filtered fluorescence traces from DataFrame to NumPy array
- data_fig1_stim_tot = cells_traces_fig1_stim_tot.to_numpy(dtype=float)
- # Slice the data into temporal segments for analysis
- # Pre-stimulation period: from start up to stimulus onset
- data_fig1_stim_1 = data_fig1_stim_tot[:, :stim_onset]
- # Stimulation period: from stimulus onset to stimulus offset
- data_fig1_stim_2 = data_fig1_stim_tot[:, stim_onset:stim_offset]
- # Optional: Print shapes to verify correct slicing
- print(f"Total data shape: {data_fig1_stim_tot.shape}")
- print(f"Pre-stimulation data shape: {data_fig1_stim_1.shape}")
- print(f"Stimulation data shape: {data_fig1_stim_2.shape}")
- # %%
- # Rescale pre-stimulation traces using min-max normalisation (row-wise)
- normalised_data_fig1_stim_1 = minmax_scale(data_fig1_stim_1, axis=1)
- # Optional: Inspect the first few normalised traces to verify the transformation
- print("Normalised Pre-stimulation Data (first few rows):")
- print(normalised_data_fig1_stim_1[:5])
- # %%
- # Rescale stimulation-period traces using min-max normalisation (row-wise)
- normalised_data_fig1_stim_2 = minmax_scale(data_fig1_stim_2, axis=1)
- # Optional: Inspect the first few normalised stimulation traces
- print("Normalised Stimulation Data (first few rows):")
- print(normalised_data_fig1_stim_2[:5])
- # %% [markdown]
- # ### 📈 corrcoef: Standard Pearson Correlation Coefficient
- # %%
- # Compute the correlation matrix for pre-stimulation traces
- max_corr_fig1_stim_1 = np.corrcoef(normalised_data_fig1_stim_1, rowvar=True)
- # Compute the correlation matrix for stimulation-period traces
- max_corr_fig1_stim_2 = np.corrcoef(normalised_data_fig1_stim_2, rowvar=True)
- # %% [markdown]
- # ## 🖼️ Figure S2: Visualisation of Trace Filtering and Clustering
- # %%
- # Set up the figure canvas with 4 vertical subplots
- plt.figure(figsize=(15, 14))
- # ─────────────────────────────────────────────────────────────
- # 🔹 Plot 1: Original Traces (before any filtering)
- ax1 = plt.subplot(4, 1, 1)
- # Normalise raw traces for visualisation
- scaled_data = minmax_scale(data_fig1_stim_artifact[:, :stim_offset], axis=1)
- # Display as heatmap
- plt.imshow(scaled_data, cmap="viridis", interpolation='none')
- # Mark stimulus onset with a vertical white line
- ax1.plot([1204, 1204], [0, total_cells_fig1_stim-1], '-w')
- # Configure plot aesthetics
- ax1.set_aspect('auto')
- ax1.set_xlabel('Time [sec]')
- ax1.set_ylabel('Cell ID')
- ax1.invert_yaxis()
- ax1.set_title('Original Traces')
- ax1.set_xticks([0, 500, 1000, 1500, 2000])
- ax1.set_xticklabels([0, 50, 100, 150, 200])
- # Add colourbar for ΔF/F₀ intensity
- cmap = plt.get_cmap("viridis")
- sm = ScalarMappable(cmap=cmap)
- sm.set_clim(vmin=0, vmax=1)
- cb = plt.colorbar(cmap=cmap, fraction=0.04, pad=0.03)
- cb.set_label('$\Delta$ F$/$F$_0$ (scaled)')
- cb.set_ticks([0, 0.5, 1])
- # ─────────────────────────────────────────────────────────────
- # 🔹 Plot 2: Power Spectral Density (PSD) at 0.1 Hz
- ax2 = plt.subplot(4, 1, 2)
- # Loop through each cell to compute and plot PSD
- for i in range(total_cells_fig1_stim):
- freq, Pxx_den = periodogram(data_fig1_stim_artifact[i, stim_onset:stim_offset], fs)
- Pxx_den = Pxx_den / max(Pxx_den) # Normalise PSD
- stimulus_freq_PSD = Pxx_den[round(stimulus_freq * (stim_offset - stim_onset) / fs)]
- # Mark cells based on PSD threshold
- if stimulus_freq_PSD < PSD_thr:
- labels_removed_PSD.append(i)
- ax2.scatter(i, stimulus_freq_PSD, marker='.', s=300, c='black') # Below threshold
- else:
- ax2.scatter(i, stimulus_freq_PSD, marker='.', s=300, c='red') # Above threshold
- # Add horizontal threshold line
- ax2.plot([-1, total_cells_fig1_stim], [0.37, 0.37], '--', c='gray')
- ax2.set_xlim([-1, total_cells_fig1_stim])
- ax2.set_ylim([-0.05, 1.1])
- ax2.set_xlabel('Cell ID')
- ax2.set_ylabel('PSD')
- ax2.set_title('Power Spectral Density (PSD) at 0.1 Hz')
- ax2.annotate(0.37, (10, 0.4), ha='center')
- # ─────────────────────────────────────────────────────────────
- # 🔹 Plot 3: Traces Sorted by K-means Clustering
- ax3 = plt.subplot(4, 1, 3)
- # Display sorted traces based on clustering labels
- plt.imshow(minmax_scale(data_fig1_stim_artifact[i_labels_artifact, :stim_offset], axis=1), cmap="viridis", interpolation='none')
- # Mark stimulus onset
- ax3.plot([1204, 1204], [0, total_cells_fig1_stim-1], '-w')
- # Configure plot aesthetics
- ax3.set_aspect('auto')
- ax3.set_xlabel('Time [sec]')
- ax3.set_ylabel('Cell ID')
- ax3.invert_yaxis()
- ax3.set_title('K-means Sorted Traces')
- ax3.set_xticks([0, 500, 1000, 1500, 2000])
- ax3.set_xticklabels([0, 50, 100, 150, 200])
- # Add colourbar
- cmap = plt.get_cmap("viridis")
- sm = ScalarMappable(cmap=cmap)
- sm.set_clim(vmin=0, vmax=1)
- cb = plt.colorbar(cmap=cmap, fraction=0.04, pad=0.03)
- cb.set_label('$\Delta$ F$/$F$_0$ (scaled)')
- cb.set_ticks([0, 0.5, 1])
- # ─────────────────────────────────────────────────────────────
- # 🔹 Plot 4: Purely GABA Traces (after filtering)
- ax4 = plt.subplot(4, 1, 4)
- # Display final cleaned traces
- plt.imshow(minmax_scale(data_fig1_stim_tot[:, :stim_offset], axis=1), cmap="viridis", interpolation='none')
- # Mark stimulus onset
- ax4.plot([1204, 1204], [0, n_cells_fig1_stim-1], '-w')
- # Configure plot aesthetics
- ax4.set_aspect('auto')
- ax4.set_xlabel('Time [sec]')
- ax4.set_ylabel('Cell ID')
- ax4.invert_yaxis()
- ax4.set_title('Purely GABA Traces')
- ax4.set_xticks([0, 500, 1000, 1500, 2000])
- ax4.set_xticklabels([0, 50, 100, 150, 200])
- # Add colourbar
- cmap = plt.get_cmap("viridis")
- sm = ScalarMappable(cmap=cmap)
- sm.set_clim(vmin=0, vmax=1)
- cb = plt.colorbar(cmap=cmap, fraction=0.04, pad=0.03)
- cb.set_label('$\Delta$ F$/$F$_0$ (scaled)')
- cb.set_ticks([0, 0.5, 1])
- # ─────────────────────────────────────────────────────────────
- # Final layout adjustments and export
- plt.tight_layout()
- # Optional: Save figure to disk
- # plt.savefig("FigS1_revised.svg", dpi=600)
- # Display the figure
- plt.show()
- # %%
- # Visualise a few example stimulus traces after normalisation
- X = minmax_scale(data_fig1_stim_artifact[:, :stim_offset], axis=1)
- plt.figure(figsize=(10, 6))
- for i in range(3, 5): # plot a small subset of neurons
- plt.plot(X[i, :], linewidth=1)
- plt.xlim(0, stim_offset)
- plt.xlabel("Time")
- plt.ylabel("Normalised activity")
- plt.title("Stimulus traces (neurons 3–7)")
- plt.show()
- # %% [markdown]
- # # 🧪 Full Workflow: G28 Stress (Figure 1)
- # %% [markdown]
- # This is a example restraint stress trial for figure 1 from animal G28.
- # %%
- # Load and transpose the data
- data_fig1_stress = pd.read_csv(stress_fig1_csv_fname).T
- # Clean up row labels
- data_fig1_stress.index = data_fig1_stress.index.str.strip()
- # Preview the last 6 cells
- data_fig1_stress.tail(5) # You can change to .tail(2) for a quicker peek
- # %% [markdown]
- # ### ✂️ Data Slicing for G28 Stress
- # %%
- # Extract signal data (excluding metadata)
- cells_traces_fig1_stress_tot = data_fig1_stress.iloc[1:, 1:]
- # Get shape info
- n_cells_fig1_stress, _ = cells_traces_fig1_stress_tot.shape
- # Preview bottom rows
- cells_traces_fig1_stress_tot.tail()
- # %%
- # Convert DataFrame to NumPy array for numerical processing
- data_fig1_stress_tot = cells_traces_fig1_stress_tot.values.astype(float)
- # Slice the data before stimulation onset
- data_fig1_stress_1 = data_fig1_stress_tot[:, :stress_onset]
- # Slice data during stimulation period
- data_fig1_stress_2 = data_fig1_stress_tot[:, stress_onset:stress_offset]
- # %%
- # Rescale pre-stimulation traces using min-max normalisation (row-wise)
- normalised_data_fig1_stress_1 = minmax_scale(data_fig1_stress_1, axis=1)
- # Rescale stimulation-period traces using min-max normalisation (row-wise)
- normalised_data_fig1_stress_2 = minmax_scale(data_fig1_stress_2, axis=1)
- # %% [markdown]
- # ### 📈 corrcoef: Standard Pearson Correlation Coefficient
- # %%
- # Compute the correlation matrix for pre-stimulation traces
- max_corr_fig1_stress_1 = np.corrcoef(normalised_data_fig1_stress_1, rowvar=True)
- # Compute the correlation matrix for stimulation-period traces
- max_corr_fig1_stress_2 = np.corrcoef(normalised_data_fig1_stress_2, rowvar=True)
- # %% [markdown]
- # # 🖼️ Figure 1
- # %%
- # Riemannian distances between empirical correlation matrices and a null model
- # - Each value reflects how far a real correlation matrix is from a distribution of random matrices
- # - Random matrices generated via Metropolis-Hastings sampling
- # - Distances are normalised by the mean pairwise distance among sampled matrices
- # Control condition: 12 samples across 4 animals (G33–G36)
- 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]
- control_animal = ['G33', 'G33', 'G33', 'G34', 'G34', 'G34', 'G35', 'G35', 'G35', 'G36', 'G36', 'G36']
- # Stimulated condition: 20 samples across 6 animals (G7–G9, G28–G29, G37)
- stim_dist = [0.8543, 0.8024, 0.8918, 0.7506, 0.8326, 0.8286, 0.7508, 0.7120, 0.8152, 0.7978,
- 0.5823, 0.7944, 0.6782, 0.4742, 0.3286, 0.4824, 0.7652, 0.7087, 0.6085, 0.6523]
- stim_animal = ['G7', 'G7', 'G8', 'G8', 'G8', 'G8', 'G9', 'G9', 'G9', 'G9',
- 'G28', 'G28','G28', 'G29', 'G29', 'G29', 'G37', 'G37', 'G37', 'G37']
- # Stress condition: 7 samples across 6 animals (G7–G9, G28–G29, G37)
- # - Note: G37 contributes two samples from distinct sessions
- stress_dist = [1.001113156, 1.002419683, 0.969890523, 0.7640226, 0.790428686, 0.885770891, 0.810616632]
- stress_animal = ['G7', 'G8', 'G9', 'G28', 'G29', 'G37', 'G37']
- # %%
- # AUC values represent integrated response over time windows
- # - Each list contains per-sample AUCs for a given condition and time window
- # - Negative values may reflect suppression or baseline drift
- # - Stress values show large positive outliers, suggesting high activation
- # ⏱ 30-second window
- AUC_stim_30 = [-3.2941, -0.8233, 1.4743, -1.9491, 2.5396, 1.0696, -0.6789, -0.4772, -0.6625, -2.1106,
- -2.6996, 10.156, 1.2475, 4.7087, -1.5431, 3.906, 1.4121, -2.2647, -1.1277, -3.9984]
- AUC_stress_30 = [3.9668, 4.511, 2.5152, 60.6365, 23.0652, 22.4978, 41.8574]
- # ⏱ 60-second window
- AUC_stim_60 = [-3.0155, -1.267, 1.6671, -2.3267, 0.952, -0.4225, -2.2116, 1.5772, -0.1854, -1.7976,
- -1.8799, 14.8400, -2.0294, 4.1857, -0.9583, 1.1032, 0.0982, -2.7538, -0.5514, -3.5729]
- AUC_stress_60 = [4.7454, 2.3182, 0.4937, 50.6340, 21.3518, 26.5827, 51.8101]
- # ⏱ 90-second window
- AUC_stim_90 = [-1.2230, -1.1646, 1.5550, -1.1195, 1.0739, -0.7815, -2.6634, 1.4759, -0.1702, -3.1705,
- -0.7590, 13.0363, -2.0926, 3.4858, 1.1061, 2.0930, -2.0750, -4.4860, -1.3451, -3.1809]
- AUC_stress_90 = [4.2257, 1.6342, -1.6458, 39.6093, 17.7396, 24.2487, 50.9404]
- # ⏱ 120-second window
- AUC_stim_120 = [-0.6409, -0.7567, 0.7624, -0.5384, 0.7329, -0.8672, 0.0113, 1.1853, 0.6831, -0.7784,
- -0.8053, 5.1225, -0.5467, 1.7969, -0.0616, 0.3441, -1.2354, -2.4234, -0.7771, -1.499]
- AUC_stress_120 = [1.0498, -1.5431, -0.7729, 17.6906, 11.2251, 6.8057, 20.8275]
- # %%
- # --- Helper function to build sub-DataFrame ---
- def build_df(animal_list, values, condition):
- df = pd.DataFrame({
- 'Animal_id': animal_list,
- 'y': values
- })
- # Add condition and compute repeat count per animal
- df['condition'] = condition
- df['repeat'] = df.groupby('Animal_id').cumcount() + 1
- return df[['Animal_id', 'condition', 'repeat', 'y']]
- # --- Combine all conditions ---
- df_control = build_df(control_animal, control_dist, 'control')
- df_stim = build_df(stim_animal, stim_dist, 'stim')
- df_stress = build_df(stress_animal, stress_dist, 'stress')
- # Merge into one DataFrame
- df = pd.concat([df_control,df_stim, df_stress], ignore_index=True)
- # Sort for readability
- df = df.sort_values(['condition', 'Animal_id', 'repeat']).reset_index(drop=True)
- print(df)
- # %%
- # Nested repeats within animal:
- # y ~ condition + (1 | Animal_id)
- m_nested = smf.mixedlm(
- "y ~ C(condition, Treatment(reference='control'))",
- df,
- groups=df["Animal_id"] # (1 | Animal_id)
- ).fit(reml=True, method="lbfgs", maxiter=600, disp=False)
- print("\n=== Nested model (animal + animal:repeat) ===")
- print(m_nested.summary())
- pvalues_variable = m_nested.pvalues
- # %%
- # Extract p-values for Stim and Stress vs Control
- p_control_Stim = m_nested.pvalues.get("C(condition, Treatment(reference='control'))[T.stim]", None)
- p_control_Stress = m_nested.pvalues.get("C(condition, Treatment(reference='control'))[T.stim]", None)
- print(p_control_Stim, p_control_Stress)
- # %%
- # Compute p-value for stim vs stress using t-test
- # Extract coefficients and standard errors
- params = m_nested.params
- bse = m_nested.bse
- # Coefficients for stim and stress
- b_stim = params["C(condition, Treatment(reference='control'))[T.stim]"]
- b_stress = params["C(condition, Treatment(reference='control'))[T.stress]"]
- # Calculate the difference in coefficients for stress and stim conditions
- diff = b_stim - b_stress
- # Retrieve the covariance matrix of the fixed effects, which contains the variances and covariances of the estimated coefficients.
- cov_matrix = m_nested.cov_params()
- # Standard error of the difference
- se_diff = np.sqrt(cov_matrix.loc["C(condition, Treatment(reference='control'))[T.stim]", "C(condition, Treatment(reference='control'))[T.stim]"] +
- cov_matrix.loc["C(condition, Treatment(reference='control'))[T.stress]", "C(condition, Treatment(reference='control'))[T.stress]"] -
- 2 * cov_matrix.loc["C(condition, Treatment(reference='control'))[T.stim]", "C(condition, Treatment(reference='control'))[T.stress]"])
- # t-statistic
- t_stat = diff / se_diff
- # Degrees of freedom (approximation)
- df = m_nested.df_resid
- # p-value (two-tailed)
- p_Stress_Stim = 2 * t.sf(np.abs(t_stat), df)
- print(p_Stress_Stim)
- # %%
- # Diverging colour palette (brown → teal), normalised to [0, 1] for plotting
- mycolors = np.array([
- [140, 81, 10],
- [216, 179, 101],
- [246, 232, 195],
- [199, 234, 229],
- [ 90, 180, 172],
- [ 1, 102, 94]
- ]) / 255.0
- # Reduced colour palette for control conditions
- colors_ctrl = np.array([
- [166, 97, 26], # #a6611a
- [223, 194, 125], # #dfc27d
- [128, 205, 193], # #80cdc1
- [ 1, 133, 113] # #018571
- ]) / 255.0
- # %%
- # Create a figure with specific dimensions
- fig = plt.figure(figsize=(15, 9), dpi=600)
- # Set up the grid layout for subplots
- gs = gridspec.GridSpec(3, 8, figure=fig)
- # 🔹 Plot 1: Stimulus Data (Baseline)
- ax = fig.add_subplot(gs[0:4])
- # Normalise stimulus data: z-score across rows (cells), then min-max across columns (time)
- stim_data = minmax_scale(data_fig1_stim_tot[:, :stim_offset], axis=1)
- plt.imshow(stim_data, cmap="viridis", interpolation='none')
- # Set axis limits and ticks for clarity
- ax.set_xlim(0, stim_offset)
- ax.set_ylim(0, n_cells_fig1_stim-1)
- ax.set_xticks(np.arange(0, 2500, 500))
- ax.set_xticklabels(np.arange(0, 250, 50))
- ax.set_yticks([_ for _ in list(range(n_cells_fig1_stim)) if _ % 10 == 0])
- ax.set_yticklabels([_ for _ in list(range(n_cells_fig1_stim)) if _ % 10 == 0])
- ax.set_ylabel('cell ID')
- ax.set_xlabel('time [sec]')
- ax.set_aspect('auto')
- # Add colourbar for stimulus data
- cmap = plt.get_cmap("viridis")
- sm = ScalarMappable(cmap=cmap)
- sm.set_clim(vmin=0, vmax=1)
- cb = plt.colorbar(cmap=cmap, fraction=0.04, pad=0.03)
- cb.set_label('$\Delta$ F$/$F$_0$ (scaled)')
- # 🔹 Plot 2: Stress Data (Stress Onset)
- ax = fig.add_subplot(gs[4:8])
- # Normalise stress data: z-score across rows, then min-max across columns
- stress_data = minmax_scale(data_fig1_stress_tot[:, :stress_offset], axis=1)
- plt.imshow(stress_data, cmap="viridis", interpolation='none')
- # Set axis limits and ticks for clarity
- ax.set_xlim(0, stress_offset)
- ax.set_ylim(0, n_cells_fig1_stress-1)
- ax.set_xticks(np.arange(0, 4000, 500))
- ax.set_xticklabels(np.arange(0, 400, 50))
- ax.set_yticks([_ for _ in list(range(n_cells_fig1_stress)) if _ % 10 == 0])
- ax.set_yticklabels([_ for _ in list(range(n_cells_fig1_stress)) if _ % 10 == 0])
- ax.set_ylabel('cell ID')
- ax.set_xlabel('time [sec]')
- ax.set_aspect('auto')
- # Add colourbar for stress data
- cmap = plt.get_cmap("viridis")
- sm = ScalarMappable(cmap=cmap)
- sm.set_clim(vmin=0, vmax=1)
- cb = plt.colorbar(cmap=cmap, fraction=0.04, pad=0.03)
- cb.set_label('$\Delta$ F$/$F$_0$ (scaled)')
- # 🔹 Plot 3: Heatmap for Max Correlation (Baseline)
- ax = fig.add_subplot(gs[8:10])
- # Heatmap of pairwise correlations during baseline (stim)
- sns.heatmap(max_corr_fig1_stim_1, cbar_kws={'label': 'Corr'}, cmap="viridis", vmin=-1, vmax=1)
- ax.set_title('Baseline')
- ax.set(xlabel='cell ID', ylabel='cell ID')
- ax.set_xticks([_ for _ in list(range(n_cells_fig1_stress)) if _ % 10 == 0])
- ax.set_xticklabels([_ for _ in list(range(n_cells_fig1_stress)) if _ % 10 == 0])
- ax.set_yticks([_ for _ in list(range(n_cells_fig1_stress)) if _ % 10 == 0])
- ax.set_yticklabels([_ for _ in list(range(n_cells_fig1_stress)) if _ % 10 == 0])
- ax.invert_yaxis()
- # Add outline around the whole plot
- for spine in ax.spines.values():
- spine.set_visible(True)
- spine.set_linewidth(1.5)
- spine.set_edgecolor('gray')
- # Add grey outline around the colourbar
- cbar = ax.collections[0].colorbar
- cbar.outline.set_visible(True)
- cbar.outline.set_edgecolor('grey')
- cbar.outline.set_linewidth(1.0)
- # 🔹 Plot 4: Heatmap for Max Correlation (Stimulation)
- ax = fig.add_subplot(gs[10:12])
- # Heatmap of pairwise correlations during stimulation
- sns.heatmap(max_corr_fig1_stim_2, cbar_kws={'label': 'Corr'}, cmap="viridis", vmin=-1, vmax=1)
- ax.set_title('Stimulation')
- ax.set(xlabel='cell ID')
- ax.set_xticks([_ for _ in list(range(n_cells_fig1_stress)) if _ % 10 == 0])
- ax.set_xticklabels([_ for _ in list(range(n_cells_fig1_stress)) if _ % 10 == 0])
- ax.set_yticks([_ for _ in list(range(n_cells_fig1_stress)) if _ % 10 == 0])
- ax.set_yticklabels([_ for _ in list(range(n_cells_fig1_stress)) if _ % 10 == 0])
- ax.invert_yaxis()
- # Add outline around the whole plot
- for spine in ax.spines.values():
- spine.set_visible(True)
- spine.set_linewidth(1.5)
- spine.set_edgecolor('gray')
- # Add grey outline around the colourbar
- cbar = ax.collections[0].colorbar
- cbar.outline.set_visible(True)
- cbar.outline.set_edgecolor('grey')
- cbar.outline.set_linewidth(1.0)
- # 🔹 Plot 5: Heatmap for Max Correlation (Stress Baseline)
- ax = fig.add_subplot(gs[12:14])
- # Heatmap of pairwise correlations during stress baseline
- sns.heatmap(max_corr_fig1_stress_1, cbar_kws={'label': 'Corr'}, cmap="viridis", vmin=-1, vmax=1)
- ax.set_title('Baseline')
- ax.set(xlabel='cell ID', ylabel='cell ID')
- ax.set_xticks([_ for _ in list(range(n_cells_fig1_stress)) if _ % 10 == 0])
- ax.set_xticklabels([_ for _ in list(range(n_cells_fig1_stress)) if _ % 10 == 0])
- ax.set_yticks([_ for _ in list(range(n_cells_fig1_stress)) if _ % 10 == 0])
- ax.set_yticklabels([_ for _ in list(range(n_cells_fig1_stress)) if _ % 10 == 0])
- ax.invert_yaxis()
- # Add outline around the whole plot
- for spine in ax.spines.values():
- spine.set_visible(True)
- spine.set_linewidth(1.5)
- spine.set_edgecolor('gray')
- # Add grey outline around the colourbar
- cbar = ax.collections[0].colorbar
- cbar.outline.set_visible(True)
- cbar.outline.set_edgecolor('grey')
- cbar.outline.set_linewidth(1.0)
- # 🔹 Plot 6: Heatmap for Max Correlation (Stress)
- ax = fig.add_subplot(gs[14:16])
- # Heatmap of pairwise correlations during stress period
- sns.heatmap(max_corr_fig1_stress_2, cbar_kws={'label': 'Corr'}, cmap="viridis", vmin=-1, vmax=1)
- ax.set_title('Stress')
- ax.set(xlabel='cell ID')
- ax.set_xticks([_ for _ in list(range(n_cells_fig1_stress)) if _ % 10 == 0])
- ax.set_xticklabels([_ for _ in list(range(n_cells_fig1_stress)) if _ % 10 == 0])
- ax.set_yticks([_ for _ in list(range(n_cells_fig1_stress)) if _ % 10 == 0])
- ax.set_yticklabels([_ for _ in list(range(n_cells_fig1_stress)) if _ % 10 == 0])
- ax.invert_yaxis()
- # Add outline around the whole plot
- for spine in ax.spines.values():
- spine.set_visible(True)
- spine.set_linewidth(1.5)
- spine.set_edgecolor('gray')
- # Add grey outline around the colourbar
- cbar = ax.collections[0].colorbar
- cbar.outline.set_visible(True)
- cbar.outline.set_edgecolor('grey')
- cbar.outline.set_linewidth(1.0)
- # 🔹 Plot 7: Swarm plot for stimulation p-values
- colour_list = ['blue', 'red', 'green', 'orange', 'purple', 'brown']
- ax = fig.add_subplot(gs[16:19])
- # Violin + swarm plot for control condition
- cc1 = control_dist
- control_animals = pd.unique(control_animal)
- control_palette = dict(zip(
- control_animals,
- colors_ctrl[:len(control_animals)]
- ))
- df = pd.DataFrame(dict(x=np.repeat([0], len(cc1)), y=cc1, c=control_animal))
- sns.violinplot(x="x", y="y", data=df, order=['0','1','2'], fill=False, color='black')
- sns.swarmplot(
- x="x",
- y="y",
- hue="c",
- data=df,
- palette=control_palette,
- size=7,
- marker='s',
- legend=False
- )
- # Violin + swarm plot for stimulation condition
- animals = pd.unique(stim_animal)
- palette = dict(zip(animals, mycolors))
- cc2 = stim_dist
- df = pd.DataFrame(dict(x=np.repeat([1], len(cc2)), y=cc2, c=stim_animal))
- sns.violinplot(x="x", y="y", data=df, order=['0','1','2'], fill=False, color='black')
- sns.swarmplot(
- x="x",
- y="y",
- hue="c",
- data=df,
- palette=palette,
- order=[1], # x is numeric and always 1
- size=7,
- legend=False
- )
- # Violin + swarm plot for stress condition
- cc3 = stress_dist
- df = pd.DataFrame(dict(
- x=np.repeat(2, len(cc3)),
- y=cc3,
- c=stress_animal
- ))
- sns.violinplot(x="x", y="y", data=df, order=['0','1','2'], fill=False, color='black')
- sns.swarmplot(
- x="x",
- y="y",
- hue="c",
- data=df,
- palette=palette,
- order=[2],
- size=7,
- legend=False
- )
- # Add statistical annotations between groups
- # Control vs Stim
- ax.plot([0.1, 0.9], [1.2, 1.2], 'k-', lw=1)
- ax.plot([0.1, 0.1], [1.2, 1.18], 'k-', lw=1)
- ax.plot([0.9, 0.9], [1.2, 1.18], 'k-', lw=1)
- _, kruskal_p_value = kruskal(control_dist, stim_dist)
- ax.annotate(pval_to_star(p_control_Stim), (1/2, 1.21), ha='center')
- # Stim vs Stress
- ax.plot([1.1, 1.9], [1.2, 1.2], 'k-', lw=1)
- ax.plot([1.1, 1.1], [1.2, 1.18], 'k-', lw=1)
- ax.plot([1.9, 1.9], [1.2, 1.18], 'k-', lw=1)
- _, kruskal_p_value = kruskal(stress_dist, stim_dist)
- ax.annotate(pval_to_star(p_Stress_Stim), (3/2, 1.21), ha='center')
- # Control vs Stress
- ax.plot([0.1, 1.9], [1.35, 1.35], 'k-', lw=1)
- ax.plot([0.1, 0.1], [1.35, 1.33], 'k-', lw=1)
- ax.plot([1.9, 1.9], [1.35, 1.33], 'k-', lw=1)
- _, kruskal_p_value = kruskal(control_dist, stress_dist)
- ax.annotate(pval_to_star(p_control_Stress), (1, 1.36), ha='center')
- # Final axis settings
- ax.set_ylim(0.0, 1.5)
- ax.set_xlabel('')
- ax.set_ylabel('Distance')
- ax.set_xticks(range(3), labels=['Control', 'Stimulation', 'Stress'])
- # 🔹 Plot 8: Swarm plot for stress p-values
- ax = fig.add_subplot(gs[19:23])
- # Time windows used for AUC calculation
- time_points = [30, 60, 90, 120]
- # Grouped AUC data for stim and stress conditions
- stim_data = [AUC_stim_30, AUC_stim_60, AUC_stim_90, AUC_stim_120]
- stress_data = [AUC_stress_30, AUC_stress_60, AUC_stress_90, AUC_stress_120]
- # Compute median and SEM for each time window
- stim_medians = [np.median(d) for d in stim_data]
- stim_sem = [np.std(d, ddof=1) / np.sqrt(len(d)) for d in stim_data]
- stress_medians = [np.median(d) for d in stress_data]
- stress_sem = [np.std(d, ddof=1) / np.sqrt(len(d)) for d in stress_data]
- # Create a DataFrame for plotting
- df = pd.DataFrame({
- 'Time': time_points,
- 'Stim_Median': stim_medians,
- 'Stim_SEM': stim_sem,
- 'Stress_Median': stress_medians,
- 'Stress_SEM': stress_sem
- })
- ## Create the plot for AUC data
- # -----------------------------
- # Positions
- # -----------------------------
- x = np.arange(len(time_points))
- offset = 0.18
- stim_pos = x - offset
- stress_pos = x + offset
- # -----------------------------
- # Plot
- # -----------------------------
- bp_stim = plt.boxplot(
- stim_data,
- positions=stim_pos,
- widths=0.3,
- patch_artist=True,
- medianprops=dict(color='tab:blue', linewidth=2),
- flierprops=dict(
- marker='o',
- markersize=5,
- markerfacecolor='tab:blue',
- markeredgecolor='tab:blue',
- alpha=0.6
- )
- )
- bp_stress = plt.boxplot(
- stress_data,
- positions=stress_pos,
- widths=0.3,
- patch_artist=True,
- medianprops=dict(color='tab:orange', linewidth=2),
- flierprops=dict(
- marker='o',
- markersize=5,
- markerfacecolor='tab:orange',
- markeredgecolor='tab:orange',
- alpha=0.6
- )
- )
- # -----------------------------
- # Box transparency & colors
- # -----------------------------
- for box in bp_stim['boxes']:
- box.set(facecolor='tab:blue', alpha=0.45)
- for box in bp_stress['boxes']:
- box.set(facecolor='tab:orange', alpha=0.45)
- # -----------------------------
- # Connect medians
- # -----------------------------
- stim_medians = [np.median(d) for d in stim_data]
- stress_medians = [np.median(d) for d in stress_data]
- ax.plot(stim_pos, stim_medians, 'o', color='tab:blue', linewidth=2, label='Stim')
- ax.plot(stress_pos, stress_medians, 'o', color='tab:orange', linewidth=2, label='Stress')
- # Plot median ± SEM for stimulation
- #ax.errorbar(df['Time'], df['Stim_Median'], yerr=df['Stim_SEM'],
- # label='Stim', marker='o', capsize=5, linestyle='-')
- # Plot median ± SEM for stress
- #ax.errorbar(df['Time'], df['Stress_Median'], yerr=df['Stress_SEM'],
- # label='Stress', marker='s', capsize=5, linestyle='--')
- # Annotate significance for each time window using permulation test (see data/calcium_imaging/figures/fig1_stim_stress_permutation_test.py)
- mw_p_value = 0.0132
- ax.annotate(pval_to_star(mw_p_value), (0, 70), ha='center')
- mw_p_value = 0.0121
- ax.annotate(pval_to_star(mw_p_value), (1, 70), ha='center')
- mw_p_value = 0.0421
- ax.annotate(pval_to_star(mw_p_value), (2, 70), ha='center')
- mw_p_value = 0.09709
- ax.annotate(pval_to_star(mw_p_value), (3, 70), ha='center')
- # Final axis settings
- ax.set_ylim(-10, 80)
- ax.set_xlabel('Time [sec]')
- ax.set_ylabel('Time-Averaged\n Activity')
- ax.set_xticks([0,1,2,3], labels=time_points)
- ax.legend(
- )
- # Adjust layout to prevent overlap and ensure clean spacing
- plt.tight_layout()
- # Display the figure
- # fig.savefig("Fig1_revised.svg", format="svg", bbox_inches="tight", dpi=600)
- plt.show()
- # %% [markdown]
- # # 🧪 Full Workflow: G28 Stimulation (Figure 2)
- # %% [markdown]
- # An example of data analysis pipline for stimulation experimental setup.
- # %%
- # Load and transpose the data
- data_fig2_stim = pd.read_csv(stim_fig2_csv_fname).T
- # Clean up row labels
- data_fig2_stim.index = data_fig2_stim.index.str.strip()
- # Preview the last 6 cells
- data_fig2_stim.tail(6)
- # %% [markdown]
- # ## ✂️ Data Slicing for G28 Stimulation
- # %%
- # Extract signal data (excluding metadata)
- cells_traces_tot_old = data_fig2_stim.iloc[1:, 1:]
- # Get shape info
- total_cells_stim, data_stim_length_tot = cells_traces_tot_old.shape
- # Preview bottom rows
- cells_traces_tot_old.tail()
- # %%
- # Extract time axis from first row (excluding first column)
- ts_stim_tot = np.asarray(data_fig2_stim.iloc[0, 1:], dtype=float)
- # Time between frames (first timestamp is 0)
- sec_per_frame = ts_stim_tot[1]
- # Sampling frequency (Hz)
- fs = 1 / sec_per_frame
- # %%
- # Convert fluorescence traces to NumPy array
- data_stim_artifact = np.asarray(cells_traces_tot_old, dtype=float)
- # %% [markdown]
- # ## 🔍 Finding UCN3 Cells
- # %% [markdown]
- # ### 🌀 1. Power Spectral Density (PSD) Filtering
- # %%
- # Define the stimulus frequency and initialise a list to store labels of removed cells based on PSD threshold
- stimulus_freq = 0.1 # Stimulus frequency in Hz
- labels_removed_PSD = [] # List to store indices of cells with high PSD at stimulus frequency
- # Loop through each cell trace to assess frequency-domain response
- for i in range(total_cells_stim):
- # Extract stimulation-period trace for cell i
- trace_segment = data_stim_artifact[i, stim_onset:stim_offset]
- # Compute power spectral density using periodogram
- freq, Pxx_den = periodogram(trace_segment, fs=fs)
- # Normalise PSD to its peak value for comparability
- Pxx_den /= np.max(Pxx_den)
- # Identify index corresponding to stimulus frequency
- stimulus_freq_idx = round(stimulus_freq * (stim_offset - stim_onset) / fs)
- # Thresholding: retain cells with strong PSD at stimulus frequency
- if Pxx_den[stimulus_freq_idx] > PSD_thr:
- labels_removed_PSD.append(i)
- # Output: indices of cells flagged for removal based on PSD criterion
- print("Indices of cells removed based on PSD threshold:", labels_removed_PSD)
- # %%
- # Create binary mask to flag artifact-affected cells
- # - 0: cell identified as artifact (high PSD at stimulus frequency)
- # - 1: cell retained for further analysis
- labels_artifact = np.asarray([
- 0 if cell in labels_removed_PSD else 1
- for cell in range(total_cells_stim)
- ])
- # Optional: Print the binary mask to verify which cells were flagged
- # 0 = artifact (failed PSD threshold), 1 = valid (passed PSD threshold)
- print(labels_artifact)
- # %% [markdown]
- # ### 🔢 2. K-Means Clustering
- # %%
- # Apply scikit-learn K-means clustering to sort activity traces
- # Step 1: Normalise the activity data across time (columns) to ensure fair clustering
- scaled_data = scale(data_stim_artifact[:, stim_onset:stim_offset], axis=0)
- # Step 2: Set the number of clusters (2 groups: responsive vs non-responsive)
- Ks = 2
- # Step 3: Initialise and run K-means clustering
- # - random_state ensures reproducibility
- # - n_init = 10 means the algorithm runs 10 times with different centroid seeds
- # - max_iter = 100 limits the number of iterations per run
- kmeans = KMeans(n_clusters=Ks, random_state=11111, n_init=10, max_iter=100)
- # Step 4: Fit the model to the scaled data and assign cluster labels to each cell
- labels_artifact_KM = kmeans.fit_predict(scaled_data)
- # Step 5: Sort cell indices based on their assigned cluster labels
- # This helps organise or visualise cells by their activity patterns
- i_labels_artifact = np.argsort(labels_artifact_KM)
- # Step 6: Display the sorted indices for inspection or downstream analysis
- print(f"Sorted indices of cells by cluster ID:\n{i_labels_artifact}")
- # %%
- # Determine which cluster to remove based on relative size
- # Step 1: Count the number of cells in each cluster
- size_cluster_0 = np.sum(labels_artifact_KM == 0)
- size_cluster_1 = np.sum(labels_artifact_KM == 1)
- # Step 2: Compare cluster sizes to identify the smaller one
- # The assumption here is that the smaller cluster likely represents artifacts or noise
- if size_cluster_0 < size_cluster_1:
- cluster_removed = 0 # Smaller cluster is flagged for removal
- cluster_kept = 1 # Larger cluster is retained
- print("Cluster #0 (smaller) is removed.")
- elif size_cluster_1 < size_cluster_0:
- cluster_removed = 1
- cluster_kept = 0
- print("Cluster #1 (smaller) is removed.")
- else:
- # Edge case: one cluster has zero members, so no valid removal decision
- print("Clusters are equal in size — no removal applied.")
- # %%
- # Update artifact labels based on K-means clustering results
- # Step 1: Check if the cluster marked for removal contains any cells
- if np.any(labels_artifact_KM == cluster_removed):
- # Step 2: Iterate through all cells
- for i in range(len(data_stim_artifact)):
- # Only update cells that were previously marked as valid (1)
- # and now belong to the cluster flagged for removal
- if labels_artifact[i] == 1 and labels_artifact_KM[i] == cluster_removed:
- labels_artifact[i] = 0 # Mark as artifact
- # Step 3: Explicitly define which cluster was removed and which was kept
- cluster_removed = 0
- cluster_kept = 1
- # Step 4: Extract indices of cells now marked as artifacts
- labels_removed = np.where(labels_artifact == cluster_removed)[0]
- # Optional: print summary for verification
- print(f"Number of (UCN) cells removed: {len(labels_removed)}")
- print(f"Indices of removed cells: {labels_removed}")
- # %%
- # Remove resonated (artifact) cells from fluorescence intensity traces
- # Step 1: Get the row labels (e.g., cell IDs) corresponding to removed indices
- removed_row_labels = cells_traces_tot_old.index[labels_removed]
- # Step 2: Drop the identified artifact cells from the original DataFrame
- # - axis=0 means rows are being removed
- # - inplace=False ensures the original DataFrame remains unchanged
- cells_traces_stim_tot = cells_traces_tot_old.drop(
- index=removed_row_labels, axis=0, inplace=False
- )
- # Step 3: Count the number of remaining (valid) cells after removal
- n_cells_stim = len(cells_traces_stim_tot)
- # Optional: print summary
- print(f"Number of GABA cells retained after artifact removal: {n_cells_stim}")
- # %% [markdown]
- # ## Minmax Scaling over each trace
- # %%
- # Convert fluorescence traces to NumPy array (float)
- data_stim_tot = np.asarray(cells_traces_stim_tot, dtype=float)
- # Apply Min-Max scaling to each trace individually
- # - Rescales each cell's activity to [0, 1] across its full time course
- normalised_data_stim_tot = minmax_scale(data_stim_tot, axis=1)
- # %% [markdown]
- # ## ⚡️ **G28 Stimulation**
- #
- # 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).
- # %% [markdown]
- # ### ✂️ Data Slicing
- # %%
- # Slice fluorescence data to isolate stimulation period
- cells_traces_stim_2 = cells_traces_stim_tot.iloc[:, stim_onset:stim_offset]
- # Min-max normalised fluorescence during stimulation
- normalised_data_stim_2 = normalised_data_stim_tot[:, stim_onset:stim_offset]
- # Update stimulation-period length
- data_stim_length_2 = cells_traces_stim_2.shape[1]
- # Optional: preview first few traces during stimulation
- cells_traces_stim_2.head()
- # %%
- # Create time axis for stimulation period
- # - Ensures alignment with sliced fluorescence data
- ts_stim_2 = ts_stim_tot[stim_onset:stim_offset]
- # %%
- # Convert stimulation-period fluorescence data to NumPy array (float)
- # - Enables numerical operations like clustering, filtering, or plotting
- data_stim_2 = np.asarray(cells_traces_stim_2, dtype=float)
- # %% [markdown]
- # ### Clustering
- # %% [markdown]
- # #### 🌲 Hierarchical (corrcoef)
- # %%
- # Compute pairwise correlation-based distances
- # - Distance metric: 1 - Pearson correlation coefficient
- # - pdist returns a condensed distance matrix (n*(n-1)/2 elements)
- dist_corr_stim_2 = pdist(normalised_data_stim_2,
- lambda x, y: 1 - np.corrcoef(x, y)[0, 1])
- # %%
- # Convert condensed distance matrix to square form
- # - Required for AgglomerativeClustering with 'precomputed' metric
- dist_matrix = squareform(dist_corr_stim_2)
- # Apply hierarchical clustering to sort activity traces
- # - AgglomerativeClustering performs hierarchical clustering.
- # - `n_clusters=Ks_2` specifies the number of clusters.
- # - `metric='precomputed'` indicates that the distance matrix has already been computed.
- # - `linkage='average'` uses the average linkage criterion for merging clusters.
- # - `compute_distances=True` ensures that pairwise distances are retained.
- ac = AgglomerativeClustering(
- n_clusters=Ks_2,
- metric='precomputed',
- linkage='average',
- compute_distances=True
- ).fit(dist_matrix)
- # Retrieve the cluster labels assigned to each trace
- labels_hier_stim_2 = ac.labels_
- # %%
- # Compute pairwise Pearson correlation matrix for stimulation data
- # - Avoids redundant diagonal comparisons
- # - Uses z-scored traces for consistency
- max_corr_stim_2 = np.zeros((n_cells_stim, n_cells_stim))
- for i in range(n_cells_stim):
- for j in range(i + 1, n_cells_stim): # Upper triangle only
- trace_i = scale(normalised_data_stim_2[i, :])
- trace_j = scale(normalised_data_stim_2[j, :])
- corr = np.corrcoef(trace_i, trace_j)[0, 1]
- max_corr_stim_2[i, j] = corr
- max_corr_stim_2[j, i] = corr # Symmetric assignment
- # %%
- from sklearn.metrics import silhouette_score
- # Evaluate clustering quality using silhouette score
- # - Measures how well each trace fits within its assigned cluster
- # - Higher scores indicate better-defined clusters
- sil_score = silhouette_score(
- squareform(dist_corr_stim_2), # Full distance matrix
- labels_hier_stim_2, # Cluster labels
- metric="precomputed" # Use provided distances
- )
- # Display the silhouette score
- print(f'Silhouette score: {sil_score:.3f}')
- # %% [markdown]
- # #### 🔢 K-means (corrcoef)
- # %%
- # Apply scikit-learn K-means clustering to the SLxCorr distance matrix
- # Fix the random seed for reproducibility and set the number of clusters (Ks_2)
- km = KMeans(n_clusters=Ks_2, random_state=11112, n_init=10, max_iter=100)
- # Fit the model on the squared form of the SLxCorr distance matrix
- km.fit(squareform(dist_corr_stim_2))
- # Get the labels for the clusters assigned to each cell
- labels_corr_stim_2 = km.labels_
- # %%
- # Plotting the unsorted and sorted (SLxCorr, K-means) distance matrices
- plt.figure(figsize=(16, 6))
- # Subplot 1: Unsorted SLxCorr distance matrix
- plt.subplot(1, 2, 1)
- sns.heatmap(2 * minmax_scale(squareform(dist_corr_stim_2),axis=1), cmap='BrBG')
- plt.title('Unsorted SLxCorr Distance Matrix')
- # Subplot 2: Sorted SLxCorr distance matrix based on K-means labels
- plt.subplot(1, 2, 2)
- # Sorting the SLxCorr distance matrix based on the K-means cluster labels
- idx = np.argsort(labels_corr_stim_2) # Sort indices by K-means labels
- sorted_cov_dist = squareform(dist_corr_stim_2)[idx, :][:, idx] # Apply sorting
- ax = sns.heatmap(2 * minmax_scale(sorted_cov_dist,axis=1),
- cmap='BrBG', cbar_kws={'label': 'Dissimilarity'})
- ax.set(xlabel='Cell ID', ylabel='Cell ID')
- plt.title('Sorted SLxCorr Distance Matrix (K-means)')
- # Show the plot
- plt.show()
- # %% [markdown]
- # # 🧪 Full Workflow: G9 Stress (Figure 2)
- # %% [markdown]
- # An example of data analysis pipline for restraint stress experimental setup.
- # %%
- # Load stress dataset from CSV and transpose it
- # - Transposition makes each row represent a cell (e.g., for time-series or feature vectors)
- data_fig2_stress = pd.read_csv(stress_fig2_csv_fname).T
- # Clean up cell ID labels by stripping whitespace
- # - Ensures consistent indexing and avoids matching issues
- data_fig2_stress.index = data_fig2_stress.index.str.strip()
- # Display the last few rows of the transposed dataset
- # - Useful for inspecting cell-level data or verifying formatting
- data_fig2_stress.tail()
- # %% [markdown]
- # ## ✂️ Data Slicing
- # %%
- # Extract fluorescence intensity traces from deconvoluted stress data
- # - Skip first row (likely metadata or time vector)
- # - Skip first column (likely cell labels or non-numeric info)
- cells_traces_stress_tot = data_fig2_stress.iloc[1:, 1:]
- # Determine dataset dimensions
- # - n_cells_stress: number of cells (rows)
- # - data_stress_length_tot: number of time points or features (columns)
- n_cells_stress, data_stress_length_tot = cells_traces_stress_tot.shape
- # Preview the last few rows of the fluorescence intensity matrix
- # - Each row = one cell's trace
- # - Each column = one time point or feature
- cells_traces_stress_tot.tail()
- # %%
- # Extract time axis from the first row (excluding first column)
- # - Assumes first row contains timestamps
- # - Converts to NumPy array of floats for numerical operations
- ts_stress_tot = np.asarray(data_fig2_stress.iloc[0, 1:], dtype=float)
- # Determine time per frame (seconds)
- # - Assumes uniform sampling; uses second timestamp as Δt
- sec_per_frame = ts_stress_tot[1]
- # Compute sampling frequency (Hz)
- # - fs = frames per second = 1 / time per frame
- fs = 1 / sec_per_frame
- # %% [markdown]
- # ## Minmax Scaling over each trace
- # %%
- # Convert fluorescence intensity data to NumPy array (float)
- # - Enables fast numerical operations and compatibility with signal processing tools
- # - Each row = one cell's activity trace
- # - Each column = one time point
- data_stress_tot = np.asarray(cells_traces_stress_tot, dtype=float)
- # Normalise each cell's fluorescence trace to [0, 1]
- # - axis=1 ensures scaling is done per row (i.e., per cell)
- # - Preserves temporal dynamics while removing amplitude bias
- normalised_data_stress_tot = minmax_scale(data_stress_tot, axis=1)
- # %% [markdown]
- # ## 🔥 **G7 Stress**
- #
- # 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).
- # %% [markdown]
- # ### ✂️ Data Slicing
- # %%
- # Slice fluorescence intensity traces during stimulation period
- # - Columns correspond to time points; slicing isolates stress window
- cells_traces_stress_2 = cells_traces_stress_tot.iloc[:, stress_onset:stress_offset]
- # Slice normalized traces for the same stimulation window
- # - Ensures amplitude-independent comparison during stress
- normalised_data_stress_2 = normalised_data_stress_tot[:, stress_onset:stress_offset]
- # Get number of time points during stimulation
- data_stress_length_2 = cells_traces_stress_2.shape[1]
- # Preview top rows of sliced raw intensity data
- cells_traces_stress_2.head()
- # %%
- # Extract time axis corresponding to stimulation period
- ts_stress_2 = ts_stress_tot[stress_onset:stress_offset]
- # %%
- # Convert sliced fluorescence intensity data to NumPy array (float)
- data_stress_2 = np.asarray(cells_traces_stress_2.iloc[:, :], dtype=float)
- # %% [markdown]
- # ### Clustering
- # %% [markdown]
- # #### 🌲 Hierarchical (corrcoef)
- # %%
- # Compute pairwise Pearson correlation-based distances
- # - Each trace is standardized (zero mean, unit variance) across time
- # - Distance = 1 - Pearson correlation coefficient
- # → High correlation → small distance
- dist_corr_stress_2 = pdist(
- scale(normalised_data_stress_2, axis=1),
- lambda x, y: 1 - np.corrcoef(x, y)[0, 1]
- )
- # Output is a condensed distance matrix
- # - Suitable for clustering, silhouette scoring, or dendrograms
- # %%
- # Apply hierarchical clustering to standardized activity traces
- # - Uses precomputed Pearson correlation-based distances
- # - Average linkage merges clusters based on mean pairwise distance
- ac = AgglomerativeClustering(
- n_clusters=Ks_2, # 🔢 Desired number of clusters
- metric='precomputed', # 📏 Use custom distance matrix
- linkage='average', # 🔗 Average linkage for merging
- compute_distances=True # 📐 Retain distance info for dendrograms
- ).fit(squareform(dist_corr_stress_2)) # 🔄 Convert condensed to square distance matrix
- # Extract cluster labels for each cell trace
- labels_hier_stress_2 = ac.labels_
- # ✅ `labels_hier_stress_2` contains the cluster assignment for each trace
- # %%
- # Initialize symmetric matrix to store pairwise Pearson correlations
- # - Shape: (n_cells_stress, n_cells_stress)
- # - Diagonal remains zero (self-comparisons skipped)
- max_corr_stress_2 = np.zeros((n_cells_stress, n_cells_stress))
- # Compute pairwise correlations (upper triangle only)
- # - Each trace is standardised (zero mean, unit variance)
- # - Matrix is filled symmetrically to avoid redundant computation
- for first in range(n_cells_stress):
- for second in range(first + 1, n_cells_stress):
- corr = np.corrcoef(
- scale(normalised_data_stress_2[first, :]),
- scale(normalised_data_stress_2[second, :])
- )[0, 1]
- max_corr_stress_2[first, second] = corr
- max_corr_stress_2[second, first] = corr # 🔁 Symmetric fill
- # %%
- # Evaluate clustering quality using silhouette score
- # - Measures how similar each cell is to its own cluster vs other clusters
- # - Uses precomputed Pearson correlation-based distances
- sil_score = silhouette_score(
- squareform(dist_corr_stress_2), # 🔄 Full pairwise distance matrix
- labels_hier_stress_2, # 🏷️ Cluster labels
- metric="precomputed" # 📏 Use custom distance metric
- )
- # Display silhouette score (range: -1 to 1)
- # - Higher = better-defined clusters
- print(f'Silhouette score: {sil_score:.3f}')
- # %% [markdown]
- # #### 🔢 K-means (corrcoef)
- # %%
- # Note: KMeans typically expects feature vectors, not distance matrices.
- # - Applying it to a distance matrix (especially non-Euclidean) may distort clustering.
- # - Consider using MDS or t-SNE to embed the distance matrix into feature space first.
- # Apply K-means clustering to the SLxCorr distance matrix
- # - `random_state` ensures reproducibility
- # - `n_init=10` runs the algorithm multiple times to avoid poor local minima
- # - `max_iter=100` limits iterations per run
- km = KMeans(n_clusters=Ks_2, random_state=11112, n_init=10, max_iter=100)
- # Fit the model on the square distance matrix
- # - Assumes rows are interpretable as feature vectors (approximate)
- km.fit(squareform(dist_corr_stress_2))
- # Extract cluster labels for each cell
- labels_corr_stress_2 = km.labels_
- # %% [markdown]
- # # 🖼️ Figure 2
- # %%
- # Load hierarchical clustering cell percentage data
- # - Each row = one trial
- # - Each column = one cluster category (e.g., Cluster 0, Cluster 1, ...)
- cell_percentage_hier = pd.read_csv(hier_clustering_cell_perc_fname)
- # Preview the bottom rows to inspect structure and completeness
- cell_percentage_hier.tail()
- # %%
- # Extract cluster-1 cell percentages for each stimulated animal
- # NaN values (empty clusters) are removed
- cluster1_percentage = [
- cell_percentage_hier[k].dropna().values
- for k in ['G7 (#1)', 'G8 (#1)', 'G9 (#1)', 'G28 (#1)', 'G29 (#1)', 'G37 (#1)']
- ]
- # Extract cluster-2 cell percentages for each stimulated animal
- # NaN values (empty clusters) are removed
- cluster2_percentage = [
- cell_percentage_hier[k].dropna().values
- for k in ['G7 (#2)', 'G8 (#2)', 'G9 (#2)', 'G28 (#2)', 'G29 (#2)', 'G37 (#2)']
- ]
- flat_cluster1stim = np.concatenate(cluster1_percentage)
- flat_cluster2stim = np.concatenate(cluster2_percentage)
- # Extract cluster percentages for each restraint stress animal
- flat_cluster1stress = cell_percentage_hier['Stress (#1)'].dropna().values
- flat_cluster2stress = cell_percentage_hier['Stress (#2)'].dropna().values
- # Animal labels corresponding to the flattened cluster entries
- stim_animal = [
- 'G7', 'G7',
- 'G8', 'G8', 'G8', 'G8',
- 'G9', 'G9', 'G9', 'G9',
- 'G28', 'G28', 'G28',
- 'G29', 'G29', 'G29',
- 'G37', 'G37', 'G37', 'G37'
- ]
- stress_animal = ['G7', 'G8', 'G9', 'G28', 'G29', 'G37', 'G37']
- def build_df_two_clusters(animal_list, y1, y2, condition):
- y1 = np.asarray(y1, dtype=float)
- y2 = np.asarray(y2, dtype=float)
- animal_list = np.asarray(animal_list)
- if len(animal_list) != len(y1) or len(animal_list) != len(y2):
- raise ValueError(
- f"Length mismatch: animals={len(animal_list)}, y1={len(y1)}, y2={len(y2)}"
- )
- df = pd.DataFrame({
- "Animal_id": animal_list,
- "y1": y1, # cluster 1 size
- "y2": y2 # cluster 2 size
- })
- df["condition"] = condition
- df["repeat"] = df.groupby("Animal_id").cumcount() + 1
- return df[["Animal_id", "condition", "repeat", "y1", "y2"]]
- # --- Build condition-specific dataframes ---
- df_stim = build_df_two_clusters(stim_animal, flat_cluster1stim, flat_cluster2stim, "stim")
- df_stress = build_df_two_clusters(stress_animal, flat_cluster1stress, flat_cluster2stress, "stress")
- # --- Merge into one dataframe ---
- df = pd.concat([df_stim, df_stress], ignore_index=True)
- df = df.sort_values(["condition", "Animal_id", "repeat"]).reset_index(drop=True)
- print(df)
- # %%
- # df has columns: Animal_id, condition, repeat, y1, y2
- df_long = (
- df[["Animal_id", "condition", "repeat", "y1", "y2"]]
- .melt(
- id_vars=["Animal_id", "condition", "repeat"],
- value_vars=["y1", "y2"],
- var_name="cluster",
- value_name="y"
- )
- )
- # clean cluster labels
- df_long["cluster"] = df_long["cluster"].str.strip().map({"y1": "cluster1", "y2": "cluster2"})
- # drop any rows where y is missing (should usually be none)
- df_long = df_long.dropna(subset=["y"]).reset_index(drop=True)
- print(df_long.head(10))
- print(df_long.columns)
- # %%
- # Fit mixed-effects model with interaction between cluster and condition
- m_nested = smf.mixedlm(
- "y ~ cluster * condition",
- df_long,
- groups=df_long["Animal_id"],
- ).fit(reml=False, method="nm", maxiter=2000, disp=True)
- print(m_nested.summary())
- # %%
- # Compute marginal R²: variance explained by fixed effects only
- var_fixed = np.var(m_nested.fittedvalues)
- var_random = m_nested.cov_re.iloc[0, 0]
- var_resid = m_nested.scale
- R2_marginal = var_fixed / (var_fixed + var_random + var_resid)
- print(R2_marginal)
- # Extract p value for the fixed effect of cluster identity (cluster2 vs reference)
- pvals_all_clusters = m_nested.pvalues.get("cluster[T.cluster2]")
- print(pvals_all_clusters)
- # %%
- # Subset to stimulation only
- df_stim_only = df_long[df_long["condition"] == "stim"].copy()
- # Fit mixed-effects model: cluster effect within stim
- m_stim = smf.mixedlm(
- "y ~ cluster",
- df_stim_only,
- groups=df_stim_only["Animal_id"],
- re_formula="~1",
- ).fit(reml=False, method="nm", maxiter=2000, disp=True)
- print(m_stim.summary())
- # %%
- # Compute marginal R² for the stimulation model (fixed effects only)
- var_fixed = np.var(m_stim.fittedvalues)
- var_random = m_stim.cov_re.iloc[0, 0]
- var_resid = m_stim.scale
- R2_marginal = var_fixed / (var_fixed + var_random + var_resid)
- print(R2_marginal)
- # Extract p value for the cluster effect under stimulation
- pvals_stim_clusters = m_stim.pvalues.get("cluster[T.cluster2]")
- print(pvals_stim_clusters)
- # %%
- # Subset to stress only
- df_stress_only = df_long[df_long["condition"] == "stress"].copy()
- # Fit mixed-effects model: cluster effect within stress
- m_stress = smf.mixedlm(
- "y ~ cluster",
- df_stress_only,
- groups=df_stress_only["Animal_id"],
- re_formula="~1",
- ).fit(reml=False, method="nm", maxiter=2000, disp=True)
- print(m_stress.summary())
- # %%
- # Compute marginal R² for the stress model (fixed effects only)
- var_fixed = np.var(m_stress.fittedvalues)
- var_random = m_stress.cov_re.iloc[0, 0]
- var_resid = m_stress.scale
- R2_marginal = var_fixed / (var_fixed + var_random + var_resid)
- print(R2_marginal)
- # Extract p value for the cluster effect under stress
- pvals_stress_clusters = m_stress.pvalues.get("cluster[T.cluster2]")
- print(pvals_stress_clusters)
- # %%
- # Extract cluster category labels from column headers
- categories_percent_hier = cell_percentage_hier.columns.tolist()
- # Extract total number of GABAergic cells across trials
- cluster1_cells_allanimals_stim = cell_percentage_hier['Stimulation (#1)']
- cluster2_cells_allanimals_stim = cell_percentage_hier['Stimulation (#2)']
- cluster1_cells_allanimals_stress = cell_percentage_hier['Stress (#1)']
- cluster2_cells_allanimals_stress = cell_percentage_hier['Stress (#2)']
- # Remove all the NaNs
- cluster1_cells_allanimals_stim = cluster1_cells_allanimals_stim[~np.isnan(cluster1_cells_allanimals_stim)]
- cluster2_cells_allanimals_stim = cluster2_cells_allanimals_stim[~np.isnan(cluster2_cells_allanimals_stim)]
- cluster1_cells_allanimals_stress = cluster1_cells_allanimals_stress[~np.isnan(cluster1_cells_allanimals_stress)]
- cluster2_cells_allanimals_stress = cluster2_cells_allanimals_stress[~np.isnan(cluster2_cells_allanimals_stress)]
- # %%
- # Set up figure layout
- fig = plt.figure(figsize=(10, 15)) # Define overall figure size
- gs = gridspec.GridSpec(8, 3, figure=fig) # Create an 8x3 grid for subplot placement
- # Sort cell indices by hierarchical clustering labels (stimulus condition)
- idx = np.argsort(labels_hier_stim_2)
- # 🔳 Plot 1: Heatmap of pairwise correlations (stimulus condition)
- ax = fig.add_subplot(gs[0:2, 0]) # Top-left subplot
- ax = sns.heatmap(max_corr_stim_2[idx, :][:, idx], # Correlation matrix sorted by cluster
- cbar_kws={'label': 'Corr', 'location': 'top'}, # Colourbar settings
- cmap="viridis", vmin=-1, vmax=1) # Colourmap and value range
- ax.set(xlabel='cell ID', ylabel='cell ID') # Axis labels
- ax.set_xticks([_ for _ in range(n_cells_stim) if _ % 10 == 0]) # X-axis ticks every 10 cells
- ax.set_xticklabels([_ for _ in range(n_cells_stim) if _ % 10 == 0]) # X-axis tick labels
- ax.set_yticks([_ for _ in range(n_cells_stim) if _ % 10 == 0]) # Y-axis ticks every 10 cells
- ax.set_yticklabels([_ for _ in range(n_cells_stim) if _ % 10 == 0]) # Y-axis tick labels
- ax.invert_yaxis() # Show first cell at top
- # Add plot outline
- for spine in ax.spines.values():
- spine.set_visible(True)
- spine.set_linewidth(1.5)
- spine.set_edgecolor('gray')
- # Add colourbar outline
- cbar = ax.collections[0].colorbar
- cbar.outline.set_visible(True)
- cbar.outline.set_edgecolor('grey')
- cbar.outline.set_linewidth(1.0)
- # 🔳 Plot 2: Heatmap of normalised fluorescence traces (stimulus condition)
- ax = plt.subplot(gs[0:2, 1:3]) # Top-right subplot
- im = ax.imshow(minmax_scale(scale(data_stim_2[idx, :], axis=1), axis=1), aspect='auto',
- cmap="viridis", interpolation='none', vmin=0, vmax=1, origin='upper') # Heatmap of scaled traces
- ax.set_xlabel('')
- ax.set_xticks([])
- ax.set_yticks(range(0, 40, 30))
- ax.set_yticklabels([])
- ax.invert_yaxis()
- # Add colourbar for fluorescence heatmap
- cbar = plt.colorbar(im, ax=ax, orientation='horizontal', location='top', aspect=50, pad=0.05)
- cbar.set_label('$\Delta$ F$/$F$_0$ (scaled)')
- # 🔳 Plot 3: Average traces per cluster (stimulus condition)
- ax = plt.subplot(gs[2, 1:3]) # Middle-right subplot
- for Ki in range(Ks_2): # Loop through clusters
- js = np.where(labels_hier_stim_2 == Ki) # Get cell indices for cluster Ki
- avg = np.mean(np.squeeze(data_stim_2[js, :]), axis=0) # Average trace
- avg = minmax_scale(avg) # Normalise trace
- ax.plot(avg, c=GABA_greens[Ki-1]) # Plot with cluster colour
- ax.set_xlim(0, 120)
- ax.set_xlabel('time [sec]')
- ax.set_xticks(range(0, 1400, 200), labels=range(0, 140, 20))
- ax.set_ylabel('$\Delta F/F_0$')
- ax.set_yticks([0, 0.5, 1])
- # Sort cell indices by hierarchical clustering labels (stress condition)
- idx = np.argsort(labels_hier_stress_2)
- # 🔳 Plot 4: Heatmap of pairwise correlations (stress condition)
- ax = fig.add_subplot(gs[3:5, 0]) # Middle-left subplot
- ax = sns.heatmap(max_corr_stress_2[idx, :][:, idx],
- cbar_kws={'label': 'Corr', 'location': 'top'},
- cmap="viridis", vmin=-1, vmax=1)
- ax.set(xlabel='cell ID', ylabel='cell ID')
- ax.set_xticks([_ for _ in range(n_cells_stress) if _ % 20 == 0])
- ax.set_xticklabels([_ for _ in range(n_cells_stress) if _ % 20 == 0])
- ax.set_yticks([_ for _ in range(n_cells_stress) if _ % 20 == 0])
- ax.set_yticklabels([_ for _ in range(n_cells_stress) if _ % 20 == 0])
- ax.invert_yaxis()
- # Add plot outline
- for spine in ax.spines.values():
- spine.set_visible(True)
- spine.set_linewidth(1.5)
- spine.set_edgecolor('gray')
- # Add colourbar outline
- cbar = ax.collections[0].colorbar
- cbar.outline.set_visible(True)
- cbar.outline.set_edgecolor('grey')
- cbar.outline.set_linewidth(1.0)
- # 🔳 Plot 5: Heatmap of normalised fluorescence traces (stress condition)
- ax = plt.subplot(gs[3:5, 1:3]) # Middle-right subplot
- im = ax.imshow(minmax_scale(scale(data_stress_2[idx, :], axis=1), axis=1), aspect='auto',
- cmap="viridis", interpolation='none', vmin=0, vmax=1, origin='upper')
- ax.set_xlabel('')
- ax.set_xticks([])
- ax.set_yticks(range(0, 40, 30))
- ax.set_yticklabels([])
- ax.invert_yaxis()
- # Add colourbar for fluorescence heatmap
- cbar = plt.colorbar(im, ax=ax, orientation='horizontal', location='top', aspect=50, pad=0.05)
- cbar.set_label('$\Delta$ F$/$F$_0$ (scaled)')
- # 🔳 Plot 6: Average traces per cluster (stress condition)
- ax = plt.subplot(gs[5, 1:3]) # Bottom-right subplot
- for Ki in range(Ks_2):
- js = np.where(labels_hier_stress_2 == Ki)
- avg = np.mean(np.squeeze(data_stress_2[js, :]), axis=0)
- avg = minmax_scale(avg)
- ax.plot(avg, c=GABA_greens[Ki-1])
- ax.set_xlim(0, 120)
- ax.set_xlabel('time [sec]')
- ax.set_xticks(range(0, 1500, 300), labels=range(0, 150, 30))
- ax.set_ylabel('$\Delta F/F_0$')
- ax.set_yticks([0, 0.5, 1])
- # 🔳 Plot 7: Violin plot of cell percentages (stimulation condition, cluster group A)
- ax = plt.subplot(gs[6:8, 0]) # Bottom-left subplot
- perc1 = cluster1_cells_allanimals_stim
- df = pd.DataFrame(dict(x=np.repeat([0], len(perc1)), y=perc1))
- sns.violinplot(x="x", y="y", data=df, order=np.arange(2), inner='box', linecolor='k', color=eff_green)
- # perc2 = cell_percentage_hier[categories_percent_hier[8]] / cell_percentage_hier[categories_percent_hier[1]] * 100
- perc2 =cluster2_cells_allanimals_stim
- df = pd.DataFrame(dict(x=np.repeat([1], len(perc2)), y=perc2))
- sns.violinplot(x="x", y="y", data=df, order=np.arange(2), inner='box', linecolor='k', color=inter_green)
- ax.set_title(categories_percent_hier[7][:-5])
- ax.set_ylim(-20, 140)
- ax.set_xlabel('')
- ax.set_ylabel('% of cells')
- ax.set_xticks(range(2), labels=['cluster 1', 'cluster 2'])
- ax.set_yticks(range(0, 150, 50), labels=range(0, 150, 50))
- # Statistical tests between cluster groups
- ax.plot([0, 1], [123, 123], 'k-', lw=1)
- ax.plot([0, 0], [70, 123], 'k-', lw=1)
- ax.plot([1, 1], [118, 123], 'k-', lw=1)
- MW_p_value, _ = mannwhitneyu(perc1.dropna(), perc2.dropna())
- KS_p_value = pvals_stim_clusters
- ax.annotate(pval_to_star(KS_p_value), (0.5, 125), ha='center')
- # 🔳 Plot 8: Violin plot of cell percentages (stress condition, cluster group B)
- ax = plt.subplot(gs[6:8, 1]) # Bottom-center subplot
- perc1 = cluster1_cells_allanimals_stress
- df = pd.DataFrame(dict(x=np.repeat([0], len(perc1)), y=perc1))
- sns.violinplot(x="x", y="y", data=df, order=np.arange(2), inner='box', linecolor='k', color=eff_green)
- # Extract and normalise cell percentages for cluster 2
- perc2 = cluster2_cells_allanimals_stress
- df = pd.DataFrame(dict(x=np.repeat([1], len(perc2)), y=perc2))
- sns.violinplot(x="x", y="y", data=df, order=np.arange(2), inner='box', linecolor='k', color=inter_green)
- # Configure plot appearance
- ax.set_title(categories_percent_hier[11][:-5]) # Remove suffix from category name
- ax.set_ylim(-20, 140) # Set y-axis limits
- ax.set_xlabel('')
- ax.set_ylabel('% of cells')
- ax.set_xticks(range(2), labels=['cluster 1', 'cluster 2'])
- ax.set_yticks(range(0, 150, 50), labels=range(0, 150, 50))
- # Statistical tests between cluster groups
- ax.plot([0, 1], [123, 123], 'k-', lw=1) # Horizontal line
- ax.plot([0, 0], [70, 123], 'k-', lw=1) # Left vertical line
- ax.plot([1, 1], [118, 123], 'k-', lw=1) # Right vertical line
- MW_p_value, _ = mannwhitneyu(perc1.dropna(), perc2.dropna()) # Mann-Whitney U test
- KS_p_value = pvals_stress_clusters # Kolmogorov-Smirnov test
- ax.annotate(pval_to_star(KS_p_value), (0.5, 125), ha='center') # Annotate significance
- # Optional: Add final layout adjustments and save figure
- fig.tight_layout()
- # plt.savefig('cluster_summary_figure.svg', dpi=700) # Uncomment to save
- plt.show()
- # %% [markdown]
- # # 🖼️ Figure S3 (cluster size)
- # %%
- # Create figure for cluster percentage comparison across all animals
- fig = plt.figure(figsize=(8, 5))
- ax = plt.subplot(1, 1, 1)
- ax.tick_params(axis='both', length=0)
- # Extract cell percentages for the two clusters
- perc1 = cell_percentage_hier['All Animals (#1)']
- perc2 = cell_percentage_hier['All Animals (#2)']
- # Violin plot for cluster 1
- df1 = pd.DataFrame(dict(x=np.repeat([0], len(perc1)), y=perc1))
- sns.violinplot(x="x", y="y", data=df1, order=np.arange(2), color=eff_green)
- # Violin plot for cluster 2
- df2 = pd.DataFrame(dict(x=np.repeat([1], len(perc2)), y=perc2))
- sns.violinplot(x="x", y="y", data=df2, order=np.arange(2), color=inter_green)
- # Axis formatting and labels
- ax.set_title(categories_percent_hier[4*0 + 3][:-5])
- ax.set_ylim(-25, 150)
- ax.set_xlabel('')
- ax.set_ylabel('% of cells')
- ax.set_xticks([0, 1], labels=['cluster 1', 'cluster 2'])
- # Annotate p value for cluster comparison
- ax.annotate(f'p={pvals_all_clusters:0.3e}', (0.5, 125), ha='center')
- fig.tight_layout()
- # plt.savefig('cluster_summary_figure_supplement.svg', dpi=700)
- plt.show()
- # %% [markdown]
- # # 🖼️ Figure S4 (corrcoef)
- # %%
- # List of all relevant CSV file paths for UCN-opsin & GABA-GCaMP experiments
- all_file_paths = [
- './data/calcium_imaging/UCN3_stimulation/G7 20231110 10hz 222sti.csv', #1
- './data/calcium_imaging/UCN3_stimulation/G7 20240201 10hz 223sti.csv', #2
- './data/calcium_imaging/UCN3_stimulation/G8 20240220 10hz 223sti.csv', #3
- './data/calcium_imaging/UCN3_stimulation/G8 20240301 10hz 223sti.csv', #4
- './data/calcium_imaging/UCN3_stimulation/G8 20240202 10hz 223sti.csv', #5
- './data/calcium_imaging/UCN3_stimulation/G8 20231218 10hz 222sti.csv', #6
- './data/calcium_imaging/UCN3_stimulation/G9 20231218 10hz 222sti.csv', #7
- './data/calcium_imaging/UCN3_stimulation/G9 20240202 10hz 223sti.csv', #8
- './data/calcium_imaging/UCN3_stimulation/G9 20240307 10hz 223sti.csv', #9
- './data/calcium_imaging/UCN3_stimulation/G9 20240228 10hz 223sti.csv', #10
- './data/calcium_imaging/UCN3_stimulation/G28 20240709 10hz 223sti.csv', #11
- './data/calcium_imaging/UCN3_stimulation/G28 20240701 10hz 223sti.csv', #12
- './data/calcium_imaging/UCN3_stimulation/G28 20240704 10hz 223sti.csv', #13
- './data/calcium_imaging/UCN3_stimulation/G29 20240703 10hz 223sti.csv', #14
- './data/calcium_imaging/UCN3_stimulation/G29 20240711 10hz 223sti.csv', #15
- './data/calcium_imaging/UCN3_stimulation/G29 20240705 10hz 223sti.csv', #16
- './data/calcium_imaging/UCN3_stimulation/G37 10hz 223sti 20240720 A.csv', #17
- './data/calcium_imaging/UCN3_stimulation/G37 10hz 223sti 20240720 B.csv', #18
- './data/calcium_imaging/UCN3_stimulation/G37 10hz 223sti 20240721 A.csv', #19
- './data/calcium_imaging/UCN3_stimulation/G37 10hz 223sti 20240721 B.csv', #20
- './data/calcium_imaging/UCN3_stimulation/G7 20231218 334restraint.csv', #21
- './data/calcium_imaging/UCN3_stimulation/G8 20231218 343restraint.csv', #22
- './data/calcium_imaging/UCN3_stimulation/G9 20231218 334restraint.csv', #23
- './data/calcium_imaging/restrain_stress/G28 333restraint 20240630.csv', #24
- './data/calcium_imaging/restrain_stress/G29 333restraint 20240701.csv', #25
- './data/calcium_imaging/restrain_stress/G37 333restraint 20240719.csv', #26
- './data/calcium_imaging/restrain_stress/G37 333restraint 20240725.csv', #27
- ]
- # %%
- # Selected stimulation files for bootstrapping analysis
- # Each entry corresponds to a specific animal and session
- boot_file_paths = [
- all_file_paths[1], # G7 — 2024-02-01 — 223sti
- all_file_paths[5], # G8 — 2024-03-01 — 223sti
- all_file_paths[8], # G9 — 2024-03-07 — 223sti
- all_file_paths[12], # G28 — 2024-07-09 — 223sti
- all_file_paths[15], # G29 — 2024-07-11 — 223sti
- all_file_paths[17] # G37 — 2024-07-20 A — 223sti
- ]
- # %%
- # Create a 3x3 grid layout for plotting silhouette score distributions
- plt.figure(figsize=(15, 15)) # Set overall figure size
- # Define subplot axes for each animal/group
- ax1 = plt.subplot(3, 3, 1)
- ax2 = plt.subplot(3, 3, 2)
- ax3 = plt.subplot(3, 3, 3)
- ax4 = plt.subplot(3, 3, 4)
- ax5 = plt.subplot(3, 3, 5)
- ax6 = plt.subplot(3, 3, 6)
- # Iterate over bootstrapped stimulation datasets
- for ind, stim_csv_fname in enumerate(boot_file_paths):
- # Load stimulation data and raw traces
- data_stim, cells_traces_tot_old = load_data(stim_csv_fname)
- # Filter for UCN3+ GABAergic neurons
- data_stim_tot, n_cells = process_data(data_stim, cells_traces_tot_old)
- # Normalise each trace to its own min/max (row-wise)
- normalised_data_stim_tot = minmax_scale(data_stim_tot, axis=1)
- # Slice stimulation period from normalised traces
- normalised_data_stim_2 = normalised_data_stim_tot[:, stim_onset:stim_offset]
- # Compute pairwise correlation-based distance matrix
- # Using 1 - Pearson correlation as a dissimilarity metric
- dist_cov_2 = pdist(scale(normalised_data_stim_2, axis=1),
- lambda x, y: 1 - np.corrcoef(x, y)[0, 1])
- # Initialise array to store silhouette scores across bootstraps
- sil_score_2 = np.zeros((max_no_clusters - 1, n_resampling))
- # Loop over cluster numbers and bootstrap iterations
- for i, Ks_2 in enumerate(range(2, max_no_clusters + 1)):
- for j in range(n_resampling):
- # Resample 75% of cells for bootstrapping
- sampled_ids = np.random.choice(range(n_cells), int(0.75 * n_cells), replace=False)
- sampled_data = squareform(dist_cov_2)[sampled_ids, :][:, sampled_ids]
- # Apply hierarchical clustering with average linkage
- ac = AgglomerativeClustering(n_clusters=Ks_2, metric='precomputed',
- linkage='average', compute_distances=True).fit(sampled_data)
- # Compute silhouette score using precomputed distances
- sil_score_2[i, j] = silhouette_score(sampled_data, ac.labels_, metric='precomputed')
- # Select subplot axis based on file name prefix
- file_name = os.path.split(stim_csv_fname)[-1]
- ax = {
- 'G7': ax1, 'G8': ax2, 'G9': ax3,
- 'G28': ax4, 'G29': ax5, 'G37': ax6
- }.get(file_name.split(' ')[0], None)
- if ax:
- # Plot silhouette score distributions as violin plots
- for k, colour in enumerate(['red', 'green', 'blue']): # Cluster sizes: 2, 3, 4
- df = pd.DataFrame({'x': np.repeat([k], len(sil_score_2[k, :])), 'y': sil_score_2[k, :]})
- sns.violinplot(x="x", y="y", data=df, color=colour, linecolor='k', ax=ax)
- # Perform Dunn's test for post-hoc comparisons
- p_vals = posthoc_dunn(sil_score_2, p_adjust='fdr_bh')
- # Annotate significance between cluster numbers
- ax.plot([0, 1], [0.79, 0.79], 'k-') # 2 vs 3
- ax.plot([1, 2], [0.7, 0.7], 'k-') # 3 vs 4
- ax.plot([0, 2], [0.89, 0.89], 'k-') # 2 vs 4
- ax.annotate(pval_to_star(p_vals[1][2]), (0.5, 0.8), ha='center')
- ax.annotate(pval_to_star(p_vals[2][3]), (1.5, 0.71), ha='center')
- ax.annotate(pval_to_star(p_vals[1][3]), (1, 0.9), ha='center')
- # Configure subplot titles and axis labels
- for idx, title in enumerate(['G7', 'G8', 'G9', 'G28', 'G29', 'G37'], start=1):
- ax = plt.subplot(3, 3, idx)
- ax.set_title(title) # Animal/group ID
- ax.set_ylim(0, 1) # Score range
- ax.set_ylabel('Silhouette score')
- ax.set_xlabel('Number of Clusters')
- ax.set_xticklabels([2, 3, 4, '', 2, 3, 4]) # Cluster labels
- # Final layout and export
- plt.tight_layout() # Optimise spacing
- # plt.savefig('./gdrive/My Drive/FigS3_corr_revised.svg', dpi=300) # Save figure
- plt.show() # Display plot
- # %% [markdown]
- # # 🖼️ Figure S5 (SLxCorr)
- # %%
- # Create a 3x3 grid layout for plotting silhouette score distributions
- plt.figure(figsize=(15, 15)) # Set overall figure size
- # Define subplot axes for each animal/group
- ax1 = plt.subplot(3, 3, 1)
- ax2 = plt.subplot(3, 3, 2)
- ax3 = plt.subplot(3, 3, 3)
- ax4 = plt.subplot(3, 3, 4)
- ax5 = plt.subplot(3, 3, 5)
- ax6 = plt.subplot(3, 3, 6)
- # Iterate over bootstrapped stimulation datasets
- for ind, stim_csv_fname in enumerate(boot_file_paths):
- # Load stimulation data and raw traces
- data_stim, cells_traces_tot_old = load_data(stim_csv_fname)
- # Filter for UCN3+ GABAergic neurons
- data_stim_tot, n_cells = process_data(data_stim, cells_traces_tot_old)
- # Normalise each trace to its own min/max (row-wise)
- normalised_data_stim_tot = minmax_scale(data_stim_tot, axis=1)
- # Slice stimulation period from normalised traces
- normalised_data_stim_2 = normalised_data_stim_tot[:, stim_onset:stim_offset]
- # Compute pairwise signed lagged correlation distance matrix
- dist_corr_2 = pdist(scale(normalised_data_stim_2, axis=1),
- lambda x, y: 1 - signed_lagged_corr(x, y)[0])
- # Initialise array to store silhouette scores across bootstraps
- sil_score_2 = np.zeros((max_no_clusters - 1, n_resampling))
- # Loop over cluster numbers and bootstrap iterations
- for i, Ks_2 in enumerate(range(2, max_no_clusters + 1)):
- for j in range(n_resampling):
- # Resample 75% of cells for bootstrapping
- sampled_ids = np.random.choice(range(n_cells), int(0.75 * n_cells), replace=False)
- sampled_data = squareform(dist_corr_2)[sampled_ids, :][:, sampled_ids]
- # Apply hierarchical clustering with average linkage
- ac = AgglomerativeClustering(n_clusters=Ks_2, metric='precomputed',
- linkage='average', compute_distances=True).fit(sampled_data)
- # Compute silhouette score using precomputed distances
- sil_score_2[i, j] = silhouette_score(sampled_data, ac.labels_, metric='precomputed')
- # Select subplot axis based on file name prefix
- file_name = os.path.split(stim_csv_fname)[-1]
- ax = {
- 'G7': ax1, 'G8': ax2, 'G9': ax3,
- 'G28': ax4, 'G29': ax5, 'G37': ax6
- }.get(file_name.split(' ')[0], None)
- if ax:
- # Plot silhouette score distributions as violin plots
- for k, colour in enumerate(['red', 'green', 'blue']): # Cluster sizes: 2, 3, 4
- df = pd.DataFrame({'x': np.repeat([k], len(sil_score_2[k, :])), 'y': sil_score_2[k, :]})
- sns.violinplot(x="x", y="y", data=df, color=colour, linecolor='k', ax=ax)
- # Perform Dunn's test for post-hoc comparisons
- p_vals = posthoc_dunn(sil_score_2, p_adjust='fdr_bh')
- # Annotate significance between cluster numbers
- ax.plot([0, 1], [0.79, 0.79], 'k-') # 2 vs 3
- ax.plot([1, 2], [0.7, 0.7], 'k-') # 3 vs 4
- ax.plot([0, 2], [0.89, 0.89], 'k-') # 2 vs 4
- ax.annotate(pval_to_star(p_vals[1][2]), (0.5, 0.8), ha='center')
- ax.annotate(pval_to_star(p_vals[2][3]), (1.5, 0.71), ha='center')
- ax.annotate(pval_to_star(p_vals[1][3]), (1, 0.9), ha='center')
- # Configure subplot titles and axis labels
- for idx, title in enumerate(['G7', 'G8', 'G9', 'G28', 'G29', 'G37'], start=1):
- ax = plt.subplot(3, 3, idx)
- ax.set_title(title) # Animal/group ID
- ax.set_ylim(0, 1) # Score range
- ax.set_ylabel('Silhouette score')
- ax.set_xlabel('Number of Clusters')
- ax.set_xticklabels([2, 3, 4, '', 2, 3, 4]) # Cluster labels
- # Final layout and export
- plt.tight_layout() # Optimise spacing
- plt.show() # Display plot
- # %% [markdown]
- # # 🖼️ Figure S6 (kmeans)
- # %%
- # Create a 3x3 grid for plotting silhouette score distributions
- plt.figure(figsize=(15, 15)) # Set overall figure size
- # Define subplot axes for each animal/group
- ax1 = plt.subplot(3, 3, 1)
- ax2 = plt.subplot(3, 3, 2)
- ax3 = plt.subplot(3, 3, 3)
- ax4 = plt.subplot(3, 3, 4)
- ax5 = plt.subplot(3, 3, 5)
- ax6 = plt.subplot(3, 3, 6)
- # Iterate over bootstrapped stimulation datasets
- for ind, stim_csv_fname in enumerate(boot_file_paths):
- # Load stimulation data and raw traces
- data_stim, cells_traces_tot_old = load_data(stim_csv_fname)
- # Filter for UCN3+ GABAergic neurons
- data_stim_tot, n_cells = process_data(data_stim, cells_traces_tot_old)
- # Normalise each trace to its own min/max (row-wise)
- normalised_data_stim_tot = minmax_scale(data_stim_tot, axis=1)
- # Slice stimulation period from normalised traces
- normalised_data_stim_2 = normalised_data_stim_tot[:, stim_onset:stim_offset]
- # Compute pairwise correlation-based distance matrix
- dist_corr_2 = pdist(scale(normalised_data_stim_2, axis=1),
- lambda x, y: 1 - np.corrcoef(x, y)[0, 1])
- # Initialise array to store silhouette scores across bootstraps
- sil_score_2 = np.zeros((max_no_clusters - 1, n_resampling))
- # Loop over cluster numbers and bootstrap iterations
- for i, Ks_2 in enumerate(range(2, max_no_clusters + 1)):
- for j in range(n_resampling):
- # Resample 75% of cells for bootstrapping
- sampled_ids = np.random.choice(range(n_cells), int(0.75 * n_cells), replace=False)
- sampled_data = squareform(dist_corr_2)[sampled_ids, :][:, sampled_ids]
- # Apply K-means clustering to resampled data
- km = KMeans(n_clusters=Ks_2, n_init=10, max_iter=100).fit(sampled_data)
- # Compute silhouette score using precomputed distances
- sil_score_2[i, j] = silhouette_score(sampled_data, km.labels_, metric='precomputed')
- # Select subplot axis based on file name prefix
- file_name = os.path.split(stim_csv_fname)[-1]
- ax = {
- 'G7': ax1, 'G8': ax2, 'G9': ax3,
- 'G28': ax4, 'G29': ax5, 'G37': ax6
- }.get(file_name.split(' ')[0], None)
- if ax:
- # Plot silhouette score distributions as violin plots
- for k, colour in enumerate(['red', 'green', 'blue']):
- df = pd.DataFrame({'x': np.repeat([k], len(sil_score_2[k, :])), 'y': sil_score_2[k, :]})
- sns.violinplot(x="x", y="y", data=df, color=colour, linecolor='k', ax=ax)
- # Perform Dunn's test for post-hoc comparisons
- p_vals = posthoc_dunn(sil_score_2, p_adjust='fdr_bh')
- # Annotate significance between cluster numbers
- ax.plot([0, 1], [0.79, 0.79], 'k-') # 2 vs 3
- ax.plot([1, 2], [0.7, 0.7], 'k-') # 3 vs 4
- ax.plot([0, 2], [0.89, 0.89], 'k-') # 2 vs 4
- ax.annotate(pval_to_star(p_vals[1][2]), (0.5, 0.8), ha='center')
- ax.annotate(pval_to_star(p_vals[2][3]), (1.5, 0.71), ha='center')
- ax.annotate(pval_to_star(p_vals[1][3]), (1, 0.9), ha='center')
- # Configure subplot titles and axis labels
- for idx, title in enumerate(['G7', 'G8', 'G9', 'G28', 'G29', 'G37'], start=1):
- ax = plt.subplot(3, 3, idx)
- ax.set_title(title) # Animal/group ID
- ax.set_ylim(0, 1) # Score range
- ax.set_ylabel('Silhouette score')
- ax.set_xlabel('Number of Clusters')
- ax.set_xticklabels([2, 3, 4, '', 2, 3, 4]) # Cluster labels
- # Final layout and export
- plt.tight_layout()
- # plt.savefig('./gdrive/My Drive/FigS5_kmeans_revised.svg', dpi=300)
- plt.show()
- # %% [markdown]
- # # 🖼️ Figure S7 (stim. frequency)
- # %%
- # Distance metrics for control condition at 5 Hz stimulation
- # Each value likely represents a summary statistic (e.g., mean trace distance) per cell or ROI
- 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]
- # Distance metrics for stimulated condition at 5 Hz
- # Larger sample size suggests more cells or trials were analyzed under stimulation
- stim_dist_5Hz = [0.3697, 0.3987, 0.3547, 0.4152, 0.3759, 0.3717, 0.3927, 0.4157, 0.3807, 0.3770,
- 0.3625, 0.2734, 0.3256, 0.4289, 0.3702, 0.3674, 0.4469, 0.4589, 0.4747, 0.3439]
- # Distance metrics for control condition at 20 Hz stimulation
- # Values may reflect baseline or spontaneous activity patterns at higher frequency context
- 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]
- # Distance metrics for stimulated condition at 20 Hz
- # Used to assess how stimulation alters trace similarity or variability at higher frequency
- stim_dist_20Hz = [0.3857, 0.4040, 0.3860, 0.3939, 0.4034, 0.3658, 0.4081, 0.4177, 0.4064, 0.3816,
- 0.2990, 0.4429, 0.3006, 0.4532, 0.3107, 0.4206, 0.4544, 0.4467, 0.4376, 0.4959]
- # %%
- # =========================
- # Helpers
- # =========================
- def make_palette(animals, colors):
- uniq = pd.unique(animals)
- if len(uniq) != len(colors):
- raise ValueError("Number of animals and colors must match")
- return dict(zip(uniq, colors))
- palette_ctrl = make_palette(control_animal, colors_ctrl)
- palette_stim = make_palette(stim_animal, mycolors)
- x_order = [0, 1, 3, 4, 6, 7, 8, 9 ]
- def violin_swarm(xpos, values, animals, palette, marker):
- df = pd.DataFrame({
- "x": np.repeat(xpos, len(values)),
- "y": values,
- "c": animals
- })
- sns.violinplot(
- x="x", y="y", data=df,
- order=x_order,
- fill=False, color="k",
- linewidth=1.5,
- ax=ax
- )
- sns.swarmplot(
- x="x", y="y", hue="c", data=df,
- order=x_order,
- palette=palette,
- size=10,
- marker=marker,
- legend=False,
- ax=ax
- )
- # =========================
- # Figure
- # =========================
- fig = plt.figure(figsize=(10, 10))
- ax = plt.subplot(1, 1, 1)
- # --- 5 Hz ---
- violin_swarm(0, control_dist_5Hz, control_animal, palette_ctrl, "s")
- violin_swarm(1, stim_dist_5Hz, stim_animal, palette_stim, "o")
- # --- 10 Hz ---
- violin_swarm(4, control_dist, control_animal, palette_ctrl, "s")
- violin_swarm(6, stim_dist, stim_animal, palette_stim, "o")
- # --- 20 Hz ---
- violin_swarm(8, control_dist_20Hz, control_animal, palette_ctrl, "s")
- violin_swarm(9, stim_dist_20Hz, stim_animal, palette_stim, "o")
- # =========================
- # Axes
- # =========================
- ax.set_ylim(0.1, 1.2)
- ax.set_ylabel("Normalised Riemannian Distance")
- ax.set_xlabel("")
- ax.set_xticks(
- [0, 1, 3, 4, 6, 7],
- labels=[
- "5Hz\n Control", "5Hz\n Stim.",
- "10Hz\n Control", "10Hz\n Stim.",
- "20Hz\n Control", "20Hz\n Stim."
- ]
- )
- # =========================
- # Statistics
- # =========================
- # 5 Hz
- ax.plot([0, 1], [0.85, 0.85], "k-", lw=1)
- ax.plot([0, 0], [0.85, 0.84], "k-", lw=1)
- ax.plot([1, 1], [0.85, 0.84], "k-", lw=1)
- _, p = kruskal(control_dist_5Hz, stim_dist_5Hz)
- ax.annotate(pval_to_star(p), (0.5, 0.86), ha="center")
- # 10 Hz
- ax.plot([3, 4], [1.1, 1.1], "k-", lw=1)
- ax.plot([3, 3], [1.1, 1.09], "k-", lw=1)
- ax.plot([4, 4], [1.1, 1.09], "k-", lw=1)
- _, p = kruskal(control_dist, stim_dist)
- ax.annotate(pval_to_star(p), (3.5, 1.11), ha="center")
- # 20 Hz
- ax.plot([6, 7], [0.85, 0.85], "k-", lw=1)
- ax.plot([6, 6], [0.85, 0.84], "k-", lw=1)
- ax.plot([7, 7], [0.85, 0.84], "k-", lw=1)
- _, p = kruskal(control_dist_20Hz, stim_dist_20Hz)
- ax.annotate(pval_to_star(p), (6.5, 0.86), ha="center")
- plt.tight_layout()
- # plt.savefig("Fig_dist_violin_swarm.svg", dpi=600, bbox_inches="tight")
- plt.show()
- # %% [markdown]
- # # 📚 Used Libraries
- # %%
- # List all currently imported libraries in the global namespace
- import types
- def list_imported_modules():
- return sorted(
- val.__name__ for name, val in globals().items()
- if isinstance(val, types.ModuleType)
- )
- # Display the list
- set(list_imported_modules())
analysis_pipeline.ipynb at commit 538ba57, under CC0-1.0 · at the source
Overview
- Department of Women and Children’s Health, School of Life Course and Population Sciences, King’s College London, Guy’s Campus, London, UK
- Department of Rehabilitation Medicine, The First Affiliated Hospital of Wenzhou Medical University, Wenzhou, Zhejiang China
- Department of Mathematics and Statistics, University of Exeter, Exeter, UK
- Living Systems Institute, University of Exeter, Exeter, UK
- The Pirbright Institute, Pirbright, Surrey, UK
- Biological Sciences, University of Missouri, Columbia, MO USA
- EPSRC Hub for Quantitative Modelling in Healthcare, University of Exeter, Exeter, UK
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
538ba57379a0669c2cdbd2f5c34e6d5d9f37a1e9, 8 January 2026Availability: 1 check, the latest on 30 September 2026: the link answers
- 30 September 2026: the link answers
24 files
- MePD_model/
UCN3 traces/ , MATLAB, 75 linesUCN3traces.m - MePD_model/
UCN3 traces/ , MATLAB, 93 linesheatmaps.m - MePD_model/
UCN3 traces/ , MATLAB, 292 linesviridis.m - MePD_model/
simulations/ , MATLAB, 101 lines, 2 matchesKNDyXMePDU.m - MePD_model/
simulations/ , MATLAB, 104 lines, 1 matchKNDyXMePD_stress.m - MePD_model/
simulations/ , MATLAB, 55 linesMePDU.m - MePD_model/
simulations/ , MATLAB, 48 linesMePD_stress.m - MePD_model/
simulations/ , MATLAB, 110 lines, 1 matchcombinedMePDv1.m - MePD_model/
simulations/ , MATLAB, 187 linescoupled_simulations.m - MePD_model/
simulations/ , MATLAB, 56 linesparameter_def.m - MePD_model/
simulations/ , MATLAB, 140 linessupplementary.m - data_analysis/
analysis_pipeline.ipynb , Jupyter, 2,816 lines, 5 matches - data_analysis/
calculate_riemannian_dis , Jupyter, 173 lines, 3 matchestances.ipynb - data_analysis/
data/ , MATLAB, 116 lines, 1 matchLH_profiling/ figures/ fig5D.m - data_analysis/
data/ , MATLAB, 104 lines, 2 matchesLH_profiling/ figures/ fig5J.m - data_analysis/
data/ , MATLAB, 116 linesLH_profiling/ figures/ fig6D.m - data_analysis/
data/ , MATLAB, 104 linesLH_profiling/ figures/ fig6J.m - data_analysis/
data/ , Jupyter, 136 linesLH_profiling/ figures/ statistical_analysis.ipy nb - data_analysis/
data/ , Jupyter, 168 linescalcium_imaging/ figures/ cluster_sizes_stat_fig1. ipynb - data_analysis/
data/ , Jupyter, 140 lines, 1 matchcalcium_imaging/ figures/ norm_Riemannian distance_stat_fig_1.ipyn b - data_analysis/
data/ , Jupyter, 213 lines, 1 matchcalcium_imaging/ figures/ permutation_test_AUC_dat a_fig1.ipynb - data_analysis/
extract_corr.ipynb , Jupyter, 418 lines - LICENSE, License, 121 lines
- README.md, Text, 7 lines
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/
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/
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://
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/
url = {https://
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/
VL - 17
IS - 1
SP - 5690
SN - 2041-1723
PB - Nature Publishing Group
DO - 10.1038/
UR - https://
LA - en
ER -
CSL-JSON
{
"id": "10.1038/
"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":
"volume": "17",
"issue": "1",
"page": "5690",
"DOI": "10.1038/
"PMID": "41807404",
"PMCID": "PMC13319269",
"ISSN": "2041-1723",
"publisher": "Nature Publishing Group",
"URL": "https://
"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: iScienceIn 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/aIn 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 neuroscienceIn 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 communicationsIn 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 communicationsIn 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 cheminformaticsIn 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 neuroscienceIn 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: NatureIn 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 biologyIn 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.
Claim this paper
Correct its record
Say what each link of this record is, remove the ones that are not the paper's, add the ones that are missing. The correction becomes a new version of the record, in its Versions section.
Validate its tracing map
You validate the map as this page shows it: 1 repository of the authors' code, each at its verified commit and with its license, 22 scripts, and 17 matches between paragraphs and code (see the Code and Map sections). It then receives a DOI on Zenodo, with you (your ORCID iD) and OSCR as its creators; the code itself is not deposited.
The map's fingerprint: sha256:8c08922a17f4c7e6…
Add the badge to its README
The badge links the code to this page. Copy one of these into the README of the paper's code: only you decide where it goes, and nothing is changed for you.
Markdown
[, paste the snippet at the top, then “Commit changes…” and, to review it first, “Create a new branch and start a pull request”. You open the pull request; OSCR asks for no permission.
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.
