OSCR

Serotonergic modulation of motor subspace dynamics drives a sleep-independent quiescent state.

Code ↔ Paper

15 matches between paragraphs of the paper and lines of its authors' code, computed by the harvester (lexical-v1). Click a colored paragraph or line to see its counterpart.

The 15 matches · 2 of them tie a paragraph to a whole file, not to given lines: weak matches, whose lines are not tinted
  1. [1] § Methods › Eigenvector-randomization surrogate ↔ analysis_from_mat.py, lines 545–633 · score 0.97 · random orthonormal basis, surrogate covariance matrix, eigenvalue spectrum, QR decomposition, Gaussian matrix, geometric structure
  2. [2] § Methods › Hyperbolic and Euclidean multidimensional scaling ↔ analysis_from_mat.py, lines 636–667 · score 0.78 · triangle inequality, proper metric, chord distance, hypersphere, vectors, correlated
  3. [3] § Results › DRN 5-HT neuron activation produces bout type–dependent, graded suppression of the motor subspace ↔ analysis_from_mat.py, lines 545–633 · score 0.77 · random orthonormal basis, eigenvalue spectrum, geometric structure, covariance matrix, randomized, symmetric
  4. [4] § Methods › Hyperbolic and Euclidean multidimensional scaling ↔ analysis_from_mat.py, lines 830–868 · score 0.76 · scikit learn, Euclidean MDS, precomputed, classical, metric, dissimilarity
  5. [5] § Methods › Subspace angle analysis ↔ code/figure4/dPCA_example.m, lines 457–482 · score 0.74 · sound related subspace, activation subspace, principal angle, motor related, orth, alignment
  6. [6] § Results › DRN 5-HT activation modulates motor circuits to suppress sound-evoked responses ↔ code/figure4/dPCA_example.m, lines 457–482 · score 0.71 · sound related subspace, geometric relationship, principal angle, motor related, alignment, activation
  7. [7] § Results › DRN 5-HT activation modulates motor circuits to suppress sound-evoked responses ↔ code/figure3/dpca_example.m, lines 358–373 · score 0.67 · geometric relationship, related subspace, principal angle, motor related, alignment, activation
  8. [8] § Results › DRN 5-HT neuron activation produces bout type–dependent, graded suppression of the motor subspace ↔ code/tools/computePearsonDistanceMatrix.m, the whole file · a weak match · score 0.66 · perfectly anti correlated, Pearson correlation, uncorrelated, distances, matrix, neuron
  9. [9] § Methods › Hyperbolic and Euclidean multidimensional scaling ↔ analysis_from_mat.py, lines 37–100 · score 0.64 · Shepard diagrams, embedded distances, original distances, diagonal, Euclidean, Hyperbolic
  10. [10] § Results › DRN 5-HT neuron activation produces bout type–dependent, graded suppression of the motor subspace ↔ analysis_from_mat.py, lines 37–100 · score 0.64 · original pairwise distances, Shepard diagram, Pairwise distance matrices, Original distance matrices, Euclidean, Embedding
  11. [11] § Methods › Subspace angle analysis ↔ code/figure3/dpca_example.m, lines 358–373 · score 0.62 · activation subspace, Principal angles, motor related, orth, alignment
  12. [12] § Methods › Subspace angle analysis ↔ code/figure4/subspace_angle_analysis.m, the whole file · a weak match · score 0.62 · random subspace, principal angle, orth, sound, tailed, DRN
  13. [13] § Methods › Hyperbolic and Euclidean multidimensional scaling ↔ metric_HMDS.py, lines 162–184 · score 0.59 · metric hmds, hyperbolic distances, Poincar, optimized, embedded
  14. [14] § Results › DRN 5-HT neuron activation produces bout type–dependent, graded suppression of the motor subspace ↔ analysis_from_mat.py, lines 724–827 · score 0.59 · hyperbolic fit, pairwise distances, Hyperbolic embeddings, baseline, space, curvature
  15. [15] § Methods › Hyperbolic and Euclidean multidimensional scaling ↔ analysis_from_mat.py, lines 830–868 · score 0.56 · Euclidean MDS, pairwise distance, distance matrix, uncertainty, multidimensional, variance

Paper

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

The paper is loaded when this pane is shown.

The authors' code

Python · 923 lines · 39 KB · MIT · 8 matches

  1. import metric_HMDS as HMDS
  2. import numpy as np
  3. from scipy.io import loadmat
  4. from scipy.io.matlab import MatlabOpaque
  5. import sys
  6. import argparse
  7. import contextlib
  8. import pickle
  9. import os
  10. import io
  11. # --- Code Quality & Clarity Suggestion: Use h5py for modern .mat files ---
  12. # MATLAB v7.3+ files are HDF5 format and require h5py to be read.
  13. # We import it here and will use it in the loading function.
  14. try:
  15. import h5py
  16. except ImportError:
  17. h5py = None
  18. # Add matplotlib and scikit-learn for plotting and MDS/PCA
  19. try:
  20. import matplotlib.pyplot as plt
  21. from sklearn.manifold import MDS
  22. from sklearn.decomposition import PCA
  23. from scipy.interpolate import interp1d
  24. from sklearn.metrics import pairwise_distances
  25. except ImportError:
  26. plt = None
  27. PCA = None
  28. MDS = None
  29. class MatReadError(Exception):
  30. """Custom exception for MATLAB file reading errors."""
  31. pass
  32. def plot_shepard_diagram(original_dmat, embedded_dmat, lambda_val=None, title="Shepard Diagram", output_file=None, sample_fraction=1.0):
  33. """
  34. Creates and displays a Shepard diagram to compare original and embedded distances.
  35. Args:
  36. original_dmat (np.ndarray): The original, normalized distance matrix.
  37. embedded_dmat (np.ndarray): The distance matrix from the embedding.
  38. lambda_val (float): The fitted curvature scale parameter.
  39. title (str): The title for the plot.
  40. output_file (str, optional): If provided, saves the plot to this file path as a PDF.
  41. sample_fraction (float, optional): Fraction of points to randomly sample for plotting (e.g., 0.1 for 10%). Default is 1.0 (all points).
  42. """
  43. if plt is None:
  44. print("\nWarning: matplotlib is not installed. Skipping plot. Please run 'pip install matplotlib'.")
  45. return
  46. print("Generating Shepard diagram...")
  47. # We use the upper triangle to avoid plotting each pair twice and the diagonal.
  48. N = original_dmat.shape[0]
  49. triu_indices_row, triu_indices_col = np.triu_indices(N, k=1)
  50. if sample_fraction < 1.0:
  51. num_pairs = len(triu_indices_row)
  52. sample_size = int(num_pairs * sample_fraction)
  53. if sample_size < 1:
  54. print(f"Warning: Sample fraction {sample_fraction} is too small for the number of pairs. No points will be plotted.")
  55. return
  56. print(f"Sampling {sample_size} of {num_pairs} pairs for Shepard diagram ({sample_fraction:.1%}).")
  57. sampled_idx = np.random.choice(num_pairs, size=sample_size, replace=False)
  58. indices = (triu_indices_row[sampled_idx], triu_indices_col[sampled_idx])
  59. else:
  60. indices = (triu_indices_row, triu_indices_col)
  61. original_distances = original_dmat[indices]
  62. if lambda_val is not None:
  63. embedded_distances = embedded_dmat[indices] / lambda_val
  64. ylabel = "Embedded Hyperbolic Distances / λ"
  65. else:
  66. embedded_distances = embedded_dmat[indices]
  67. ylabel = "Embedded Euclidean Distances"
  68. # Calculate R-squared (coefficient of determination)
  69. if len(original_distances) > 1 and len(embedded_distances) > 1:
  70. r_value = np.corrcoef(original_distances, embedded_distances)[0, 1]
  71. r_squared = r_value**2
  72. else:
  73. r_squared = np.nan
  74. fig, ax = plt.subplots(figsize=(8, 8))
  75. ax.scatter(original_distances, embedded_distances, alpha=0.5, s=15, edgecolors='k', linewidths=0.5)
  76. ax.plot([0, max(original_distances.max(), embedded_distances.max())], [0, max(original_distances.max(), embedded_distances.max())], 'r--', label=f'Perfect Match (y=x)\n$R^2 = {r_squared:.2f}$')
  77. ax.set_xlabel("Original Pairwise Distances (Normalized)")
  78. ax.set_ylabel(ylabel)
  79. ax.set_title(title)
  80. ax.grid(True)
  81. ax.set_aspect('equal', 'box')
  82. ax.legend()
  83. plt.show()
  84. if output_file:
  85. print(f"Saving Shepard diagram to {output_file}...")
  86. fig.savefig(output_file, format='pdf', bbox_inches='tight')
  87. plt.close(fig)
  88. def plot_poincare_2d(poincare_coords, title="2D Hyperbolic Embedding (Poincare Disk)", colors=None, output_file=None):
  89. """
  90. Creates a 2D visualization of points in the Poincare disk.
  91. Args:
  92. poincare_coords (np.ndarray): An N x 2 array of Poincare coordinates.
  93. title (str): The title for the plot.
  94. colors (optional): An array of colors for the points.
  95. output_file (str, optional): If provided, saves the plot to this file path.
  96. """
  97. if plt is None:
  98. print("\nWarning: matplotlib is not installed. Skipping 2D plot. Please run 'pip install matplotlib'.")
  99. return
  100. print("Generating 2D Poincare disk visualization...")
  101. fig, ax = plt.subplots(figsize=(8, 8))
  102. # Draw the boundary circle
  103. circle = plt.Circle((0, 0), 1.0, color='gray', fill=False, alpha=0.5)
  104. ax.add_artist(circle)
  105. # Use provided colors (and a colormap) or default to blue
  106. point_colors = colors if colors is not None else 'b'
  107. scatter = ax.scatter(poincare_coords[:, 0], poincare_coords[:, 1], c=point_colors, s=20, cmap='Reds')
  108. ax.set_title(title)
  109. ax.set_aspect('equal', 'box')
  110. # Add a colorbar if a color sequence is provided
  111. if colors is not None:
  112. fig.colorbar(scatter, ax=ax, label="Point Index")
  113. if output_file:
  114. print(f"Saving 2D plot to {output_file}...")
  115. fig.savefig(output_file, format='pdf', bbox_inches='tight')
  116. plt.close(fig)
  117. plt.show()
  118. def plot_poincare_3d(poincare_coords, colors=None, output_file=None):
  119. """Creates an interactive 3D visualization of points in the Poincare ball.
  120. Args:
  121. poincare_coords (np.ndarray): An N x 3 array of Poincare coordinates.
  122. colors (optional): An array of colors for the points.
  123. output_file (str, optional): If provided, saves the plot to this file path.
  124. """
  125. if plt is None:
  126. print("\nWarning: matplotlib is not installed. Skipping 3D plot. Please run 'pip install matplotlib'.")
  127. return
  128. if poincare_coords.shape[1] != 3:
  129. print(f"\nWarning: 3D plot is only available for 3D embeddings. Found {poincare_coords.shape[1]} dimensions. Skipping plot.")
  130. return
  131. print("Generating interactive 3D Poincare ball visualization...")
  132. fig = plt.figure(figsize=(9, 9))
  133. ax = fig.add_subplot(111, projection='3d')
  134. # Draw the boundary sphere (wireframe)
  135. u, v = np.mgrid[0:2*np.pi:20j, 0:np.pi:10j]
  136. x = np.cos(u)*np.sin(v)
  137. y = np.sin(u)*np.sin(v)
  138. z = np.cos(v)
  139. ax.plot_wireframe(x, y, z, color="gray", alpha=0.3)
  140. # Use provided colors (and a colormap) or default to blue
  141. point_colors = colors if colors is not None else 'b'
  142. scatter = ax.scatter(poincare_coords[:, 0], poincare_coords[:, 1], poincare_coords[:, 2], c=point_colors, s=20, cmap='Reds')
  143. # Add a colorbar if a color sequence is provided
  144. if colors is not None:
  145. fig.colorbar(scatter, ax=ax, shrink=0.6, aspect=20, label="Point Index")
  146. ax.set_xlabel("X coordinate")
  147. ax.set_ylabel("Y coordinate")
  148. ax.set_zlabel("Z coordinate")
  149. ax.set_title("3D Hyperbolic Embedding (Poincare Ball)")
  150. # Set aspect ratio to be equal
  151. ax.set_box_aspect([1,1,1])
  152. if output_file:
  153. print(f"Saving 3D plot to {output_file}...")
  154. fig.savefig(output_file, format='pdf', bbox_inches='tight')
  155. plt.close(fig)
  156. plt.show()
  157. def plot_poincare_3d_projections(poincare_coords, colors=None, output_file=None):
  158. """Creates 2D projections (XY, XZ, YZ) of a 3D Poincare embedding.
  159. Args:
  160. poincare_coords (np.ndarray): An N x 3 array of Poincare coordinates.
  161. colors (optional): An array of colors for the points.
  162. output_file (str, optional): If provided, saves the plot to this file path.
  163. """
  164. if plt is None:
  165. print("\nWarning: matplotlib is not installed. Skipping 2D projections plot.")
  166. return
  167. if poincare_coords.shape[1] != 3:
  168. return # Silently fail as the calling function should handle this.
  169. print("Generating 2D projections of the 3D embedding...")
  170. fig, axes = plt.subplots(1, 3, figsize=(24, 7))
  171. fig.suptitle("2D Projections of 3D Embedding", fontsize=16)
  172. projections = [
  173. (0, 1, 'X', 'Y'),
  174. (0, 2, 'X', 'Z'),
  175. (1, 2, 'Y', 'Z')
  176. ]
  177. for ax, (d1, d2, d1_name, d2_name) in zip(axes, projections):
  178. circle = plt.Circle((0, 0), 1.0, color='gray', fill=False, alpha=0.5)
  179. ax.add_artist(circle)
  180. scatter = ax.scatter(poincare_coords[:, d1], poincare_coords[:, d2], c=colors, s=20, cmap='Reds')
  181. ax.set_title(f"{d1_name} vs {d2_name} Projection")
  182. ax.set_xlabel(f"{d1_name} coordinate")
  183. ax.set_ylabel(f"{d2_name} coordinate")
  184. ax.set_aspect('equal', 'box')
  185. ax.grid(True)
  186. if colors is not None:
  187. fig.colorbar(scatter, ax=axes.ravel().tolist(), shrink=0.8, label="Point Index")
  188. if output_file:
  189. print(f"Saving 3D projections plot to {output_file}...")
  190. fig.savefig(output_file, format='pdf', bbox_inches='tight')
  191. plt.close(fig)
  192. plt.show()
  193. def visualize_embedding(fit_results, output_prefix=None):
  194. """
  195. Visualizes the embedding. For d=2 or d=3, it plots directly.
  196. For d > 3, it uses PCA to project the data to 2D for visualization.
  197. Args:
  198. fit_results (dict): The dictionary returned by run_embedding.
  199. output_prefix (str, optional): If provided, saves plots to files with this prefix.
  200. """
  201. if plt is None:
  202. print("\nWarning: matplotlib is not installed. Skipping visualization.")
  203. return
  204. coords = fit_results['cp']
  205. dim = coords.shape[1]
  206. # Generate a color array based on the index of each point
  207. num_points = coords.shape[0]
  208. colors = np.arange(num_points)
  209. def get_path(suffix):
  210. return f"{output_prefix}_{suffix}.pdf" if output_prefix else None
  211. if dim == 2:
  212. plot_poincare_2d(coords, colors=colors, output_file=get_path("2d"))
  213. elif dim == 3:
  214. plot_poincare_3d(coords, colors=colors, output_file=get_path("3d"))
  215. plot_poincare_3d_projections(coords, colors=colors, output_file=get_path("3d_projections"))
  216. elif dim > 3:
  217. print(f"\nEmbedding dimension is {dim} (>3). Projecting to 2D using PCA for visualization.")
  218. # Use PCA to find the three principal components
  219. pca = PCA(n_components=3)
  220. projected_coords = pca.fit_transform(coords)
  221. explained_variance = sum(pca.explained_variance_ratio_) * 100
  222. print(f"The 3D PCA projection explains {explained_variance:.2f}% of the variance.")
  223. title = f"PCA Projection of {dim}D Embedding to 3D Poincare Ball"
  224. plot_poincare_3d(projected_coords, colors=colors, output_file=get_path("pca_3d"))
  225. plot_poincare_3d_projections(projected_coords, colors=colors, output_file=get_path("pca_3d_projections"))
  226. def sample_submatrix(dmat, n, seed=None):
  227. """
  228. Randomly samples N neurons from an MxM distance matrix and returns an NxN submatrix.
  229. Args:
  230. dmat (np.ndarray): The MxM distance matrix.
  231. n (int): Number of neurons to sample.
  232. seed (int, optional): Random seed for reproducibility.
  233. Returns:
  234. tuple: (submatrix, indices) where submatrix is the NxN distance matrix
  235. and indices are the sampled row/column indices.
  236. """
  237. m = dmat.shape[0]
  238. if n > m:
  239. raise ValueError(f"Cannot sample {n} neurons from a {m}x{m} matrix.")
  240. rng = np.random.default_rng(seed)
  241. indices = np.sort(rng.choice(m, size=n, replace=False))
  242. submatrix = dmat[np.ix_(indices, indices)]
  243. print(f"Sampled {n} neurons from {m}x{m} matrix -> {n}x{n} submatrix. Indices: {indices}")
  244. return submatrix, indices
  245. def load_dmat_from_mat(mat_file_path, matrix_variable_name):
  246. """
  247. Loads a distance matrix from a .mat file, handling both old and v7.3 formats.
  248. Args:
  249. mat_file_path (str): Path to the .mat file.
  250. matrix_variable_name (str): The name of the distance matrix variable inside the .mat file.
  251. Returns:
  252. np.ndarray: The loaded distance matrix.
  253. """
  254. try:
  255. # First, try reading with scipy's loadmat, which handles older formats.
  256. mat_data = loadmat(mat_file_path, simplify_cells=True)
  257. except NotImplementedError:
  258. # This error is raised for v7.3 .mat files. We switch to h5py.
  259. print("Detected MATLAB v7.3 file, switching to h5py reader...")
  260. if h5py is None:
  261. raise MatReadError("MATLAB v7.3 file detected, but 'h5py' is not installed. Please run 'pip install h5py'.")
  262. try:
  263. with h5py.File(mat_file_path, 'r') as f:
  264. # h5py doesn't load the whole file into a dict, so we access the variable directly.
  265. if matrix_variable_name not in f:
  266. available_vars = list(f.keys())
  267. raise MatReadError(f"Variable '{matrix_variable_name}' not found in HDF5 file. Available variables: {available_vars}")
  268. # Extract the data and convert to a NumPy array.
  269. # Note: h5py may read data transposed compared to MATLAB. For a symmetric
  270. # distance matrix, this is not an issue.
  271. dmat = np.array(f[matrix_variable_name], dtype=float)
  272. except Exception as e:
  273. raise MatReadError(f"Failed to read HDF5 file with h5py: {e}")
  274. except FileNotFoundError:
  275. raise MatReadError(f"The file '{mat_file_path}' was not found.")
  276. except Exception as e:
  277. raise MatReadError(f"An unexpected error occurred while reading the .mat file: {e}")
  278. else:
  279. # This block runs if scipy.io.loadmat succeeded.
  280. if matrix_variable_name not in mat_data:
  281. available_vars = [k for k in mat_data.keys() if not k.startswith('__')]
  282. raise MatReadError(f"Variable '{matrix_variable_name}' not found in .mat file. Available variables: {available_vars}")
  283. dmat = mat_data[matrix_variable_name]
  284. # Handle complex data structures that can arise from MATLAB structs/cells
  285. if isinstance(dmat, MatlabOpaque):
  286. raise MatReadError("The variable is a complex MATLAB object. Please simplify it in MATLAB before saving.")
  287. # Ensure the matrix is a numpy array of the correct type (float)
  288. dmat = np.array(dmat, dtype=float)
  289. print(f"Successfully loaded matrix '{matrix_variable_name}' with shape: {dmat.shape}")
  290. return dmat
  291. def calculate_bic(dmat, emb_mat, n_params, is_hyperbolic=False, lambda_val=None, sig_vals=None, dmat_unc=None):
  292. """
  293. Calculates the Bayesian Information Criterion (BIC) for an embedding.
  294. Args:
  295. dmat (np.ndarray): The original distance matrix.
  296. dmat_unc (np.ndarray, optional): The matrix of uncertainties for each distance.
  297. emb_mat (np.ndarray): The distance matrix from the embedding.
  298. n_params (int): The number of parameters in the model.
  299. is_hyperbolic (bool): Flag to indicate if the model is hyperbolic.
  300. lambda_val (float, optional): The lambda scale parameter for hyperbolic models.
  301. sig_vals (np.ndarray, optional): The uncertainty parameters for hyperbolic models.
  302. Returns:
  303. float: The calculated BIC value.
  304. """
  305. N = dmat.shape[0]
  306. n_pairs = N * (N - 1) / 2
  307. indices = np.triu_indices(N, k=1)
  308. original_distances = dmat[indices]
  309. embedded_distances = emb_mat[indices]
  310. if is_hyperbolic:
  311. # Combine inferred and data uncertainties, matching the Stan model
  312. inferred_seff_sq = np.array([sig_vals[i]**2 + sig_vals[j]**2 for i in range(N) for j in range(i + 1, N)])
  313. data_unc_sq = dmat_unc[indices] if dmat_unc is not None else 0
  314. seff_sq = inferred_seff_sq + data_unc_sq
  315. residuals = original_distances - (embedded_distances / lambda_val)
  316. log_likelihood = -0.5 * np.sum(np.log(2 * np.pi * seff_sq) + (residuals**2 / seff_sq))
  317. else: # Euclidean
  318. # For the Euclidean case, we assume a single global model variance (sigma_model^2)
  319. # plus the known data variance for each pair.
  320. residuals = original_distances - embedded_distances
  321. rss = np.sum(residuals**2)
  322. # Estimate the single model variance parameter via MLE.
  323. # This is equivalent to the average residual sum of squares.
  324. sigma2_model_mle = rss / n_pairs
  325. data_unc_sq = dmat_unc[indices] if dmat_unc is not None else 0
  326. total_variance = sigma2_model_mle + data_unc_sq
  327. log_likelihood = -0.5 * np.sum(np.log(2 * np.pi * total_variance) + (residuals**2 / total_variance))
  328. return n_params * np.log(n_pairs) - 2 * log_likelihood
  329. def run_embedding(dmat, embedding_dim, dmat_unc=None, verbose=False, output_path=None):
  330. """
  331. Takes a distance matrix, normalizes it, and runs the HMDS embedding.
  332. Args:
  333. dmat (np.ndarray): The input distance matrix.
  334. dmat_unc (np.ndarray, optional): The matrix of uncertainties for each distance.
  335. embedding_dim (int): The target dimension for the hyperbolic embedding.
  336. verbose (bool): If True, prints Stan's optimization progress. Defaults to False.
  337. output_path (str, optional): If provided, saves the fit_results dictionary
  338. to this path using pickle.
  339. Returns:
  340. dict: The 'fit' dictionary containing all embedding results.
  341. """
  342. # Ensure the matrix is a numpy array of the correct type (float)
  343. if not isinstance(dmat, np.ndarray) or dmat.dtype != float:
  344. dmat = np.array(dmat, dtype=float)
  345. if dmat.ndim != 2 or dmat.shape[0] != dmat.shape[1]:
  346. raise ValueError(f"Input matrix must be square. Got shape: {dmat.shape}")
  347. # --- Pre-processing (as seen in tst.py) ---
  348. # It's often a good idea to normalize the distance matrix.
  349. # The original paper and tst.py normalize it to have a max value of 2.0.
  350. print("Normalizing distance matrix...")
  351. dmat = 2.0 * dmat / np.max(dmat)
  352. # --- Run the Embedding ---
  353. print(f"Starting HMDS embedding into {embedding_dim} dimensions...")
  354. # This step can take several minutes, especially the first time.
  355. if verbose:
  356. fit = HMDS.embed(embedding_dim, dmat, dij_unc=dmat_unc)
  357. else:
  358. # Suppress the C++ output from Stan by redirecting stdout and stderr
  359. print("Stan optimization output is suppressed. This may take a while...")
  360. f = io.StringIO()
  361. with contextlib.redirect_stdout(f), contextlib.redirect_stderr(f):
  362. fit = HMDS.embed(embedding_dim, dmat, dij_unc=dmat_unc)
  363. # --- Print Results ---
  364. print("\n--- Embedding Complete ---")
  365. print(f"Fitted curvature scale parameter (lambda): {fit['lambda']:.4f}")
  366. print(f"Embedding contains {len(fit['euc'])} points.")
  367. print("Results are available in the 'fit' dictionary.")
  368. # --- Calculate BIC ---
  369. n_params = dmat.shape[0] * embedding_dim + dmat.shape[0] + 1 - embedding_dim*(embedding_dim - 1)/2 # N*D + N + 1 - D*(D-1)/2
  370. fit['bic'] = calculate_bic(fit['dmat'], fit['emb_mat'], n_params,
  371. is_hyperbolic=True, lambda_val=fit['lambda'],
  372. sig_vals=fit['sig'], dmat_unc=dmat_unc)
  373. # --- Save Results to Disk ---
  374. if output_path:
  375. try:
  376. # Ensure the directory exists
  377. os.makedirs(os.path.dirname(output_path), exist_ok=True)
  378. with open(output_path, 'wb') as f_out:
  379. pickle.dump(fit, f_out)
  380. print(f"\nEmbedding results successfully saved to: {output_path}")
  381. except Exception as e:
  382. print(f"\nWarning: Could not save results to '{output_path}'. Error: {e}")
  383. return fit
  384. def run_embedding_trials(dmat, embedding_dim, n_trials=5, dmat_unc=None, verbose=False,
  385. output_path=None):
  386. """
  387. Runs the hyperbolic embedding multiple times and aggregates lambda across trials.
  388. The Stan optimizer uses a random initialization on each call, so lambda can
  389. vary across runs. This function collects all trial results, reports mean ± std,
  390. and returns the trial with the best (lowest) BIC as the representative fit.
  391. Args:
  392. dmat (np.ndarray): The input distance matrix.
  393. embedding_dim (int): Target embedding dimension.
  394. n_trials (int): Number of independent runs. Default 5.
  395. dmat_unc (np.ndarray, optional): Uncertainty matrix, same shape as dmat.
  396. verbose (bool): If True, prints Stan output for each trial.
  397. output_path (str, optional): If provided, saves the best-BIC fit to this path.
  398. Returns:
  399. dict with keys:
  400. 'best_fit' : full fit dict for the trial with the lowest BIC
  401. 'all_fits' : list of all n_trials fit dicts
  402. 'lambda_mean' : mean lambda across trials
  403. 'lambda_std' : std of lambda across trials
  404. 'lambda_all' : array of per-trial lambda values
  405. 'bic_all' : array of per-trial BIC values
  406. """
  407. lambda_vals = []
  408. bic_vals = []
  409. all_fits = []
  410. for i in range(n_trials):
  411. print(f"\n{'='*60}")
  412. print(f"Trial {i + 1} / {n_trials}")
  413. fit = run_embedding(dmat, embedding_dim, dmat_unc=dmat_unc, verbose=verbose)
  414. lambda_vals.append(fit['lambda'])
  415. bic_vals.append(fit['bic'])
  416. all_fits.append(fit)
  417. lambda_vals = np.array(lambda_vals)
  418. bic_vals = np.array(bic_vals)
  419. best_idx = int(np.argmin(bic_vals))
  420. best_fit = all_fits[best_idx]
  421. print(f"\n{'='*60}")
  422. print(f"Trial Summary ({n_trials} runs, dim={embedding_dim})")
  423. print(f"{'Trial':<8} {'lambda':>10} {'BIC':>14}")
  424. print("-" * 34)
  425. for i, (lam, bic) in enumerate(zip(lambda_vals, bic_vals)):
  426. marker = " <-- best BIC" if i == best_idx else ""
  427. print(f"{i + 1:<8} {lam:>10.4f} {bic:>14.2f}{marker}")
  428. print("-" * 34)
  429. print(f"{'Mean':<8} {lambda_vals.mean():>10.4f}")
  430. print(f"{'Std':<8} {lambda_vals.std():>10.4f} "
  431. f"({100 * lambda_vals.std() / lambda_vals.mean():.1f}% of mean)")
  432. if output_path:
  433. try:
  434. os.makedirs(os.path.dirname(output_path), exist_ok=True)
  435. with open(output_path, 'wb') as f_out:
  436. pickle.dump(best_fit, f_out)
  437. print(f"\nBest-BIC fit saved to: {output_path}")
  438. except Exception as e:
  439. print(f"\nWarning: Could not save results to '{output_path}'. Error: {e}")
  440. return {
  441. 'best_fit' : best_fit,
  442. 'all_fits' : all_fits,
  443. 'lambda_mean' : lambda_vals.mean(),
  444. 'lambda_std' : lambda_vals.std(),
  445. 'lambda_all' : lambda_vals,
  446. 'bic_all' : bic_vals,
  447. }
  448. def surrogate_distance_matrix(cov_mat, corr_unc=None, seed=None, distance_method='chord'):
  449. """
  450. Constructs a null-model distance matrix that preserves the eigenvalue spectrum
  451. of a covariance matrix but randomises its geometric structure.
  452. Procedure
  453. ---------
  454. 1. Eigendecompose the real covariance matrix: Cov = V @ diag(w) @ V.T
  455. 2. Draw a random orthonormal basis Q via QR decomposition of a Gaussian matrix.
  456. 3. Reconstruct a surrogate covariance matrix: Cov_surr = Q @ diag(w) @ Q.T
  457. 4. Normalise to a correlation matrix: Corr_surr[i,j] = Cov_surr[i,j] / sqrt(Cov_surr[i,i] * Cov_surr[j,j])
  458. 5. Convert to a distance matrix and, if corr_unc is provided, propagate
  459. the correlation uncertainty to a distance uncertainty via corr_unc_to_dist_unc.
  460. Args:
  461. cov_mat (np.ndarray): MxM real symmetric positive-semidefinite covariance matrix.
  462. corr_unc (np.ndarray, optional): MxM matrix of correlation uncertainties.
  463. The same uncertainty matrix is used for the surrogate (the surrogate
  464. randomises geometry, not measurement precision).
  465. seed (int, optional): Random seed for reproducibility.
  466. distance_method (str): 'chord' (default) or 'linear'.
  467. Returns:
  468. dict with keys:
  469. 'corr_surrogate' : MxM surrogate correlation matrix
  470. 'dmat_surrogate' : MxM surrogate distance matrix
  471. 'dmat_unc' : MxM propagated distance uncertainty (None if corr_unc not given)
  472. 'eigenvalues' : sorted eigenvalues (descending) from the real covariance matrix
  473. 'Q' : the random orthonormal basis used
  474. """
  475. rng = np.random.default_rng(seed)
  476. M = cov_mat.shape[0]
  477. # Step 1 — sanitize and symmetrise the input before eigendecomposition.
  478. if not np.all(np.isfinite(cov_mat)):
  479. raise ValueError("Input covariance matrix contains NaN or Inf values.")
  480. cov_in = cov_mat.astype(np.float64, copy=True)
  481. cov_sym = (cov_in + cov_in.T) / 2.0
  482. # Step 2 — eigendecompose; clip negative eigenvalues to 0 (numerical noise).
  483. w, _ = np.linalg.eigh(cov_sym) # ascending order
  484. n_neg_clipped = int(np.sum(w < 0))
  485. w = np.clip(w, 0.0, None)
  486. w = w[::-1].copy() # descending, contiguous for BLAS
  487. if n_neg_clipped > 0:
  488. print(f"Note: {n_neg_clipped} negative eigenvalue(s) clipped to 0 "
  489. f"(input matrix is not perfectly PSD).")
  490. print(f"Eigenvalue range: [{w[-1]:.4f}, {w[0]:.4f}], sum={w.sum():.4f}")
  491. # Step 3 — random orthonormal basis via QR of a Gaussian matrix.
  492. G = rng.standard_normal((M, M))
  493. Q, _ = np.linalg.qr(G)
  494. # Step 4 — surrogate covariance matrix.
  495. cov_surr = np.einsum('ik,k,jk->ij', Q, w, Q)
  496. # Step 5 — convert surrogate covariance to correlation matrix.
  497. std = np.sqrt(np.diag(cov_surr))
  498. # Guard against zero variance (degenerate directions).
  499. std = np.where(std == 0, 1.0, std)
  500. corr_surr = cov_surr / np.outer(std, std)
  501. # Numerical cleanup: clip to [-1, 1] and reset diagonal.
  502. corr_surr = np.clip(corr_surr, -1.0, 1.0)
  503. np.fill_diagonal(corr_surr, 1.0)
  504. if not np.all(np.isfinite(corr_surr)):
  505. n_bad = int(np.sum(~np.isfinite(corr_surr)))
  506. raise ValueError(f"Surrogate correlation matrix contains {n_bad} non-finite "
  507. f"entries after reconstruction. Check your input covariance matrix.")
  508. # Step 6 — correlation -> distance.
  509. dmat_surr = corr_to_distance(corr_surr, method=distance_method)
  510. # Step 7 — propagate uncertainty if provided.
  511. dmat_unc = None
  512. if corr_unc is not None:
  513. dmat_unc = corr_unc_to_dist_unc(corr_surr, corr_unc, method=distance_method)
  514. print(f"Surrogate built: eigenvalue range [{w[-1]:.4f}, {w[0]:.4f}], "
  515. f"distance range [{dmat_surr[dmat_surr > 0].min():.4f}, {dmat_surr.max():.4f}]")
  516. return {
  517. 'corr_surrogate': corr_surr,
  518. 'dmat_surrogate': dmat_surr,
  519. 'dmat_unc': dmat_unc,
  520. 'eigenvalues': w,
  521. 'Q': Q,
  522. }
  523. def corr_to_distance(corr_mat, method='chord'):
  524. """
  525. Converts a correlation matrix to a distance matrix.
  526. Two methods are available:
  527. 'chord' (default) — D_ij = sqrt(2 * (1 - C_ij))
  528. The chord distance between unit vectors on a hypersphere.
  529. Satisfies the triangle inequality; a proper metric.
  530. Recommended for HMDS because the embedding model assumes a valid metric space.
  531. 'linear' — D_ij = 1 - C_ij
  532. A simpler dissimilarity. Does NOT satisfy the triangle inequality in general
  533. (e.g. three vectors at 60°/60°/120° give D(u,w)=1.5 > D(u,v)+D(v,w)=1.0).
  534. Using this with HMDS is technically misspecified; it compresses mid-range
  535. correlations and can inflate the fitted lambda relative to 'chord'.
  536. Args:
  537. corr_mat (np.ndarray): MxM correlation matrix with values in [-1, 1].
  538. method (str): 'chord' (default) or 'linear'.
  539. Returns:
  540. np.ndarray: MxM distance matrix with zeros on the diagonal.
  541. """
  542. if method == 'chord':
  543. dmat = np.sqrt(np.clip(2.0 * (1.0 - corr_mat), 0.0, None))
  544. elif method == 'linear':
  545. dmat = 1.0 - corr_mat
  546. else:
  547. raise ValueError(f"Unknown method '{method}'. Choose 'chord' or 'linear'.")
  548. np.fill_diagonal(dmat, 0.0)
  549. return dmat
  550. def corr_unc_to_dist_unc(corr_mat, corr_var, method='chord', reg=1e-8):
  551. """
  552. Propagates per-element variance from a correlation matrix to a distance matrix
  553. using first-order error propagation (the delta method).
  554. The input and output are both **variances**, matching what the Stan model expects:
  555. seff = sqrt(sig[i]^2 + sig[j]^2 + deltaij_unc[i,j])
  556. where deltaij_unc is treated as variance (added to squared sigma terms).
  557. Because the linear distance D_linear = 1 - C, Var(C) = Var(D_linear), so you
  558. can pass the loaded variance matrix (e.g. var_dij_laserOn) directly as corr_var.
  559. Chord method — D_chord = sqrt(2*(1 - C))
  560. dD/dC = -1 / D_chord
  561. Var(D_chord) = (dD/dC)^2 * Var(C) = Var(C) / D_chord^2
  562. Diverges as D_chord → 0 (C → 1). The denominator is regularised:
  563. Var(D_chord) = Var(C) / max(D_chord, reg)^2
  564. Linear method — D_linear = 1 - C
  565. dD/dC = -1 → Var(D_linear) = Var(C) (pass-through, reg unused).
  566. Args:
  567. corr_mat (np.ndarray): MxM correlation matrix (values in [-1, 1]).
  568. corr_var (np.ndarray): MxM matrix of correlation variances (>= 0).
  569. Equal to Var(D_linear) since D_linear = 1 - C.
  570. method (str): 'chord' (default) or 'linear'.
  571. reg (float): Regularisation floor for the chord-distance denominator.
  572. Entries with D_chord < reg are treated as D_chord = reg.
  573. Default 1e-8 (negligible on the [0, 2] distance scale).
  574. Returns:
  575. np.ndarray: MxM distance-variance matrix. Diagonal is 0.
  576. Pass directly as dmat_unc to run_embedding().
  577. """
  578. if corr_var.shape != corr_mat.shape:
  579. raise ValueError("corr_var must have the same shape as corr_mat.")
  580. if method == 'chord':
  581. dmat = np.sqrt(np.clip(2.0 * (1.0 - corr_mat), 0.0, None))
  582. n_reg = np.sum((dmat < reg) & ~np.eye(dmat.shape[0], dtype=bool))
  583. if n_reg > 0:
  584. print(f"Note: {n_reg} off-diagonal pairs have D_chord < {reg:.0e}; "
  585. f"denominator floored at {reg:.0e} for variance propagation.")
  586. dmat_var = corr_var / np.maximum(dmat, reg) ** 2
  587. elif method == 'linear':
  588. dmat_var = corr_var.copy()
  589. else:
  590. raise ValueError(f"Unknown method '{method}'. Choose 'chord' or 'linear'.")
  591. np.fill_diagonal(dmat_var, 0.0)
  592. return dmat_var
  593. def outlier_sensitivity_analysis(dmat, embedding_dim, removal_fractions=(0.05, 0.10, 0.20),
  594. dmat_unc=None, verbose=False):
  595. """
  596. Assesses whether the hyperbolic fit is driven by a tail of outlier neurons.
  597. Neurons are ranked by their mean pairwise distance to all others (their
  598. "centrality" in distance space). The top removal_fractions of most-extreme
  599. neurons are removed one fraction at a time, and the hyperbolic embedding is
  600. refit on each pruned submatrix. The returned lambda values reveal whether
  601. curvature is a distributed population property or an artefact of a few outliers.
  602. Interpretation guide
  603. --------------------
  604. - lambda drops sharply (>30%) after removing 5-10%: curvature is outlier-driven;
  605. the hierarchical interpretation is weak.
  606. - lambda stays within ~20-30% of the full-data value through 20% removal:
  607. curvature reflects a distributed property of the population.
  608. Args:
  609. dmat (np.ndarray): The MxM distance matrix (raw, un-normalised).
  610. embedding_dim (int): Target dimension for each hyperbolic embedding.
  611. removal_fractions (tuple of float): Fractions of neurons to remove (cumulative).
  612. dmat_unc (np.ndarray, optional): Uncertainty matrix, same shape as dmat.
  613. verbose (bool): Whether to print Stan optimisation output.
  614. Returns:
  615. dict: Keys are 'full' and each fraction string (e.g. '5%').
  616. Values are dicts with 'lambda', 'n_neurons', 'removed_indices', 'fit'.
  617. """
  618. M = dmat.shape[0]
  619. # Rank neurons by mean pairwise distance (excluding self-distance on diagonal)
  620. mean_dist = (dmat.sum(axis=1) - np.diag(dmat)) / (M - 1)
  621. rank_order = np.argsort(mean_dist)[::-1] # descending: most extreme first
  622. results = {}
  623. # --- Full-data baseline ---
  624. print(f"\n{'='*60}")
  625. print(f"[Baseline] Fitting on all {M} neurons...")
  626. fit_full = run_embedding(dmat, embedding_dim, dmat_unc=dmat_unc, verbose=verbose)
  627. lambda_full = fit_full['lambda']
  628. results['full'] = {
  629. 'lambda': lambda_full,
  630. 'n_neurons': M,
  631. 'removed_indices': np.array([], dtype=int),
  632. 'fit': fit_full,
  633. }
  634. print(f"[Baseline] lambda = {lambda_full:.4f}")
  635. # --- Pruned subsets ---
  636. for frac in removal_fractions:
  637. n_remove = int(np.round(frac * M))
  638. removed_idx = rank_order[:n_remove]
  639. keep_idx = np.sort(np.setdiff1d(np.arange(M), removed_idx))
  640. n_keep = len(keep_idx)
  641. label = f"{int(frac * 100)}%"
  642. print(f"\n{'='*60}")
  643. print(f"[Remove top {label}] Removing {n_remove} most-extreme neurons, "
  644. f"refitting on {n_keep} neurons...")
  645. sub_dmat = dmat[np.ix_(keep_idx, keep_idx)]
  646. sub_unc = dmat_unc[np.ix_(keep_idx, keep_idx)] if dmat_unc is not None else None
  647. fit_pruned = run_embedding(sub_dmat, embedding_dim, dmat_unc=sub_unc, verbose=verbose)
  648. lambda_pruned = fit_pruned['lambda']
  649. pct_change = 100.0 * (lambda_pruned - lambda_full) / lambda_full
  650. results[label] = {
  651. 'lambda': lambda_pruned,
  652. 'n_neurons': n_keep,
  653. 'removed_indices': removed_idx,
  654. 'fit': fit_pruned,
  655. }
  656. print(f"[Remove top {label}] lambda = {lambda_pruned:.4f} "
  657. f"({pct_change:+.1f}% vs baseline)")
  658. # --- Summary table ---
  659. print(f"\n{'='*60}")
  660. print("Outlier Sensitivity Summary")
  661. print(f"{'Condition':<18} {'N neurons':>10} {'lambda':>10} {'Δλ vs full':>12}")
  662. print("-" * 52)
  663. print(f"{'Full data':<18} {M:>10} {lambda_full:>10.4f} {'—':>12}")
  664. for frac in removal_fractions:
  665. label = f"{int(frac * 100)}%"
  666. r = results[label]
  667. pct_change = 100.0 * (r['lambda'] - lambda_full) / lambda_full
  668. print(f"{'Remove top ' + label:<18} {r['n_neurons']:>10} "
  669. f"{r['lambda']:>10.4f} {pct_change:>+11.1f}%")
  670. stable = all(
  671. abs(results[f"{int(f*100)}%"]['lambda'] - lambda_full) / lambda_full <= 0.30
  672. for f in removal_fractions
  673. )
  674. print()
  675. if stable:
  676. print("Verdict: lambda is STABLE (<= 30% change). Curvature appears to be a "
  677. "distributed population property.")
  678. else:
  679. print("Verdict: lambda is UNSTABLE (> 30% change for at least one removal). "
  680. "The hyperbolic fit may be driven by a small tail of outlier neurons.")
  681. return results
  682. def run_euclidean_embedding(dmat, embedding_dim, dmat_unc=None):
  683. """
  684. Performs classical multidimensional scaling (MDS) for a Euclidean embedding.
  685. Args:
  686. dmat (np.ndarray): The input distance matrix.
  687. embedding_dim (int): The target dimension for the Euclidean embedding.
  688. dmat_unc (np.ndarray, optional): The matrix of uncertainties for each distance.
  689. Returns:
  690. dict or None: A dictionary containing the embedding results, or None if
  691. scikit-learn is not installed.
  692. """
  693. if MDS is None:
  694. print("\nWarning: scikit-learn is not installed. Skipping Euclidean MDS. Please run 'pip install scikit-learn'.")
  695. return None
  696. print(f"\nStarting Euclidean MDS embedding into {embedding_dim} dimensions...")
  697. # Normalize the distance matrix for fair comparison with hyperbolic results
  698. dmat = 2.0 * dmat / np.max(dmat)
  699. # scikit-learn's MDS expects dissimilarities, which is what dmat is.
  700. # We use metric=True for classical MDS.
  701. mds = MDS(n_components=embedding_dim, dissimilarity='precomputed', metric=True,
  702. random_state=0, normalized_stress=False)
  703. # Fit the model and get the embedded coordinates
  704. coords = mds.fit_transform(dmat)
  705. # Calculate the pairwise distances in the new Euclidean embedding
  706. embedded_dmat = pairwise_distances(coords, metric='euclidean')
  707. # --- Calculate BIC ---
  708. n_params = dmat.shape[0] * embedding_dim + 1 # N*D + 1 (for variance)
  709. bic = calculate_bic(dmat, embedded_dmat, n_params, dmat_unc=dmat_unc)
  710. return {'dmat': dmat, 'emb_mat': embedded_dmat, 'coords': coords, 'bic': bic}
  711. if __name__ == '__main__':
  712. # --- Code Quality & Clarity Suggestion: Using argparse ---
  713. # This makes the script more reusable by allowing you to pass file paths
  714. # and parameters from the command line instead of editing the code.
  715. parser = argparse.ArgumentParser(description="Run Hyperbolic MDS on a distance matrix from a .mat file.")
  716. parser.add_argument("mat_file", help="Path to the input .mat file.")
  717. parser.add_argument("matrix_name", help="Name of the distance matrix variable within the .mat file.")
  718. parser.add_argument("--unc-name", help="Name of the distance uncertainty matrix variable within the .mat file (optional).")
  719. parser.add_argument("-d", "--dim", type=int, default=3, help="Target dimension for the embedding (default: 3).")
  720. parser.add_argument("--no-plot", action="store_true", help="Suppress the Shepard diagram plot.")
  721. parser.add_argument("-o", "--output", help="Output file prefix to save plots as PDF files instead of displaying them.")
  722. parser.add_argument("--shepard-sample", type=float, default=1.0, help="Fraction of points to sample for the Shepard diagram (e.g., 0.1 for 10%). Default is 1.0 (all points).")
  723. args = parser.parse_args()
  724. try:
  725. # Command-line workflow
  726. print(f"Loading data from: {args.mat_file}")
  727. distance_matrix = load_dmat_from_mat(args.mat_file, args.matrix_name)
  728. distance_unc_matrix = None
  729. if args.unc_name:
  730. print(f"Loading uncertainty data from variable: {args.unc_name}")
  731. distance_unc_matrix = load_dmat_from_mat(args.mat_file, args.unc_name)
  732. # --- Hyperbolic Embedding ---
  733. fit_results = run_embedding(distance_matrix, args.dim,
  734. dmat_unc=distance_unc_matrix,
  735. verbose=True)
  736. print(f"Hyperbolic Embedding BIC: {fit_results['bic']:.2f}")
  737. if not args.no_plot:
  738. # Shepard diagram for Hyperbolic embedding
  739. plot_shepard_diagram(fit_results['dmat'], fit_results['emb_mat'],
  740. fit_results['lambda'], title="Shepard Diagram: Hyperbolic Embedding",
  741. output_file=f"{args.output}_hyperbolic_shepard.pdf" if args.output else None,
  742. sample_fraction=args.shepard_sample)
  743. # Visualize the embedding, using PCA if dimension > 3
  744. visualize_embedding(fit_results, output_prefix=args.output)
  745. # --- Euclidean Embedding ---
  746. euclidean_results = run_euclidean_embedding(distance_matrix, args.dim)
  747. print(f"Euclidean Embedding BIC: {euclidean_results['bic']:.2f}")
  748. if euclidean_results and not args.no_plot:
  749. # Shepard diagram for Euclidean embedding
  750. plot_shepard_diagram(euclidean_results['dmat'], euclidean_results['emb_mat'],
  751. title="Shepard Diagram: Euclidean Embedding",
  752. output_file=f"{args.output}_euclidean_shepard.pdf" if args.output else None,
  753. sample_fraction=args.shepard_sample)
  754. except MatReadError as e:
  755. print(f"Error: {e}", file=sys.stderr)
  756. sys.exit(1)

analysis_from_mat.py at commit e69dc9c, under MIT · at the source

Overview

Authors: Kexin Qi1,2, Yuming Chai1,2, Guodong Tan1,2, Daguang Li1,2, Quan Wen1,2,3
ORCID iDs: Yuming Chai, Quan Wen
  1. Division of Life Sciences and Medicine, University of Science and Technology of China, Hefei, China
  2. Hefei National Laboratory for Physical Sciences at the Microscale, Center for Integrative Imaging, University of Science and Technology of China, Hefei, China
  3. School of Data Science, University of Science and Technology of China, Hefei, China
Journal: eLife, volume 15, article RP110370
Dates: published online 20 July 2026
Type: Research article · Language: English
License: CC BY
Identifiers: DOI 10.7554/elife.110370 · PMID 42474031 · PMCID PMC13384494 · OpenAlex W7154474291
Open access: gold, a free copy (OpenAlex)
Status: code verified
Categories: zebrafish (organism), systems (subfield)
Methods: Connectivity, Statistics, Smoothing, state filtering, decompositions, Preprocessing, Machine learning, fMRI & imaging
Keywords: Zebrafish
MeSH: Dorsal Raphe Nucleus*, Motor Activity*, Serotonergic Neurons*, Serotonin*, Sleep*, Zebrafish*, Acoustic Stimulation, Animals, Larva, Optogenetics (* major topic)
Topic: Zebrafish Biomedical Research Applications (Cell Biology, Biochemistry, Genetics and Molecular Biology), according to OpenAlex
Funding: STI2030-Major Projects (2022ZD0211900)
Citations: not cited yet (Europe PMC); 73 references in the paper

Abstract

The dorsal raphe nucleus (DRN) serotonergic (5-HT) system has been implicated in regulating sleep and motor control; however, its specific role remains controversial. In this study, we found that optogenetic activation of DRN 5-HT neurons in larval zebrafish induced a quiescent state and a reduced response to acoustic stimuli. Unlike sleep, the induced quiescent state was not accompanied by a loss of postural control, and nighttime activation of DRN 5-HT neurons led to a subsequent sleep rebound. Whole brain light field imaging combined with demixed principal component analysis (dPCA) revealed distinct neural subspaces related to DRN activation, sound responses, and motor activity. DRN 5-HT activation selectively modulated the motor-related subspace while leaving the sound-evoked subspace unaffected. Unlike DRN activation, sleep induced by mepyramine significantly altered sound-evoked neuronal activity patterns. Further analysis demonstrated that serotonin had a graded effect on the motor subspace, wherein downstream neurons responsible for particular bout types were more significantly influenced. Embedding motor population activity in a curved geometric space revealed that the degree of curvature scales with behavioral suppression across animals, providing a quantitative signature of the quiescent state. Together, these results elucidate that serotonergic modulation promotes behavioral quiescence through selective regulation of motor populations.

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

Repositories

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

wenquan/BayesianHMDS

License: MIT
State: the link answers, verified on 27 September 2026
Evidence: files inventoried
Commit: e69dc9c35f04a35c0fafddd4a37bb14a29690007, 15 July 2026
Languages: Python (5), Jupyter (3), Shell (2)
Size: 28 files, 10 scripts
Software Heritage: not archived
Found in: “Data availability”
Holds: README, license file, environment (requirements.txt), 3 notebooks
Not found: CITATION.cff, tests, continuous integration, documentation
Tools: NumPy (8 files), Matplotlib (4 files), scikit-learn (2 files), SciPy (2 files), h5py (1 file), Stan (1 file)
Availability: 1 check, the latest on 27 September 2026: the link answers
  • 27 September 2026: the link answers
12 files

kexin2016/zebrafish-quiescent-state

License: MIT
State: the link answers, verified on 27 September 2026
Evidence: files inventoried
Commit: 05465637c1ffe7cab8a07ab1c5292799fd4431e0, 24 September 2026
Languages: MATLAB (47)
Size: 60 files, 47 scripts
Software Heritage: archived
Found in: the references
Holds: README, license file
Not found: CITATION.cff, environment file, tests, continuous integration, documentation
Availability: 1 check, the latest on 27 September 2026: the link answers
  • 27 September 2026: the link answers
49 files

The paper's code and data availability statement is in the Data section.

Tracing map

Proposed by the machine: these links were found in the paper and verified at the source, without human review. The map will receive a Zenodo DOI once one of the paper's authors has validated it with their ORCID.

What the map holds:

  • 2 repositories of the authors' code, each at its verified commit, with its license and how the link was found in the paper;
  • 57 scripts, each with its path and the digest of its content;
  • 15 matches between paragraphs of the paper and lines of the code (method lexical-v1);
  • neither the text of the paper nor the code itself.

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

Data

Datasets cited

Data availability

The Bayesian multi-dimensional scaling (MDS) code used in this study is publicly available at https://github.com/wenquan/BayesianHMDS, copy archived at Wen, 2026. Code used for figure generation and data visualization is available at https://github.com/kexin2016/zebrafish-quiescent-state, copy archived at Qi, 2026. The behavioral and processed datasets generated and analyzed during this study have been deposited in Figshare and are publicly available at https://doi.org/10.6084/m9.figshare.32121937.

The following dataset was generated:

Qi K, Chai Y, Tan G, Li D, Wen Q. 2026. Serotonergic modulation of motor subspace dynamics drives a sleep-independent quiescent state. figshare.

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

Versions

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

Version 1, 27 September 2026: the first record

Recorded: type, language, journal, volume, pages, dates, 5 authors, 1 keyword, 10 MeSH terms, 1 funder, 69 references.

Cite

This paper

Qi, K., Chai, Y., Tan, G., Li, D., & Wen, Q. (2026). Serotonergic modulation of motor subspace dynamics drives a sleep-independent quiescent state. eLife, 15, RP110370. https://doi.org/10.7554/elife.110370

BibTeX

@article{qi2026serotonergic,
author = {Qi, Kexin and Chai, Yuming and Tan, Guodong and Li, Daguang and Wen, Quan},
title = {{Serotonergic modulation of motor subspace dynamics drives a sleep-independent quiescent state}},
journal = {eLife},
year = {2026},
month = jul,
volume = {15},
pages = {RP110370},
publisher = {eLife Sciences Publications, Ltd},
issn = {2050-084X},
doi = {10.7554/elife.110370},
url = {https://doi.org/10.7554/elife.110370},
pmid = {42474031},
pmcid = {PMC13384494}
}

RIS

TY - JOUR
AU - Qi, Kexin
AU - Chai, Yuming
AU - Tan, Guodong
AU - Li, Daguang
AU - Wen, Quan
TI - Serotonergic modulation of motor subspace dynamics drives a sleep-independent quiescent state
T2 - eLife
J2 - Elife
PY - 2026
DA - 2026/07/20
VL - 15
SP - RP110370
SN - 2050-084X
PB - eLife Sciences Publications, Ltd
DO - 10.7554/elife.110370
UR - https://doi.org/10.7554/elife.110370
LA - en
ER -

CSL-JSON

{
"id": "10.7554/elife.110370",
"type": "article-journal",
"title": "Serotonergic modulation of motor subspace dynamics drives a sleep-independent quiescent state",
"container-title": "eLife",
"author": [
{
"family": "Qi",
"given": "Kexin"
},
{
"family": "Chai",
"given": "Yuming"
},
{
"family": "Tan",
"given": "Guodong"
},
{
"family": "Li",
"given": "Daguang"
},
{
"family": "Wen",
"given": "Quan"
}
],
"container-title-short": "Elife",
"volume": "15",
"page": "RP110370",
"DOI": "10.7554/elife.110370",
"PMID": "42474031",
"PMCID": "PMC13384494",
"ISSN": "2050-084X",
"publisher": "eLife Sciences Publications, Ltd",
"URL": "https://doi.org/10.7554/elife.110370",
"language": "en",
"issued": {
"date-parts": [
[
2026,
7,
20
]
]
}
}

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

Similar papers

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

[1] doi:10.1038/s41467-026-75490-y [code]
Topographically organized dorsal raphe activity modulates forebrain sensory-motor representations and contributes to defensive behaviors.
Journal: Nature communications
In common: Parallel Computing Toolbox, Image Processing Toolbox, Statistics and Machine Learning Toolbox, zebrafish, 13 references
[2] doi:10.1038/s41467-026-72222-0 [code]
Eye movement kinematics reveal novel circadian organization of sleep substates.
Journal: Nature communications
In common: zebrafish, systems, 12 references
[3] doi:10.1016/j.neuron.2026.07.016 [code]
Inferring brain-wide interactions using data-constrained recurrent neural network models.
Journal: Neuron
In common: Parallel Computing Toolbox, Image Processing Toolbox, Statistics and Machine Learning Toolbox, 2 other tools, zebrafish, systems, 4 references
[4] doi:10.1038/s41467-026-76242-8 [code]
Whole-brain, all-optical interrogation of neuronal dynamics underlying gut and vascular interoception in zebrafish.
Journal: Nature communications
In common: Parallel Computing Toolbox, h5py, Image Processing Toolbox, 4 other tools, zebrafish, systems, 1 reference
[5] doi:10.1038/s41467-026-71725-0 [code]
Interactions across hemispheres in prefrontal cortex reflect global cognitive processing.
Journal: Nature communications
In common: Parallel Computing Toolbox, Image Processing Toolbox, Statistics and Machine Learning Toolbox, 4 other tools, 2 references
[6] doi:10.1016/j.neuron.2026.03.034 [code]
Dentate gyrus interneurons modulate winner-take-all network dynamics in freely behaving mice.
Journal: Neuron
In common: Parallel Computing Toolbox, h5py, Image Processing Toolbox, 5 other tools, systems
[7] doi:10.1038/s41467-026-73106-z [code]
Respiratory pauses highlight sleep architecture in mice.
Journal: Nature communications
In common: Parallel Computing Toolbox, h5py, Image Processing Toolbox, 4 other tools, 1 reference
[8] doi:10.1038/s41467-026-71568-9 [code]
Convergent and selective representations of pain, appetitive processes, aversive processes, and cognitive control in the insula.
Journal: Nature communications
In common: Parallel Computing Toolbox, Image Processing Toolbox, Statistics and Machine Learning Toolbox, 4 other tools, 1 reference
[9] doi:10.1038/s41592-026-03154-2 [code]
Simultaneous single-cell calcium imaging of neuronal population activity and brain-wide BOLD fMRI.
Journal: Nature methods
In common: Parallel Computing Toolbox, h5py, Image Processing Toolbox, 5 other tools
[10] doi:10.1038/s41586-026-10631-3 [code]
A prognostic human brain network for diffuse midline glioma.
Journal: Nature
In common: Parallel Computing Toolbox, h5py, Image Processing Toolbox, 5 other tools

Contribute

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

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

Request its removal

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

Discussion, reproductions, activity

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

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

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