OSCR

An emergent disease-associated motor neuron state precedes cell death in ALS.

Code ↔ Paper

40 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 40 matches
  1. [1] § STAR★METHODS › METHOD DETAILS › Multiome Sequencing: Cholinergic Neuron Peak Calling with Downsampled Datasets ↔ 07. Multiome 3 - input preparation for Fig. 3D-F, S4E-H.ipynb, lines 358–384 · score 0.97 · bgdGroups, maxCells, useGroups, PeakMatrix, testMethod, getMarkerFeatures
  2. [2] § STAR★METHODS › METHOD DETAILS › Multiome Sequencing: Alpha Motor Neuron Differentially Accessible Peaks with Disease ↔ 07. Multiome 3 - input preparation for Fig. 3D-F, S4E-H.ipynb, lines 358–384 · score 0.97 · bgdGroups, maxCells, useGroups, PeakMatrix, testMethod, getMarkerFeatures
  3. [3] § STAR★METHODS › METHOD DETAILS › snRNA-seq/Multiome Sequencing: Label Transfer from Multiome to snRNA-seq Alpha Motor Neurons ↔ 11. Multiome & snRNA-seq 1 - input preparation (cross-modal label transfer, multiome RNA differential expression).ipynb, lines 169–174 · score 0.96 · FindTransferAnchors, TransferData, query.assay, reference.assay, AddMetaData, multiome RNA
  4. [4] § STAR★METHODS › METHOD DETAILS › Multiome Sequencing: Cholinergic Neuron and Alpha Motor Neuron Subclustering ↔ 05. Multiome 1 - initial processing and input preparation for Fig. 3B-C, 3G-J, S4A-D, S5A-B.ipynb, lines 383–400 · score 0.94 · Gex_nUMI, firstSelection, varFeatures, depthCol, clusterParams, addIterativeLSI
  5. [5] § STAR★METHODS › METHOD DETAILS › Multiome Sequencing: Clustering and Doublet Removal ↔ 05. Multiome 1 - initial processing and input preparation for Fig. 3B-C, 3G-J, S4A-D, S5A-B.ipynb, lines 177–194 · score 0.94 · Gex_nUMI, firstSelection, varFeatures, depthCol, clusterParams, addIterativeLSI
  6. [6] § RESULTS › A DM signature in alpha motor neurons ↔ 04. snRNA-seq 4 - Fig. 2A-J, S2E, S3A-H.ipynb, lines 433–531 · score 0.93 · synaptic transmission, axon guidance, proteasomal protein catabolism, endoplasmic reticulum, unfolded protein, amino acid
  7. [7] § STAR★METHODS › METHOD DETAILS › snRNA-seq: Cholinergic Neuron and Alpha Motor Neuron Subclustering ↔ 01. snRNA-seq 1 - initial processing and input preparation.ipynb, lines 539–550 · score 0.92 · RunUMAP, FindClusters, FindNeighbors, RunPCA, ScaleData, DefaultAssay
  8. [8] § STAR★METHODS › METHOD DETAILS › MERFISH: Generation of a Custom Motor Neuron Segmentation Model ↔ 14. MERFISH spatial transcriptomics analysis.ipynb, lines 388–487 · score 0.92 · Aldh1l1, Cx3cr1, Slc6a5, Slc5a7, Slc17a6, Trem2
  9. [9] § STAR★METHODS › METHOD DETAILS › Multiome Sequencing: Identification of Positive Transcription Factor Regulators ↔ 09. Multiome 5 - input preparation for Fig. 4A-D, S6A-G.ipynb, lines 209–216 · score 0.92 · correlateMatrices, useMatrix1, useMatrix2, LSI_Combined, MotifMatrix, reducedDims
  10. [10] § STAR★METHODS › METHOD DETAILS › snRNA-seq: Data Pre-Processing and Ambient RNA Removal ↔ 01. snRNA-seq 1 - initial processing and input preparation.ipynb, lines 76–89 · score 0.92 · NormalizeData, FindClusters, FindNeighbors, RunPCA, ScaleData, FindVariableFeatures
  11. [11] § STAR★METHODS › METHOD DETAILS › Multiome Sequencing: Cholinergic Neuron Peak Calling with Downsampled Datasets ↔ 07. Multiome 3 - input preparation for Fig. 3D-F, S4E-H.ipynb, lines 168–192 · score 0.90 · minReplicates, minCells, addGroupCoverages, addPeakMatrix, addReproduciblePeakSet, groupBy
  12. [12] § STAR★METHODS › METHOD DETAILS › Multiome Sequencing: Cholinergic Neuron Peak Calling with Downsampled Datasets ↔ 07. Multiome 3 - input preparation for Fig. 3D-F, S4E-H.ipynb, lines 168–192 · score 0.90 · minReplicates, minCells, addGroupCoverages, addPeakMatrix, addReproduciblePeakSet, peak matrices
  13. [13] § STAR★METHODS › METHOD DETAILS › snRNA-seq: Integration, Quality Control, and Clustering ↔ 01. snRNA-seq 1 - initial processing and input preparation.ipynb, lines 215–223 · score 0.86 · RunUMAP, FindClusters, FindNeighbors, RunPCA, ScaleData, DefaultAssay
  14. [14] § RESULTS › TFs associated with the DM state transition ↔ 10. Multiome 6 - Fig. 4A-D, S6A-G.ipynb, lines 64–126 · score 0.86 · E4f1, ps sum, perturbation score, CellOracle, Jund, Klf6
  15. [15] § RESULTS › A DM signature in alpha motor neurons ↔ 04. snRNA-seq 4 - Fig. 2A-J, S2E, S3A-H.ipynb, lines 880–949 · score 0.84 · extracellular matrix organization, potassium ion transport, integrated stress response, amino acid, negative regulation, signaling
  16. [16] § STAR★METHODS › METHOD DETAILS › In Vitro Motor Neuron Differentiation, Lentiviral Transduction, Western Blot Analysis, Bulk RNA Sequencing, and Gene Set Enrichment Analysis ↔ 13. iMN in vitro TF OE - Fig. 5.ipynb, lines 131–205 · score 0.83 · fgseaMultilevel, minSize, maxSize, DESeq2, NES, ranked
  17. [17] § STAR★METHODS › METHOD DETAILS › snRNA-seq: Differential Expression and Gene Ontology (GO) Enrichment Analyses ↔ 02. snRNA-seq 2 - DESeq2 differential expression analysis.ipynb, lines 136–163 · score 0.82 · DESeqDataSetFromMatrix, minReplicatesForReplace, ex, DESeq2, padj, frame
  18. [18] § STAR★METHODS › METHOD DETAILS › snRNA-seq: Integration, Quality Control, and Clustering ↔ 01. snRNA-seq 1 - initial processing and input preparation.ipynb, lines 360–362 · score 0.82 · Read10X, endpoint female, nextseqs_11_18, CreateSeuratObject, cellranger, intron
  19. [19] § STAR★METHODS › METHOD DETAILS › MERFISH: Cholinergic Neuron and Alpha Motor Neuron Subclustering ↔ 14. MERFISH spatial transcriptomics analysis.ipynb, lines 734–748 · score 0.80 · n_iterations, min_dist, igraph, flavor, BBKNN, external
  20. [20] § STAR★METHODS › METHOD DETAILS › MERFISH: Alpha Motor Neuron Morphological Quantification ↔ 14. MERFISH spatial transcriptomics analysis.ipynb, lines 5867–5891 · score 0.79 · Otsu threshold, DAPI_high_pass, log10 transformed, MERFISH
  21. [21] § RESULTS › A DM signature in alpha motor neurons ↔ 13. iMN in vitro TF OE - Fig. 5.ipynb, lines 591–660 · score 0.75 · axon guidance, proteasomal protein catabolism, endoplasmic reticulum, axonogenesis, synaptic, transport
  22. [22] § RESULTS › TFs associated with the DM state transition ↔ 10. Multiome 6 - Fig. 4A-D, S6A-G.ipynb, lines 64–126 · score 0.72 · Nfe2l1, Nfe2l2, Nfe2l3, Xbp1, Nfil3, CREB3
  23. [23] § STAR★METHODS › METHOD DETAILS › MERFISH: Data Pre-Processing and Initial Clustering ↔ 14. MERFISH spatial transcriptomics analysis.ipynb, lines 734–748 · score 0.71 · n_iterations, igraph, flavor, BBKNN, external, UMAP
  24. [24] § RESULTS › Gene expression changes in spinal motor neurons during neurodegeneration ↔ 03. snRNA-seq 3 - Fig. 2B, 2D-H, S1B-C, S2A-D.ipynb, lines 663–732 · score 0.70 · sciatic nerve crush, nerve injury, SOD1 G93A, gamma, disease, gene
  25. [25] § STAR★METHODS › METHOD DETAILS › MERFISH: Reactive Glial Cell Classification ↔ 14. MERFISH spatial transcriptomics analysis.ipynb, lines 3759–3813 · score 0.69 · control astrocytes, control microglia, Apoe, Gfap, thresholds, reactive
  26. [26] § STAR★METHODS › METHOD DETAILS › snRNA-seq/Multiome Sequencing: In Silico Transcription Factor Perturbation with CellOracle ↔ 14. MERFISH spatial transcriptomics analysis.ipynb, lines 5194–5271 · score 0.69 · fast firing, slow firing, late DM, early DM, UMAP, Scanpy
  27. [27] § STAR★METHODS › METHOD DETAILS › MERFISH: Label Transfer from snRNA-seq to MERFISH Alpha Motor Neurons ↔ 14. MERFISH spatial transcriptomics analysis.ipynb, lines 5051–5192 · score 0.69 · Fast Firing, Slow Firing, Late DM, Early DM, Predicted, Intermediate
  28. [28] § STAR★METHODS › METHOD DETAILS › snRNA-seq: Integration, Quality Control, and Clustering ↔ 01. snRNA-seq 1 - initial processing and input preparation.ipynb, lines 141–150 · score 0.65 · nova_CZI, sod1_mn_nuclei_2_9, seq
  29. [29] § STAR★METHODS › METHOD DETAILS › snRNA-seq: Integration, Quality Control, and Clustering ↔ 11. Multiome & snRNA-seq 1 - input preparation (cross-modal label transfer, multiome RNA differential expression).ipynb, lines 157–160 · score 0.65 · NormalizeData, FindVariableFeatures, vst, selection, seq
  30. [30] § STAR★METHODS › METHOD DETAILS › Multiome Sequencing: Cholinergic Neuron Peak Calling with Downsampled Datasets ↔ 08. Multiome 4 - Fig. 3D-F, S4E-H.ipynb, lines 268–334 · score 0.63 · downsampled control, Gamma MNs, Alpha MNs, ArchR, Peak, cholinergic
  31. [31] § STAR★METHODS › METHOD DETAILS › Multiome Sequencing: Cholinergic Neuron Peak Calling with Downsampled Datasets ↔ 07. Multiome 3 - input preparation for Fig. 3D-F, S4E-H.ipynb, lines 243–250 · score 0.62 · Gamma MNs, metadata column, Alpha MNs, ArchR, downsampled, neurons
  32. [32] § STAR★METHODS › METHOD DETAILS › Human Spinal Cord snRNA-seq/Fragment-seq: Cross-Species Wilcoxon Rank-Based Gene Set Enrichment and DM Scoring ↔ 14. MERFISH spatial transcriptomics analysis.ipynb, lines 1332–1420 · score 0.61 · log2 fold change, downregulated genes, upregulated genes, snRNA, seq
  33. [33] § STAR★METHODS › METHOD DETAILS › MERFISH: Data Pre-Processing and Initial Clustering ↔ 14. MERFISH spatial transcriptomics analysis.ipynb, lines 168–222 · score 0.61 · detected genes, filtered AnnData, blank, MERFISH, slide, transcripts
  34. [34] § STAR★METHODS › METHOD DETAILS › Human Spinal Cord snRNA-seq/Fragment-seq: Data Pre-Processing and Integration ↔ 11. Multiome & snRNA-seq 1 - input preparation (cross-modal label transfer, multiome RNA differential expression).ipynb, lines 157–160 · score 0.60 · NormalizeData, FindVariableFeatures, nfeature, vst, RNA seq
  35. [35] § STAR★METHODS › METHOD DETAILS › MERFISH: Alpha Motor Neuron Differential Expression Analysis with Disease ↔ 14. MERFISH spatial transcriptomics analysis.ipynb, lines 1250–1324 · score 0.59 · Benjamini Hochberg, Fold changes, raw, log2, MERFISH, mid
  36. [36] § STAR★METHODS › METHOD DETAILS › MERFISH: Cell Segmentation, Transcript Partitioning, and Cell Metadata Calculation ↔ 14. MERFISH spatial transcriptomics analysis.ipynb, lines 69–166 · score 0.59 · sum signals, cell metadata, DAPI, MERFISH, transcript
  37. [37] § STAR★METHODS › METHOD DETAILS › MERFISH: Generation of a Custom Motor Neuron Segmentation Model ↔ 05. Multiome 1 - initial processing and input preparation for Fig. 3B-C, 3G-J, S4A-D, S5A-B.ipynb, lines 227–238 · score 0.57 · Slc5a7, Slc17a6, Aqp4, Mog, Atf3, cholinergic
  38. [38] § STAR★METHODS › METHOD DETAILS › snRNA-seq: Differential Expression and Gene Ontology (GO) Enrichment Analyses ↔ 04. snRNA-seq 4 - Fig. 2A-J, S2E, S3A-H.ipynb, lines 41–61 · score 0.57 · GO Biological Process, DESeq2, downregulated, upregulated, cholinergic, gene
  39. [39] § STAR★METHODS › METHOD DETAILS › Human Spinal Cord snRNA-seq/Fragment-seq: Differential Gene Expression Analysis ↔ 14. MERFISH spatial transcriptomics analysis.ipynb, lines 1250–1324 · score 0.56 · Benjamini Hochberg, fold change, FDR, raw, Gene
  40. [40] § STAR★METHODS › METHOD DETAILS › Human Spinal Cord snRNA-seq/Fragment-seq: Cross-Species Wilcoxon Rank-Based Gene Set Enrichment and DM Scoring ↔ 14. MERFISH spatial transcriptomics analysis.ipynb, lines 1332–1420 · score 0.54 · log2 fold change, downregulated genes, v1, upregulated, snRNA, seq

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 · 6,326 lines · 198 KB · no license · 13 matches

  1. # %%
  2. import anndata as ad
  3. import scanpy as sc
  4. import pandas as pd
  5. import os
  6. import numpy as np
  7. import squidpy as sq
  8. import bbknn
  9. import seaborn as sns
  10. import matplotlib.pyplot as plt
  11. from matplotlib.colors import ListedColormap
  12. import shutil
  13. import scvi
  14. import scipy.sparse
  15. from scipy.stats import pearsonr, spearmanr
  16. # %%
  17. working_dir = "/home/users/ogautier/oak/Shared/SOD1_Paper/Vizgen/mn_nonmn_segmentation"
  18. os.chdir(working_dir)
  19. figures_dir = os.path.join(working_dir, "figures")
  20. # %% [markdown]
  21. # ### Create anndata objects with metadata
  22. # %%
  23. def is_point_inside_rotated_rect(px, py, rx, ry, width, height, angle):
  24. """
  25. Check if a point (px, py) is inside a rotated rectangle.
  26. - (rx, ry): Bottom-left corner of the rectangle
  27. - width, height: Dimensions of the rectangle
  28. - angle: Rotation angle in degrees
  29. """
  30. # Convert angle to radians
  31. theta = np.radians(-angle) # Negative to rotate in the opposite direction
  32. # Translate point and rectangle to origin
  33. px, py = px - rx, py - ry
  34. # Apply inverse rotation to the point
  35. rotated_x = px * np.cos(theta) - py * np.sin(theta)
  36. rotated_y = px * np.sin(theta) + py * np.cos(theta)
  37. # Check if rotated point is within rectangle bounds
  38. return 0 <= rotated_x <= width and 0 <= rotated_y <= height
  39. # %%
  40. slide_list = ['202401150953_MsSpinalCord-VS119-Lumbar-Ctrl5-SOD11_Beta8',
  41. '202405281419_MsSpinalCord-VS223-Lumbar-CM5-SM8-S1_VMSC01801',
  42. '202406071120_MsSpinalCord-VS223-Lumbar-CE5-SE6-S1_VMSC01801',
  43. '202401151110_MsSpinalCord-VS119-Lumbar-Ctrl1-SOD8_VMSC07101',
  44. '202401241136_HuSpinalCord-VS119-Lumbar-Ctrl2-SOD9_Beta8',
  45. '202405281702_MsSpinalCord-VS223-Lumbar-CE2-SE4-S1_VMSC02701',
  46. '202406071119_MsSpinalCord-VS223-Lumbar-CE1-SE3-S2_Beta8',
  47. '202405281416_MsSpinalCord-VS223-Lumbar-CM2-SM4-S1_VMSC15302',
  48. '202401241119_HuSpinalCord-VS119-Cervical-Ctrl3-SOD11_VMSC07101_reanalysis',
  49. '202401150956_MsSpinalCord-VS119-Cervical-Ctrl2-SOD10_VMSC10802',
  50. '202406031049_MsSpinalCord-VS223-Cervical-CE2-SE3-S1_VMSC10802',
  51. '202406071222_MsSpinalCord-VS223-Cervical-CE5-SE5-S2_VMSC02011',
  52. '202406071118_MsSpinalCord-VS223-Cervical-CM2-SM4-S1_VMSC10802',
  53. '202312251515_MsSpinalCord-L4-S1-VS119-YS_Beta8',
  54. '202405281527_MsSpinalCord-VS223-Lubmar-CM4-SM6-S1_VMSC02011',
  55. #'202405281422_MsSpinalCord-VS223-Lumbar-CE3-SE5-S1_VMSC12002', Exclude due to batch effect
  56. '202406031050_MsSpinalCord-VS223-Cervical-CM5-SM5-S1_VMSC07201',
  57. '202406071239_MsSpinalCord-VS223-Lumbar-CM3-SM5-S2_VMSC02701',
  58. '202401241200_HuSpinalCord-VS119-Lubmar-Ctrl3-SOD10_VMSC10802']
  59. # %%
  60. csv_file_path = 'Merfish_Metadata.csv'
  61. rect_df_all = pd.read_csv(csv_file_path)
  62. data_fn = "analysis_outputs_7000_900/cell_by_gene.csv"
  63. meta_fn = "analysis_outputs_7000_900/cell_metadata.csv"
  64. sumsig_fn = "analysis_outputs_7000_900/sum_signals.csv"
  65. for slide_dir in slide_list:
  66. print(slide_dir)
  67. # read in and make anndata object
  68. adata = sc.read_csv(os.path.join(slide_dir, data_fn), first_column_names=True)
  69. print(f'adata initial shape is {adata.shape}')
  70. metadata = pd.read_csv(os.path.join(slide_dir,meta_fn), index_col=0)
  71. metadata.index = metadata.index.map(str)
  72. ### make sumsigs consistent start
  73. sumsig = pd.read_csv(os.path.join(slide_dir,sumsig_fn), index_col=0)
  74. sumsig.index = sumsig.index.map(str)
  75. if 'hSOD1G93A_raw' not in sumsig:
  76. sumsig['hSOD1G93A_raw'] = [float('nan')] * sumsig['DAPI_raw'].shape[0]
  77. sumsig['hSOD1G93A_high_pass'] = [float('nan')] * sumsig['DAPI_raw'].shape[0]
  78. ### end
  79. result = pd.merge(metadata, sumsig, left_index=True, right_index=True)
  80. result.index = adata.obs_names
  81. adata.obs = result
  82. adata.obsm['spatial'] = adata.obs[["center_x", "center_y"]].to_numpy()
  83. # get additional critical metadata from rect_df_all
  84. rect_df = rect_df_all[rect_df_all['slide'] == slide_dir]
  85. x, y = adata.obsm['spatial'][:, 0], adata.obsm['spatial'][:, 1]
  86. # initialize metadata columns
  87. slide = ['na'] * len(x)
  88. section = ['na'] * len(x)
  89. region = ['na'] * len(x)
  90. stage = ['na'] * len(x)
  91. # loop through rect_df to populate metadata of appropriate cells
  92. # rect_df allows us to map a coordinate to section and its
  93. # corresponding conditions
  94. for index, row in rect_df.iterrows():
  95. rect_x, rect_y = row['x']-150, row['y']-150
  96. rect_width, rect_height = int(row['w']*1.2), int(row['h']*1.2)
  97. rect_angle = row['angle']
  98. for i in range(len(x)):
  99. point_x, point_y = adata.obsm['spatial'][i, 0], adata.obsm['spatial'][i, 1]
  100. inside = is_point_inside_rotated_rect(point_x,
  101. point_y,
  102. rect_x,
  103. rect_y,
  104. rect_width,
  105. rect_height,
  106. rect_angle)
  107. if inside == True:
  108. section[i] = row['section']
  109. slide[i] = row['slide']
  110. region[i] = row['region']
  111. stage[i] = row['stage']
  112. # append metadata lists to adata.obs
  113. adata.obs["section"] = section
  114. adata.obs["slide"] = slide
  115. adata.obs["region"] = region
  116. adata.obs["stage"] = stage
  117. # remove cells that are not assigned section metadata
  118. # Ensure 'section' is treated as a string
  119. adata.obs["section"] = adata.obs["section"].astype(str)
  120. adata = adata[adata.obs["section"] != "na"].copy()
  121. print(f'adata shape after filtering is {adata.shape}')
  122. # make VS metadata column
  123. VS = []
  124. slide = adata.obs['slide'].to_list()
  125. for s in slide:
  126. if "VS119" in s:
  127. VS.append("VS119")
  128. elif "VS223" in s:
  129. VS.append("VS223")
  130. else:
  131. print("uh oh")
  132. adata.obs['VS'] = VS
  133. list(set(adata.obs['stage'].to_list()))
  134. print(f"adata.obs.shape is {adata.obs.shape}")
  135. # save anndata
  136. save_name = f"{slide_dir}.h5ad"
  137. adata.write_h5ad(os.path.join(working_dir,
  138. slide_dir,
  139. "analysis_outputs_7000_900",
  140. save_name))
  141. # %% [markdown]
  142. # ### Filter and combine anndata objects
  143. # %%
  144. def filter_anndata(slide_list, min_count, min_genes):
  145. """
  146. Filters anndata objects for each slide in slide_list based on transcript count and detected genes.
  147. Parameters:
  148. slide_list (list): List of slide names.
  149. min_count (int): Minimum transcript count threshold.
  150. min_genes (int): Minimum number of detected genes.
  151. Returns:
  152. None
  153. """
  154. for slide in slide_list:
  155. # Construct file paths
  156. input_path = f"{slide}/analysis_outputs_7000_900/{slide}.h5ad"
  157. output_dir = f"{slide}/analysis_outputs_7000_900/"
  158. output_path = os.path.join(output_dir, f"{slide}_filtered.h5ad")
  159. # Check if the file exists
  160. if not os.path.exists(input_path):
  161. print(f"File not found: {input_path}, skipping...")
  162. continue
  163. print(f"Processing {slide}...")
  164. # Load the AnnData object
  165. adata = sc.read_h5ad(input_path)
  166. # Remove blank genes
  167. non_blank_genes = [gene for gene in adata.var_names if "blank" not in gene.lower()]
  168. adata = adata[:, non_blank_genes].copy()
  169. # Compute number of detected genes and total transcript count
  170. adata.obs["num_genes"] = np.count_nonzero(adata.X, axis=1)
  171. adata.obs["num_transcripts"] = np.sum(adata.X, axis=1)
  172. # Filter cells based on criteria
  173. prev_len = adata.shape[0]
  174. adata = adata[
  175. (adata.obs["num_transcripts"] > min_count) &
  176. (adata.obs["num_genes"] > min_genes), :
  177. ].copy()
  178. print(f"Filtered from {prev_len} to {adata.shape[0]} cells.")
  179. # Ensure output directory exists
  180. os.makedirs(output_dir, exist_ok=True)
  181. # Save the processed AnnData object
  182. adata.write_h5ad(output_path)
  183. print(f"Saved filtered data to {output_path}\n")
  184. # %%
  185. # Filter anndata objects
  186. min_count = 20
  187. min_genes = 5
  188. filter_anndata(slide_list, min_count, min_genes)
  189. # %%
  190. # Combine filtered anndata objects
  191. idx = 0
  192. for slide_dir in slide_list:
  193. print(slide_dir)
  194. # read in and make anndata object
  195. load_dir = os.path.join(slide_dir,
  196. "analysis_outputs_7000_900",
  197. (slide_dir+'_filtered.h5ad'))
  198. adata = sc.read_h5ad(load_dir)
  199. if idx == 0:
  200. adata_combined = adata.copy()
  201. idx += 1
  202. continue
  203. adata_combined = ad.concat([adata_combined, adata]).copy()
  204. adata_combined.obs_names_make_unique()
  205. print(f'adata_combined shape is {adata_combined.shape}')
  206. idx += 1
  207. # %%
  208. # Saving count data
  209. adata_combined.layers["counts"] = adata_combined.X.copy()
  210. # %%
  211. # save anndata
  212. save_name = f"adata_objects/combined_filtered_anndata.h5ad"
  213. adata_combined.write_h5ad(os.path.join(working_dir, save_name))
  214. # %% [markdown]
  215. # # All cells BBKNN integration
  216. # %%
  217. # Read in AnnData
  218. adata = sc.read_h5ad(f"adata_objects/combined_filtered_anndata.h5ad")
  219. # %%
  220. # Add metadata columns
  221. # Slide + section and slide + stage
  222. adata.obs["slide_section"] = adata.obs["slide"].astype(str) + "_" + adata.obs["section"].astype(str)
  223. adata.obs["slide_stage"] = adata.obs["slide"].astype(str) + "_" + adata.obs["stage"].astype(str)
  224. # Auxillary channels normalized by volume
  225. columns_to_normalize = [
  226. "Gfap_raw", "Gfap_high_pass",
  227. "Apoe_raw", "Apoe_high_pass",
  228. "hSOD1G93A_raw", "hSOD1G93A_high_pass"
  229. ]
  230. for col in columns_to_normalize:
  231. norm_col = col + "_norm"
  232. adata.obs[norm_col] = adata.obs[col] / adata.obs["volume"]
  233. # %%
  234. # Remove low-quality tissue sections
  235. # List of slide_section values to remove
  236. to_remove = [
  237. '202401150953_MsSpinalCord-VS119-Lumbar-Ctrl5-SOD11_Beta8_R2C2',
  238. '202401151110_MsSpinalCord-VS119-Lumbar-Ctrl1-SOD8_VMSC07101_R2C1',
  239. '202405281416_MsSpinalCord-VS223-Lumbar-CM2-SM4-S1_VMSC15302_R2C1',
  240. '202406031050_MsSpinalCord-VS223-Cervical-CM5-SM5-S1_VMSC07201_R2C1'
  241. ]
  242. # Keep cells NOT in the to_remove list
  243. adata = adata[~adata.obs['slide_section'].isin(to_remove)].copy()
  244. # %%
  245. # Normalization of data by volume
  246. adata.X = adata.X/np.array(adata.obs['volume'])[:,None]
  247. # Normalize total to 250
  248. sc.pp.normalize_total(adata, target_sum=250)
  249. # Log transform
  250. sc.pp.log1p(adata)
  251. # Z-score (need to do)
  252. sc.pp.scale(adata, max_value=10)
  253. # Run pca
  254. sc.tl.pca(adata)
  255. # %%
  256. # Neighbors and UMAP
  257. sc.external.pp.bbknn(adata, batch_key='VS') # use inplace of sc.pp.neighbors()
  258. sc.tl.umap(adata)
  259. # %%
  260. # Clustering
  261. sc.tl.leiden(adata,
  262. key_added="leiden_0.5",
  263. resolution=0.5,
  264. flavor="igraph",
  265. n_iterations=2)
  266. # %%
  267. sc.pl.umap(adata, color="leiden_0.5")
  268. # %% [markdown]
  269. # ## Fig. S1D
  270. # %%
  271. import matplotlib.pyplot as plt
  272. import scanpy as sc
  273. # 0) Set all font sizes to 7
  274. plt.rcParams.update({
  275. "font.size": 7,
  276. "axes.titlesize": 7,
  277. "axes.labelsize": 7,
  278. "xtick.labelsize": 7,
  279. "ytick.labelsize": 7,
  280. "legend.fontsize": 7,
  281. "legend.title_fontsize": 7,
  282. "figure.titlesize": 7
  283. })
  284. # 1) Create a high‐DPI figure (this sets the base resolution for rasterized artists)
  285. fig, ax = plt.subplots(dpi=300) # <-- 300 DPI here
  286. # 2) Draw UMAP off‐screen, no title, into that axes
  287. sc.pl.umap(
  288. adata,
  289. color="leiden_0.5",
  290. legend_loc='on data',
  291. legend_fontsize=7, # redundant now, but explicit
  292. title="",
  293. show=False,
  294. ax=ax
  295. )
  296. # 3) Rasterize only the scatter collections
  297. for coll in ax.collections:
  298. coll.set_rasterized(True)
  299. # 4) Fix the output size in inches
  300. fig.set_size_inches(2.15, 2.15)
  301. # 5) Save with an even higher DPI if you like (affects only raster parts in a vector file)
  302. fig.savefig(
  303. "figures/umap/UMAP_all_leiden.svg",
  304. format="svg",
  305. bbox_inches="tight",
  306. dpi=600 # <-- rasterized points will now be 600 DPI
  307. )
  308. # %%
  309. sc.pl.umap(adata, color="stage")
  310. # %%
  311. sc.pl.umap(adata, color="slide")
  312. # %% [markdown]
  313. # ## Fig. S1E
  314. # %%
  315. import matplotlib.pyplot as plt
  316. import seaborn as sns
  317. import os
  318. genes = ["Chat", "Prph", "Slc5a7", "Rbfox3", "Slc17a6", "Gad1", "Slc6a5", "Tnfrsf12a", "Atf3", "Cx3cr1", "Trem2",
  319. "Mog", "Stmn4", "Aqp4", "Aldh1l1", "Slc2a1", "Slc7a1", "Il34", "Anxa2", "Atp1a1", "Hsp90ab1", "Gria3",
  320. "Grik4"]
  321. # Define the desired cluster order
  322. desired_order = ["6", "10", "9", "12", "1", "2", "7", "8", "0", "11", "5", "4", "3"]
  323. adata_counts = adata[:, genes].copy()
  324. adata_counts.X = adata_counts.layers['counts']
  325. # Convert to a DataFrame
  326. counts_df = pd.DataFrame(adata_counts.X, index=adata_counts.obs_names, columns=adata_counts.var_names)
  327. # Normalize total counts to 250
  328. sc.pp.normalize_total(adata_counts, target_sum=250)
  329. # Add cluster information
  330. cluster_key = "leiden_0.5" # Update this if using a different cluster key
  331. counts_df["cluster"] = adata_counts.obs[cluster_key].values
  332. # Compute average expression per cluster
  333. cluster_avg = counts_df.groupby("cluster")[genes].mean().T # Transpose to have genes on y-axis
  334. # Convert row_min and row_max to NumPy arrays
  335. row_min = cluster_avg.min(axis=1).to_numpy()
  336. row_max = cluster_avg.max(axis=1).to_numpy()
  337. # Avoid division by zero by checking where min == max
  338. constant_rows = row_max == row_min
  339. # Perform row normalization (Min-Max Scaling)
  340. cluster_avg_norm = (cluster_avg - row_min[:, np.newaxis]) / (row_max - row_min)[:, np.newaxis]
  341. # Set constant rows to 0 (or another value like NaN if needed)
  342. cluster_avg_norm[constant_rows] = 0
  343. # Reorder the columns of the DataFrame
  344. cluster_avg_norm = cluster_avg_norm[desired_order]
  345. # Plot the heatmap
  346. fig, ax = plt.subplots(figsize=(5.6, 3.5))
  347. # draw heatmap with only two ticks at 0 and 1
  348. heatmap = sns.heatmap(
  349. cluster_avg_norm,
  350. cmap="viridis",
  351. annot=False,
  352. fmt=".2f",
  353. linewidths=0.5,
  354. ax=ax,
  355. cbar_kws={
  356. "ticks": [0.0, 1.0], # only these two positions
  357. "shrink": 0.9,
  358. "pad": 0.13
  359. }
  360. )
  361. # 1) Remove all tick-marks (but keep the labels)
  362. ax.tick_params(axis="both", which="both", length=0)
  363. # 2) Rotate the x-axis labels 90°
  364. ax.set_xticklabels(ax.get_xticklabels(), rotation=-90)
  365. # 3) Move the gene/row names (y-tick labels) to the right & make them horizontal
  366. ax.tick_params(axis="y", labelleft=False, labelright=True, left=False, right=True)
  367. ax.set_yticklabels(ax.get_yticklabels(), rotation=0)#, ha="right")
  368. # 4) Clear axis titles and labels
  369. ax.set_xlabel("")
  370. ax.set_ylabel("")
  371. ax.set_title("")
  372. # 5) Tweak the colorbar
  373. cbar = heatmap.collections[0].colorbar
  374. # a) remove the little tick‐lines
  375. cbar.ax.tick_params(length=0)
  376. # b) replace the two tick labels
  377. cbar.ax.set_yticklabels(["Min", "Max"])
  378. # c) remove any other labels (there won’t be any beyond your two ticks)
  379. # (no extra step needed since we only set two ticks)
  380. # d) add a vertically-oriented label “By Row” centered along the bar
  381. cbar.ax.set_ylabel(
  382. "By Row",
  383. rotation=-90, # text runs horizontally
  384. va="center", # center along the bar
  385. labelpad=-6 # push it out away from the bar
  386. )
  387. # 6) Save
  388. heatmap_path = os.path.join(figures_dir, "heatmap_all.svg")
  389. fig.savefig(heatmap_path, dpi=300, bbox_inches="tight", transparent=True)
  390. plt.show()
  391. # %%
  392. # Add cell class labels
  393. type_map = {'0': 'Astrocytes',
  394. '1': 'Microglia/Macrophages',
  395. '2': 'Oligodendrocytes',
  396. '3': 'Other',
  397. '4': 'Putative Ependymal Cells',
  398. '5': 'Putative Perivascular/Meningeal Cells',
  399. '6': 'Cholinergic Neurons',
  400. '7': 'Oligodendrocytes',
  401. '8': 'Oligodendrocytes',
  402. '9': 'Non-Cholinergic Interneurons',
  403. '10': 'Non-Cholinergic Interneurons',
  404. '11': 'Putative Vascular Cells',
  405. '12': 'Disease-Associated Interneurons'}
  406. cell_class = []
  407. leiden_cluster = adata.obs['leiden_0.5'].to_list()
  408. for l in leiden_cluster:
  409. cell_class.append(type_map[l])
  410. adata.obs['cell_class'] = cell_class
  411. # %%
  412. # Plot the new sub-clusters
  413. sc.pl.umap(adata, color="cell_class")
  414. # %% [markdown]
  415. # ## Fig. S1F
  416. # %%
  417. # 0) Set all font sizes to 7
  418. plt.rcParams.update({
  419. "font.size": 7,
  420. "axes.titlesize": 7,
  421. "axes.labelsize": 7,
  422. "xtick.labelsize": 7,
  423. "ytick.labelsize": 7,
  424. "legend.fontsize": 7,
  425. "legend.title_fontsize": 7,
  426. "figure.titlesize": 7
  427. })
  428. # 1) Create a high‐DPI figure (this sets the base resolution for rasterized artists)
  429. fig, ax = plt.subplots(dpi=300) # <-- 300 DPI here
  430. # your abbr dict
  431. abbr = {
  432. "Astrocytes": "Ast",
  433. "Cholinergic Neurons": "CholN",
  434. "Disease-Associated Interneurons": "DAI",
  435. "Microglia/Macrophages": "MG",
  436. "Non-Cholinergic Interneurons": "NCI",
  437. "Oligodendrocytes": "Oligo",
  438. "Other": "Other",
  439. "Putative Ependymal Cells": "Epen",
  440. "Putative Perivascular/Meningeal Cells": "PVM",
  441. "Putative Vascular Cells": "Vasc"
  442. }
  443. # 2) Draw UMAP off‐screen, no title, into that axes
  444. sc.pl.umap(
  445. adata,
  446. color="cell_class",
  447. legend_loc='on data',
  448. legend_fontsize=7,
  449. title="",
  450. show=False,
  451. ax=ax
  452. )
  453. # 2b) Replace each on‐data label with its abbr
  454. for txt in ax.texts:
  455. orig = txt.get_text()
  456. if orig in abbr:
  457. txt.set_text(abbr[orig])
  458. # now continue with your rasterization + sizing + saving…
  459. # 3) Rasterize only the scatter collections
  460. for coll in ax.collections:
  461. coll.set_rasterized(True)
  462. # 4) Fix the output size in inches
  463. fig.set_size_inches(2.15, 2.15)
  464. # 5) Save...
  465. fig.savefig(
  466. "figures/umap/UMAP_cell_class.svg",
  467. format="svg",
  468. bbox_inches="tight",
  469. dpi=600
  470. )
  471. # %%
  472. # save anndata
  473. save_name = f"adata_objects/all_anndata.h5ad"
  474. adata.write_h5ad(os.path.join(working_dir, save_name))
  475. # %%
  476. adata = sc.read_h5ad(os.path.join(working_dir, f"adata_objects/all_anndata.h5ad"))
  477. # %% [markdown]
  478. # ## Subcluster cholinergic neurons
  479. # %%
  480. adata = sc.read_h5ad(os.path.join(working_dir, f"adata_objects/all_anndata.h5ad"))
  481. # %%
  482. # Select cholinergic neurons
  483. adata_chol = adata[adata.obs["cell_class"] == "Cholinergic Neurons"].copy()
  484. # %%
  485. # Neighbors and UMAP
  486. sc.external.pp.bbknn(adata_chol, batch_key='VS') # use inplace of sc.pp.neighbors()
  487. sc.tl.umap(adata_chol,
  488. min_dist=0.1,
  489. spread=1.0)
  490. # %%
  491. # Clustering
  492. sc.tl.leiden(adata_chol,
  493. key_added="chol_leiden_1",
  494. resolution=1,
  495. flavor="igraph",
  496. n_iterations=2)
  497. # %%
  498. # Plot the sub-clusters
  499. sc.pl.umap(adata_chol, color="chol_leiden_1")
  500. # %%
  501. # Remove Mog+ oligodendrocyte doublets
  502. sc.pl.umap(adata_chol, color=["Mog"])
  503. # %% [markdown]
  504. # #### Get barcodes for peri-motor neurons oligodendrocytes
  505. # %%
  506. # Assess volume distribution of exisiting Oligodendrocytes
  507. import matplotlib.pyplot as plt
  508. import seaborn as sns
  509. # Subset to Oligodendrocytes
  510. adata_oligo = adata[adata.obs['cell_class'] == 'Oligodendrocytes']
  511. # Extract the volume values
  512. vals = adata_oligo.obs['volume'].dropna()
  513. # Plot
  514. fig, ax = plt.subplots(figsize=(4, 6))
  515. sns.violinplot(y=vals, inner="box", ax=ax)
  516. ax.set_ylabel('volume')
  517. ax.set_title('Oligodendrocytes — Volume Distribution')
  518. plt.tight_layout()
  519. plt.show()
  520. # Compute quartiles
  521. q1 = vals.quantile(0.25)
  522. median = vals.quantile(0.50)
  523. q3 = vals.quantile(0.75)
  524. iqr = q3 - q1
  525. # Compute whisker positions (1.5 × IQR rule)
  526. lower_whisker = vals[vals >= (q1 - 1.5 * iqr)].min()
  527. upper_whisker = vals[vals <= (q3 + 1.5 * iqr)].max()
  528. # Print them out
  529. print(f"Count: {len(vals)}")
  530. print(f"Q1 (25th pct): {q1:.3f}")
  531. print(f"Median (50th): {median:.3f}")
  532. print(f"Q3 (75th pct): {q3:.3f}")
  533. print(f"IQR: {iqr:.3f}")
  534. print(f"Lower whisker: {lower_whisker:.3f}")
  535. print(f"Upper whisker: {upper_whisker:.3f}")
  536. # %%
  537. # Assess volume distribution of putative Oligodendrocytes/Oligo-MN doublets
  538. import matplotlib.pyplot as plt
  539. import seaborn as sns
  540. # Subset to putative Oligodendrocytes/Oligo-MN doublets
  541. adata_periMN_oligo = adata_chol[adata_chol.obs["chol_leiden_1"].isin(["12", "13", "14"])]
  542. # Extract the volume values
  543. vals = adata_periMN_oligo.obs['volume'].dropna()
  544. # Plot
  545. fig, ax = plt.subplots(figsize=(4, 6))
  546. sns.violinplot(y=vals, inner="box", ax=ax)
  547. ax.set_ylabel('volume')
  548. ax.set_title('Volume Distribution')
  549. plt.tight_layout()
  550. plt.show()
  551. # Compute quartiles
  552. q1 = vals.quantile(0.25)
  553. median = vals.quantile(0.50)
  554. q3 = vals.quantile(0.75)
  555. iqr = q3 - q1
  556. # Compute whisker positions (1.5 × IQR rule)
  557. lower_whisker = vals[vals >= (q1 - 1.5 * iqr)].min()
  558. upper_whisker = vals[vals <= (q3 + 1.5 * iqr)].max()
  559. # Print them out
  560. print(f"Count: {len(vals)}")
  561. print(f"Q1 (25th pct): {q1:.3f}")
  562. print(f"Median (50th): {median:.3f}")
  563. print(f"Q3 (75th pct): {q3:.3f}")
  564. print(f"IQR: {iqr:.3f}")
  565. print(f"Lower whisker: {lower_whisker:.3f}")
  566. print(f"Upper whisker: {upper_whisker:.3f}")
  567. # %%
  568. # Save metadata for cells within the lower and upper whiskers
  569. # Build the boolean mask
  570. mask = (
  571. (adata_periMN_oligo.obs['volume'] >= lower_whisker) &
  572. (adata_periMN_oligo.obs['volume'] <= upper_whisker)
  573. )
  574. # Get the filtered obs DataFrame
  575. filtered_obs = adata_periMN_oligo.obs.loc[mask]
  576. # Change cell_class to Oligodendrocytes
  577. filtered_obs['cell_class'] = 'Oligodendrocytes'
  578. # Save with the index as a column
  579. filtered_obs.to_csv(os.path.join(working_dir, 'periMN_oligo_metadata.csv'), index=True)
  580. # %% [markdown]
  581. # #### Back to cholinergic neurons
  582. # %%
  583. clusters_to_remove = ["12", "13", "14"]
  584. adata_chol_filtered = adata_chol[~adata_chol.obs["chol_leiden_1"].isin(clusters_to_remove)].copy()
  585. sc.pl.umap(adata_chol_filtered, color="chol_leiden_1")
  586. # %%
  587. # Re-run BBKNN, UMAP, and clustering
  588. # Neighbors and UMAP
  589. sc.external.pp.bbknn(adata_chol_filtered, batch_key='VS') # use inplace of sc.pp.neighbors()
  590. sc.tl.umap(adata_chol_filtered,
  591. min_dist=0.5,
  592. spread=1.0)
  593. # Clustering
  594. sc.tl.leiden(adata_chol_filtered,
  595. key_added="chol_leiden_1.2",
  596. resolution=1.2,
  597. flavor="igraph",
  598. n_iterations=2)
  599. # %%
  600. # Plot the new sub-clusters
  601. sc.pl.umap(adata_chol_filtered, color="chol_leiden_1.2")
  602. sc.pl.umap(adata_chol_filtered, color="stage")
  603. sc.pl.umap(adata_chol_filtered, color="slide")
  604. # %% [markdown]
  605. # ## Fig. S2F
  606. # %%
  607. import matplotlib.pyplot as plt
  608. import scanpy as sc
  609. # 0) Set all font sizes to 7
  610. plt.rcParams.update({
  611. "font.size": 7,
  612. "axes.titlesize": 7,
  613. "axes.labelsize": 7,
  614. "xtick.labelsize": 7,
  615. "ytick.labelsize": 7,
  616. "legend.fontsize": 7,
  617. "legend.title_fontsize": 7,
  618. "figure.titlesize": 7
  619. })
  620. # 1) Create a high‐DPI figure (this sets the base resolution for rasterized artists)
  621. fig, ax = plt.subplots(dpi=300) # <-- 300 DPI here
  622. # 2) Draw UMAP off‐screen, no title, into that axes
  623. sc.pl.umap(
  624. adata_chol_filtered,
  625. color="chol_leiden_1.2",
  626. size=5,
  627. legend_loc='on data',
  628. legend_fontsize=7, # redundant now, but explicit
  629. title="",
  630. show=False,
  631. ax=ax
  632. )
  633. # 3) Rasterize only the scatter collections
  634. for coll in ax.collections:
  635. coll.set_rasterized(True)
  636. # 4) Fix the output size in inches
  637. fig.set_size_inches(2.15, 2.15)
  638. # 5) Save with an even higher DPI if you like (affects only raster parts in a vector file)
  639. fig.savefig(
  640. "figures/umap/UMAP_chol_leiden.svg",
  641. format="svg",
  642. bbox_inches="tight",
  643. dpi=600 # <-- rasterized points will now be 600 DPI
  644. )
  645. # %% [markdown]
  646. # ## Fig. S2G
  647. # %%
  648. import matplotlib.pyplot as plt
  649. import seaborn as sns
  650. import os
  651. genes = ["Bcl6", "Stk32a", "Vipr2", "Npas1", "Creb5", "Plch1", "Gap43", "Chat", "Slc5a7", "Prph"]
  652. # Define the desired cluster order
  653. desired_order = ['12', '4', '10', '18', '2', '7', '8', '15', '14', '19',
  654. '11', '17', '5', '3', '6', '16', '9', '13', '1', '0']
  655. adata_chol_filtered_counts = adata_chol_filtered[:, genes].copy()
  656. adata_chol_filtered_counts.X = adata_chol_filtered_counts.layers['counts']
  657. # Convert to a DataFrame
  658. counts_df = pd.DataFrame(adata_chol_filtered_counts.X, index=adata_chol_filtered_counts.obs_names, columns=adata_chol_filtered_counts.var_names)
  659. # Normalize total counts to 250
  660. sc.pp.normalize_total(adata_chol_filtered_counts, target_sum=250)
  661. # Add cluster information
  662. cluster_key = "chol_leiden_1.2" # Update this if using a different cluster key
  663. counts_df["cluster"] = adata_chol_filtered_counts.obs[cluster_key].values
  664. # Compute average expression per cluster
  665. cluster_avg = counts_df.groupby("cluster")[genes].mean().T # Transpose to have genes on y-axis
  666. # Convert row_min and row_max to NumPy arrays
  667. row_min = cluster_avg.min(axis=1).to_numpy()
  668. row_max = cluster_avg.max(axis=1).to_numpy()
  669. # Avoid division by zero by checking where min == max
  670. constant_rows = row_max == row_min
  671. # Perform row normalization (Min-Max Scaling)
  672. cluster_avg_norm = (cluster_avg - row_min[:, np.newaxis]) / (row_max - row_min)[:, np.newaxis]
  673. # Set constant rows to 0 (or another value like NaN if needed)
  674. cluster_avg_norm[constant_rows] = 0
  675. # Reorder the columns of the DataFrame
  676. cluster_avg_norm = cluster_avg_norm[desired_order]
  677. # Plot the heatmap
  678. fig, ax = plt.subplots(figsize=(5.7, 2.4))
  679. # draw heatmap with only two ticks at 0 and 1
  680. heatmap = sns.heatmap(
  681. cluster_avg_norm,
  682. cmap="viridis",
  683. annot=False,
  684. fmt=".2f",
  685. linewidths=0.5,
  686. ax=ax,
  687. cbar_kws={
  688. "ticks": [0.0, 1.0], # only these two positions
  689. "shrink": 0.9,
  690. "pad": 0.13
  691. }
  692. )
  693. # 1) Remove all tick-marks (but keep the labels)
  694. ax.tick_params(axis="both", which="both", length=0)
  695. # 2) Rotate the x-axis labels 90°
  696. ax.set_xticklabels(ax.get_xticklabels(), rotation=-90)
  697. # 3) Move the gene/row names (y-tick labels) to the right & make them horizontal
  698. ax.tick_params(axis="y", labelleft=False, labelright=True, left=False, right=True)
  699. ax.set_yticklabels(ax.get_yticklabels(), rotation=0)#, ha="right")
  700. # 4) Clear axis titles and labels
  701. ax.set_xlabel("")
  702. ax.set_ylabel("")
  703. ax.set_title("")
  704. # 5) Tweak the colorbar
  705. cbar = heatmap.collections[0].colorbar
  706. # a) remove the little tick‐lines
  707. cbar.ax.tick_params(length=0)
  708. # b) replace the two tick labels
  709. cbar.ax.set_yticklabels(["Min", "Max"])
  710. # c) remove any other labels (there won’t be any beyond your two ticks)
  711. # (no extra step needed since we only set two ticks)
  712. # d) add a vertically-oriented label “By Row” centered along the bar
  713. cbar.ax.set_ylabel(
  714. "By Row",
  715. rotation=-90, # text runs horizontally
  716. va="center", # center along the bar
  717. labelpad=-6 # push it out away from the bar
  718. )
  719. # 6) Save
  720. heatmap_path = os.path.join(figures_dir, "heatmap_chol.svg")
  721. fig.savefig(heatmap_path, dpi=300, bbox_inches="tight", transparent=True)
  722. plt.show()
  723. # %%
  724. # Add cholinergic type labels
  725. type_map = {'0': 'Cholinergic Interneurons',
  726. '1': 'Visceral MNs',
  727. '2': 'Alpha MNs',
  728. '3': 'Alpha MNs',
  729. '4': 'Alpha MNs',
  730. '5': 'Alpha MNs',
  731. '6': 'Alpha MNs',
  732. '7': 'Alpha MNs',
  733. '8': 'Alpha MNs',
  734. '9': 'Gamma MNs',
  735. '10': 'Alpha MNs',
  736. '11': 'Alpha MNs',
  737. '12': 'Alpha MNs',
  738. '13': 'Gamma* MNs',
  739. '14': 'Alpha MNs',
  740. '15': 'Alpha MNs',
  741. '16': 'Alpha MNs',
  742. '17': 'Alpha MNs',
  743. '18': 'Alpha MNs',
  744. '19': 'Alpha MNs'}
  745. cholinergic_type = []
  746. leiden_cluster = adata_chol_filtered.obs['chol_leiden_1.2'].to_list()
  747. for l in leiden_cluster:
  748. cholinergic_type.append(type_map[l])
  749. adata_chol_filtered.obs['cholinergic_type'] = cholinergic_type
  750. # %%
  751. # Plot the annotated UMAP
  752. sc.pl.umap(adata_chol_filtered, color="cholinergic_type", save="/UMAP_cholinergic_type.png")
  753. # %% [markdown]
  754. # ## Fig. S2H
  755. # %%
  756. # 0) Set all font sizes to 7
  757. plt.rcParams.update({
  758. "font.size": 7,
  759. "axes.titlesize": 7,
  760. "axes.labelsize": 7,
  761. "xtick.labelsize": 7,
  762. "ytick.labelsize": 7,
  763. "legend.fontsize": 7,
  764. "legend.title_fontsize": 7,
  765. "figure.titlesize": 7
  766. })
  767. # 1) Create a high‐DPI figure (this sets the base resolution for rasterized artists)
  768. fig, ax = plt.subplots(dpi=300) # <-- 300 DPI here
  769. # your abbr dict
  770. abbr = {
  771. "Alpha MNs": "Alpha MNs",
  772. "Gamma* MNs": "Gamma* MNs",
  773. "Gamma MNs": "Gamma MNs",
  774. "Visceral MNs": "Visceral \nMNs",
  775. "Cholinergic Interneurons": "Chol. \nInt."
  776. }
  777. # 2) Draw UMAP off‐screen, no title, into that axes
  778. sc.pl.umap(
  779. adata_chol_filtered,
  780. size=5,
  781. color="cholinergic_type",
  782. legend_loc='on data',
  783. legend_fontsize=7,
  784. title="",
  785. show=False,
  786. ax=ax
  787. )
  788. # 2b) Replace each on‐data label with its abbr
  789. for txt in ax.texts:
  790. orig = txt.get_text()
  791. if orig in abbr:
  792. txt.set_text(abbr[orig])
  793. # now continue with your rasterization + sizing + saving…
  794. # 3) Rasterize only the scatter collections
  795. for coll in ax.collections:
  796. coll.set_rasterized(True)
  797. # 4) Fix the output size in inches
  798. fig.set_size_inches(2.15, 2.15)
  799. # 5) Save...
  800. fig.savefig(
  801. "figures/umap/UMAP_chol_type.svg",
  802. format="svg",
  803. bbox_inches="tight",
  804. dpi=600
  805. )
  806. # %%
  807. sc.pl.violin(adata_chol_filtered, keys=['volume'], groupby='cholinergic_type', rotation=90)
  808. # %%
  809. # Build the mask for cells to keep
  810. keep_mask = adata_chol_filtered.obs['volume'] >= 1000
  811. # Make a new AnnData
  812. adata_chol_filtered_highvol = adata_chol_filtered[keep_mask, :].copy()
  813. # %%
  814. # Plot the new sub-clusters
  815. sc.pl.umap(adata_chol_filtered_highvol, color="chol_leiden_1.2")
  816. # %%
  817. # Plot the annotated UMAP
  818. sc.pl.umap(adata_chol_filtered_highvol, color="cholinergic_type")
  819. # %%
  820. sc.pl.violin(adata_chol_filtered_highvol, keys=['volume'], groupby='cholinergic_type', rotation=90)
  821. # %%
  822. # Save anndata
  823. save_name = f"adata_objects/cholinergic_anndata.h5ad"
  824. adata_chol_filtered.write_h5ad(os.path.join(working_dir, save_name))
  825. save_name = f"adata_objects/cholinergic_anndata_highvol.h5ad"
  826. adata_chol_filtered_highvol.write_h5ad(os.path.join(working_dir, save_name))
  827. # %%
  828. adata = sc.read_h5ad(os.path.join(working_dir, f"adata_objects/cholinergic_anndata.h5ad"))
  829. # %%
  830. adata_chol_filtered = adata
  831. # %% [markdown]
  832. # ## Subcluster alpha MNs
  833. # %%
  834. adata = sc.read_h5ad(os.path.join(working_dir, f"adata_objects/cholinergic_anndata_highvol.h5ad"))
  835. # %%
  836. # Select alpha MNs
  837. adata_alpha = adata[adata.obs["cholinergic_type"] == "Alpha MNs"].copy()
  838. # %%
  839. # Neighbors and UMAP
  840. sc.external.pp.bbknn(adata_alpha, batch_key='VS') # use inplace of sc.pp.neighbors()
  841. sc.tl.umap(adata_alpha,
  842. min_dist=0.5,
  843. spread=1.0)
  844. # %%
  845. # Clustering
  846. sc.tl.leiden(adata_alpha,
  847. key_added="alpha_leiden_0.45",
  848. resolution=0.45,
  849. flavor="igraph",
  850. n_iterations=2)
  851. # %%
  852. # Plot the sub-clusters
  853. sc.pl.umap(adata_alpha, color="alpha_leiden_0.45", save="/UMAP_alpha_leiden.png")
  854. # %%
  855. import matplotlib.pyplot as plt
  856. import scanpy as sc
  857. # 0) Set all font sizes to 7
  858. plt.rcParams.update({
  859. "font.size": 7,
  860. "axes.titlesize": 7,
  861. "axes.labelsize": 7,
  862. "xtick.labelsize": 7,
  863. "ytick.labelsize": 7,
  864. "legend.fontsize": 7,
  865. "legend.title_fontsize": 7,
  866. "figure.titlesize": 7
  867. })
  868. # 1) Create a high‐DPI figure (this sets the base resolution for rasterized artists)
  869. fig, ax = plt.subplots(dpi=300) # <-- 300 DPI here
  870. # 2) Draw UMAP off‐screen, no title, into that axes
  871. sc.pl.umap(
  872. adata_alpha,
  873. color="alpha_leiden_0.45",
  874. size=10,
  875. legend_loc='on data',
  876. legend_fontsize=7, # redundant now, but explicit
  877. title="",
  878. show=False,
  879. ax=ax
  880. )
  881. # 3) Rasterize only the scatter collections
  882. for coll in ax.collections:
  883. coll.set_rasterized(True)
  884. # 4) Fix the output size in inches
  885. fig.set_size_inches(2.15, 2.15)
  886. # 5) Save with an even higher DPI if you like (affects only raster parts in a vector file)
  887. fig.savefig(
  888. "figures/umap/UMAP_alpha_leiden.svg",
  889. format="svg",
  890. bbox_inches="tight",
  891. dpi=600 # <-- rasterized points will now be 600 DPI
  892. )
  893. # %%
  894. sc.pl.violin(adata_alpha, keys=['volume'], groupby='alpha_leiden_0.45')
  895. # %%
  896. # Plot the sub-clusters
  897. sc.pl.umap(adata_alpha, color="stage")
  898. sc.pl.umap(adata_alpha, color="slide")
  899. # %%
  900. sc.pl.umap(adata_alpha, color=["Prkcd", "Chodl", "Atf3", "Gap43"])
  901. # %%
  902. # Save anndata
  903. save_name = f"adata_objects/alpha_anndata.h5ad"
  904. adata_alpha.write_h5ad(os.path.join(working_dir, save_name))
  905. # %% [markdown]
  906. # ## Alpha MN UMAP labeled by stage
  907. # %%
  908. adata_alpha = sc.read_h5ad(os.path.join(working_dir, f"adata_objects/alpha_anndata.h5ad"))
  909. # %% [markdown]
  910. # ## Fig. S3I
  911. # %%
  912. import scanpy as sc
  913. import matplotlib.pyplot as plt
  914. import pandas as pd
  915. # ---- Enforce stage order ----
  916. desired_order = ["Control", "Early", "Mid", "End"]
  917. adata_alpha.obs["stage"] = pd.Categorical(
  918. adata_alpha.obs["stage"],
  919. categories=desired_order,
  920. ordered=True
  921. )
  922. # Global font size = 7
  923. plt.rcParams.update({
  924. "font.size": 7,
  925. "axes.titlesize": 7,
  926. "axes.labelsize": 7,
  927. "xtick.labelsize": 7,
  928. "ytick.labelsize": 7,
  929. "legend.fontsize": 7,
  930. "legend.title_fontsize": 6
  931. })
  932. # Create UMAP and return figure
  933. fig = sc.pl.umap(
  934. adata_alpha,
  935. color="stage",
  936. title="",
  937. size=5,
  938. show=False,
  939. return_fig=True
  940. )
  941. # Set exact figure size AFTER creation
  942. fig.set_size_inches(1.85, 1.3306)
  943. plt.tight_layout()
  944. plt.show()
  945. fig.savefig(
  946. "figures/umap/adata_alpha_stage_umap.svg",
  947. format="svg",
  948. bbox_inches="tight"
  949. )
  950. # %% [markdown]
  951. # ## Differentially expressed genes in alpha MNs
  952. # %%
  953. # Read in AnnData
  954. adata = sc.read_h5ad(os.path.join(working_dir, f"adata_objects/alpha_anndata_label_transfer.h5ad"))
  955. # %%
  956. # Get all gene names from the AnnData object
  957. gene_names = adata.var_names
  958. # Convert to DataFrame with a column name
  959. genes_df = pd.DataFrame(gene_names, columns=["Gene"])
  960. # Save to CSV (no row index, just gene names)
  961. genes_df.to_csv("MERFISH_genes.csv", index=False)
  962. # %%
  963. # Read the CSV file of differentially expressed genes with disease in Alpha MNs from snRNA-seq
  964. alpha_snrna_end = pd.read_csv("/home/users/ogautier/oak/Shared/SOD1_Paper/RNA/DESeq2/Cholinergic_Type/Alpha MNs_sod.end_vs_ctl.csv", index_col=0)
  965. # %%
  966. # Filter alpha_snrna_end for padj < 0.01 & gene/row name in adata
  967. alpha_snrna_end_sig = alpha_snrna_end[
  968. (alpha_snrna_end["padj"] < 0.01) & alpha_snrna_end.index.isin(adata.var_names)
  969. ]
  970. # %%
  971. # Get upregulated and downregulated genes
  972. alpha_snrna_upregulated = alpha_snrna_end_sig[
  973. (alpha_snrna_end_sig["log2FoldChange"] > 0)
  974. ]
  975. alpha_snrna_downregulated = alpha_snrna_end_sig[
  976. (alpha_snrna_end_sig["log2FoldChange"] < 0)
  977. ]
  978. # %%
  979. alpha_snrna_end_sig
  980. # %%
  981. alpha_snrna_upregulated
  982. # %%
  983. alpha_snrna_downregulated
  984. # %%
  985. # Filter adata to keep genes in alpha_snrna_end_sig
  986. mask = adata.var_names.isin(alpha_snrna_end_sig.index)
  987. adata_filtered = adata[:, mask]
  988. # %%
  989. adata_filtered = adata_filtered[:, :].copy()
  990. adata_filtered.X = adata_filtered.layers['counts']
  991. # Normalization of data by volume
  992. adata_filtered.X = adata_filtered.X/np.array(adata_filtered.obs['volume'])[:,None]
  993. # Normalize total to 250
  994. sc.pp.normalize_total(adata_filtered, target_sum=250)
  995. # Log transform
  996. sc.pp.log1p(adata_filtered)
  997. # %%
  998. import pandas as pd
  999. import numpy as np
  1000. from scipy.stats import mannwhitneyu
  1001. from statsmodels.stats.multitest import multipletests
  1002. # Initialize lists to store the results
  1003. genes = []
  1004. p_values_comparison1 = []
  1005. log2foldchange_comparison1 = []
  1006. p_values_comparison2 = []
  1007. log2foldchange_comparison2 = []
  1008. # Iterate over all genes
  1009. for gene in adata_filtered.var_names:
  1010. # Extract expression data (dense format) for the gene
  1011. control_expr = np.asarray(adata_filtered[adata_filtered.obs["stage"] == "Control", gene].X).flatten()
  1012. mid_expr = np.asarray(adata_filtered[adata_filtered.obs["stage"] == "Mid", gene].X).flatten()
  1013. end_expr = np.asarray(adata_filtered[adata_filtered.obs["stage"] == "End", gene].X).flatten()
  1014. # Control vs Mid
  1015. p_mid = float(mannwhitneyu(control_expr, mid_expr, alternative='two-sided')[1])
  1016. fc_mid = np.nanmean(np.exp(mid_expr) - 1) / np.nanmean(np.exp(control_expr) - 1)
  1017. log2_fc_mid = np.log2(fc_mid)
  1018. # Control vs End
  1019. p_end = float(mannwhitneyu(control_expr, end_expr, alternative='two-sided')[1])
  1020. fc_end = np.nanmean(np.exp(end_expr) - 1) / np.nanmean(np.exp(control_expr) - 1)
  1021. log2_fc_end = np.log2(fc_end)
  1022. # Store results
  1023. genes.append(gene)
  1024. p_values_comparison1.append(p_mid)
  1025. log2foldchange_comparison1.append(log2_fc_mid)
  1026. p_values_comparison2.append(p_end)
  1027. log2foldchange_comparison2.append(log2_fc_end)
  1028. # Create a DataFrame with the raw p-values and log2 fold changes
  1029. results_df = pd.DataFrame({
  1030. "Gene": genes,
  1031. "p-value_Control_vs_Mid": p_values_comparison1,
  1032. "log2FoldChange_Control_vs_Mid": log2foldchange_comparison1,
  1033. "p-value_Control_vs_End": p_values_comparison2,
  1034. "log2FoldChange_Control_vs_End": log2foldchange_comparison2
  1035. })
  1036. # Adjust p-values using Benjamini-Hochberg (FDR) correction
  1037. # For Control vs Mid:
  1038. adj_mid = multipletests(results_df["p-value_Control_vs_Mid"], method="fdr_bh")
  1039. results_df["adj-p-value_Control_vs_Mid"] = adj_mid[1]
  1040. # For Control vs End:
  1041. adj_end = multipletests(results_df["p-value_Control_vs_End"], method="fdr_bh")
  1042. results_df["adj-p-value_Control_vs_End"] = adj_end[1]
  1043. # Sort by adjusted p-value for one of the comparisons:
  1044. results_df = results_df.sort_values("adj-p-value_Control_vs_End")
  1045. # Specify the desired column order
  1046. desired_order = [
  1047. "Gene",
  1048. "log2FoldChange_Control_vs_Mid",
  1049. "p-value_Control_vs_Mid",
  1050. "adj-p-value_Control_vs_Mid",
  1051. "log2FoldChange_Control_vs_End",
  1052. "p-value_Control_vs_End",
  1053. "adj-p-value_Control_vs_End"
  1054. ]
  1055. # Reorder the DataFrame
  1056. results_df = results_df[desired_order]
  1057. # Display the reordered DataFrame
  1058. results_df
  1059. # %%
  1060. # Save to CSV in the figures directory
  1061. output_path = os.path.join(figures_dir, "alpha_differential_gene_expression_results.csv")
  1062. results_df.to_csv(output_path, index=False)
  1063. # %% [markdown]
  1064. # ## Fig. S3J
  1065. # %%
  1066. import matplotlib.pyplot as plt
  1067. from matplotlib_venn import venn3
  1068. # 1) Prepare gene sets as before
  1069. # Upregulated Genes (adj‐p < 0.01, log2FC > 0)
  1070. mid_upregulated = results_df[
  1071. (results_df["adj-p-value_Control_vs_Mid"] < 0.01) &
  1072. (results_df["log2FoldChange_Control_vs_Mid"] > 0)
  1073. ]
  1074. end_upregulated = results_df[
  1075. (results_df["adj-p-value_Control_vs_End"] < 0.01) &
  1076. (results_df["log2FoldChange_Control_vs_End"] > 0)
  1077. ]
  1078. # Downregulated Genes (adj‐p < 0.01, log2FC < 0)
  1079. mid_downregulated = results_df[
  1080. (results_df["adj-p-value_Control_vs_Mid"] < 0.01) &
  1081. (results_df["log2FoldChange_Control_vs_Mid"] < 0)
  1082. ]
  1083. end_downregulated = results_df[
  1084. (results_df["adj-p-value_Control_vs_End"] < 0.01) &
  1085. (results_df["log2FoldChange_Control_vs_End"] < 0)
  1086. ]
  1087. mid_up_set = set(mid_upregulated["Gene"])
  1088. end_up_set = set(end_upregulated["Gene"])
  1089. mid_down_set = set(mid_downregulated["Gene"])
  1090. end_down_set = set(end_downregulated["Gene"])
  1091. snrna_up_set = set(alpha_snrna_upregulated.index)
  1092. snrna_down_set = set(alpha_snrna_downregulated.index)
  1093. # 2) Create one figure with 2 rows, 1 column
  1094. # Size specified in inches: width=1.075", height=2.15"
  1095. fig, axes = plt.subplots(
  1096. nrows=2, ncols=1,
  1097. figsize=(1.075, 1.95),
  1098. dpi=300
  1099. )
  1100. # 3) Top subplot: Upregulated venn3
  1101. v1 = venn3(
  1102. subsets=[mid_up_set, end_up_set, snrna_up_set],
  1103. set_labels=("MERFISH (Mid)", "MERFISH (End)", "snRNA-seq"),
  1104. ax=axes[0]
  1105. )
  1106. axes[0].set_title("Upregulated Genes", fontsize=7)
  1107. # Adjust all label fonts in the first Venn
  1108. for text in v1.set_labels:
  1109. text.set_fontsize(7)
  1110. for text in v1.subset_labels:
  1111. if text: # some subset regions may be empty (None)
  1112. text.set_fontsize(7)
  1113. # 4) Bottom subplot: Downregulated venn3
  1114. v2 = venn3(
  1115. subsets=[mid_down_set, end_down_set, snrna_down_set],
  1116. set_labels=("MERFISH (Mid)", "MERFISH (End)", "snRNA-seq"),
  1117. ax=axes[1]
  1118. )
  1119. axes[1].set_title("Downregulated Genes", fontsize=7)
  1120. # Adjust all label fonts in the second Venn
  1121. for text in v2.set_labels:
  1122. text.set_fontsize(7)
  1123. for text in v2.subset_labels:
  1124. if text:
  1125. text.set_fontsize(7)
  1126. # 5) Manually adjust margins so no labels get cut off
  1127. fig.subplots_adjust(
  1128. left=0.12,
  1129. right=0.98,
  1130. top=0.98,
  1131. bottom=0.02,
  1132. hspace=0.3 # space between the two plots
  1133. )
  1134. # 6) Save the combined figure
  1135. fig.savefig("figures/alpha_venn_up_down.svg", dpi=300)
  1136. # 7) Display
  1137. plt.show()
  1138. # %% [markdown]
  1139. # ## Apoptosis/Stress genes
  1140. # %%
  1141. # Read in AnnData
  1142. adata = sc.read_h5ad(os.path.join(working_dir, f"adata_objects/alpha_anndata_label_transfer.h5ad"))
  1143. # %%
  1144. adata_violin = adata[:, :].copy()
  1145. adata_violin.X = adata_violin.layers['counts']
  1146. # Normalization of data by volume
  1147. adata_violin.X = adata_violin.X/np.array(adata_violin.obs['volume'])[:,None]
  1148. # Normalize total to 250
  1149. sc.pp.normalize_total(adata_violin, target_sum=250)
  1150. # Log transform
  1151. sc.pp.log1p(adata_violin)
  1152. # %% [markdown]
  1153. # ## Fig. S3K
  1154. # %%
  1155. import os
  1156. import numpy as np
  1157. import matplotlib as mpl
  1158. import matplotlib.pyplot as plt
  1159. import scanpy as sc
  1160. from matplotlib.collections import PolyCollection
  1161. # global font size
  1162. mpl.rcParams.update({
  1163. "font.size": 7,
  1164. "axes.titlesize": 7,
  1165. "axes.labelsize": 7,
  1166. "xtick.labelsize": 7,
  1167. "ytick.labelsize": 7,
  1168. "axes.linewidth": 0.5,
  1169. "xtick.major.width": 0.5,
  1170. "ytick.major.width": 0.5,
  1171. "xtick.minor.width": 0.5,
  1172. "ytick.minor.width": 0.5
  1173. })
  1174. # genes and stage order
  1175. keys = ['Hrk', 'Ddit3', 'Trib3']
  1176. order = ['Control', 'Early', 'Mid', 'End']
  1177. # prepare figure with independent y-axes
  1178. fig, axes = plt.subplots(
  1179. nrows=1, ncols=len(keys),
  1180. figsize=(2, 0.9),
  1181. sharey=False
  1182. )
  1183. # draw one violins
  1184. # then compute and plot a single median dot
  1185. for idx, (ax, gene) in enumerate(zip(axes, keys)):
  1186. sc.pl.violin(
  1187. adata_violin,
  1188. keys=[gene],
  1189. groupby='stage',
  1190. order=order,
  1191. rotation=90,
  1192. multi_panel=False,
  1193. stripplot=False, # no individual points
  1194. jitter=False, # no jitter
  1195. inner=None, # no quartile/median lines
  1196. ax=ax,
  1197. show=False
  1198. )
  1199. # thin the violin outlines
  1200. for coll in ax.collections:
  1201. if isinstance(coll, PolyCollection):
  1202. coll.set_linewidth(0.75)
  1203. # thin the axes spines
  1204. for spine in ax.spines.values():
  1205. spine.set_linewidth(0.5)
  1206. # title & axis labels
  1207. ax.set_title(gene)
  1208. if idx == 0:
  1209. ax.set_ylabel("Expression")
  1210. else:
  1211. ax.set_ylabel("")
  1212. ax.set_xlabel("")
  1213. ax.tick_params(axis='y', labelleft=True)
  1214. ax.grid(False)
  1215. # get expression values and overlay medians
  1216. expr = adata_violin.obs_vector(gene)
  1217. for xi, stage in enumerate(order):
  1218. mask = adata_violin.obs['stage'] == stage
  1219. med = np.median(expr[mask.values])
  1220. ax.scatter(xi, med,
  1221. color='black',
  1222. marker='o',
  1223. s=1,
  1224. zorder=10)
  1225. # Reduce horizontal space between panels
  1226. fig.subplots_adjust(wspace=0.8) # try 0.05–0.2
  1227. plt.show()
  1228. fig.savefig(
  1229. "figures/violin/apoptosis_stress_genes_violin_stage.svg",
  1230. format="svg",
  1231. bbox_inches="tight",
  1232. pad_inches=0.02 # reduces outer whitespace
  1233. )
  1234. # %% [markdown]
  1235. # ## Alpha MN label transfer from snRNA-seq data
  1236. # %% [markdown]
  1237. # #### Create h5ad file for all data
  1238. # %%
  1239. # read the matrix
  1240. adata = sc.read_10x_mtx(
  1241. "/oak/stanford/groups/agitler/Shared/SOD1_Paper/RNA/files_to_make_h5ad/final_obj/",
  1242. var_names='gene_symbols',
  1243. make_unique=True
  1244. )
  1245. # %%
  1246. # read the metadata
  1247. md = pd.read_csv(
  1248. "/oak/stanford/groups/agitler/Shared/SOD1_Paper/RNA/files_to_make_h5ad/final_obj/cell_metadata.tsv",
  1249. sep="\t",
  1250. index_col=0
  1251. )
  1252. # %%
  1253. # make sure the index matches adata.obs_names
  1254. md = md.reindex(adata.obs_names)
  1255. # %%
  1256. # assign
  1257. adata.obs = md
  1258. # %%
  1259. # Save AnnData
  1260. save_name = f"adata_objects/all_snrna_data.h5ad"
  1261. adata.write_h5ad(os.path.join(working_dir, save_name))
  1262. # %% [markdown]
  1263. # #### Create h5ad file from alpha MN snRNA-seq data
  1264. # %%
  1265. # read the matrix
  1266. adata = sc.read_10x_mtx(
  1267. "/oak/stanford/groups/agitler/Shared/SOD1_Paper/RNA/files_to_make_h5ad/alpha_label_transfer_50/",
  1268. var_names='gene_symbols',
  1269. make_unique=True
  1270. )
  1271. # %%
  1272. # read the metadata
  1273. md = pd.read_csv(
  1274. "/oak/stanford/groups/agitler/Shared/SOD1_Paper/RNA/files_to_make_h5ad/alpha_label_transfer_50/cell_metadata.tsv",
  1275. sep="\t",
  1276. index_col=0
  1277. )
  1278. # %%
  1279. # make sure the index matches adata.obs_names
  1280. md = md.reindex(adata.obs_names)
  1281. # %%
  1282. # assign
  1283. adata.obs = md
  1284. # %%
  1285. # Save AnnData
  1286. save_name = f"adata_objects/alpha_snrna_data.h5ad"
  1287. adata.write_h5ad(os.path.join(working_dir, save_name))
  1288. # %% [markdown]
  1289. # #### Label transfer
  1290. # %%
  1291. # Load MERFISH and snRNA-seq datasets
  1292. merfish = sc.read_h5ad(os.path.join(working_dir, f"adata_objects/alpha_anndata.h5ad"))
  1293. snrna = sc.read_h5ad(os.path.join(working_dir, f"adata_objects/alpha_snrna_data.h5ad"))
  1294. # %%
  1295. merfish.X = merfish.layers["counts"]
  1296. # %%
  1297. # Find common genes between MERFISH and snRNA-seq
  1298. common_genes = list(set(merfish.var_names) & set(snrna.var_names))
  1299. # %%
  1300. # Filter both datasets to keep only the common genes
  1301. merfish = merfish[:, common_genes]
  1302. snrna = snrna[:, common_genes]
  1303. # %%
  1304. merfish.obs["batch"] = "MERFISH"
  1305. snrna.obs["batch"] = "snRNA"
  1306. # Concatenate datasets
  1307. adata_combined = ad.concat([merfish, snrna], join="outer", label="batch", keys=["MERFISH", "snRNA"])
  1308. # %%
  1309. scvi.model.SCVI.setup_anndata(adata_combined, batch_key="batch")
  1310. model = scvi.model.SCVI(adata_combined)
  1311. # %%
  1312. model.train()
  1313. # %%
  1314. adata_combined.obs['predicted.id'] = adata_combined.obs['predicted.id'].cat.add_categories('Unknown')
  1315. adata_combined.obs = adata_combined.obs.fillna(value = {'predicted.id': 'Unknown'})
  1316. # %%
  1317. model2 = scvi.model.SCANVI.from_scvi_model(model, adata = adata_combined, unlabeled_category = 'Unknown',
  1318. labels_key = 'predicted.id')
  1319. # %%
  1320. model2.train(max_epochs = 400)
  1321. # %%
  1322. adata_combined.obs['predicted'] = model2.predict(adata_combined)
  1323. # %% [markdown]
  1324. # #### Add the predicted labels to the alpha MN anndata object
  1325. # %%
  1326. cell_mapper = dict(zip(adata_combined.obs.index, adata_combined.obs.predicted))
  1327. # %%
  1328. adata = sc.read_h5ad(os.path.join(working_dir, f"adata_objects/alpha_anndata.h5ad"))
  1329. # %%
  1330. adata.obs['predicted.id'] = adata.obs.index.map(cell_mapper)
  1331. # %%
  1332. # Plot the transferred labels
  1333. sc.pl.umap(adata, color="predicted.id")
  1334. # %%
  1335. sc.pl.violin(
  1336. adata,
  1337. keys=['Prkcd', 'Chodl', 'Atf3', 'Gap43'],
  1338. groupby='predicted.id',
  1339. order=["Slow-Firing", "Intermediate", "Fast-Firing", "Early DAMN", "Late DAMN"],
  1340. rotation=90
  1341. )
  1342. # %%
  1343. # Save anndata
  1344. save_name = f"adata_objects/alpha_anndata_label_transfer.h5ad"
  1345. adata.write_h5ad(os.path.join(working_dir, save_name))
  1346. # %% [markdown]
  1347. # ## Alpha MN violin plots
  1348. # %%
  1349. # Read in AnnData
  1350. adata_alpha = sc.read_h5ad(os.path.join(working_dir, f"adata_objects/alpha_anndata_label_transfer.h5ad"))
  1351. # %%
  1352. adata_violin = adata_alpha[:, :].copy()
  1353. adata_violin.X = adata_violin.layers['counts']
  1354. # Normalization of data by volume
  1355. adata_violin.X = adata_violin.X/np.array(adata_violin.obs['volume'])[:,None]
  1356. # Normalize total to 250
  1357. sc.pp.normalize_total(adata_violin, target_sum=250)
  1358. # Log transform
  1359. sc.pp.log1p(adata_violin)
  1360. # %% [markdown]
  1361. # ## Fig. 2K
  1362. # %%
  1363. import os
  1364. import numpy as np
  1365. import matplotlib as mpl
  1366. import matplotlib.pyplot as plt
  1367. import scanpy as sc
  1368. from matplotlib.collections import PolyCollection
  1369. # ensure output folder exists
  1370. os.makedirs("figures/violin", exist_ok=True)
  1371. # global font size and thin axis spines
  1372. mpl.rcParams.update({
  1373. "font.size": 7,
  1374. "axes.titlesize": 7,
  1375. "axes.labelsize": 7,
  1376. "xtick.labelsize": 7,
  1377. "ytick.labelsize": 7,
  1378. "axes.linewidth": 0.5,
  1379. "xtick.major.width": 0.5,
  1380. "ytick.major.width": 0.5,
  1381. "xtick.minor.width": 0.5,
  1382. "ytick.minor.width": 0.5
  1383. })
  1384. # genes and stage order
  1385. keys = [
  1386. 'Tenm1','Kcng1','Kcnj14','Nalcn','Grm8','Grik4','Gria3',
  1387. 'Gabra5','Nefh','Ina','Slc7a11','Slc7a1','Sqstm1','Taf15'
  1388. ]
  1389. order = ['Control','Early','Mid','End']
  1390. # figure: 2×7 panels
  1391. ncol = len(keys)//2
  1392. fig, axes = plt.subplots(2, ncol, figsize=(6.5936, 2.5296), sharey=False)
  1393. axes_flat = axes.flatten()
  1394. for idx, (ax, gene) in enumerate(zip(axes_flat, keys)):
  1395. sc.pl.violin(
  1396. adata_violin,
  1397. keys=[gene],
  1398. groupby='stage',
  1399. order=order,
  1400. rotation=90,
  1401. multi_panel=False,
  1402. stripplot=False,
  1403. jitter=False,
  1404. inner=None,
  1405. ax=ax,
  1406. show=False
  1407. )
  1408. # thin the violin outlines
  1409. for coll in ax.collections:
  1410. if isinstance(coll, PolyCollection):
  1411. coll.set_linewidth(0.75)
  1412. # thin the axes spines
  1413. for spine in ax.spines.values():
  1414. spine.set_linewidth(0.5)
  1415. # title & labels
  1416. ax.set_title(gene)
  1417. if idx % ncol == 0:
  1418. ax.set_ylabel("Expression")
  1419. else:
  1420. ax.set_ylabel("")
  1421. ax.set_xlabel("")
  1422. ax.tick_params(axis='y', labelleft=True)
  1423. # overlay median dot
  1424. expr = adata_violin.obs_vector(gene)
  1425. for xi, stage in enumerate(order):
  1426. med = np.median(expr[adata_violin.obs['stage'] == stage])
  1427. ax.scatter(xi, med, color='black', s=1, zorder=10)
  1428. for idx, ax in enumerate(axes_flat):
  1429. # first ncol are top row
  1430. if idx < ncol:
  1431. ax.tick_params(axis='x', labelbottom=False)
  1432. else:
  1433. ax.tick_params(axis='x', labelbottom=True)
  1434. plt.tight_layout()
  1435. plt.show()
  1436. fig.savefig(
  1437. "figures/violin/disease_genes_violin.svg",
  1438. format="svg",
  1439. bbox_inches="tight"
  1440. )
  1441. # %% [markdown]
  1442. # ## Fig. 4E
  1443. # %%
  1444. import os
  1445. import numpy as np
  1446. import matplotlib as mpl
  1447. import matplotlib.pyplot as plt
  1448. import scanpy as sc
  1449. from matplotlib.collections import PolyCollection
  1450. # global font size
  1451. mpl.rcParams.update({
  1452. "font.size": 7,
  1453. "axes.titlesize": 7,
  1454. "axes.labelsize": 7,
  1455. "xtick.labelsize": 7,
  1456. "ytick.labelsize": 7,
  1457. "axes.linewidth": 0.5,
  1458. "xtick.major.width": 0.5,
  1459. "ytick.major.width": 0.5,
  1460. "xtick.minor.width": 0.5,
  1461. "ytick.minor.width": 0.5
  1462. })
  1463. # genes and stage order
  1464. keys = ['Nfia', 'Nfil3', 'Atf5', 'Atf3', 'Jun']
  1465. order = ['Control', 'Early', 'Mid', 'End']
  1466. # prepare figure with independent y-axes
  1467. fig, axes = plt.subplots(
  1468. nrows=1, ncols=len(keys),
  1469. figsize=(6.5, 1.4712),
  1470. sharey=False
  1471. )
  1472. # draw one violins
  1473. # then compute and plot a single median dot
  1474. for idx, (ax, gene) in enumerate(zip(axes, keys)):
  1475. sc.pl.violin(
  1476. adata_violin,
  1477. keys=[gene],
  1478. groupby='stage',
  1479. order=order,
  1480. rotation=90,
  1481. multi_panel=False,
  1482. stripplot=False, # no individual points
  1483. jitter=False, # no jitter
  1484. inner=None, # no quartile/median lines
  1485. ax=ax,
  1486. show=False
  1487. )
  1488. # thin the violin outlines
  1489. for coll in ax.collections:
  1490. if isinstance(coll, PolyCollection):
  1491. coll.set_linewidth(0.75)
  1492. # thin the axes spines
  1493. for spine in ax.spines.values():
  1494. spine.set_linewidth(0.5)
  1495. # title & axis labels
  1496. ax.set_title(gene)
  1497. if idx == 0:
  1498. ax.set_ylabel("Expression")
  1499. else:
  1500. ax.set_ylabel("")
  1501. ax.set_xlabel("")
  1502. ax.tick_params(axis='y', labelleft=True)
  1503. ax.grid(False)
  1504. # get expression values and overlay medians
  1505. expr = adata_violin.obs_vector(gene)
  1506. for xi, stage in enumerate(order):
  1507. mask = adata_violin.obs['stage'] == stage
  1508. med = np.median(expr[mask.values])
  1509. ax.scatter(xi, med,
  1510. color='black',
  1511. marker='o',
  1512. s=1,
  1513. zorder=10)
  1514. # tidy up, display & save
  1515. plt.tight_layout()
  1516. plt.show()
  1517. fig.savefig(
  1518. "figures/violin/disease_TFs_violin.svg",
  1519. format="svg",
  1520. bbox_inches="tight"
  1521. )
  1522. # %% [markdown]
  1523. # ## Create a final anndata object where initial cholinergic neurons are replaced
  1524. # %%
  1525. # Read in AnnData
  1526. adata_all = sc.read_h5ad(os.path.join(working_dir, f"adata_objects/all_anndata.h5ad"))
  1527. adata_chol = sc.read_h5ad(os.path.join(working_dir, f"adata_objects/cholinergic_anndata_highvol.h5ad"))
  1528. adata_alpha = sc.read_h5ad(os.path.join(working_dir, f"adata_objects/alpha_anndata_label_transfer.h5ad"))
  1529. # %%
  1530. # Change cell class of peri-MN oligodendrocytes
  1531. df = pd.read_csv(os.path.join(working_dir, 'periMN_oligo_metadata.csv'), index_col=0)
  1532. common = adata_all.obs.index.intersection(df.index)
  1533. adata_all.obs.loc[common, 'cell_class'] = 'Oligodendrocytes'
  1534. # %%
  1535. # Remove cells where cell_class == "Cholinergic Neurons" from adata_all
  1536. adata_all_filtered = adata_all[adata_all.obs["cell_class"] != "Cholinergic Neurons"].copy()
  1537. # Concatenate adata_all_filtered with adata_chol
  1538. adata_all_chol_cat = adata_all_filtered.concatenate(adata_chol, index_unique=None)
  1539. # Remove cells where cholinergic_type == "Alpha MNs" from adata_all_chol_cat
  1540. adata_all_chol_filtered = adata_all_chol_cat[adata_all_chol_cat.obs["cholinergic_type"] != "Alpha MNs"].copy()
  1541. # Concatenate adata_all_chol_cat with adata_alpha
  1542. adata_final = adata_all_chol_filtered.concatenate(adata_alpha, index_unique=None)
  1543. # %%
  1544. # Neighbors and UMAP
  1545. sc.external.pp.bbknn(adata_final, batch_key='VS') # use inplace of sc.pp.neighbors()
  1546. sc.tl.umap(adata_final)
  1547. sc.pl.umap(adata_final, color="cell_class")
  1548. # %% [markdown]
  1549. # #### Add DAMN_status metadata
  1550. # %%
  1551. # Create DAMN_status column
  1552. adata_final.obs['DAMN_status'] = pd.NA # initialize with NA
  1553. # Mark DAMN cells
  1554. adata_final.obs.loc[
  1555. adata_final.obs['predicted.id'].isin(['Early DAMN', 'Late DAMN']),
  1556. 'DAMN_status'
  1557. ] = 'DAMN'
  1558. # Mark Non-DAMN cells (non-NA and not DAMN)
  1559. adata_final.obs.loc[
  1560. (~adata_final.obs['predicted.id'].isin(['Early DAMN', 'Late DAMN'])) &
  1561. (adata_final.obs['predicted.id'].notna()),
  1562. 'DAMN_status'
  1563. ] = 'Non-DAMN'
  1564. # %%
  1565. # Save anndata
  1566. save_name = f"adata_objects/all_anndata_final.h5ad"
  1567. adata_final.write_h5ad(os.path.join(working_dir, save_name))
  1568. # %% [markdown]
  1569. # ## Rotate tissue sections for visualization
  1570. # %% [markdown]
  1571. # #### Adapted from Sun et al., Nature (2025)
  1572. # %%
  1573. adata = sc.read_h5ad(os.path.join(working_dir, f"adata_objects/all_anndata_final.h5ad"))
  1574. # %%
  1575. # Check initial visualization
  1576. fig, axarr = plt.subplots(20, 6, sharex=False, sharey=False, figsize=(20, 40)) # 20 rows, 6 columns
  1577. mids = np.sort(np.unique(adata.obs['slide_section']))
  1578. for i in range(20):
  1579. for j in range(6):
  1580. index = i * 6 + j # Compute the 1D index from 2D indices
  1581. if index < len(mids): # Ensure we don't go out of bounds
  1582. mid = mids[index]
  1583. sub_adata = adata[adata.obs['slide_section'] == mid].copy()
  1584. sc.pl.embedding(sub_adata, 'spatial', color="cell_class", size=50, show=False,
  1585. ax=axarr[i, j], vmin=-10, vmax=10, title=None)
  1586. axarr[i, j].legend_.remove() if axarr[i, j].get_legend() else None # Remove legend if it exists
  1587. plt.tight_layout()
  1588. plt.show()
  1589. # %%
  1590. # Function for rotating about the origin
  1591. def rotate(p, origin=(0, 0), degrees=0):
  1592. # Rigid rotation by degrees around origin
  1593. angle = np.deg2rad(degrees)
  1594. R = np.array([[np.cos(angle), -np.sin(angle)],
  1595. [np.sin(angle), np.cos(angle)]])
  1596. o = np.atleast_2d(origin)
  1597. p = np.atleast_2d(p)
  1598. return np.squeeze((R @ (p.T-o.T) + o.T).T)
  1599. # %%
  1600. # Center spatial coordinates
  1601. new_spatial = np.zeros(adata.obsm['spatial'].shape)
  1602. mids = np.sort(np.unique(adata.obs['slide_section']))
  1603. for mid in mids:
  1604. X = np.array(adata[adata.obs.slide_section==mid].obsm["spatial"].copy())
  1605. new_spatial[adata.obs.slide_section==mid,:] = X-np.mean(X,axis=0)
  1606. adata.obsm['spatial'] = new_spatial
  1607. # %%
  1608. # Rotate by manually determined angles
  1609. rotation_dict = {
  1610. '202312251515_MsSpinalCord-L4-S1-VS119-YS_Beta8_R1C1': 180,
  1611. '202312251515_MsSpinalCord-L4-S1-VS119-YS_Beta8_R1C2': 176,
  1612. '202312251515_MsSpinalCord-L4-S1-VS119-YS_Beta8_R1C3': -177,
  1613. '202401150953_MsSpinalCord-VS119-Lumbar-Ctrl5-SOD11_Beta8_R1C1': 168,
  1614. '202401150953_MsSpinalCord-VS119-Lumbar-Ctrl5-SOD11_Beta8_R1C2': 168,
  1615. '202401150953_MsSpinalCord-VS119-Lumbar-Ctrl5-SOD11_Beta8_R1C3': 173,
  1616. '202401150953_MsSpinalCord-VS119-Lumbar-Ctrl5-SOD11_Beta8_R2C1': -12,
  1617. '202401150953_MsSpinalCord-VS119-Lumbar-Ctrl5-SOD11_Beta8_R2C3': 123,
  1618. '202401150956_MsSpinalCord-VS119-Cervical-Ctrl2-SOD10_VMSC10802_R1C1': -175,
  1619. '202401150956_MsSpinalCord-VS119-Cervical-Ctrl2-SOD10_VMSC10802_R1C2': -167,
  1620. '202401150956_MsSpinalCord-VS119-Cervical-Ctrl2-SOD10_VMSC10802_R1C3': 170,
  1621. '202401150956_MsSpinalCord-VS119-Cervical-Ctrl2-SOD10_VMSC10802_R2C1': 7,
  1622. '202401150956_MsSpinalCord-VS119-Cervical-Ctrl2-SOD10_VMSC10802_R2C2': 7,
  1623. '202401150956_MsSpinalCord-VS119-Cervical-Ctrl2-SOD10_VMSC10802_R2C3': 8,
  1624. '202401151110_MsSpinalCord-VS119-Lumbar-Ctrl1-SOD8_VMSC07101_R1C1': 170,
  1625. '202401151110_MsSpinalCord-VS119-Lumbar-Ctrl1-SOD8_VMSC07101_R1C2': 166,
  1626. '202401151110_MsSpinalCord-VS119-Lumbar-Ctrl1-SOD8_VMSC07101_R1C3': 158,
  1627. '202401151110_MsSpinalCord-VS119-Lumbar-Ctrl1-SOD8_VMSC07101_R2C2': -18,
  1628. '202401151110_MsSpinalCord-VS119-Lumbar-Ctrl1-SOD8_VMSC07101_R2C3': -175, #19
  1629. '202401241119_HuSpinalCord-VS119-Cervical-Ctrl3-SOD11_VMSC07101_reanalysis_R1C1': 180,
  1630. '202401241119_HuSpinalCord-VS119-Cervical-Ctrl3-SOD11_VMSC07101_reanalysis_R1C2': -166,
  1631. '202401241119_HuSpinalCord-VS119-Cervical-Ctrl3-SOD11_VMSC07101_reanalysis_R1C3': -172,
  1632. '202401241119_HuSpinalCord-VS119-Cervical-Ctrl3-SOD11_VMSC07101_reanalysis_R1C4': 179,
  1633. '202401241119_HuSpinalCord-VS119-Cervical-Ctrl3-SOD11_VMSC07101_reanalysis_R2C1': 0,
  1634. '202401241119_HuSpinalCord-VS119-Cervical-Ctrl3-SOD11_VMSC07101_reanalysis_R2C2': 0,
  1635. '202401241119_HuSpinalCord-VS119-Cervical-Ctrl3-SOD11_VMSC07101_reanalysis_R2C3': 3,
  1636. '202401241136_HuSpinalCord-VS119-Lumbar-Ctrl2-SOD9_Beta8_R1C1': 174,
  1637. '202401241136_HuSpinalCord-VS119-Lumbar-Ctrl2-SOD9_Beta8_R1C2': 180,
  1638. '202401241136_HuSpinalCord-VS119-Lumbar-Ctrl2-SOD9_Beta8_R1C3': 174,
  1639. '202401241136_HuSpinalCord-VS119-Lumbar-Ctrl2-SOD9_Beta8_R2C1': 0,
  1640. '202401241136_HuSpinalCord-VS119-Lumbar-Ctrl2-SOD9_Beta8_R2C2': 3,
  1641. '202401241136_HuSpinalCord-VS119-Lumbar-Ctrl2-SOD9_Beta8_R2C3': 12,
  1642. '202401241136_HuSpinalCord-VS119-Lumbar-Ctrl2-SOD9_Beta8_R2C4': 25,
  1643. '202401241200_HuSpinalCord-VS119-Lubmar-Ctrl3-SOD10_VMSC10802_R1C1': -175,
  1644. '202401241200_HuSpinalCord-VS119-Lubmar-Ctrl3-SOD10_VMSC10802_R1C2': -178,
  1645. '202401241200_HuSpinalCord-VS119-Lubmar-Ctrl3-SOD10_VMSC10802_R1C3': 165,
  1646. '202401241200_HuSpinalCord-VS119-Lubmar-Ctrl3-SOD10_VMSC10802_R2C1': 6,
  1647. '202401241200_HuSpinalCord-VS119-Lubmar-Ctrl3-SOD10_VMSC10802_R2C2': 7,
  1648. '202401241200_HuSpinalCord-VS119-Lubmar-Ctrl3-SOD10_VMSC10802_R2C3': -4,
  1649. '202405281416_MsSpinalCord-VS223-Lumbar-CM2-SM4-S1_VMSC15302_R1C1': -30,
  1650. '202405281416_MsSpinalCord-VS223-Lumbar-CM2-SM4-S1_VMSC15302_R1C2': -19,
  1651. '202405281416_MsSpinalCord-VS223-Lumbar-CM2-SM4-S1_VMSC15302_R1C3': -16,
  1652. '202405281416_MsSpinalCord-VS223-Lumbar-CM2-SM4-S1_VMSC15302_R2C2': 155,
  1653. '202405281416_MsSpinalCord-VS223-Lumbar-CM2-SM4-S1_VMSC15302_R2C3': 152,
  1654. '202405281416_MsSpinalCord-VS223-Lumbar-CM2-SM4-S1_VMSC15302_R2C4': -28,
  1655. '202405281419_MsSpinalCord-VS223-Lumbar-CM5-SM8-S1_VMSC01801_R1C1': 35,
  1656. '202405281419_MsSpinalCord-VS223-Lumbar-CM5-SM8-S1_VMSC01801_R1C2': 34,
  1657. '202405281419_MsSpinalCord-VS223-Lumbar-CM5-SM8-S1_VMSC01801_R1C3': 32,
  1658. '202405281419_MsSpinalCord-VS223-Lumbar-CM5-SM8-S1_VMSC01801_R2C1': -144,
  1659. '202405281419_MsSpinalCord-VS223-Lumbar-CM5-SM8-S1_VMSC01801_R2C2': -146,
  1660. '202405281419_MsSpinalCord-VS223-Lumbar-CM5-SM8-S1_VMSC01801_R2C3': -144,
  1661. '202405281419_MsSpinalCord-VS223-Lumbar-CM5-SM8-S1_VMSC01801_R2C4': -144,
  1662. '202405281527_MsSpinalCord-VS223-Lubmar-CM4-SM6-S1_VMSC02011_R1C1': 41,
  1663. '202405281527_MsSpinalCord-VS223-Lubmar-CM4-SM6-S1_VMSC02011_R1C2': 51,
  1664. '202405281527_MsSpinalCord-VS223-Lubmar-CM4-SM6-S1_VMSC02011_R1C3': 36,
  1665. '202405281527_MsSpinalCord-VS223-Lubmar-CM4-SM6-S1_VMSC02011_R2C1': -146,
  1666. '202405281527_MsSpinalCord-VS223-Lubmar-CM4-SM6-S1_VMSC02011_R2C2': -133,
  1667. '202405281527_MsSpinalCord-VS223-Lubmar-CM4-SM6-S1_VMSC02011_R2C3': -145,
  1668. '202405281527_MsSpinalCord-VS223-Lubmar-CM4-SM6-S1_VMSC02011_R2C4': -155,
  1669. '202405281702_MsSpinalCord-VS223-Lumbar-CE2-SE4-S1_VMSC02701_R1C1': -43,
  1670. '202405281702_MsSpinalCord-VS223-Lumbar-CE2-SE4-S1_VMSC02701_R1C2': -39,
  1671. '202405281702_MsSpinalCord-VS223-Lumbar-CE2-SE4-S1_VMSC02701_R1C3': -44,
  1672. '202405281702_MsSpinalCord-VS223-Lumbar-CE2-SE4-S1_VMSC02701_R1C4': -41,
  1673. '202405281702_MsSpinalCord-VS223-Lumbar-CE2-SE4-S1_VMSC02701_R2C1': 138,
  1674. '202405281702_MsSpinalCord-VS223-Lumbar-CE2-SE4-S1_VMSC02701_R2C2': 135,
  1675. '202405281702_MsSpinalCord-VS223-Lumbar-CE2-SE4-S1_VMSC02701_R2C3': 127,
  1676. '202405281702_MsSpinalCord-VS223-Lumbar-CE2-SE4-S1_VMSC02701_R2C4': 145,
  1677. '202406031049_MsSpinalCord-VS223-Cervical-CE2-SE3-S1_VMSC10802_R1C1': -27,
  1678. '202406031049_MsSpinalCord-VS223-Cervical-CE2-SE3-S1_VMSC10802_R1C2': -29,
  1679. '202406031049_MsSpinalCord-VS223-Cervical-CE2-SE3-S1_VMSC10802_R1C3': -30,
  1680. '202406031049_MsSpinalCord-VS223-Cervical-CE2-SE3-S1_VMSC10802_R1C4': -28,
  1681. '202406031049_MsSpinalCord-VS223-Cervical-CE2-SE3-S1_VMSC10802_R2C1': 145,
  1682. '202406031049_MsSpinalCord-VS223-Cervical-CE2-SE3-S1_VMSC10802_R2C2': 155,
  1683. '202406031049_MsSpinalCord-VS223-Cervical-CE2-SE3-S1_VMSC10802_R2C3': 139,
  1684. '202406031050_MsSpinalCord-VS223-Cervical-CM5-SM5-S1_VMSC07201_R1C1': 49,
  1685. '202406031050_MsSpinalCord-VS223-Cervical-CM5-SM5-S1_VMSC07201_R1C2': 42,
  1686. '202406031050_MsSpinalCord-VS223-Cervical-CM5-SM5-S1_VMSC07201_R1C3': 37,
  1687. '202406031050_MsSpinalCord-VS223-Cervical-CM5-SM5-S1_VMSC07201_R2C2': -138,
  1688. '202406031050_MsSpinalCord-VS223-Cervical-CM5-SM5-S1_VMSC07201_R2C3': -143,
  1689. '202406031050_MsSpinalCord-VS223-Cervical-CM5-SM5-S1_VMSC07201_R2C4': -128,
  1690. '202406071118_MsSpinalCord-VS223-Cervical-CM2-SM4-S1_VMSC10802_R1C1': -17,
  1691. '202406071118_MsSpinalCord-VS223-Cervical-CM2-SM4-S1_VMSC10802_R1C2': -4,
  1692. '202406071118_MsSpinalCord-VS223-Cervical-CM2-SM4-S1_VMSC10802_R1C3': -7,
  1693. '202406071118_MsSpinalCord-VS223-Cervical-CM2-SM4-S1_VMSC10802_R2C1': 174,
  1694. '202406071118_MsSpinalCord-VS223-Cervical-CM2-SM4-S1_VMSC10802_R2C2': 177,
  1695. '202406071118_MsSpinalCord-VS223-Cervical-CM2-SM4-S1_VMSC10802_R2C3': -172,
  1696. '202406071118_MsSpinalCord-VS223-Cervical-CM2-SM4-S1_VMSC10802_R2C4': 178,
  1697. '202406071119_MsSpinalCord-VS223-Lumbar-CE1-SE3-S2_Beta8_R1C1': 0,
  1698. '202406071119_MsSpinalCord-VS223-Lumbar-CE1-SE3-S2_Beta8_R1C2': -16,
  1699. '202406071119_MsSpinalCord-VS223-Lumbar-CE1-SE3-S2_Beta8_R1C3': 2,
  1700. '202406071119_MsSpinalCord-VS223-Lumbar-CE1-SE3-S2_Beta8_R1C4': 8,
  1701. '202406071119_MsSpinalCord-VS223-Lumbar-CE1-SE3-S2_Beta8_R2C1': -171,
  1702. '202406071119_MsSpinalCord-VS223-Lumbar-CE1-SE3-S2_Beta8_R2C2': -175,
  1703. '202406071119_MsSpinalCord-VS223-Lumbar-CE1-SE3-S2_Beta8_R2C3': -173,
  1704. '202406071119_MsSpinalCord-VS223-Lumbar-CE1-SE3-S2_Beta8_R2C4': -165,
  1705. '202406071120_MsSpinalCord-VS223-Lumbar-CE5-SE6-S1_VMSC01801_R1C1': -171,
  1706. '202406071120_MsSpinalCord-VS223-Lumbar-CE5-SE6-S1_VMSC01801_R1C2': -12,
  1707. '202406071120_MsSpinalCord-VS223-Lumbar-CE5-SE6-S1_VMSC01801_R1C3': -1,
  1708. '202406071120_MsSpinalCord-VS223-Lumbar-CE5-SE6-S1_VMSC01801_R1C4': 12,
  1709. '202406071120_MsSpinalCord-VS223-Lumbar-CE5-SE6-S1_VMSC01801_R2C1': 180,
  1710. '202406071120_MsSpinalCord-VS223-Lumbar-CE5-SE6-S1_VMSC01801_R2C2': -174,
  1711. '202406071120_MsSpinalCord-VS223-Lumbar-CE5-SE6-S1_VMSC01801_R2C3': -176,
  1712. '202406071120_MsSpinalCord-VS223-Lumbar-CE5-SE6-S1_VMSC01801_R2C4': 166,
  1713. '202406071222_MsSpinalCord-VS223-Cervical-CE5-SE5-S2_VMSC02011_R1C1': -57,
  1714. '202406071222_MsSpinalCord-VS223-Cervical-CE5-SE5-S2_VMSC02011_R1C2': -45,
  1715. '202406071222_MsSpinalCord-VS223-Cervical-CE5-SE5-S2_VMSC02011_R1C3': -38,
  1716. '202406071222_MsSpinalCord-VS223-Cervical-CE5-SE5-S2_VMSC02011_R1C4': -47,
  1717. '202406071222_MsSpinalCord-VS223-Cervical-CE5-SE5-S2_VMSC02011_R2C1': 149,
  1718. '202406071222_MsSpinalCord-VS223-Cervical-CE5-SE5-S2_VMSC02011_R2C2': 147,
  1719. '202406071222_MsSpinalCord-VS223-Cervical-CE5-SE5-S2_VMSC02011_R2C3': 149,
  1720. '202406071239_MsSpinalCord-VS223-Lumbar-CM3-SM5-S2_VMSC02701_R1C1': -13,
  1721. '202406071239_MsSpinalCord-VS223-Lumbar-CM3-SM5-S2_VMSC02701_R1C2': -17,
  1722. '202406071239_MsSpinalCord-VS223-Lumbar-CM3-SM5-S2_VMSC02701_R1C3': -16,
  1723. '202406071239_MsSpinalCord-VS223-Lumbar-CM3-SM5-S2_VMSC02701_R2C1': 170,
  1724. '202406071239_MsSpinalCord-VS223-Lumbar-CM3-SM5-S2_VMSC02701_R2C2': 166,
  1725. '202406071239_MsSpinalCord-VS223-Lumbar-CM3-SM5-S2_VMSC02701_R2C3': 169,
  1726. '202406071239_MsSpinalCord-VS223-Lumbar-CM3-SM5-S2_VMSC02701_R2C4': 163}
  1727. # %%
  1728. # Rotational alignment
  1729. new_spatial = np.zeros(adata.obsm['spatial'].shape)
  1730. mids = np.sort(np.unique(adata.obs['slide_section']))
  1731. for mid in mids:
  1732. X = np.array(adata[adata.obs.slide_section==mid].obsm["spatial"].copy())
  1733. new_spatial[adata.obs.slide_section==mid,:] = rotate(X, degrees=360-rotation_dict[mid])
  1734. adata.obsm['spatial'] = new_spatial
  1735. # %%
  1736. # Check alignment
  1737. fig, axarr = plt.subplots(20, 6, sharex=False, sharey=False, figsize=(20, 40)) # 20 rows, 6 columns
  1738. mids = np.sort(np.unique(adata.obs['slide_section']))
  1739. for i in range(20):
  1740. for j in range(6):
  1741. index = i * 6 + j # Compute the 1D index from 2D indices
  1742. if index < len(mids): # Ensure we don't go out of bounds
  1743. mid = mids[index]
  1744. sub_adata = adata[adata.obs['slide_section'] == mid].copy()
  1745. sc.pl.embedding(sub_adata, 'spatial', color="cell_class", size=50, show=False,
  1746. ax=axarr[i, j], vmin=-10, vmax=10, title=None)
  1747. axarr[i, j].legend_.remove() if axarr[i, j].get_legend() else None # Remove legend if it exists
  1748. plt.tight_layout()
  1749. plt.show()
  1750. # %%
  1751. # Save anndata
  1752. save_name = f"adata_objects/all_anndata_final_rotated.h5ad"
  1753. adata.write_h5ad(os.path.join(working_dir, save_name))
  1754. # %% [markdown]
  1755. # ## Get spatial plot
  1756. # %%
  1757. # Read in AnnData
  1758. adata = sc.read_h5ad(os.path.join(working_dir, f"adata_objects/all_anndata_final_rotated.h5ad"))
  1759. # %% [markdown]
  1760. # ## Fig. 1C
  1761. # %%
  1762. import matplotlib.pyplot as plt
  1763. import squidpy as sq
  1764. import numpy as np
  1765. import pandas as pd
  1766. from matplotlib.colors import ListedColormap
  1767. # 1. Subset the data
  1768. slide_id = '202312251515_MsSpinalCord-L4-S1-VS119-YS_Beta8_R1C2'
  1769. sub_adata = adata[adata.obs['slide_section'] == slide_id].copy()
  1770. # 2. Assign custom cell group
  1771. def assign_group(row):
  1772. if row["cell_class"] == "Cholinergic Neurons":
  1773. return "CholN"
  1774. elif row["cell_class"] == "Non-Cholinergic Interneurons":
  1775. return "NCI"
  1776. elif row["cell_class"] in ["Astrocytes", "Microglia/Macrophages", "Oligodendrocytes"]:
  1777. return "Glia"
  1778. else:
  1779. return "Other"
  1780. sub_adata.obs["cell_group"] = sub_adata.obs.apply(assign_group, axis=1)
  1781. # 3. Remove "Other" and set categorical order
  1782. sub_adata = sub_adata[sub_adata.obs["cell_group"] != "Other"].copy()
  1783. sub_adata.obs["cell_group"] = pd.Categorical(
  1784. sub_adata.obs["cell_group"],
  1785. categories=["CholN", "NCI", "Glia"],
  1786. ordered=True
  1787. )
  1788. # 4. Define colormap matching the order above
  1789. colormap = ListedColormap(["#FF0000", "#0000FF", "#C0C0C0"]) # red, blue, light gray
  1790. # 5. Plot
  1791. fig, ax = plt.subplots(figsize=(10, 10), dpi=600)
  1792. sq.pl.spatial_scatter(
  1793. sub_adata,
  1794. shape=None,
  1795. color="cell_group",
  1796. size=150,
  1797. library_id="spatial",
  1798. palette=colormap,
  1799. ax=ax,
  1800. legend_loc="upper right",
  1801. title="",
  1802. axis_label=""
  1803. )
  1804. # Rasterize the scatter points
  1805. for coll in ax.collections:
  1806. coll.set_rasterized(True)
  1807. # Adjust legend font size manually
  1808. legend = ax.get_legend()
  1809. if legend is not None:
  1810. for text in legend.get_texts():
  1811. text.set_fontsize(10)
  1812. # Remove ticks and box
  1813. ax.set_xticks([])
  1814. ax.set_yticks([])
  1815. ax.axis("off")
  1816. # Rotate 180°
  1817. ax.invert_xaxis()
  1818. ax.invert_yaxis()
  1819. plt.show()
  1820. # 6. Save high-resolution SVG
  1821. fig_path = os.path.join(figures_dir, "spatial_plot_clean.svg")
  1822. fig.savefig(fig_path, format="svg", bbox_inches="tight", dpi=600)
  1823. # %% [markdown]
  1824. # ## Get a subset of high-quality tissue sections for cell type ratio and spatial analyses
  1825. # %%
  1826. # Read in AnnData
  1827. adata = sc.read_h5ad(os.path.join(working_dir, f"adata_objects/all_anndata_final_rotated.h5ad"))
  1828. # %%
  1829. # Specify which sections/section parts to keep or remove
  1830. sections_to_keep_dict = {
  1831. '202312251515_MsSpinalCord-L4-S1-VS119-YS_Beta8_R1C1': "remove",
  1832. '202312251515_MsSpinalCord-L4-S1-VS119-YS_Beta8_R1C2': "remove",
  1833. '202312251515_MsSpinalCord-L4-S1-VS119-YS_Beta8_R1C3': "keep",
  1834. '202401150953_MsSpinalCord-VS119-Lumbar-Ctrl5-SOD11_Beta8_R1C1': "keep",
  1835. '202401150953_MsSpinalCord-VS119-Lumbar-Ctrl5-SOD11_Beta8_R1C2': "keep",
  1836. '202401150953_MsSpinalCord-VS119-Lumbar-Ctrl5-SOD11_Beta8_R1C3': "keep",
  1837. '202401150953_MsSpinalCord-VS119-Lumbar-Ctrl5-SOD11_Beta8_R2C1': "remove",
  1838. '202401150953_MsSpinalCord-VS119-Lumbar-Ctrl5-SOD11_Beta8_R2C3': "remove",
  1839. '202401150956_MsSpinalCord-VS119-Cervical-Ctrl2-SOD10_VMSC10802_R1C1': "left",
  1840. '202401150956_MsSpinalCord-VS119-Cervical-Ctrl2-SOD10_VMSC10802_R1C2': "keep",
  1841. '202401150956_MsSpinalCord-VS119-Cervical-Ctrl2-SOD10_VMSC10802_R1C3': "remove",
  1842. '202401150956_MsSpinalCord-VS119-Cervical-Ctrl2-SOD10_VMSC10802_R2C1': "remove",
  1843. '202401150956_MsSpinalCord-VS119-Cervical-Ctrl2-SOD10_VMSC10802_R2C2': "keep",
  1844. '202401150956_MsSpinalCord-VS119-Cervical-Ctrl2-SOD10_VMSC10802_R2C3': "right",
  1845. '202401151110_MsSpinalCord-VS119-Lumbar-Ctrl1-SOD8_VMSC07101_R1C1': "remove",
  1846. '202401151110_MsSpinalCord-VS119-Lumbar-Ctrl1-SOD8_VMSC07101_R1C2': "left",
  1847. '202401151110_MsSpinalCord-VS119-Lumbar-Ctrl1-SOD8_VMSC07101_R1C3': "remove",
  1848. '202401151110_MsSpinalCord-VS119-Lumbar-Ctrl1-SOD8_VMSC07101_R2C2': "keep",
  1849. '202401151110_MsSpinalCord-VS119-Lumbar-Ctrl1-SOD8_VMSC07101_R2C3': "remove",
  1850. '202401241119_HuSpinalCord-VS119-Cervical-Ctrl3-SOD11_VMSC07101_reanalysis_R1C1': "remove",
  1851. '202401241119_HuSpinalCord-VS119-Cervical-Ctrl3-SOD11_VMSC07101_reanalysis_R1C2': "keep",
  1852. '202401241119_HuSpinalCord-VS119-Cervical-Ctrl3-SOD11_VMSC07101_reanalysis_R1C3': "keep",
  1853. '202401241119_HuSpinalCord-VS119-Cervical-Ctrl3-SOD11_VMSC07101_reanalysis_R1C4': "right",
  1854. '202401241119_HuSpinalCord-VS119-Cervical-Ctrl3-SOD11_VMSC07101_reanalysis_R2C1': "remove",
  1855. '202401241119_HuSpinalCord-VS119-Cervical-Ctrl3-SOD11_VMSC07101_reanalysis_R2C2': "keep",
  1856. '202401241119_HuSpinalCord-VS119-Cervical-Ctrl3-SOD11_VMSC07101_reanalysis_R2C3': "keep",
  1857. '202401241136_HuSpinalCord-VS119-Lumbar-Ctrl2-SOD9_Beta8_R1C1': "keep",
  1858. '202401241136_HuSpinalCord-VS119-Lumbar-Ctrl2-SOD9_Beta8_R1C2': "right",
  1859. '202401241136_HuSpinalCord-VS119-Lumbar-Ctrl2-SOD9_Beta8_R1C3': "right",
  1860. '202401241136_HuSpinalCord-VS119-Lumbar-Ctrl2-SOD9_Beta8_R2C1': "remove",
  1861. '202401241136_HuSpinalCord-VS119-Lumbar-Ctrl2-SOD9_Beta8_R2C2': "keep",
  1862. '202401241136_HuSpinalCord-VS119-Lumbar-Ctrl2-SOD9_Beta8_R2C3': "right",
  1863. '202401241136_HuSpinalCord-VS119-Lumbar-Ctrl2-SOD9_Beta8_R2C4': "remove",
  1864. '202401241200_HuSpinalCord-VS119-Lubmar-Ctrl3-SOD10_VMSC10802_R1C1': "right",
  1865. '202401241200_HuSpinalCord-VS119-Lubmar-Ctrl3-SOD10_VMSC10802_R1C2': "keep",
  1866. '202401241200_HuSpinalCord-VS119-Lubmar-Ctrl3-SOD10_VMSC10802_R1C3': "right",
  1867. '202401241200_HuSpinalCord-VS119-Lubmar-Ctrl3-SOD10_VMSC10802_R2C1': "remove",
  1868. '202401241200_HuSpinalCord-VS119-Lubmar-Ctrl3-SOD10_VMSC10802_R2C2': "keep",
  1869. '202401241200_HuSpinalCord-VS119-Lubmar-Ctrl3-SOD10_VMSC10802_R2C3': "remove",
  1870. '202405281416_MsSpinalCord-VS223-Lumbar-CM2-SM4-S1_VMSC15302_R1C1': "keep",
  1871. '202405281416_MsSpinalCord-VS223-Lumbar-CM2-SM4-S1_VMSC15302_R1C2': "keep",
  1872. '202405281416_MsSpinalCord-VS223-Lumbar-CM2-SM4-S1_VMSC15302_R1C3': "keep",
  1873. '202405281416_MsSpinalCord-VS223-Lumbar-CM2-SM4-S1_VMSC15302_R2C2': "left",
  1874. '202405281416_MsSpinalCord-VS223-Lumbar-CM2-SM4-S1_VMSC15302_R2C3': "keep",
  1875. '202405281416_MsSpinalCord-VS223-Lumbar-CM2-SM4-S1_VMSC15302_R2C4': "keep",
  1876. '202405281419_MsSpinalCord-VS223-Lumbar-CM5-SM8-S1_VMSC01801_R1C1': "keep",
  1877. '202405281419_MsSpinalCord-VS223-Lumbar-CM5-SM8-S1_VMSC01801_R1C2': "keep",
  1878. '202405281419_MsSpinalCord-VS223-Lumbar-CM5-SM8-S1_VMSC01801_R1C3': "right",
  1879. '202405281419_MsSpinalCord-VS223-Lumbar-CM5-SM8-S1_VMSC01801_R2C1': "keep",
  1880. '202405281419_MsSpinalCord-VS223-Lumbar-CM5-SM8-S1_VMSC01801_R2C2': "keep",
  1881. '202405281419_MsSpinalCord-VS223-Lumbar-CM5-SM8-S1_VMSC01801_R2C3': "keep",
  1882. '202405281419_MsSpinalCord-VS223-Lumbar-CM5-SM8-S1_VMSC01801_R2C4': "keep",
  1883. '202405281527_MsSpinalCord-VS223-Lubmar-CM4-SM6-S1_VMSC02011_R1C1': "keep",
  1884. '202405281527_MsSpinalCord-VS223-Lubmar-CM4-SM6-S1_VMSC02011_R1C2': "remove",
  1885. '202405281527_MsSpinalCord-VS223-Lubmar-CM4-SM6-S1_VMSC02011_R1C3': "keep",
  1886. '202405281527_MsSpinalCord-VS223-Lubmar-CM4-SM6-S1_VMSC02011_R2C1': "keep",
  1887. '202405281527_MsSpinalCord-VS223-Lubmar-CM4-SM6-S1_VMSC02011_R2C2': "keep",
  1888. '202405281527_MsSpinalCord-VS223-Lubmar-CM4-SM6-S1_VMSC02011_R2C3': "right",
  1889. '202405281527_MsSpinalCord-VS223-Lubmar-CM4-SM6-S1_VMSC02011_R2C4': "remove",
  1890. '202405281702_MsSpinalCord-VS223-Lumbar-CE2-SE4-S1_VMSC02701_R1C1': "right",
  1891. '202405281702_MsSpinalCord-VS223-Lumbar-CE2-SE4-S1_VMSC02701_R1C2': "remove",
  1892. '202405281702_MsSpinalCord-VS223-Lumbar-CE2-SE4-S1_VMSC02701_R1C3': "remove",
  1893. '202405281702_MsSpinalCord-VS223-Lumbar-CE2-SE4-S1_VMSC02701_R1C4': "remove",
  1894. '202405281702_MsSpinalCord-VS223-Lumbar-CE2-SE4-S1_VMSC02701_R2C1': "remove",
  1895. '202405281702_MsSpinalCord-VS223-Lumbar-CE2-SE4-S1_VMSC02701_R2C2': "remove",
  1896. '202405281702_MsSpinalCord-VS223-Lumbar-CE2-SE4-S1_VMSC02701_R2C3': "keep",
  1897. '202405281702_MsSpinalCord-VS223-Lumbar-CE2-SE4-S1_VMSC02701_R2C4': "remove",
  1898. '202406031049_MsSpinalCord-VS223-Cervical-CE2-SE3-S1_VMSC10802_R1C1': "keep",
  1899. '202406031049_MsSpinalCord-VS223-Cervical-CE2-SE3-S1_VMSC10802_R1C2': "keep",
  1900. '202406031049_MsSpinalCord-VS223-Cervical-CE2-SE3-S1_VMSC10802_R1C3': "right",
  1901. '202406031049_MsSpinalCord-VS223-Cervical-CE2-SE3-S1_VMSC10802_R1C4': "keep",
  1902. '202406031049_MsSpinalCord-VS223-Cervical-CE2-SE3-S1_VMSC10802_R2C1': "left",
  1903. '202406031049_MsSpinalCord-VS223-Cervical-CE2-SE3-S1_VMSC10802_R2C2': "right",
  1904. '202406031049_MsSpinalCord-VS223-Cervical-CE2-SE3-S1_VMSC10802_R2C3': "remove",
  1905. '202406031050_MsSpinalCord-VS223-Cervical-CM5-SM5-S1_VMSC07201_R1C1': "keep",
  1906. '202406031050_MsSpinalCord-VS223-Cervical-CM5-SM5-S1_VMSC07201_R1C2': "remove",
  1907. '202406031050_MsSpinalCord-VS223-Cervical-CM5-SM5-S1_VMSC07201_R1C3': "keep",
  1908. '202406031050_MsSpinalCord-VS223-Cervical-CM5-SM5-S1_VMSC07201_R2C2': "remove",
  1909. '202406031050_MsSpinalCord-VS223-Cervical-CM5-SM5-S1_VMSC07201_R2C3': "keep",
  1910. '202406031050_MsSpinalCord-VS223-Cervical-CM5-SM5-S1_VMSC07201_R2C4': "remove",
  1911. '202406071118_MsSpinalCord-VS223-Cervical-CM2-SM4-S1_VMSC10802_R1C1': "keep",
  1912. '202406071118_MsSpinalCord-VS223-Cervical-CM2-SM4-S1_VMSC10802_R1C2': "keep",
  1913. '202406071118_MsSpinalCord-VS223-Cervical-CM2-SM4-S1_VMSC10802_R1C3': "keep",
  1914. '202406071118_MsSpinalCord-VS223-Cervical-CM2-SM4-S1_VMSC10802_R2C1': "right",
  1915. '202406071118_MsSpinalCord-VS223-Cervical-CM2-SM4-S1_VMSC10802_R2C2': "left",
  1916. '202406071118_MsSpinalCord-VS223-Cervical-CM2-SM4-S1_VMSC10802_R2C3': "left",
  1917. '202406071118_MsSpinalCord-VS223-Cervical-CM2-SM4-S1_VMSC10802_R2C4': "remove",
  1918. '202406071119_MsSpinalCord-VS223-Lumbar-CE1-SE3-S2_Beta8_R1C1': "remove",
  1919. '202406071119_MsSpinalCord-VS223-Lumbar-CE1-SE3-S2_Beta8_R1C2': "keep",
  1920. '202406071119_MsSpinalCord-VS223-Lumbar-CE1-SE3-S2_Beta8_R1C3': "right",
  1921. '202406071119_MsSpinalCord-VS223-Lumbar-CE1-SE3-S2_Beta8_R1C4': "keep",
  1922. '202406071119_MsSpinalCord-VS223-Lumbar-CE1-SE3-S2_Beta8_R2C1': "keep",
  1923. '202406071119_MsSpinalCord-VS223-Lumbar-CE1-SE3-S2_Beta8_R2C2': "left",
  1924. '202406071119_MsSpinalCord-VS223-Lumbar-CE1-SE3-S2_Beta8_R2C3': "keep",
  1925. '202406071119_MsSpinalCord-VS223-Lumbar-CE1-SE3-S2_Beta8_R2C4': "right",
  1926. '202406071120_MsSpinalCord-VS223-Lumbar-CE5-SE6-S1_VMSC01801_R1C1': "remove",
  1927. '202406071120_MsSpinalCord-VS223-Lumbar-CE5-SE6-S1_VMSC01801_R1C2': "keep",
  1928. '202406071120_MsSpinalCord-VS223-Lumbar-CE5-SE6-S1_VMSC01801_R1C3': "remove",
  1929. '202406071120_MsSpinalCord-VS223-Lumbar-CE5-SE6-S1_VMSC01801_R1C4': "keep",
  1930. '202406071120_MsSpinalCord-VS223-Lumbar-CE5-SE6-S1_VMSC01801_R2C1': "remove",
  1931. '202406071120_MsSpinalCord-VS223-Lumbar-CE5-SE6-S1_VMSC01801_R2C2': "left",
  1932. '202406071120_MsSpinalCord-VS223-Lumbar-CE5-SE6-S1_VMSC01801_R2C3': "left",
  1933. '202406071120_MsSpinalCord-VS223-Lumbar-CE5-SE6-S1_VMSC01801_R2C4': "right",
  1934. '202406071222_MsSpinalCord-VS223-Cervical-CE5-SE5-S2_VMSC02011_R1C1': "right",
  1935. '202406071222_MsSpinalCord-VS223-Cervical-CE5-SE5-S2_VMSC02011_R1C2': "keep",
  1936. '202406071222_MsSpinalCord-VS223-Cervical-CE5-SE5-S2_VMSC02011_R1C3': "keep",
  1937. '202406071222_MsSpinalCord-VS223-Cervical-CE5-SE5-S2_VMSC02011_R1C4': "keep",
  1938. '202406071222_MsSpinalCord-VS223-Cervical-CE5-SE5-S2_VMSC02011_R2C1': "remove",
  1939. '202406071222_MsSpinalCord-VS223-Cervical-CE5-SE5-S2_VMSC02011_R2C2': "remove",
  1940. '202406071222_MsSpinalCord-VS223-Cervical-CE5-SE5-S2_VMSC02011_R2C3': "left",
  1941. '202406071239_MsSpinalCord-VS223-Lumbar-CM3-SM5-S2_VMSC02701_R1C1': "right",
  1942. '202406071239_MsSpinalCord-VS223-Lumbar-CM3-SM5-S2_VMSC02701_R1C2': "keep",
  1943. '202406071239_MsSpinalCord-VS223-Lumbar-CM3-SM5-S2_VMSC02701_R1C3': "keep",
  1944. '202406071239_MsSpinalCord-VS223-Lumbar-CM3-SM5-S2_VMSC02701_R2C1': "right",
  1945. '202406071239_MsSpinalCord-VS223-Lumbar-CM3-SM5-S2_VMSC02701_R2C2': "left",
  1946. '202406071239_MsSpinalCord-VS223-Lumbar-CM3-SM5-S2_VMSC02701_R2C3': "remove",
  1947. '202406071239_MsSpinalCord-VS223-Lumbar-CM3-SM5-S2_VMSC02701_R2C4': "right"}
  1948. # %%
  1949. # Create a new column in adata.obs that maps each cell's slide_section to its label in sections_to_keep_dict
  1950. adata.obs["section_label"] = adata.obs["slide_section"].map(sections_to_keep_dict)
  1951. # Subset the AnnData object based on the section label
  1952. adata_keep = adata[adata.obs["section_label"] == "keep"].copy()
  1953. adata_remove = adata[adata.obs["section_label"] == "remove"].copy()
  1954. adata_left = adata[adata.obs["section_label"] == "left"].copy()
  1955. adata_right = adata[adata.obs["section_label"] == "right"].copy()
  1956. # %%
  1957. highlight_classes = {
  1958. "Non-Cholinergic Interneurons": "#1f77b4", # blue
  1959. "Cholinergic Neurons": "#ff7f0e", # orange
  1960. "Putative Ependymal Cells": "#2ca02c" # green
  1961. }
  1962. # %%
  1963. import matplotlib.pyplot as plt
  1964. import scanpy as sc
  1965. import numpy as np
  1966. # Make sure cell_class is categorical
  1967. adata_keep.obs['cell_class'] = adata_keep.obs['cell_class'].astype('category')
  1968. # Unique slide sections
  1969. keep_mids = np.sort(adata_keep.obs['slide_section'].unique())
  1970. # Loop through each slide_section
  1971. for mid in keep_mids:
  1972. print(f"Plotting slide_section: {mid}")
  1973. # Subset data
  1974. sub_keep = adata_keep[adata_keep.obs['slide_section'] == mid].copy()
  1975. # Create color list for categories
  1976. unique_classes = sub_keep.obs['cell_class'].cat.categories
  1977. class_colors = [highlight_classes.get(cls, "#d3d3d3") for cls in unique_classes]
  1978. sub_keep.uns['cell_class_colors'] = class_colors
  1979. # Plot spatial embedding
  1980. sc.pl.embedding(
  1981. sub_keep,
  1982. basis='spatial',
  1983. color='cell_class',
  1984. size=40,
  1985. title=f"Slide Section: {mid}",
  1986. show=True
  1987. )
  1988. # %%
  1989. import matplotlib.pyplot as plt
  1990. import scanpy as sc
  1991. import numpy as np
  1992. # Make sure cell_class is categorical
  1993. adata_remove.obs['cell_class'] = adata_remove.obs['cell_class'].astype('category')
  1994. # Unique slide sections
  1995. remove_mids = np.sort(adata_remove.obs['slide_section'].unique())
  1996. # Loop through each slide_section
  1997. for mid in remove_mids:
  1998. print(f"Plotting slide_section: {mid}")
  1999. # Subset data
  2000. sub_remove = adata_remove[adata_remove.obs['slide_section'] == mid].copy()
  2001. # Create color list for categories
  2002. unique_classes = sub_remove.obs['cell_class'].cat.categories
  2003. class_colors = [highlight_classes.get(cls, "#d3d3d3") for cls in unique_classes]
  2004. sub_remove.uns['cell_class_colors'] = class_colors
  2005. # Plot spatial embedding
  2006. sc.pl.embedding(
  2007. sub_remove,
  2008. basis='spatial',
  2009. color='cell_class',
  2010. size=40,
  2011. title=f"Slide Section: {mid}",
  2012. show=True
  2013. )
  2014. # %%
  2015. import scanpy as sc
  2016. import matplotlib.pyplot as plt
  2017. import numpy as np
  2018. from matplotlib.colors import to_hex
  2019. # Degrees of rotation and x-shift as fraction of section width
  2020. line_config = {
  2021. "202401150956_MsSpinalCord-VS119-Cervical-Ctrl2-SOD10_VMSC10802_R1C1": {"angle": -6, "x_shift_frac": 0.0375},
  2022. "202401151110_MsSpinalCord-VS119-Lumbar-Ctrl1-SOD8_VMSC07101_R1C2": {"angle": -7, "x_shift_frac": 0.0325},
  2023. "202405281416_MsSpinalCord-VS223-Lumbar-CM2-SM4-S1_VMSC15302_R2C2": {"angle": 1, "x_shift_frac": 0.02},
  2024. "202406031049_MsSpinalCord-VS223-Cervical-CE2-SE3-S1_VMSC10802_R2C1": {"angle": -2, "x_shift_frac": -0.015},
  2025. "202406071118_MsSpinalCord-VS223-Cervical-CM2-SM4-S1_VMSC10802_R2C2": {"angle": -5, "x_shift_frac": 0.07},
  2026. "202406071118_MsSpinalCord-VS223-Cervical-CM2-SM4-S1_VMSC10802_R2C3": {"angle": -3, "x_shift_frac": 0.035},
  2027. "202406071119_MsSpinalCord-VS223-Lumbar-CE1-SE3-S2_Beta8_R2C2": {"angle": 0, "x_shift_frac": 0.02},
  2028. "202406071120_MsSpinalCord-VS223-Lumbar-CE5-SE6-S1_VMSC01801_R2C2": {"angle": -1, "x_shift_frac": 0.01},
  2029. "202406071120_MsSpinalCord-VS223-Lumbar-CE5-SE6-S1_VMSC01801_R2C3": {"angle": 0, "x_shift_frac": 0.01},
  2030. "202406071222_MsSpinalCord-VS223-Cervical-CE5-SE5-S2_VMSC02011_R2C3": {"angle": -2, "x_shift_frac": 0.025},
  2031. "202406071239_MsSpinalCord-VS223-Lumbar-CM3-SM5-S2_VMSC02701_R2C2": {"angle": -2, "x_shift_frac": 0.025}
  2032. }
  2033. # Store filtered subsets
  2034. filtered_subsets = []
  2035. mids = np.sort(np.unique(adata_left.obs['slide_section']))
  2036. for mid in mids:
  2037. print(f"Plotting and filtering slide_section: {mid}")
  2038. sub_adata = adata_left[adata_left.obs['slide_section'] == mid].copy()
  2039. sub_adata.obs['cell_class'] = sub_adata.obs['cell_class'].astype('category')
  2040. coords = sub_adata.obsm['spatial']
  2041. x = coords[:, 0]
  2042. y = coords[:, 1]
  2043. config = line_config.get(mid, {"angle": 0, "x_shift_frac": 0})
  2044. angle_deg = config["angle"]
  2045. x_shift_frac = config["x_shift_frac"]
  2046. section_width = x.max() - x.min()
  2047. x_shift = x_shift_frac * section_width
  2048. x_center = np.median(x) + x_shift
  2049. y_min, y_max = y.min(), y.max()
  2050. print(f"Slide: {mid}")
  2051. print(f" x range: {x.min()} to {x.max()} (width = {section_width:.2f})")
  2052. print(f" x_shift_frac: {x_shift_frac} => x_shift: {x_shift:.2f}")
  2053. print(f" x_center: {x_center:.2f}\n")
  2054. # === Filter for left side ===
  2055. theta = np.deg2rad(-angle_deg)
  2056. rot_matrix = np.array([
  2057. [np.cos(theta), -np.sin(theta)],
  2058. [np.sin(theta), np.cos(theta)]
  2059. ])
  2060. coords_shifted = coords.copy()
  2061. coords_shifted[:, 0] -= x_center # translate
  2062. coords_rotated = coords_shifted @ rot_matrix.T # rotate
  2063. mask_left = coords_rotated[:, 0] < 0
  2064. sub_filtered = sub_adata[mask_left].copy()
  2065. filtered_subsets.append(sub_filtered)
  2066. # === Plotting ===
  2067. unique_classes = sub_adata.obs['cell_class'].cat.categories
  2068. class_colors = [highlight_classes.get(cls, "#d3d3d3") for cls in unique_classes]
  2069. sub_adata.uns['cell_class_colors'] = class_colors
  2070. line_x = np.array([0, 0])
  2071. line_y = np.array([y_min - 50, y_max + 50])
  2072. theta_plot = np.deg2rad(angle_deg)
  2073. rot_matrix_plot = np.array([
  2074. [np.cos(theta_plot), -np.sin(theta_plot)],
  2075. [np.sin(theta_plot), np.cos(theta_plot)]
  2076. ])
  2077. line_coords = np.stack([line_x, line_y])
  2078. rotated = rot_matrix_plot @ line_coords
  2079. rotated[0, :] += x_center
  2080. fig, ax = plt.subplots(1, 1, figsize=(5, 5))
  2081. sc.pl.embedding(
  2082. sub_adata,
  2083. basis='spatial',
  2084. color='cell_class',
  2085. size=50,
  2086. show=False,
  2087. ax=ax,
  2088. title=f"Slide Section: {mid}"
  2089. )
  2090. ax.plot(rotated[0], rotated[1], color="black", linestyle="--", linewidth=2)
  2091. ax.axvline(x.min(), color='gray', linestyle=':', linewidth=1)
  2092. ax.axvline(x.max(), color='gray', linestyle=':', linewidth=1)
  2093. if ax.get_legend():
  2094. ax.legend_.remove()
  2095. plt.tight_layout()
  2096. plt.show()
  2097. # === Combine filtered subsets into new AnnData ===
  2098. adata_left_filtered = filtered_subsets[0].concatenate(
  2099. *filtered_subsets[1:],
  2100. batch_key=None
  2101. )
  2102. print(f"Original shape: {adata_left.shape}")
  2103. print(f"Filtered (left side) shape: {adata_left_filtered.shape}")
  2104. # %%
  2105. import matplotlib.pyplot as plt
  2106. import scanpy as sc
  2107. import numpy as np
  2108. # Make sure cell_class is categorical
  2109. adata_left_filtered.obs['cell_class'] = adata_left_filtered.obs['cell_class'].astype('category')
  2110. # Get sorted slide_section values
  2111. filtered_mids = np.sort(adata_left_filtered.obs['slide_section'].unique())
  2112. for mid in filtered_mids:
  2113. print(f"Plotting filtered (left-side) cells for slide_section: {mid}")
  2114. # Subset filtered AnnData
  2115. sub_filtered = adata_left_filtered[adata_left_filtered.obs['slide_section'] == mid].copy()
  2116. # Use consistent coloring as before
  2117. unique_classes = sub_filtered.obs['cell_class'].cat.categories
  2118. class_colors = [highlight_classes.get(cls, "#d3d3d3") for cls in unique_classes]
  2119. sub_filtered.uns['cell_class_colors'] = class_colors
  2120. # Plot
  2121. sc.pl.embedding(
  2122. sub_filtered,
  2123. basis='spatial',
  2124. color='cell_class',
  2125. size=50,
  2126. title=f"Filtered Left Cells: {mid}",
  2127. show=True
  2128. )
  2129. # %%
  2130. import scanpy as sc
  2131. import matplotlib.pyplot as plt
  2132. import numpy as np
  2133. from matplotlib.colors import to_hex
  2134. # Degrees of rotation and x-shift as fraction of section width
  2135. line_config = {
  2136. "202401150956_MsSpinalCord-VS119-Cervical-Ctrl2-SOD10_VMSC10802_R2C3": {"angle": -2, "x_shift_frac": -0.05},
  2137. "202401241119_HuSpinalCord-VS119-Cervical-Ctrl3-SOD11_VMSC07101_reanalysis_R1C4": {"angle": 6, "x_shift_frac": 0},
  2138. "202401241136_HuSpinalCord-VS119-Lumbar-Ctrl2-SOD9_Beta8_R1C2": {"angle": -4, "x_shift_frac": -0.02},
  2139. "202401241136_HuSpinalCord-VS119-Lumbar-Ctrl2-SOD9_Beta8_R1C3": {"angle": 0, "x_shift_frac": -0.065},
  2140. "202401241136_HuSpinalCord-VS119-Lumbar-Ctrl2-SOD9_Beta8_R2C3": {"angle": 0, "x_shift_frac": -0.005},
  2141. "202401241200_HuSpinalCord-VS119-Lubmar-Ctrl3-SOD10_VMSC10802_R1C1": {"angle": -2, "x_shift_frac": -0.01},
  2142. "202401241200_HuSpinalCord-VS119-Lubmar-Ctrl3-SOD10_VMSC10802_R1C3": {"angle": 1, "x_shift_frac": -0.18},
  2143. "202405281419_MsSpinalCord-VS223-Lumbar-CM5-SM8-S1_VMSC01801_R1C3": {"angle": -3, "x_shift_frac": -0.02},
  2144. "202405281527_MsSpinalCord-VS223-Lubmar-CM4-SM6-S1_VMSC02011_R2C3": {"angle": 6, "x_shift_frac": -0.045},
  2145. "202405281702_MsSpinalCord-VS223-Lumbar-CE2-SE4-S1_VMSC02701_R1C1": {"angle": 2, "x_shift_frac": -0.015},
  2146. "202406031049_MsSpinalCord-VS223-Cervical-CE2-SE3-S1_VMSC10802_R1C3": {"angle": 4, "x_shift_frac": -0.015},
  2147. "202406031049_MsSpinalCord-VS223-Cervical-CE2-SE3-S1_VMSC10802_R2C2": {"angle": 2, "x_shift_frac": 0},
  2148. "202406071118_MsSpinalCord-VS223-Cervical-CM2-SM4-S1_VMSC10802_R2C1": {"angle": 2, "x_shift_frac": -0.01},
  2149. "202406071119_MsSpinalCord-VS223-Lumbar-CE1-SE3-S2_Beta8_R1C3": {"angle": -5, "x_shift_frac": -0.0075},
  2150. "202406071119_MsSpinalCord-VS223-Lumbar-CE1-SE3-S2_Beta8_R2C4": {"angle": 3, "x_shift_frac": -0.0525},
  2151. "202406071120_MsSpinalCord-VS223-Lumbar-CE5-SE6-S1_VMSC01801_R2C4": {"angle": 4, "x_shift_frac": -0.01},
  2152. "202406071222_MsSpinalCord-VS223-Cervical-CE5-SE5-S2_VMSC02011_R1C1": {"angle": 6, "x_shift_frac": -0.035},
  2153. "202406071239_MsSpinalCord-VS223-Lumbar-CM3-SM5-S2_VMSC02701_R1C1": {"angle": -1, "x_shift_frac": -0.005},
  2154. "202406071239_MsSpinalCord-VS223-Lumbar-CM3-SM5-S2_VMSC02701_R2C1": {"angle": 0, "x_shift_frac": -0.008},
  2155. "202406071239_MsSpinalCord-VS223-Lumbar-CM3-SM5-S2_VMSC02701_R2C4": {"angle": 5, "x_shift_frac": -.03}
  2156. }
  2157. # Store filtered subsets
  2158. filtered_subsets = []
  2159. mids = np.sort(np.unique(adata_right.obs['slide_section']))
  2160. for mid in mids:
  2161. print(f"Plotting and filtering slide_section: {mid}")
  2162. sub_adata = adata_right[adata_right.obs['slide_section'] == mid].copy()
  2163. sub_adata.obs['cell_class'] = sub_adata.obs['cell_class'].astype('category')
  2164. coords = sub_adata.obsm['spatial']
  2165. x = coords[:, 0]
  2166. y = coords[:, 1]
  2167. config = line_config.get(mid, {"angle": 0, "x_shift_frac": 0})
  2168. angle_deg = config["angle"]
  2169. x_shift_frac = config["x_shift_frac"]
  2170. section_width = x.max() - x.min()
  2171. x_shift = x_shift_frac * section_width
  2172. x_center = np.median(x) + x_shift
  2173. y_min, y_max = y.min(), y.max()
  2174. print(f"Slide: {mid}")
  2175. print(f" x range: {x.min()} to {x.max()} (width = {section_width:.2f})")
  2176. print(f" x_shift_frac: {x_shift_frac} => x_shift: {x_shift:.2f}")
  2177. print(f" x_center: {x_center:.2f}\n")
  2178. # === Filter for right side ===
  2179. theta = np.deg2rad(-angle_deg)
  2180. rot_matrix = np.array([
  2181. [np.cos(theta), -np.sin(theta)],
  2182. [np.sin(theta), np.cos(theta)]
  2183. ])
  2184. coords_shifted = coords.copy()
  2185. coords_shifted[:, 0] -= x_center # translate
  2186. coords_rotated = coords_shifted @ rot_matrix.T # rotate
  2187. mask_right = coords_rotated[:, 0] > 0
  2188. sub_filtered = sub_adata[mask_right].copy()
  2189. filtered_subsets.append(sub_filtered)
  2190. # === Plotting ===
  2191. unique_classes = sub_adata.obs['cell_class'].cat.categories
  2192. class_colors = [highlight_classes.get(cls, "#d3d3d3") for cls in unique_classes]
  2193. sub_adata.uns['cell_class_colors'] = class_colors
  2194. line_x = np.array([0, 0])
  2195. line_y = np.array([y_min - 50, y_max + 50])
  2196. theta_plot = np.deg2rad(angle_deg)
  2197. rot_matrix_plot = np.array([
  2198. [np.cos(theta_plot), -np.sin(theta_plot)],
  2199. [np.sin(theta_plot), np.cos(theta_plot)]
  2200. ])
  2201. line_coords = np.stack([line_x, line_y])
  2202. rotated = rot_matrix_plot @ line_coords
  2203. rotated[0, :] += x_center
  2204. fig, ax = plt.subplots(1, 1, figsize=(5, 5))
  2205. sc.pl.embedding(
  2206. sub_adata,
  2207. basis='spatial',
  2208. color='cell_class',
  2209. size=50,
  2210. show=False,
  2211. ax=ax,
  2212. title=f"Slide Section: {mid}"
  2213. )
  2214. ax.plot(rotated[0], rotated[1], color="black", linestyle="--", linewidth=2)
  2215. ax.axvline(x.min(), color='gray', linestyle=':', linewidth=1)
  2216. ax.axvline(x.max(), color='gray', linestyle=':', linewidth=1)
  2217. if ax.get_legend():
  2218. ax.legend_.remove()
  2219. plt.tight_layout()
  2220. plt.show()
  2221. # === Combine filtered subsets into new AnnData ===
  2222. adata_right_filtered = filtered_subsets[0].concatenate(
  2223. *filtered_subsets[1:],
  2224. batch_key=None
  2225. )
  2226. print(f"Original shape: {adata_right.shape}")
  2227. print(f"Filtered (right side) shape: {adata_right_filtered.shape}")
  2228. # %%
  2229. import matplotlib.pyplot as plt
  2230. import scanpy as sc
  2231. import numpy as np
  2232. # Make sure cell_class is categorical
  2233. adata_right_filtered.obs['cell_class'] = adata_right_filtered.obs['cell_class'].astype('category')
  2234. # Get sorted slide_section values
  2235. filtered_mids = np.sort(adata_right_filtered.obs['slide_section'].unique())
  2236. for mid in filtered_mids:
  2237. print(f"Plotting filtered (right-side) cells for slide_section: {mid}")
  2238. # Subset filtered AnnData
  2239. sub_filtered = adata_right_filtered[adata_right_filtered.obs['slide_section'] == mid].copy()
  2240. # Use consistent coloring as before
  2241. unique_classes = sub_filtered.obs['cell_class'].cat.categories
  2242. class_colors = [highlight_classes.get(cls, "#d3d3d3") for cls in unique_classes]
  2243. sub_filtered.uns['cell_class_colors'] = class_colors
  2244. # Plot
  2245. sc.pl.embedding(
  2246. sub_filtered,
  2247. basis='spatial',
  2248. color='cell_class',
  2249. size=50,
  2250. title=f"Filtered right Cells: {mid}",
  2251. show=True
  2252. )
  2253. # %%
  2254. adata_filtered = adata_keep.concatenate(
  2255. adata_left_filtered,
  2256. adata_right_filtered,
  2257. index_unique=None
  2258. )
  2259. # %%
  2260. # Save anndata
  2261. save_name = f"adata_objects/all_anndata_final_rotated_filtered.h5ad"
  2262. adata_filtered.write_h5ad(os.path.join(working_dir, save_name))
  2263. # %% [markdown]
  2264. # ## Cell types neighboring motor neurons in control and disease
  2265. # %%
  2266. # Read in AnnData
  2267. adata = sc.read_h5ad(os.path.join(working_dir, f"adata_objects/all_anndata_final_rotated_filtered.h5ad"))
  2268. # %% [markdown]
  2269. # ## Fig. 6A
  2270. # %%
  2271. import os
  2272. import pandas as pd
  2273. import numpy as np
  2274. import seaborn as sns
  2275. import matplotlib.pyplot as plt
  2276. from collections import defaultdict
  2277. import squidpy as sq
  2278. # Ensure all text in the figure is at least size 7
  2279. plt.rcParams.update({
  2280. "font.size": 7,
  2281. "axes.titlesize": 7,
  2282. "axes.labelsize": 7,
  2283. "xtick.labelsize": 7,
  2284. "ytick.labelsize": 7,
  2285. "legend.fontsize": 7,
  2286. "legend.title_fontsize": 7
  2287. })
  2288. # Parameters
  2289. groups = ["Non-DAMN", "DAMN"]
  2290. title_map = {"Non-DAMN": "Non-DM", "DAMN": "DM"}
  2291. # Abbreviations for cell class names
  2292. abbr = {
  2293. "Astrocytes": "Ast",
  2294. "Cholinergic Neurons": "CholN",
  2295. "Disease-Associated Interneurons": "DAI",
  2296. "Microglia/Macrophages": "MG",
  2297. "Non-Cholinergic Interneurons": "NCI",
  2298. "Oligodendrocytes": "Oligo",
  2299. "Other": "Other",
  2300. "Putative Ependymal Cells": "Epen",
  2301. "Putative Perivascular/Meningeal Cells": "PVM",
  2302. "Putative Vascular Cells": "Vasc"
  2303. }
  2304. # Prepare accumulators
  2305. neighbor_counts = {g: [defaultdict(int) for _ in range(6)] for g in groups}
  2306. cell_totals = {g: 0 for g in groups}
  2307. # Loop over each slide_section
  2308. for sec in adata.obs["slide_section"].unique():
  2309. sub = adata[adata.obs["slide_section"] == sec].copy()
  2310. # compute 6-NN on this section
  2311. sq.gr.spatial_neighbors(sub, coord_type="generic", n_neighs=6, key_added="spatial_sec")
  2312. conn = sub.obsp["spatial_sec_connectivities"]
  2313. classes = sub.obs["cell_class"]
  2314. status = sub.obs["DAMN_status"]
  2315. for g in groups:
  2316. mask = (status == g)
  2317. idx = np.where(mask)[0]
  2318. cell_totals[g] += len(idx)
  2319. for i in idx:
  2320. row = conn[i].tocoo()
  2321. top6 = sorted(zip(row.col, row.data), key=lambda x: -x[1])[:6]
  2322. for rank, (nbr, _) in enumerate(top6):
  2323. neighbor_counts[g][rank][classes.iloc[nbr]] += 1
  2324. # Build DataFrame of proportions: count / total cells
  2325. rows = []
  2326. for g in groups:
  2327. total_cells = cell_totals[g]
  2328. for rank, ctr in enumerate(neighbor_counts[g], start=1):
  2329. for cell_type, count in ctr.items():
  2330. rows.append({
  2331. "Neighbor Rank": str(rank),
  2332. "Cell Class": cell_type,
  2333. "Proportion": count / total_cells,
  2334. "Group": g
  2335. })
  2336. df_combined = pd.DataFrame(rows)
  2337. # Pivot for plotting
  2338. df_pivot = df_combined.pivot_table(
  2339. index=["Neighbor Rank", "Group"],
  2340. columns="Cell Class",
  2341. values="Proportion",
  2342. aggfunc="sum"
  2343. ).fillna(0)
  2344. # Plot: Non-DM vs DM with the desired size and save as SVG
  2345. fig, axes = plt.subplots(1, 2, figsize=(3.25, 2.15), sharey=True)
  2346. for ax, g in zip(axes, groups):
  2347. data = df_pivot.xs(g, level="Group")
  2348. bottom = np.zeros(len(data))
  2349. for ct in data.columns:
  2350. ax.bar(
  2351. data.index,
  2352. data[ct],
  2353. bottom=bottom,
  2354. label=abbr.get(ct, ct)
  2355. )
  2356. bottom += data[ct]
  2357. ax.set_title(title_map.get(g, g))
  2358. ax.set_xlabel("Neighbor Rank")
  2359. ax.set_ylabel("Proportion")
  2360. # legend on the right of the second subplot, with abbreviations
  2361. axes[1].legend(
  2362. title="Cell Class",
  2363. bbox_to_anchor=(1.02, 0.5),
  2364. loc="center left",
  2365. ncol=1
  2366. )
  2367. plt.tight_layout()
  2368. # Define the filename and directory
  2369. fig_path = os.path.join(figures_dir, "neighbor_barplots_dm.svg")
  2370. # Save high-quality SVG
  2371. fig.savefig(
  2372. fig_path,
  2373. format="svg",
  2374. bbox_inches="tight"
  2375. )
  2376. # %%
  2377. from scipy.stats import fisher_exact
  2378. from statsmodels.stats.multitest import multipletests
  2379. # Create a pivot table from df_combined for easier comparison;
  2380. # here, the index will be (Neighbor Rank, Group)
  2381. df_pivot = df_combined.pivot_table(
  2382. index=["Neighbor Rank", "Group"],
  2383. columns="Cell Class",
  2384. values="Proportion",
  2385. aggfunc="sum"
  2386. ).fillna(0)
  2387. # Get indices of DAMN and Non-DAMN cells
  2388. damn_indices = np.where(adata.obs["DAMN_status"] == "DAMN")[0]
  2389. nondamn_indices = np.where(adata.obs["DAMN_status"] == "Non-DAMN")[0]
  2390. # Precompute total numbers in each group using the same criteria from Block 1.
  2391. damn_total = len(damn_indices) # DAMN group total
  2392. nondamn_total = len(nondamn_indices) # Non-DAMN group total
  2393. fisher_results = []
  2394. # Loop over each unique neighbor rank found in df_combined
  2395. for rank in df_combined["Neighbor Rank"].unique():
  2396. # For each cell class, compare proportions between DAMN and Non-DAMN at this rank.
  2397. for cell_class in df_pivot.columns:
  2398. # Use the pivot table to get proportions:
  2399. try:
  2400. prop_damn = df_pivot.loc[(rank, "DAMN"), cell_class]
  2401. except KeyError:
  2402. prop_damn = 0
  2403. try:
  2404. prop_nondamn = df_pivot.loc[(rank, "Non-DAMN"), cell_class]
  2405. except KeyError:
  2406. prop_nondamn = 0
  2407. # Convert proportions to counts using precomputed totals:
  2408. count_damn = prop_damn * damn_total
  2409. count_nondamn = prop_nondamn * nondamn_total
  2410. # Build the 2x2 contingency table:
  2411. contingency_table = np.array([
  2412. [count_damn, damn_total - count_damn],
  2413. [count_nondamn, nondamn_total - count_nondamn]
  2414. ])
  2415. # Perform Fisher's Exact Test:
  2416. try:
  2417. odds_ratio, p_value = fisher_exact(contingency_table)
  2418. except Exception:
  2419. odds_ratio, p_value = np.nan, np.nan
  2420. fisher_results.append({
  2421. "Neighbor Rank": rank,
  2422. "Cell Class": cell_class,
  2423. "Odds Ratio": odds_ratio,
  2424. "p-value": p_value
  2425. })
  2426. # Convert the results list to a DataFrame
  2427. fisher_results_df = pd.DataFrame(fisher_results)
  2428. # Apply multiple hypothesis corrections:
  2429. fisher_results_df["padj"] = multipletests(fisher_results_df["p-value"], method="bonferroni")[1]
  2430. # Display the Fisher's test results DataFrame
  2431. fisher_results_df
  2432. # %%
  2433. # Save as CSV without row names
  2434. fisher_results_df.to_csv("DAMN_nearest_neighbors_fisher_results.csv", index=False)
  2435. # %% [markdown]
  2436. # ## Fig. S7A
  2437. # %%
  2438. import os
  2439. import pandas as pd
  2440. import numpy as np
  2441. import seaborn as sns
  2442. import matplotlib.pyplot as plt
  2443. from collections import defaultdict
  2444. import squidpy as sq
  2445. # Ensure all text in the figure is at least size 7
  2446. plt.rcParams.update({
  2447. "font.size": 7,
  2448. "axes.titlesize": 7,
  2449. "axes.labelsize": 7,
  2450. "xtick.labelsize": 7,
  2451. "ytick.labelsize": 7,
  2452. "legend.fontsize": 7,
  2453. "legend.title_fontsize": 7
  2454. })
  2455. # Abbreviations for cell class names
  2456. abbr = {
  2457. "Astrocytes": "Ast",
  2458. "Cholinergic Neurons": "CholN",
  2459. "Disease-Associated Interneurons": "DAI",
  2460. "Microglia/Macrophages": "MG",
  2461. "Non-Cholinergic Interneurons": "NCI",
  2462. "Oligodendrocytes": "Oligo",
  2463. "Other": "Other",
  2464. "Putative Ependymal Cells": "Epen",
  2465. "Putative Perivascular/Meningeal Cells": "PVM",
  2466. "Putative Vascular Cells": "Vasc"
  2467. }
  2468. # Parameters
  2469. cholinergic_types = ["Alpha MNs", "Gamma MNs", "Gamma* MNs"]
  2470. conditions = {
  2471. "Control": lambda st: st == "Control",
  2472. "Disease": lambda st: st.isin(["Early", "Mid", "End"])
  2473. }
  2474. # Prepare accumulators
  2475. neighbor_counts = {}
  2476. cell_totals = {}
  2477. for sec in adata.obs["slide_section"].unique():
  2478. sub = adata[adata.obs["slide_section"] == sec].copy()
  2479. sq.gr.spatial_neighbors(sub, coord_type="generic", n_neighs=6, key_added="spatial_sec")
  2480. conn = sub.obsp["spatial_sec_connectivities"]
  2481. classes = sub.obs["cell_class"]
  2482. stages = sub.obs["stage"]
  2483. types = sub.obs["cholinergic_type"]
  2484. for chol in cholinergic_types:
  2485. for cond_label, cond_fn in conditions.items():
  2486. mask = (types == chol) & cond_fn(stages)
  2487. idx = np.where(mask)[0]
  2488. if len(idx) == 0:
  2489. continue
  2490. key = (chol, cond_label)
  2491. if key not in neighbor_counts:
  2492. neighbor_counts[key] = [defaultdict(int) for _ in range(6)]
  2493. cell_totals[key] = 0
  2494. cell_totals[key] += len(idx)
  2495. for i in idx:
  2496. row = conn[i].tocoo()
  2497. top6 = sorted(zip(row.col, row.data), key=lambda x: -x[1])[:6]
  2498. for rank, (nbr, _) in enumerate(top6, start=1):
  2499. neighbor_counts[key][rank-1][classes.iloc[nbr]] += 1
  2500. # Build DataFrame of proportions
  2501. rows = []
  2502. for (chol, cond_label), rank_dicts in neighbor_counts.items():
  2503. total_cells = cell_totals[(chol, cond_label)]
  2504. for rank, ctr in enumerate(rank_dicts, start=1):
  2505. for cell_type, count in ctr.items():
  2506. rows.append({
  2507. "Neighbor Rank": str(rank),
  2508. "Cell Class": cell_type,
  2509. "Proportion": count / total_cells,
  2510. "Cholinergic Type": chol,
  2511. "Stage": cond_label
  2512. })
  2513. df_combined = pd.DataFrame(rows)
  2514. # Pivot for plotting
  2515. df_pivot = df_combined.pivot_table(
  2516. index=["Neighbor Rank", "Cholinergic Type", "Stage"],
  2517. columns="Cell Class",
  2518. values="Proportion",
  2519. aggfunc="sum"
  2520. ).fillna(0)
  2521. # Plot: 3 rows = skeletal MN types, 2 cols = conditions
  2522. fig, axes = plt.subplots(
  2523. nrows=3,
  2524. ncols=2,
  2525. figsize=(3.25, 4.3),
  2526. sharey=True
  2527. )
  2528. for i, chol in enumerate(cholinergic_types):
  2529. for j, cond_label in enumerate(conditions):
  2530. ax = axes[i, j]
  2531. try:
  2532. data = df_pivot.xs(
  2533. (chol, cond_label),
  2534. level=("Cholinergic Type", "Stage")
  2535. )
  2536. except KeyError:
  2537. data = pd.DataFrame()
  2538. bottom = np.zeros(len(data))
  2539. for ct in data.columns:
  2540. ax.bar(
  2541. data.index,
  2542. data[ct],
  2543. bottom=bottom,
  2544. label=abbr.get(ct, ct)
  2545. )
  2546. bottom += data[ct]
  2547. # custom titles with extra padding for Gamma MNs rows
  2548. if chol == "Gamma MNs" and cond_label == "Control":
  2549. title_str = "Gamma MNs - Control "
  2550. elif chol == "Gamma MNs" and cond_label == "Disease":
  2551. title_str = " Gamma MNs - Disease"
  2552. elif chol == "Gamma* MNs" and cond_label == "Control":
  2553. title_str = "Gamma* MNs - Control "
  2554. elif chol == "Gamma* MNs" and cond_label == "Disease":
  2555. title_str = " Gamma* MNs - Disease"
  2556. else:
  2557. title_str = f"{chol} – {cond_label}"
  2558. ax.set_title(title_str)
  2559. ax.set_xlabel("Neighbor Rank")
  2560. if j == 0:
  2561. ax.set_ylabel("Proportion")
  2562. else:
  2563. ax.set_ylabel("")
  2564. # only add legend on the top‐right subplot
  2565. if i == 0 and j == 1:
  2566. handles, labels = ax.get_legend_handles_labels()
  2567. ax.legend(
  2568. handles,
  2569. labels,
  2570. title="Cell Class",
  2571. bbox_to_anchor=(1.02, 0.5),
  2572. loc="center left",
  2573. labelspacing=0.2,
  2574. handletextpad=0.3,
  2575. columnspacing=0.5
  2576. )
  2577. plt.tight_layout()
  2578. fig_path = os.path.join(figures_dir, "neighbor_barplots_skeletal_disease.svg")
  2579. fig.savefig(fig_path, format="svg", bbox_inches="tight")
  2580. # %%
  2581. from scipy.stats import fisher_exact
  2582. from statsmodels.stats.multitest import multipletests
  2583. # Extract needed data
  2584. stage = adata.obs["stage"]
  2585. # Pre-compute totals for each condition
  2586. control_total = len(np.where(stage == "Control")[0])
  2587. disease_total = len(np.where(stage.isin(["Early", "Mid", "End"]))[0])
  2588. fisher_results = []
  2589. # Loop over each cholinergic type, neighbor rank (1-6), and each cell class
  2590. for cholinergic_group in cholinergic_types:
  2591. for rank in range(1, 7):
  2592. rank_str = str(rank)
  2593. # Get proportions for Control and Disease for this (cholinergic_group, rank)
  2594. try:
  2595. control_data = df_pivot.loc[(rank_str, cholinergic_group, "Control")]
  2596. except KeyError:
  2597. control_data = pd.Series(0, index=df_pivot.columns)
  2598. try:
  2599. disease_data = df_pivot.loc[(rank_str, cholinergic_group, "Disease")]
  2600. except KeyError:
  2601. disease_data = pd.Series(0, index=df_pivot.columns)
  2602. # Loop over each cell class
  2603. for cell_class in df_pivot.columns:
  2604. control_prop = control_data[cell_class]
  2605. disease_prop = disease_data[cell_class]
  2606. control_count = control_prop * control_total
  2607. disease_count = disease_prop * disease_total
  2608. contingency_table = np.array([
  2609. [disease_count, disease_total - disease_count], # Disease group as the first row
  2610. [control_count, control_total - control_count] # Control group as the second row
  2611. ])
  2612. # Perform Fisher's Exact Test
  2613. try:
  2614. odds_ratio, p_value = fisher_exact(contingency_table)
  2615. except Exception:
  2616. odds_ratio, p_value = np.nan, np.nan
  2617. fisher_results.append({
  2618. "Cholinergic Type": cholinergic_group,
  2619. "Neighbor Rank": rank,
  2620. "Cell Class": cell_class,
  2621. "Odds Ratio": odds_ratio,
  2622. "p-value": p_value
  2623. })
  2624. # Convert results to DataFrame
  2625. fisher_results_df = pd.DataFrame(fisher_results)
  2626. # Apply Bonferroni correction to the p-values
  2627. fisher_results_df["padj"] = multipletests(fisher_results_df["p-value"], method="bonferroni")[1]
  2628. # Display the final results
  2629. fisher_results_df
  2630. # %%
  2631. # Save as CSV without row names
  2632. fisher_results_df.to_csv("Skeletal_nearest_neighbors_fisher_results.csv", index=False)
  2633. # %% [markdown]
  2634. # ## Cell type abundance changes with disease
  2635. # %%
  2636. # Read in AnnData
  2637. adata = sc.read_h5ad(os.path.join(working_dir, f"adata_objects/all_anndata_final_rotated_filtered.h5ad"))
  2638. # %% [markdown]
  2639. # ## Fig. 6B and S7B
  2640. # %%
  2641. import os
  2642. import numpy as np
  2643. import pandas as pd
  2644. import matplotlib.pyplot as plt
  2645. import seaborn as sns
  2646. from scipy.stats import ttest_ind
  2647. from statannotations.Annotator import Annotator
  2648. from statsmodels.stats.multitest import multipletests
  2649. # ——————————————————————————————————
  2650. # 1) Ensure all text in the figures is size 7
  2651. # ——————————————————————————————————
  2652. plt.rcParams.update({
  2653. "font.size": 7,
  2654. "axes.titlesize": 7,
  2655. "axes.labelsize": 7,
  2656. "xtick.labelsize": 7,
  2657. "ytick.labelsize": 7,
  2658. "legend.fontsize": 7,
  2659. "legend.title_fontsize": 7
  2660. })
  2661. # 2) Color mapping
  2662. point_colors = {
  2663. "Microglia/Macrophages": "#d62728",
  2664. "Astrocytes": "#1f77b4",
  2665. "Oligodendrocytes": "#8c564b",
  2666. "Alpha MNs": "#1f77b4",
  2667. "Gamma MNs": "#2ca02c",
  2668. "Gamma* MNs": "#d62728",
  2669. }
  2670. stage_order = ["Control", "Early", "Mid", "End"]
  2671. # 3) Ratio function (denominator = all interneurons)
  2672. def compute_ratio(x, column, value):
  2673. num = (x[column] == value).sum()
  2674. den = (
  2675. (x["cell_class"] == "Non-Cholinergic Interneurons").sum() +
  2676. (x["cholinergic_type"] == "Cholinergic Interneurons").sum() +
  2677. (x["cholinergic_type"] == "Disease-Associated Interneurons").sum()
  2678. )
  2679. return num / den if den > 0 else np.nan
  2680. # =============================================================================
  2681. # First plot: 1×2 for Microglia/Macrophages & Astrocytes
  2682. # =============================================================================
  2683. cell_types_g1 = [
  2684. ("cell_class", "Microglia/Macrophages", "Microglia/Macrophages"),
  2685. ("cell_class", "Astrocytes", "Astrocytes"),
  2686. ]
  2687. fig1, axs1 = plt.subplots(1, 2, figsize=(3.25, 2.15), sharey=False)
  2688. stat_tests1 = {}
  2689. for ax, (col, val, title) in zip(axs1, cell_types_g1):
  2690. # a) per-slide_stage raw ratios
  2691. df = (
  2692. adata.obs
  2693. .groupby(["slide_section", "slide_stage", "stage"], observed=True)
  2694. .apply(lambda x: compute_ratio(x, col, val))
  2695. .reset_index(name="celltype_ratio")
  2696. )
  2697. # b) average per slide_stage
  2698. df_avg = (
  2699. df
  2700. .groupby("slide_stage", as_index=False)
  2701. .agg(celltype_ratio=("celltype_ratio", "mean"),
  2702. stage=("stage", "first"))
  2703. )
  2704. df_avg["stage"] = pd.Categorical(df_avg["stage"], categories=stage_order, ordered=True)
  2705. # c) scale by Control mean
  2706. ctrl_mean = df_avg.loc[df_avg.stage == "Control", "celltype_ratio"].mean()
  2707. df_avg["scaled"] = df_avg["celltype_ratio"] / ctrl_mean
  2708. # d) plotting with dot size = 4
  2709. sns.stripplot(
  2710. data=df_avg, x="stage", y="scaled",
  2711. jitter=True, alpha=0.7, size=4,
  2712. color="black", ax=ax
  2713. )
  2714. sns.pointplot(
  2715. data=df_avg, x="stage", y="scaled",
  2716. estimator="mean", errorbar=("ci", 95),
  2717. markers="o", color=point_colors[title],
  2718. ax=ax
  2719. )
  2720. ax.set_title(title)
  2721. ax.set_xlabel("")
  2722. ax.set_ylabel("Relative Abundance")
  2723. # e) two-sided, unequal-variance t-tests vs Control
  2724. ctrl_vals = df_avg.loc[df_avg.stage == "Control", "scaled"]
  2725. raw_p = []
  2726. for s in ["Early", "Mid", "End"]:
  2727. grp = df_avg.loc[df_avg.stage == s, "scaled"]
  2728. raw_p.append(
  2729. ttest_ind(ctrl_vals, grp, equal_var=False).pvalue
  2730. if len(grp) > 0 else np.nan
  2731. )
  2732. raw_p = np.array(raw_p)
  2733. mask = ~np.isnan(raw_p)
  2734. corr = np.full_like(raw_p, np.nan, dtype=float)
  2735. if mask.sum() > 0:
  2736. _, p_corr, _, _ = multipletests(raw_p[mask], method="bonferroni")
  2737. corr[mask] = p_corr
  2738. pvals_corr = corr.tolist()
  2739. # f) annotate
  2740. annot = Annotator(
  2741. ax,
  2742. [("Control", "Early"), ("Control", "Mid"), ("Control", "End")],
  2743. data=df_avg, x="stage", y="scaled",
  2744. order=stage_order, perform_stat_test=False
  2745. )
  2746. annot.configure(test=None, text_format="star", loc="inside")
  2747. annot.set_pvalues_and_annotate(pvals_corr)
  2748. # Nudge tick labels on fig1
  2749. for ax in axs1:
  2750. labels = [t.get_text() for t in ax.get_xticklabels()]
  2751. new_labels = [
  2752. "Control  " if lbl == "Control" else
  2753. " Early" if lbl == "Early" else
  2754. lbl
  2755. for lbl in labels
  2756. ]
  2757. ax.set_xticklabels(new_labels)
  2758. fig1.tight_layout()
  2759. fig1.savefig(
  2760. os.path.join(figures_dir, "celltype_group1.svg"),
  2761. format="svg", bbox_inches="tight"
  2762. )
  2763. plt.show()
  2764. # =============================================================================
  2765. # Second plot: 2×2 for Oligodendrocytes & Motor Neuron subtypes
  2766. # =============================================================================
  2767. cell_types_g2 = [
  2768. ("cell_class", "Oligodendrocytes", "Oligodendrocytes"),
  2769. ("cholinergic_type", "Alpha MNs", "Alpha MNs"),
  2770. ("cholinergic_type", "Gamma MNs", "Gamma MNs"),
  2771. ("cholinergic_type", "Gamma* MNs", "Gamma* MNs"),
  2772. ]
  2773. fig2, axs2 = plt.subplots(2, 2, figsize=(3.25, 4.3), sharey=False)
  2774. axs2_flat = axs2.flatten()
  2775. stat_tests2 = {}
  2776. for ax, (col, val, title) in zip(axs2_flat, cell_types_g2):
  2777. # a) per-slide_stage raw ratios
  2778. df = (
  2779. adata.obs
  2780. .groupby(["slide_section", "slide_stage", "stage"], observed=True)
  2781. .apply(lambda x: compute_ratio(x, col, val))
  2782. .reset_index(name="celltype_ratio")
  2783. )
  2784. # b) average per slide_stage
  2785. df_avg = (
  2786. df
  2787. .groupby("slide_stage", as_index=False)
  2788. .agg(celltype_ratio=("celltype_ratio", "mean"),
  2789. stage=("stage", "first"))
  2790. )
  2791. df_avg["stage"] = pd.Categorical(df_avg["stage"], categories=stage_order, ordered=True)
  2792. # c) scale by Control mean
  2793. ctrl_mean = df_avg.loc[df_avg.stage == "Control", "celltype_ratio"].mean()
  2794. df_avg["scaled"] = df_avg["celltype_ratio"] / ctrl_mean
  2795. # d) plotting
  2796. sns.stripplot(
  2797. data=df_avg, x="stage", y="scaled",
  2798. jitter=True, alpha=0.7, size=4,
  2799. color="black", ax=ax
  2800. )
  2801. sns.pointplot(
  2802. data=df_avg, x="stage", y="scaled",
  2803. estimator="mean", errorbar=("ci", 95), markers="o",
  2804. color=point_colors[title], ax=ax
  2805. )
  2806. ax.set_title(title)
  2807. ax.set_xlabel("")
  2808. # only leftmost column gets y-label
  2809. if ax in (axs2[0, 0], axs2[1, 0]):
  2810. ax.set_ylabel("Relative Abundance")
  2811. else:
  2812. ax.set_ylabel("")
  2813. # e) one-sided t-tests vs Control
  2814. ctrl_vals = df_avg.loc[df_avg.stage == "Control", "scaled"]
  2815. raw_p = []
  2816. for s in ["Early", "Mid", "End"]:
  2817. grp = df_avg.loc[df_avg.stage == s, "scaled"]
  2818. raw_p.append(
  2819. ttest_ind(ctrl_vals, grp, equal_var=False).pvalue
  2820. if len(grp) > 0 else np.nan
  2821. )
  2822. raw_p = np.array(raw_p)
  2823. mask = ~np.isnan(raw_p)
  2824. corr = np.full_like(raw_p, np.nan, dtype=float)
  2825. if mask.sum() > 0:
  2826. _, p_corr, _, _ = multipletests(raw_p[mask], method="bonferroni")
  2827. corr[mask] = p_corr
  2828. pvals_corr = corr.tolist()
  2829. # f) annotate
  2830. annot = Annotator(
  2831. ax,
  2832. [("Control", "Early"), ("Control", "Mid"), ("Control", "End")],
  2833. data=df_avg, x="stage", y="scaled",
  2834. order=stage_order, perform_stat_test=False
  2835. )
  2836. annot.configure(test=None, text_format="star", loc="inside")
  2837. annot.set_pvalues_and_annotate(pvals_corr)
  2838. # Nudge tick labels on fig2
  2839. for ax in axs2_flat:
  2840. labels = [t.get_text() for t in ax.get_xticklabels()]
  2841. new_labels = [
  2842. "Control  " if lbl == "Control" else
  2843. " Early" if lbl == "Early" else
  2844. lbl
  2845. for lbl in labels
  2846. ]
  2847. ax.set_xticklabels(new_labels)
  2848. fig2.tight_layout()
  2849. fig2.savefig(
  2850. os.path.join(figures_dir, "celltype_group2.svg"),
  2851. format="svg", bbox_inches="tight"
  2852. )
  2853. plt.show()
  2854. # %% [markdown]
  2855. # ## Fig. 6H
  2856. # %%
  2857. import os
  2858. import numpy as np
  2859. import pandas as pd
  2860. import matplotlib.pyplot as plt
  2861. import seaborn as sns
  2862. from scipy.stats import ttest_ind
  2863. from statannotations.Annotator import Annotator
  2864. from statsmodels.stats.multitest import multipletests
  2865. # ——————————————————————————————————
  2866. # Ensure all text is at least size 7
  2867. # ——————————————————————————————————
  2868. plt.rcParams.update({
  2869. "font.size": 7,
  2870. "axes.titlesize": 7,
  2871. "axes.labelsize": 7,
  2872. "xtick.labelsize": 7,
  2873. "ytick.labelsize": 7,
  2874. "legend.fontsize": 7,
  2875. "legend.title_fontsize": 7
  2876. })
  2877. # Define cell class of interest
  2878. cell_class_value = "Disease-Associated Interneurons"
  2879. # Order for stage categories
  2880. stage_order = ["Control", "Early", "Mid", "End"]
  2881. # 1) Compute ratios per slide_section using NCI + Cholinergic as denominator
  2882. df = (
  2883. adata.obs
  2884. .groupby(["slide_section", "slide_stage", "stage", "region"], observed=True)
  2885. .apply(lambda x: (x["cell_class"] == cell_class_value).sum() /
  2886. ((x["cell_class"] == "Non-Cholinergic Interneurons") |
  2887. (x["cell_class"] == "Cholinergic Interneurons")).sum()
  2888. if ((x["cell_class"] == "Non-Cholinergic Interneurons") |
  2889. (x["cell_class"] == "Cholinergic Interneurons")).sum() > 0
  2890. else np.nan
  2891. )
  2892. .reset_index(name="celltype_ratio")
  2893. )
  2894. # 2) Average ratio per slide_stage
  2895. df_stage_avg = df.groupby("slide_stage", as_index=False).agg(
  2896. celltype_ratio=("celltype_ratio", "mean"),
  2897. stage=("stage", "first"),
  2898. region=("region", "first")
  2899. )
  2900. # 3) Categorical ordering
  2901. df_stage_avg["stage"] = pd.Categorical(df_stage_avg["stage"],
  2902. categories=stage_order,
  2903. ordered=True)
  2904. # 4) Scale by Control mean
  2905. control_mean = df_stage_avg.loc[df_stage_avg.stage == "Control", "celltype_ratio"].mean()
  2906. df_stage_avg["celltype_ratio_scaled"] = df_stage_avg["celltype_ratio"] / control_mean
  2907. # 5) Plot
  2908. fig, ax = plt.subplots(figsize=(1.625, 2.15))
  2909. sns.stripplot(
  2910. data=df_stage_avg,
  2911. x="stage", y="celltype_ratio_scaled",
  2912. jitter=True, alpha=0.7, color="black",size=4,
  2913. ax=ax
  2914. )
  2915. sns.pointplot(
  2916. data=df_stage_avg,
  2917. x="stage", y="celltype_ratio_scaled",
  2918. estimator="mean", errorbar=("ci", 95),
  2919. markers="o", color="#55A868",
  2920. ax=ax
  2921. )
  2922. ax.set_title("DAI")
  2923. ax.set_xlabel(None)
  2924. ax.set_ylabel("Relative Abundance")
  2925. # 6) Comparisons
  2926. box_pairs = [("Control", "Early"), ("Control", "Mid"), ("Control", "End")]
  2927. control_vals = df_stage_avg.loc[df_stage_avg.stage == "Control", "celltype_ratio_scaled"]
  2928. early_vals = df_stage_avg.loc[df_stage_avg.stage == "Early", "celltype_ratio_scaled"]
  2929. mid_vals = df_stage_avg.loc[df_stage_avg.stage == "Mid", "celltype_ratio_scaled"]
  2930. end_vals = df_stage_avg.loc[df_stage_avg.stage == "End", "celltype_ratio_scaled"]
  2931. # 7) Unequal‑variance t‑tests
  2932. p_ce = ttest_ind(control_vals, early_vals, equal_var=False).pvalue if len(early_vals)>0 else np.nan
  2933. p_cm = ttest_ind(control_vals, mid_vals, equal_var=False).pvalue if len(mid_vals)>0 else np.nan
  2934. p_cE = ttest_ind(control_vals, end_vals, equal_var=False).pvalue if len(end_vals)>0 else np.nan
  2935. pvals = [p_ce, p_cm, p_cE]
  2936. # 8) Bonferroni correction
  2937. _, pvals_corr, _, _ = multipletests(pvals, method="bonferroni")
  2938. # 9) Annotate
  2939. annotator = Annotator(
  2940. ax, box_pairs,
  2941. data=df_stage_avg,
  2942. x="stage", y="celltype_ratio_scaled",
  2943. order=stage_order,
  2944. perform_stat_test=False
  2945. )
  2946. annotator.configure(test=None, text_format="star", loc="inside")
  2947. annotator.set_pvalues_and_annotate(pvals_corr)
  2948. # 9b) Nudge “Control” and “Early” apart via padded labels
  2949. padded = []
  2950. for l in stage_order:
  2951. if l == "Control":
  2952. padded.append("Control ")
  2953. elif l == "Early":
  2954. padded.append(" Early")
  2955. else:
  2956. padded.append(l)
  2957. ax.set_xticklabels(padded)
  2958. # 10) Save & show
  2959. plt.tight_layout()
  2960. out_path = os.path.join(figures_dir, "DAI_ratio_small.svg")
  2961. fig.savefig(out_path, format="svg", bbox_inches="tight")
  2962. plt.show()
  2963. # 11) Print corrected p‑values
  2964. print("Bonferroni‑corrected p‑values:")
  2965. for cmp, p in zip(["Control vs Early","Control vs Mid","Control vs End"], pvals_corr):
  2966. print(f" {cmp}: p = {p:.3e}")
  2967. # %% [markdown]
  2968. # ## Microglia/Macrophage follow-up
  2969. # %% [markdown]
  2970. # ### Check if control microglia/macrophages are near gene & transcript cutoff
  2971. # %%
  2972. # Read in AnnData
  2973. adata = sc.read_h5ad(os.path.join(working_dir, f"adata_objects/all_anndata_final_rotated.h5ad"))
  2974. # %% [markdown]
  2975. # ## Fig. S7C
  2976. # %%
  2977. import os
  2978. import seaborn as sns
  2979. import matplotlib.pyplot as plt
  2980. import pandas as pd
  2981. import numpy as np
  2982. # Filter to only Microglia/Macrophages
  2983. microglia = adata[adata.obs['cell_class'] == "Microglia/Macrophages"].copy()
  2984. # Split into control and disease
  2985. control = microglia[microglia.obs['stage'] == "Control"].copy()
  2986. disease = microglia[microglia.obs['stage'] != "Control"].copy()
  2987. # Create a combined DataFrame for plotting
  2988. control_df = pd.DataFrame({
  2989. 'num_transcripts': control.obs['num_transcripts'],
  2990. 'num_genes': control.obs['num_genes'],
  2991. 'condition': 'Control'
  2992. })
  2993. disease_df = pd.DataFrame({
  2994. 'num_transcripts': disease.obs['num_transcripts'],
  2995. 'num_genes': disease.obs['num_genes'],
  2996. 'condition': 'Disease'
  2997. })
  2998. plot_df = pd.concat([control_df, disease_df])
  2999. # Plotting
  3000. fig, axs = plt.subplots(1, 2, figsize=(1.625, 2.15))
  3001. for ax, ycol, ylabel in zip(
  3002. axs,
  3003. ['num_transcripts', 'num_genes'],
  3004. ['# Transcripts', '# Genes']
  3005. ):
  3006. # draw violin, no inner box
  3007. sns.violinplot(
  3008. data=plot_df,
  3009. x='condition',
  3010. y=ycol,
  3011. color="#d62728",
  3012. inner=None,
  3013. ax=ax
  3014. )
  3015. # drop x-label, set y-label
  3016. ax.set_xlabel("")
  3017. ax.set_ylabel(ylabel)
  3018. # adjust y-axis spacing
  3019. ax.tick_params(axis='y', pad = 0.5) # space between ticks and tick labels
  3020. ax.yaxis.labelpad = 0.5 # space between label and tick labels
  3021. # rotate x-tick labels
  3022. ax.tick_params(axis='x', rotation=90)
  3023. # compute & plot medians
  3024. medians = plot_df.groupby('condition')[ycol].median()
  3025. for i, median_val in enumerate(medians):
  3026. ax.scatter(i, median_val, color='black', s=8, zorder=3)
  3027. # add a single title above both plots
  3028. fig.suptitle("Microglia/Macrophages", x=0.6, y=0.93, fontsize=7)
  3029. # adjust so that suptitle and plots don't overlap
  3030. plt.tight_layout(rect=[0, 0, 1, 1])
  3031. # save and show
  3032. out_path = os.path.join(figures_dir, "microglia_transcripts_genes.svg")
  3033. plt.savefig(out_path, format="svg", bbox_inches="tight")
  3034. plt.show()
  3035. # Function to compute median and mean
  3036. def describe_metric(data, metric):
  3037. values = data.obs[metric]
  3038. median = np.median(values)
  3039. mean = np.mean(values)
  3040. return median, mean
  3041. # Compute for num_transcripts and num_genes
  3042. metrics = ["num_transcripts", "num_genes"]
  3043. for condition_name, data in zip(["Control", "Disease"], [control, disease]):
  3044. print(f"\n{condition_name} condition:")
  3045. for metric in metrics:
  3046. median_val, mean_val = describe_metric(data, metric)
  3047. print(f" {metric}: median = {median_val:.2f}, mean = {mean_val:.2f}")
  3048. # %% [markdown]
  3049. # ### Spatial plots of microglia/macrophages with disease
  3050. # %%
  3051. # Read in AnnData
  3052. adata = sc.read_h5ad(os.path.join(working_dir, f"adata_objects/all_anndata_final_rotated.h5ad"))
  3053. # %%
  3054. import os
  3055. import numpy as np
  3056. import pandas as pd
  3057. import matplotlib.pyplot as plt
  3058. # 1) Ensure all text in the figures is size 7
  3059. plt.rcParams.update({
  3060. "font.size": 7,
  3061. "axes.titlesize": 7,
  3062. "axes.labelsize": 7,
  3063. "xtick.labelsize": 7,
  3064. "ytick.labelsize": 7,
  3065. "legend.fontsize": 7,
  3066. "legend.title_fontsize": 7
  3067. })
  3068. # 2) Define (slide, section) pairs
  3069. sections = [
  3070. ("202405281702_MsSpinalCord-VS223-Lumbar-CE2-SE4-S1_VMSC02701", "R1C2"),
  3071. ("202405281416_MsSpinalCord-VS223-Lumbar-CM2-SM4-S1_VMSC15302", "R2C3"),
  3072. ("202401241200_HuSpinalCord-VS119-Lubmar-Ctrl3-SOD10_VMSC10802", "R1C2"),
  3073. ("202405281702_MsSpinalCord-VS223-Lumbar-CE2-SE4-S1_VMSC02701", "R2C3"),
  3074. ("202405281416_MsSpinalCord-VS223-Lumbar-CM2-SM4-S1_VMSC15302", "R1C3"),
  3075. ("202401241200_HuSpinalCord-VS119-Lubmar-Ctrl3-SOD10_VMSC10802", "R2C2")
  3076. ]
  3077. # 3) Extract subsets
  3078. sub_adata_list = [
  3079. adata[(adata.obs["slide"] == slide_id) & (adata.obs["section"] == section_id)].copy()
  3080. for slide_id, section_id in sections
  3081. ]
  3082. # 4) Compute global bounds with padding
  3083. xmins, xmaxs, ymins, ymaxs = [], [], [], []
  3084. for sub in sub_adata_list:
  3085. coords = sub.obsm["spatial"]
  3086. xmins.append(coords[:, 0].min()); xmaxs.append(coords[:, 0].max())
  3087. ymins.append(coords[:, 1].min()); ymaxs.append(coords[:, 1].max())
  3088. global_xmin, global_xmax = min(xmins), max(xmaxs)
  3089. global_ymin, global_ymax = min(ymins), max(ymaxs)
  3090. dx, dy = global_xmax - global_xmin, global_ymax - global_ymin
  3091. global_xmin -= 0.05 * dx
  3092. global_xmax += 0.00 * dx
  3093. global_ymin -= 0.05 * dy
  3094. global_ymax += 0.075 * dy
  3095. # ------------------ Microglia/Macrophages (2×3) — Swapped Rows & Titles ------------------ #
  3096. fig1, axes1 = plt.subplots(2, 3, figsize=(4.35, 2.2))
  3097. axes1 = axes1.reshape(2, 3)
  3098. axes1 = np.flipud(axes1).flatten() # Flip the subplot rows
  3099. # Correctly reordered titles to match flipped layout
  3100. titles1 = [
  3101. "Early – Disease", "Mid – Disease", "End – Disease",
  3102. "Early – Control", "Mid – Control", "End – Control"
  3103. ]
  3104. for ax, sub, title in zip(axes1, sub_adata_list, titles1):
  3105. coords = sub.obsm["spatial"]
  3106. mask = (sub.obs["cell_class"] == "Microglia/Macrophages").values
  3107. ax.scatter(coords[~mask, 0], coords[~mask, 1],
  3108. s=4, c="lightgray", marker="o", linewidth=0)
  3109. ax.scatter(coords[mask, 0], coords[mask, 1],
  3110. s=4, c="#d62728", marker="o", linewidth=0)
  3111. ax.set_xlim(global_xmin, global_xmax)
  3112. ax.set_ylim(global_ymin, global_ymax)
  3113. ax.set_xticks([]); ax.set_yticks([])
  3114. ax.set_title(title, pad=2)
  3115. fig1.suptitle("Microglia/Macrophages", y=0.93, fontsize=7)
  3116. fig1.subplots_adjust(top=0.88)
  3117. plt.tight_layout()
  3118. fig1_path = os.path.join(figures_dir, "microglia_macrophages.svg")
  3119. fig1.savefig(fig1_path, format="svg", bbox_inches="tight")
  3120. plt.show()
  3121. # %% [markdown]
  3122. # ## Reactive glia with disease
  3123. # %%
  3124. adata = sc.read_h5ad(os.path.join(working_dir, f"adata_objects/all_anndata_final_rotated.h5ad"))
  3125. # %% [markdown]
  3126. # ### Define reactive glia
  3127. # %%
  3128. # Step 1: Compute the 95th percentiles for Microglia and Astrocytes in each slide
  3129. control_95th_percentile_microglia = {}
  3130. control_95th_percentile_astrocytes = {}
  3131. # Exclude control only slide
  3132. excluded_slide = "202312251515_MsSpinalCord-L4-S1-VS119-YS_Beta8"
  3133. unique_slides = [s for s in adata.obs["slide"].unique() if s != excluded_slide]
  3134. for slide in unique_slides:
  3135. # Microglia: use Apoe
  3136. control_microglia = adata.obs[
  3137. (adata.obs["slide"] == slide) &
  3138. (adata.obs["stage"] == "Control") &
  3139. (adata.obs["cell_class"] == "Microglia/Macrophages")
  3140. ]["Apoe_high_pass_norm"].dropna()
  3141. control_95th_percentile_microglia[slide] = (
  3142. np.percentile(control_microglia, 95) if not control_microglia.empty else np.nan
  3143. )
  3144. # Astrocytes: use Gfap
  3145. control_astrocytes = adata.obs[
  3146. (adata.obs["slide"] == slide) &
  3147. (adata.obs["stage"] == "Control") &
  3148. (adata.obs["cell_class"] == "Astrocytes")
  3149. ]["Gfap_high_pass_norm"].dropna()
  3150. control_95th_percentile_astrocytes[slide] = (
  3151. np.percentile(control_astrocytes, 95) if not control_astrocytes.empty else np.nan
  3152. )
  3153. # Step 2: Define functions to determine reactivity
  3154. def is_reactive_microglia(row):
  3155. slide = row["slide"]
  3156. if row["cell_class"] == "Microglia/Macrophages" and slide in control_95th_percentile_microglia:
  3157. threshold = control_95th_percentile_microglia[slide]
  3158. if not np.isnan(threshold):
  3159. return row["Apoe_high_pass_norm"] > threshold
  3160. return False
  3161. def is_reactive_or_wm_astrocyte(row):
  3162. slide = row["slide"]
  3163. if row["cell_class"] == "Astrocytes" and slide in control_95th_percentile_astrocytes:
  3164. threshold = control_95th_percentile_astrocytes[slide]
  3165. if not np.isnan(threshold):
  3166. return row["Gfap_high_pass_norm"] > threshold
  3167. return False
  3168. # Step 3: Apply the functions to create new metadata columns
  3169. adata.obs["reactive_microglia"] = adata.obs.apply(is_reactive_microglia, axis=1)
  3170. adata.obs["reactive_or_wm_astrocytes"] = adata.obs.apply(is_reactive_or_wm_astrocyte, axis=1)
  3171. # %%
  3172. # Save anndata
  3173. save_name = f"adata_objects/all_anndata_final_rotated_w_anno.h5ad"
  3174. adata.write_h5ad(os.path.join(working_dir, save_name))
  3175. # %% [markdown]
  3176. # ### Reactive glia spatial plots
  3177. # %%
  3178. adata = sc.read_h5ad(os.path.join(working_dir, f"adata_objects/all_anndata_final_rotated_w_anno.h5ad"))
  3179. # %% [markdown]
  3180. # ## Fig. 6C
  3181. # %%
  3182. import os
  3183. import numpy as np
  3184. import pandas as pd
  3185. import matplotlib.pyplot as plt
  3186. import seaborn as sns
  3187. from scipy.spatial import ConvexHull
  3188. # Ensure all text in the figures is size 7
  3189. plt.rcParams.update({
  3190. "font.size": 7,
  3191. "axes.titlesize": 7,
  3192. "axes.labelsize": 7,
  3193. "xtick.labelsize": 7,
  3194. "ytick.labelsize": 7,
  3195. "legend.fontsize": 7,
  3196. "legend.title_fontsize": 7
  3197. })
  3198. # Define (slide, section) pairs
  3199. sections = [
  3200. ("202405281702_MsSpinalCord-VS223-Lumbar-CE2-SE4-S1_VMSC02701", "R1C2"),
  3201. ("202405281416_MsSpinalCord-VS223-Lumbar-CM2-SM4-S1_VMSC15302", "R2C3"),
  3202. ("202401241200_HuSpinalCord-VS119-Lubmar-Ctrl3-SOD10_VMSC10802", "R1C2"),
  3203. ("202405281702_MsSpinalCord-VS223-Lumbar-CE2-SE4-S1_VMSC02701", "R2C3"),
  3204. ("202405281416_MsSpinalCord-VS223-Lumbar-CM2-SM4-S1_VMSC15302", "R1C3"),
  3205. ("202401241200_HuSpinalCord-VS119-Lubmar-Ctrl3-SOD10_VMSC10802", "R2C2")
  3206. ]
  3207. # Extract subsets
  3208. sub_adata_list = [
  3209. adata[(adata.obs["slide"] == slide_id) & (adata.obs["section"] == section_id)].copy()
  3210. for slide_id, section_id in sections
  3211. ]
  3212. # Compute global bounds
  3213. xmins, xmaxs, ymins, ymaxs = [], [], [], []
  3214. for sub in sub_adata_list:
  3215. coords = sub.obsm["spatial"]
  3216. xmins.append(coords[:, 0].min()); xmaxs.append(coords[:, 0].max())
  3217. ymins.append(coords[:, 1].min()); ymaxs.append(coords[:, 1].max())
  3218. global_xmin, global_xmax = min(xmins), max(xmaxs)
  3219. global_ymin, global_ymax = min(ymins), max(ymaxs)
  3220. dx, dy = global_xmax - global_xmin, global_ymax - global_ymin
  3221. global_xmin -= 0.05 * dx
  3222. global_xmax += 0.00 * dx
  3223. global_ymin -= 0.05 * dy
  3224. global_ymax += 0.075 * dy
  3225. # Subplot titles
  3226. titles = [
  3227. "Early – Disease", "Mid – Disease", "End – Disease",
  3228. "Early – Control", "Mid – Control", "End – Control"
  3229. ]
  3230. # ------------------ Combined 3×3 Plot ------------------ #
  3231. fig, axes = plt.subplots(3, 3, figsize=(3.1132, 1.9816))
  3232. axes = axes.reshape(3, 3)
  3233. # ------------------ Top 2 rows: Reactive Microglia ------------------ #
  3234. # Flip rows vertically
  3235. marker_axes = np.flipud(axes[:2]).flatten()
  3236. for ax, sub, title in zip(marker_axes, sub_adata_list, titles):
  3237. coords = sub.obsm["spatial"]
  3238. mask = sub.obs["reactive_microglia"].astype(bool).values
  3239. ax.scatter(coords[~mask, 0], coords[~mask, 1], s=4, c="lightgray", marker="o", linewidth=0)
  3240. ax.scatter(coords[ mask, 0], coords[ mask, 1], s=4, c="#d62728", marker="o", linewidth=0)
  3241. ax.set_xlim(global_xmin, global_xmax)
  3242. ax.set_ylim(global_ymin, global_ymax)
  3243. ax.set_xticks([]); ax.set_yticks([])
  3244. ax.set_title(title, pad=2)
  3245. # ------------------ Top 2 rows: Rasterize points ------------------ #
  3246. for ax in axes[:2].flatten():
  3247. for coll in ax.collections:
  3248. coll.set_rasterized(True)
  3249. # ------------------ Bottom row: Reactive Microglia Density + Convex Hull ------------------ #
  3250. for ax, sub, title in zip(axes[2], sub_adata_list[:3], titles[:3]):
  3251. coords = sub.obsm["spatial"]
  3252. # Plot reactive microglia density
  3253. mask = (sub.obs["cell_class"] == "Microglia/Macrophages") & (sub.obs["reactive_microglia"] == True)
  3254. coords_mask = coords[mask.values]
  3255. sns.kdeplot(
  3256. x=coords_mask[:, 0], y=coords_mask[:, 1],
  3257. fill=True, thresh=0, levels=100, cmap="Reds", bw_method="scott", ax=ax
  3258. )
  3259. # Add convex hull of all cells
  3260. if coords.shape[0] >= 3:
  3261. try:
  3262. hull = ConvexHull(coords)
  3263. hull_coords = coords[hull.vertices]
  3264. hull_path = np.append(hull_coords, [hull_coords[0]], axis=0) # close the loop
  3265. ax.plot(hull_path[:, 0], hull_path[:, 1], color="black", linewidth=0.5)
  3266. except:
  3267. pass # silently skip if hull fails
  3268. ax.set_xlim(global_xmin, global_xmax)
  3269. ax.set_ylim(global_ymin, global_ymax)
  3270. ax.set_xticks([]); ax.set_yticks([])
  3271. ax.set_title(title, pad=2)
  3272. # Final formatting
  3273. plt.tight_layout(pad=0.325)
  3274. fig.suptitle("Reactive Microglia/Macrophages", y=1, fontsize=7)
  3275. fig.subplots_adjust(top=0.90)
  3276. out_path = os.path.join(figures_dir, "reactive_microglia_combined_with_hull.svg")
  3277. fig.savefig(out_path, format="svg", bbox_inches="tight", dpi=600)
  3278. plt.show()
  3279. # %% [markdown]
  3280. # ### Dorsal/ventral location of reactive glia and disease-associated interneurons
  3281. # %%
  3282. adata = sc.read_h5ad(os.path.join(working_dir, f"adata_objects/all_anndata_final_rotated_w_anno.h5ad"))
  3283. # %%
  3284. # Normalize y values and add to spatial data
  3285. import pandas as pd
  3286. # 1) pull out the y‑coordinate and slide_section (as string)
  3287. y = pd.Series(adata.obsm["spatial"][:, 1], index=adata.obs.index, name="y")
  3288. slide_sec = adata.obs["slide_section"].astype(str)
  3289. # 2) shift each section so its minimum y becomes zero
  3290. y_shifted = y.groupby(slide_sec).transform(lambda v: v - v.min())
  3291. # 3) compute the median of the shifted values for each section
  3292. y_median = y_shifted.groupby(slide_sec).transform("median")
  3293. # 4) normalize by dividing by that median
  3294. adata.obs["y_normalized"] = (y_shifted / y_median).values
  3295. # %% [markdown]
  3296. # ## Fig. 6D
  3297. # %%
  3298. import os
  3299. import numpy as np
  3300. import pandas as pd
  3301. import matplotlib.pyplot as plt
  3302. import seaborn as sns
  3303. from scipy.stats import f_oneway
  3304. from statsmodels.stats.multicomp import pairwise_tukeyhsd
  3305. from statannotations.Annotator import Annotator
  3306. # ——————————————————————————————————
  3307. # Ensure all text is at least size 7
  3308. # ——————————————————————————————————
  3309. plt.rcParams.update({
  3310. "font.size": 7,
  3311. "axes.titlesize": 7,
  3312. "axes.labelsize": 7,
  3313. "xtick.labelsize": 7,
  3314. "ytick.labelsize": 7,
  3315. "legend.fontsize": 7,
  3316. "legend.title_fontsize": 7
  3317. })
  3318. # Subset adata to only include "Lumbar" region
  3319. adata_lumbar = adata[adata.obs["region"] == "Lumbar"].copy()
  3320. # 1) Filter reactive microglia & drop Control
  3321. stage_order = ["Early", "Mid", "End"]
  3322. reactive = (
  3323. adata_lumbar.obs
  3324. .loc[
  3325. adata_lumbar.obs["reactive_microglia"] == True,
  3326. ["y_normalized", "stage", "slide_stage", "slide_section"]
  3327. ]
  3328. .copy()
  3329. )
  3330. reactive = reactive[reactive["stage"].isin(stage_order)]
  3331. # 2) Per‑slide_section average of y_normalized
  3332. ss_avg = (
  3333. reactive
  3334. .groupby(["slide_section", "slide_stage", "stage"], observed=True)
  3335. .agg(avg_y=("y_normalized", "mean"))
  3336. .reset_index()
  3337. )
  3338. # 3) Per‑slide_stage average of those slide_section avgs
  3339. stg_avg = (
  3340. ss_avg
  3341. .groupby(["slide_stage", "stage"], observed=True)
  3342. .agg(avg_y=("avg_y", "mean"))
  3343. .reset_index()
  3344. )
  3345. # -------------------------
  3346. # 4) STATISTICS
  3347. # -------------------------
  3348. # ANOVA across stages
  3349. groups = [stg_avg.loc[stg_avg.stage == s, "avg_y"] for s in stage_order]
  3350. F, p_an = f_oneway(*groups)
  3351. print(f"ANOVA F = {F:.3f}, p = {p_an:.3e}")
  3352. # Tukey HSD
  3353. tukey = pairwise_tukeyhsd(
  3354. endog=stg_avg["avg_y"],
  3355. groups=stg_avg["stage"],
  3356. alpha=0.05
  3357. )
  3358. print("\nTukey HSD results:")
  3359. print(tukey.summary())
  3360. # extract p‑values for annotator
  3361. tuk_df = pd.DataFrame(tukey._results_table.data[1:], columns=tukey._results_table.data[0])
  3362. pairs = [("Early", "Mid"), ("Early", "End"), ("Mid", "End")]
  3363. pvals = []
  3364. for a, b in pairs:
  3365. m = tuk_df.query("(group1==@a & group2==@b) or (group1==@b & group2==@a)")
  3366. pvals.append(m["p-adj"].values[0] if not m.empty else np.nan)
  3367. # -------------------------
  3368. # 5) PLOT
  3369. # -------------------------
  3370. fig, ax = plt.subplots(figsize=(1.625, 2.15))
  3371. # boxplot with all boxes colored red
  3372. sns.boxplot(
  3373. data=stg_avg,
  3374. x="stage",
  3375. y="avg_y",
  3376. order=stage_order,
  3377. color="#d62728",
  3378. showfliers=False,
  3379. ax=ax
  3380. )
  3381. # stripplot on top
  3382. sns.stripplot(
  3383. data=stg_avg,
  3384. x="stage",
  3385. y="avg_y",
  3386. order=stage_order,
  3387. color="black",
  3388. size=4,
  3389. jitter=True,
  3390. alpha=0.8,
  3391. ax=ax
  3392. )
  3393. # add stat annotations
  3394. annotator = Annotator(
  3395. ax, pairs, data=stg_avg,
  3396. x="stage", y="avg_y",
  3397. order=stage_order
  3398. )
  3399. annotator.configure(test=None, text_format="star", loc="inside")
  3400. annotator.set_pvalues(pvals)
  3401. annotator.annotate()
  3402. ax.set_xlabel("")
  3403. ax.set_ylabel("Avg. Dorsal/Ventral Location")
  3404. ax.set_title("Reactive MG - Lumbar")
  3405. plt.tight_layout()
  3406. out_path = os.path.join(figures_dir, "reactive_microglia_DV_small.svg")
  3407. fig.savefig(out_path, format="svg", bbox_inches="tight")
  3408. plt.show()
  3409. # %% [markdown]
  3410. # ## Fig. 6J
  3411. # %%
  3412. import numpy as np
  3413. import pandas as pd
  3414. import matplotlib.pyplot as plt
  3415. import seaborn as sns
  3416. from scipy.stats import ttest_rel
  3417. from statannotations.Annotator import Annotator
  3418. # ——————————————————————————————————
  3419. # Ensure all text in the figure is size 7
  3420. # ——————————————————————————————————
  3421. plt.rcParams.update({
  3422. "font.size": 7,
  3423. "axes.titlesize": 7,
  3424. "axes.labelsize": 7,
  3425. "xtick.labelsize": 7,
  3426. "ytick.labelsize": 7,
  3427. "legend.fontsize": 7,
  3428. "legend.title_fontsize": 7,
  3429. "figure.titlesize": 7
  3430. })
  3431. # Subset to Lumbar region AND exclude Control-stage
  3432. adata_sub = adata[
  3433. (adata.obs["region"] == "Lumbar") &
  3434. (adata.obs["stage"] != "Control")
  3435. ].copy()
  3436. # Define the cell types (for filtering only)
  3437. cell_types = {
  3438. "Disease-Associated Interneurons": "Disease-Associated Interneurons",
  3439. "Motor Neurons": ["Alpha MNs", "Gamma MNs", "Gamma* MNs"]
  3440. }
  3441. # 1) Filter each and tag a simplified label
  3442. dai = (
  3443. adata_sub.obs
  3444. .loc[
  3445. adata_sub.obs["cell_class"] == cell_types["Disease-Associated Interneurons"],
  3446. ["y_normalized", "slide_section", "slide_stage", "stage"]
  3447. ]
  3448. .copy()
  3449. )
  3450. dai["cell_type"] = "DAI"
  3451. mot = (
  3452. adata_sub.obs
  3453. .loc[
  3454. adata_sub.obs["cholinergic_type"].isin(cell_types["Motor Neurons"]),
  3455. ["y_normalized", "slide_section", "slide_stage", "stage"]
  3456. ]
  3457. .copy()
  3458. )
  3459. mot["cell_type"] = "Skeletal MNs"
  3460. combined = pd.concat([dai, mot], ignore_index=True)
  3461. # 2) Per‑slide_section average of y_normalized
  3462. ss_avg = (
  3463. combined
  3464. .groupby(["slide_section", "slide_stage", "stage", "cell_type"], observed=True)
  3465. .agg(avg_y=("y_normalized", "mean"))
  3466. .reset_index()
  3467. )
  3468. # 3) Per‑slide_stage average of those slide_section avgs
  3469. stg_lvl_avg = (
  3470. ss_avg
  3471. .groupby(["slide_stage", "stage", "cell_type"], observed=True)
  3472. .agg(stage_avg_y=("avg_y", "mean"))
  3473. .reset_index()
  3474. )
  3475. # 4) ONE‑SIDED PAIRED t‑TEST (H₁: DAI > Skeletal MNs)
  3476. paired = (
  3477. stg_lvl_avg
  3478. .pivot(index="slide_stage", columns="cell_type", values="stage_avg_y")
  3479. .dropna()
  3480. )
  3481. from scipy.stats import ttest_rel
  3482. t_stat, p_two_sided = ttest_rel(paired["DAI"], paired["Skeletal MNs"])
  3483. if t_stat > 0:
  3484. p_one_sided = p_two_sided / 2
  3485. else:
  3486. p_one_sided = 1 - p_two_sided / 2
  3487. print(f"paired one‑sided t = {t_stat:.3f}, p = {p_one_sided:.3e}")
  3488. pairs = [("DAI", "Skeletal MNs")]
  3489. pvals = [p_one_sided]
  3490. # 5) PLOT using slide_stage averages with custom colors
  3491. plt.figure(figsize=(1.625, 2.15))
  3492. ax = sns.boxplot(
  3493. data=stg_lvl_avg,
  3494. x="cell_type",
  3495. y="stage_avg_y",
  3496. order=["DAI", "Skeletal MNs"],
  3497. palette={"DAI": "#2ca02c", "Skeletal MNs": "#ff7f0e"},
  3498. showfliers=False,
  3499. ax=plt.gca()
  3500. )
  3501. sns.stripplot(
  3502. data=stg_lvl_avg,
  3503. x="cell_type",
  3504. y="stage_avg_y",
  3505. order=["DAI", "Skeletal MNs"],
  3506. color="black",
  3507. size=4,
  3508. jitter=True,
  3509. alpha=0.8,
  3510. ax=ax
  3511. )
  3512. # connect paired points from same slide_stage
  3513. for slide_stage in paired.index:
  3514. ax.plot(
  3515. ["DAI", "Skeletal MNs"],
  3516. [paired.loc[slide_stage, "DAI"], paired.loc[slide_stage, "Skeletal MNs"]],
  3517. color="gray", linewidth=0.8, alpha=0.6
  3518. )
  3519. # annotate
  3520. annotator = Annotator(
  3521. ax, [("DAI", "Skeletal MNs")], data=stg_lvl_avg,
  3522. x="cell_type", y="stage_avg_y",
  3523. order=["DAI", "Skeletal MNs"]
  3524. )
  3525. annotator.configure(test=None, text_format="star", loc="inside")
  3526. annotator.set_pvalues([p_one_sided])
  3527. annotator.annotate()
  3528. ax.set_xlabel("")
  3529. ax.set_ylabel("Avg. Dorsal/Ventral Location")
  3530. ax.set_title("DAI vs. Skeletal MNs")
  3531. plt.tight_layout()
  3532. out_path = os.path.join(figures_dir, "DAI_MN_DV_paired_small.svg")
  3533. plt.savefig(out_path, format="svg", bbox_inches="tight")
  3534. plt.show()
  3535. # %% [markdown]
  3536. # ## Reactive and non-reactive MG - distance to nearest MN
  3537. # %%
  3538. adata = sc.read_h5ad(os.path.join(working_dir, f"adata_objects/all_anndata_final_rotated_w_anno.h5ad"))
  3539. # %% [markdown]
  3540. # ## Fig. 6E
  3541. # %%
  3542. import os
  3543. import numpy as np
  3544. import pandas as pd
  3545. import matplotlib.pyplot as plt
  3546. import seaborn as sns
  3547. from scipy.spatial.distance import cdist
  3548. from scipy.stats import ttest_rel
  3549. from statannotations.Annotator import Annotator
  3550. # ——————————————————————————————
  3551. # Ensure all text is size 7
  3552. # ——————————————————————————————
  3553. plt.rcParams.update({
  3554. "font.size": 7,
  3555. "axes.titlesize": 7,
  3556. "axes.labelsize": 7,
  3557. "xtick.labelsize": 7,
  3558. "ytick.labelsize": 7,
  3559. "legend.fontsize": 7,
  3560. "legend.title_fontsize": 7
  3561. })
  3562. # ——————————————————————————————
  3563. # 1) Subset to Lumbar & Early
  3564. # ——————————————————————————————
  3565. adata_sub = adata[
  3566. (adata.obs["region"] == "Lumbar") &
  3567. (adata.obs["stage"] == "Early")
  3568. ].copy()
  3569. # ——————————————————————————————
  3570. # 2) Compute per-slide_stage avg distances
  3571. # ——————————————————————————————
  3572. records = []
  3573. for slide_stage in adata_sub.obs["slide_stage"].unique():
  3574. mask_ss = adata_sub.obs["slide_stage"] == slide_stage
  3575. sections = adata_sub.obs.loc[mask_ss, "slide_section"].unique()
  3576. for reactive_status in [True, False]:
  3577. sec_means = []
  3578. for sec in sections:
  3579. mask_sec = mask_ss & (adata_sub.obs["slide_section"] == sec)
  3580. coords = adata_sub.obsm["spatial"][mask_sec.values]
  3581. is_motor = (
  3582. adata_sub.obs.loc[mask_sec, "cholinergic_type"]
  3583. .isin(["Alpha MNs","Gamma MNs","Gamma* MNs"])
  3584. .to_numpy()
  3585. )
  3586. # restrict to microglia/macrophages first
  3587. is_micro_class = (
  3588. adata_sub.obs.loc[mask_sec, "cell_class"] == "Microglia/Macrophages"
  3589. ).to_numpy()
  3590. is_reactive_flag = (
  3591. adata_sub.obs.loc[mask_sec, "reactive_microglia"]
  3592. .astype(bool)
  3593. .to_numpy()
  3594. )
  3595. is_micro = is_micro_class & (is_reactive_flag == reactive_status)
  3596. pts_micro = coords[is_micro]
  3597. pts_motor = coords[is_motor]
  3598. if pts_micro.size and pts_motor.size:
  3599. D = cdist(pts_micro, pts_motor, "euclidean")
  3600. sec_means.append(D.min(axis=1).mean())
  3601. if sec_means:
  3602. records.append({
  3603. "slide_stage": slide_stage,
  3604. "reactive": reactive_status,
  3605. "avg_dist": np.mean(sec_means)
  3606. })
  3607. dist_df = pd.DataFrame(records)
  3608. # ——————————————————————————————
  3609. # 3) Build plot_df
  3610. # ——————————————————————————————
  3611. plot_df = dist_df.copy()
  3612. plot_df["cell_type"] = plot_df["reactive"].map({
  3613. True: "Reactive",
  3614. False: "Non-Reactive"
  3615. })
  3616. # ——————————————————————————————
  3617. # 4) Compute one-sided p-value (Non-Reactive > Reactive)
  3618. # ——————————————————————————————
  3619. pivot = plot_df.pivot(
  3620. index="slide_stage",
  3621. columns="cell_type",
  3622. values="avg_dist"
  3623. ).dropna(subset=["Reactive", "Non-Reactive"])
  3624. stat, p_two = ttest_rel(pivot["Non-Reactive"], pivot["Reactive"])
  3625. p_one = p_two / 2 if stat > 0 else 1 - p_two / 2
  3626. print(f"One-sided p-value (Non-Reactive > Reactive): {p_one:.3e}")
  3627. # ——————————————————————————————
  3628. # 5) Plot paired box+strip with lines and annotate
  3629. # ——————————————————————————————
  3630. fig, ax = plt.subplots(figsize=(1.625, 2.15))
  3631. sns.boxplot(
  3632. data=plot_df,
  3633. x="cell_type", y="avg_dist",
  3634. order=["Reactive","Non-Reactive"],
  3635. palette={"Reactive":"#d62728","Non-Reactive":"#ea9393"},
  3636. showfliers=False,
  3637. linewidth=1,
  3638. ax=ax, zorder=1
  3639. )
  3640. sns.stripplot(
  3641. data=plot_df,
  3642. x="cell_type", y="avg_dist",
  3643. order=["Reactive","Non-Reactive"],
  3644. color="black", size=4, jitter=True, alpha=0.8,
  3645. ax=ax, zorder=2
  3646. )
  3647. for ss, grp in plot_df.groupby("slide_stage"):
  3648. y0 = grp.loc[grp.cell_type=="Reactive", "avg_dist"].values
  3649. y1 = grp.loc[grp.cell_type=="Non-Reactive","avg_dist"].values
  3650. if y0.size and y1.size:
  3651. ax.plot(
  3652. ["Reactive","Non-Reactive"],
  3653. [y0[0], y1[0]],
  3654. color="gray", linewidth=0.8, alpha=0.6, zorder=3
  3655. )
  3656. pairs = [("Reactive", "Non-Reactive")]
  3657. annot = Annotator(
  3658. ax, pairs,
  3659. data=plot_df,
  3660. x="cell_type", y="avg_dist",
  3661. order=["Reactive","Non-Reactive"]
  3662. )
  3663. annot.set_pvalues([p_one])
  3664. annot.configure(text_format="star", loc="inside")
  3665. annot.annotate()
  3666. ax.set_title("Early Disease - Lumbar", fontsize=7)
  3667. ax.set_xlabel("Microglia/Macrophages")
  3668. ax.set_ylabel("Avg. Dist. to Nearest MN")
  3669. # ——————————————————————————————
  3670. # FORCE tick + spine thickness to 1
  3671. # ——————————————————————————————
  3672. ax.tick_params(width=1)
  3673. for spine in ax.spines.values():
  3674. spine.set_linewidth(1)
  3675. plt.tight_layout()
  3676. out_path = os.path.join(
  3677. figures_dir,
  3678. "reactive_microglia_nearest_MN_small_fixed.svg"
  3679. )
  3680. fig.savefig(out_path, format="svg", bbox_inches="tight")
  3681. plt.show()
  3682. # %% [markdown]
  3683. # ## Reactive/WM and other Astrocytes - distance to nearest reactive MG
  3684. # %%
  3685. import numpy as np
  3686. import pandas as pd
  3687. import matplotlib.pyplot as plt
  3688. import seaborn as sns
  3689. from scipy.spatial.distance import cdist
  3690. from scipy.stats import ttest_rel
  3691. from statannotations.Annotator import Annotator
  3692. # ——————————————————————————————
  3693. # 1) Subset to Lumbar & Early
  3694. # ——————————————————————————————
  3695. adata_sub = adata[
  3696. (adata.obs["region"] == "Lumbar") &
  3697. (adata.obs["stage"] == "Early")
  3698. ].copy()
  3699. # ——————————————————————————————
  3700. # 2) Compute per-slide_stage avg distances
  3701. # ——————————————————————————————
  3702. records = []
  3703. for slide_stage in adata_sub.obs["slide_stage"].unique():
  3704. mask_ss = adata_sub.obs["slide_stage"] == slide_stage
  3705. sections = adata_sub.obs.loc[mask_ss, "slide_section"].unique()
  3706. for is_reactive_wm, label in [(True, "Reactive/WM"), (False, "Other")]:
  3707. sec_means = []
  3708. for sec in sections:
  3709. mask_sec = mask_ss & (adata_sub.obs["slide_section"] == sec)
  3710. coords = adata_sub.obsm["spatial"][mask_sec.values]
  3711. # Query: astrocytes of this group
  3712. is_astro = (
  3713. (adata_sub.obs.loc[mask_sec, "cell_class"] == "Astrocytes") &
  3714. (adata_sub.obs.loc[mask_sec, "reactive_or_wm_astrocytes"] == is_reactive_wm)
  3715. ).to_numpy()
  3716. # Reference: reactive microglia
  3717. is_reactive_micro = (
  3718. (adata_sub.obs.loc[mask_sec, "cell_class"] == "Microglia/Macrophages") &
  3719. (adata_sub.obs.loc[mask_sec, "reactive_microglia"])
  3720. ).to_numpy()
  3721. pts_astro = coords[is_astro]
  3722. pts_micro = coords[is_reactive_micro]
  3723. if pts_astro.size and pts_micro.size:
  3724. D = cdist(pts_astro, pts_micro, metric="euclidean")
  3725. sec_means.append(D.min(axis=1).mean())
  3726. if sec_means:
  3727. records.append({
  3728. "slide_stage": slide_stage,
  3729. "astro_group": label,
  3730. "avg_dist": np.mean(sec_means)
  3731. })
  3732. # ——————————————————————————————
  3733. # 3) Create plot_df and perform stats
  3734. # ——————————————————————————————
  3735. dist_df = pd.DataFrame(records)
  3736. plot_df = dist_df.copy()
  3737. plot_df["cell_type"] = plot_df["astro_group"]
  3738. # Paired one-sided t-test (Other > Reactive/WM)
  3739. pivot = plot_df.pivot(index="slide_stage", columns="cell_type", values="avg_dist")
  3740. stat, p_two = ttest_rel(pivot["Other"], pivot["Reactive/WM"])
  3741. p_one = p_two / 2 if stat > 0 else 1 - p_two / 2
  3742. print(f"One-sided p-value (Other > Reactive/WM): {p_one:.3e}")
  3743. # ——————————————————————————————
  3744. # 4) Plot
  3745. # ——————————————————————————————
  3746. plt.rcParams.update({
  3747. "font.size": 7,
  3748. "axes.titlesize": 7,
  3749. "axes.labelsize": 7,
  3750. "xtick.labelsize": 7,
  3751. "ytick.labelsize": 7,
  3752. "legend.fontsize": 7
  3753. })
  3754. fig, ax = plt.subplots(figsize=(1.625, 2.15))
  3755. sns.boxplot(
  3756. data=plot_df,
  3757. x="cell_type", y="avg_dist",
  3758. order=["Reactive/WM", "Other"],
  3759. palette={"Reactive/WM": "#1f77b4", "Other": "#8fbbd9"},
  3760. showfliers=False,
  3761. ax=ax, zorder=1
  3762. )
  3763. sns.stripplot(
  3764. data=plot_df,
  3765. x="cell_type", y="avg_dist",
  3766. order=["Reactive/WM", "Other"],
  3767. color="black", size=4, jitter=True, alpha=0.8,
  3768. ax=ax, zorder=2
  3769. )
  3770. # Paired lines
  3771. for ss, grp in plot_df.groupby("slide_stage"):
  3772. y_reactive = grp.loc[grp.cell_type == "Reactive/WM", "avg_dist"].values
  3773. y_other = grp.loc[grp.cell_type == "Other", "avg_dist"].values
  3774. if y_reactive.size and y_other.size:
  3775. ax.plot(
  3776. ["Reactive/WM", "Other"],
  3777. [y_reactive[0], y_other[0]],
  3778. color="gray", linewidth=0.8, alpha=0.6, zorder=3
  3779. )
  3780. # Annotate p-value
  3781. pairs = [("Reactive/WM", "Other")]
  3782. annot = Annotator(
  3783. ax, pairs,
  3784. data=plot_df,
  3785. x="cell_type", y="avg_dist",
  3786. order=["Reactive/WM", "Other"]
  3787. )
  3788. annot.set_pvalues([p_one])
  3789. annot.configure(text_format="star", loc="inside")
  3790. annot.annotate()
  3791. # Labels
  3792. ax.set_title("Early Disease - Lumbar")
  3793. ax.set_xlabel("Astrocytes")
  3794. ax.set_ylabel("Avg. Dist. to Nearest Reactive MG")
  3795. plt.tight_layout()
  3796. fig.savefig(os.path.join(figures_dir, "astro_dist_to_reactive_microglia_early.svg"), format="svg", bbox_inches="tight")
  3797. plt.show()
  3798. # %% [markdown]
  3799. # ## Fig. 6F
  3800. # %%
  3801. import numpy as np
  3802. import pandas as pd
  3803. import matplotlib.pyplot as plt
  3804. import seaborn as sns
  3805. from scipy.spatial.distance import cdist
  3806. from scipy.stats import ttest_rel
  3807. from statannotations.Annotator import Annotator
  3808. # ——————————————————————————————
  3809. # 1) Subset to Lumbar & Mid
  3810. # ——————————————————————————————
  3811. adata_sub = adata[
  3812. (adata.obs["region"] == "Lumbar") &
  3813. (adata.obs["stage"] == "Mid")
  3814. ].copy()
  3815. # ——————————————————————————————
  3816. # 2) Compute per-slide_stage avg distances
  3817. # ——————————————————————————————
  3818. records = []
  3819. for slide_stage in adata_sub.obs["slide_stage"].unique():
  3820. mask_ss = adata_sub.obs["slide_stage"] == slide_stage
  3821. sections = adata_sub.obs.loc[mask_ss, "slide_section"].unique()
  3822. for is_reactive_wm, label in [(True, "Reactive/WM"), (False, "Other")]:
  3823. sec_means = []
  3824. for sec in sections:
  3825. mask_sec = mask_ss & (adata_sub.obs["slide_section"] == sec)
  3826. coords = adata_sub.obsm["spatial"][mask_sec.values]
  3827. # Query: astrocytes of this group
  3828. is_astro = (
  3829. (adata_sub.obs.loc[mask_sec, "cell_class"] == "Astrocytes") &
  3830. (adata_sub.obs.loc[mask_sec, "reactive_or_wm_astrocytes"] == is_reactive_wm)
  3831. ).to_numpy()
  3832. # Reference: reactive microglia
  3833. is_reactive_micro = (
  3834. (adata_sub.obs.loc[mask_sec, "cell_class"] == "Microglia/Macrophages") &
  3835. (adata_sub.obs.loc[mask_sec, "reactive_microglia"])
  3836. ).to_numpy()
  3837. pts_astro = coords[is_astro]
  3838. pts_micro = coords[is_reactive_micro]
  3839. if pts_astro.size and pts_micro.size:
  3840. D = cdist(pts_astro, pts_micro, metric="euclidean")
  3841. sec_means.append(D.min(axis=1).mean())
  3842. if sec_means:
  3843. records.append({
  3844. "slide_stage": slide_stage,
  3845. "astro_group": label,
  3846. "avg_dist": np.mean(sec_means)
  3847. })
  3848. # ——————————————————————————————
  3849. # 3) Create plot_df and perform stats
  3850. # ——————————————————————————————
  3851. dist_df = pd.DataFrame(records)
  3852. plot_df = dist_df.copy()
  3853. plot_df["cell_type"] = plot_df["astro_group"]
  3854. # Paired one-sided t-test (Other > Reactive/WM)
  3855. pivot = plot_df.pivot(index="slide_stage", columns="cell_type", values="avg_dist")
  3856. stat, p_two = ttest_rel(pivot["Other"], pivot["Reactive/WM"])
  3857. p_one = p_two / 2 if stat > 0 else 1 - p_two / 2
  3858. print(f"One-sided p-value (Other > Reactive/WM): {p_one:.3e}")
  3859. # ——————————————————————————————
  3860. # 4) Plot
  3861. # ——————————————————————————————
  3862. plt.rcParams.update({
  3863. "font.size": 7,
  3864. "axes.titlesize": 7,
  3865. "axes.labelsize": 7,
  3866. "xtick.labelsize": 7,
  3867. "ytick.labelsize": 7,
  3868. "legend.fontsize": 7
  3869. })
  3870. fig, ax = plt.subplots(figsize=(1.625, 2.15))
  3871. sns.boxplot(
  3872. data=plot_df,
  3873. x="cell_type", y="avg_dist",
  3874. order=["Reactive/WM", "Other"],
  3875. palette={"Reactive/WM": "#1f77b4", "Other": "#8fbbd9"},
  3876. showfliers=False,
  3877. ax=ax, zorder=1
  3878. )
  3879. sns.stripplot(
  3880. data=plot_df,
  3881. x="cell_type", y="avg_dist",
  3882. order=["Reactive/WM", "Other"],
  3883. color="black", size=4, jitter=True, alpha=0.8,
  3884. ax=ax, zorder=2
  3885. )
  3886. # Paired lines
  3887. for ss, grp in plot_df.groupby("slide_stage"):
  3888. y_reactive = grp.loc[grp.cell_type == "Reactive/WM", "avg_dist"].values
  3889. y_other = grp.loc[grp.cell_type == "Other", "avg_dist"].values
  3890. if y_reactive.size and y_other.size:
  3891. ax.plot(
  3892. ["Reactive/WM", "Other"],
  3893. [y_reactive[0], y_other[0]],
  3894. color="gray", linewidth=0.8, alpha=0.6, zorder=3
  3895. )
  3896. # Annotate p-value
  3897. pairs = [("Reactive/WM", "Other")]
  3898. annot = Annotator(
  3899. ax, pairs,
  3900. data=plot_df,
  3901. x="cell_type", y="avg_dist",
  3902. order=["Reactive/WM", "Other"]
  3903. )
  3904. annot.set_pvalues([p_one])
  3905. annot.configure(text_format="star", loc="inside")
  3906. annot.annotate()
  3907. # Labels
  3908. ax.set_title("Mid Disease - Lumbar")
  3909. ax.set_xlabel("Astrocytes")
  3910. ax.set_ylabel("Avg. Dist. to Nearest Reactive MG")
  3911. plt.tight_layout()
  3912. fig.savefig(os.path.join(figures_dir, "astro_dist_to_reactive_microglia_mid.svg"), format="svg", bbox_inches="tight")
  3913. plt.show()
  3914. # %%
  3915. import numpy as np
  3916. import pandas as pd
  3917. import matplotlib.pyplot as plt
  3918. import seaborn as sns
  3919. from scipy.spatial.distance import cdist
  3920. from scipy.stats import ttest_rel
  3921. from statannotations.Annotator import Annotator
  3922. # ——————————————————————————————
  3923. # 1) Subset to Lumbar & End
  3924. # ——————————————————————————————
  3925. adata_sub = adata[
  3926. (adata.obs["region"] == "Lumbar") &
  3927. (adata.obs["stage"] == "End")
  3928. ].copy()
  3929. # ——————————————————————————————
  3930. # 2) Compute per-slide_stage avg distances
  3931. # ——————————————————————————————
  3932. records = []
  3933. for slide_stage in adata_sub.obs["slide_stage"].unique():
  3934. mask_ss = adata_sub.obs["slide_stage"] == slide_stage
  3935. sections = adata_sub.obs.loc[mask_ss, "slide_section"].unique()
  3936. for is_reactive_wm, label in [(True, "Reactive/WM"), (False, "Other")]:
  3937. sec_means = []
  3938. for sec in sections:
  3939. mask_sec = mask_ss & (adata_sub.obs["slide_section"] == sec)
  3940. coords = adata_sub.obsm["spatial"][mask_sec.values]
  3941. # Query: astrocytes of this group
  3942. is_astro = (
  3943. (adata_sub.obs.loc[mask_sec, "cell_class"] == "Astrocytes") &
  3944. (adata_sub.obs.loc[mask_sec, "reactive_or_wm_astrocytes"] == is_reactive_wm)
  3945. ).to_numpy()
  3946. # Reference: reactive microglia
  3947. is_reactive_micro = (
  3948. (adata_sub.obs.loc[mask_sec, "cell_class"] == "Microglia/Macrophages") &
  3949. (adata_sub.obs.loc[mask_sec, "reactive_microglia"])
  3950. ).to_numpy()
  3951. pts_astro = coords[is_astro]
  3952. pts_micro = coords[is_reactive_micro]
  3953. if pts_astro.size and pts_micro.size:
  3954. D = cdist(pts_astro, pts_micro, metric="euclidean")
  3955. sec_means.append(D.min(axis=1).mean())
  3956. if sec_means:
  3957. records.append({
  3958. "slide_stage": slide_stage,
  3959. "astro_group": label,
  3960. "avg_dist": np.mean(sec_means)
  3961. })
  3962. # ——————————————————————————————
  3963. # 3) Create plot_df and perform stats
  3964. # ——————————————————————————————
  3965. dist_df = pd.DataFrame(records)
  3966. plot_df = dist_df.copy()
  3967. plot_df["cell_type"] = plot_df["astro_group"]
  3968. # Paired one-sided t-test (Other > Reactive/WM)
  3969. pivot = plot_df.pivot(index="slide_stage", columns="cell_type", values="avg_dist")
  3970. stat, p_two = ttest_rel(pivot["Other"], pivot["Reactive/WM"])
  3971. p_one = p_two / 2 if stat > 0 else 1 - p_two / 2
  3972. print(f"One-sided p-value (Other > Reactive/WM): {p_one:.3e}")
  3973. # ——————————————————————————————
  3974. # 4) Plot
  3975. # ——————————————————————————————
  3976. plt.rcParams.update({
  3977. "font.size": 7,
  3978. "axes.titlesize": 7,
  3979. "axes.labelsize": 7,
  3980. "xtick.labelsize": 7,
  3981. "ytick.labelsize": 7,
  3982. "legend.fontsize": 7
  3983. })
  3984. fig, ax = plt.subplots(figsize=(1.625, 2.15))
  3985. sns.boxplot(
  3986. data=plot_df,
  3987. x="cell_type", y="avg_dist",
  3988. order=["Reactive/WM", "Other"],
  3989. palette={"Reactive/WM": "#1f77b4", "Other": "#8fbbd9"},
  3990. showfliers=False,
  3991. ax=ax, zorder=1
  3992. )
  3993. sns.stripplot(
  3994. data=plot_df,
  3995. x="cell_type", y="avg_dist",
  3996. order=["Reactive/WM", "Other"],
  3997. color="black", size=4, jitter=True, alpha=0.8,
  3998. ax=ax, zorder=2
  3999. )
  4000. # Paired lines
  4001. for ss, grp in plot_df.groupby("slide_stage"):
  4002. y_reactive = grp.loc[grp.cell_type == "Reactive/WM", "avg_dist"].values
  4003. y_other = grp.loc[grp.cell_type == "Other", "avg_dist"].values
  4004. if y_reactive.size and y_other.size:
  4005. ax.plot(
  4006. ["Reactive/WM", "Other"],
  4007. [y_reactive[0], y_other[0]],
  4008. color="gray", linewidth=0.8, alpha=0.6, zorder=3
  4009. )
  4010. # Annotate p-value
  4011. pairs = [("Reactive/WM", "Other")]
  4012. annot = Annotator(
  4013. ax, pairs,
  4014. data=plot_df,
  4015. x="cell_type", y="avg_dist",
  4016. order=["Reactive/WM", "Other"]
  4017. )
  4018. annot.set_pvalues([p_one])
  4019. annot.configure(text_format="star", loc="inside")
  4020. annot.annotate()
  4021. # Labels
  4022. ax.set_title("End Disease - Lumbar")
  4023. ax.set_xlabel("Astrocytes")
  4024. ax.set_ylabel("Avg. Dist. to Nearest Reactive MG")
  4025. # Ensure y-axis ticks are integers only
  4026. ax.yaxis.set_major_locator(plt.MaxNLocator(integer=True))
  4027. plt.tight_layout()
  4028. fig.savefig(os.path.join(figures_dir, "astro_dist_to_reactive_microglia_end.svg"), format="svg", bbox_inches="tight")
  4029. plt.show()
  4030. # %% [markdown]
  4031. # ## Reactive glia, DAI, and DAMN proportions with disease
  4032. # %%
  4033. adata = sc.read_h5ad(os.path.join(working_dir, f"adata_objects/all_anndata_final_rotated_w_anno.h5ad"))
  4034. # %% [markdown]
  4035. # ## Fig. S7D
  4036. # %%
  4037. import matplotlib.pyplot as plt
  4038. import seaborn as sns
  4039. # 0) Set all fonts to size 7
  4040. plt.rcParams.update({
  4041. "font.size": 7,
  4042. "axes.titlesize": 7,
  4043. "axes.labelsize": 7,
  4044. "xtick.labelsize": 7,
  4045. "ytick.labelsize": 7,
  4046. "legend.fontsize": 7,
  4047. "legend.title_fontsize": 7
  4048. })
  4049. # Step 1: Subset to Microglia/Macrophages only
  4050. mg_obs = adata.obs[adata.obs["cell_class"] == "Microglia/Macrophages"].copy()
  4051. # Step 2: Count reactive vs non‑reactive by stage
  4052. stage_reactive_counts = (
  4053. mg_obs
  4054. .groupby(["stage", "reactive_microglia"], observed=True)
  4055. .size()
  4056. .unstack(fill_value=0)
  4057. )
  4058. # Step 3: Convert to proportions within each stage
  4059. stage_reactive_props = stage_reactive_counts.div(stage_reactive_counts.sum(axis=1), axis=0)
  4060. # Step 4: Ensure stage order
  4061. stage_order = ["Control", "Early", "Mid", "End"]
  4062. stage_reactive_props = stage_reactive_props.reindex(stage_order).fillna(0)
  4063. # Step 5: Force True first (bottom) then False (top)
  4064. stage_reactive_props = stage_reactive_props.reindex(columns=[True, False])
  4065. # Step 6: Plot
  4066. fig, ax = plt.subplots(figsize=(1.625, 2.15))
  4067. stage_reactive_props.plot(
  4068. kind="bar",
  4069. stacked=True,
  4070. color=["#d62728", "#ea9393"], # True (bottom), False (top)
  4071. edgecolor="black",
  4072. ax=ax
  4073. )
  4074. # Step 7: Labels, legend, title
  4075. ax.set_xlabel("") # no x-axis label
  4076. ax.set_ylabel("Proportion")
  4077. ax.set_xticklabels(stage_order, rotation=0)
  4078. ax.legend(
  4079. title="Reactive",
  4080. labels=["True", "False"],
  4081. loc="upper center",
  4082. bbox_to_anchor=(0.5, -0.12),
  4083. ncol=2,
  4084. handlelength=1, # shorten the legend color box length
  4085. handletextpad=0.4, # reduce space between color box and text
  4086. columnspacing=0.8, # reduce space between columns
  4087. borderpad=0.3 # reduce padding inside the legend box
  4088. )
  4089. ax.set_title("Microglia/Macrophages")
  4090. plt.tight_layout()
  4091. out_path = os.path.join(figures_dir, "reactive_microglia_proportion.svg")
  4092. plt.savefig(out_path, format="svg", bbox_inches="tight")
  4093. plt.show()
  4094. # %% [markdown]
  4095. # ## Fig. S7E
  4096. # %%
  4097. import matplotlib.pyplot as plt
  4098. import seaborn as sns
  4099. # 0) Set all fonts to size 7
  4100. plt.rcParams.update({
  4101. "font.size": 7,
  4102. "axes.titlesize": 7,
  4103. "axes.labelsize": 7,
  4104. "xtick.labelsize": 7,
  4105. "ytick.labelsize": 7,
  4106. "legend.fontsize": 7,
  4107. "legend.title_fontsize": 7
  4108. })
  4109. # Step 1: Subset to Astrocytes only
  4110. ast_obs = adata.obs[adata.obs["cell_class"] == "Astrocytes"].copy()
  4111. # Step 2: Count reactive vs non‑reactive by stage
  4112. stage_reactive_counts = (
  4113. ast_obs
  4114. .groupby(["stage", "reactive_or_wm_astrocytes"], observed=True)
  4115. .size()
  4116. .unstack(fill_value=0)
  4117. )
  4118. # Step 3: Convert to proportions within each stage
  4119. stage_reactive_props = stage_reactive_counts.div(stage_reactive_counts.sum(axis=1), axis=0)
  4120. # Step 4: Ensure stage order
  4121. stage_order = ["Control", "Early", "Mid", "End"]
  4122. stage_reactive_props = stage_reactive_props.reindex(stage_order).fillna(0)
  4123. # Step 5: Force True first (bottom) then False (top)
  4124. stage_reactive_props = stage_reactive_props.reindex(columns=[True, False])
  4125. # Step 6: Plot
  4126. fig, ax = plt.subplots(figsize=(1.625, 2.15))
  4127. stage_reactive_props.plot(
  4128. kind="bar",
  4129. stacked=True,
  4130. color=["#1f77b4", "#8fbbd9"], # True (bottom), False (top)
  4131. edgecolor="black",
  4132. ax=ax
  4133. )
  4134. # Step 7: Labels, legend, title
  4135. ax.set_xlabel("") # no x-axis label
  4136. ax.set_ylabel("Proportion")
  4137. ax.set_xticklabels(stage_order, rotation=0)
  4138. ax.legend(
  4139. title="Reactive",
  4140. labels=["True", "False"],
  4141. loc="upper center",
  4142. bbox_to_anchor=(0.5, -0.12),
  4143. ncol=2,
  4144. handlelength=1, # shorten the legend color box length
  4145. handletextpad=0.4, # reduce space between color box and text
  4146. columnspacing=0.8, # reduce space between columns
  4147. borderpad=0.3 # reduce padding inside the legend box
  4148. )
  4149. ax.set_title("Astrocytes")
  4150. plt.tight_layout()
  4151. out_path = os.path.join(figures_dir, "reactive_astrocyte_proportion.svg")
  4152. plt.savefig(out_path, format="svg", bbox_inches="tight")
  4153. plt.show()
  4154. # %% [markdown]
  4155. # ## Fig. S7F
  4156. # %%
  4157. import matplotlib.pyplot as plt
  4158. import seaborn as sns
  4159. # 0) Set all fonts to size 7
  4160. plt.rcParams.update({
  4161. "font.size": 7,
  4162. "axes.titlesize": 7,
  4163. "axes.labelsize": 7,
  4164. "xtick.labelsize": 7,
  4165. "ytick.labelsize": 7,
  4166. "legend.fontsize": 7,
  4167. "legend.title_fontsize": 7
  4168. })
  4169. # Step 1: Subset to Alpha MNs only
  4170. alpha_obs = adata.obs[adata.obs["cholinergic_type"] == "Alpha MNs"].copy()
  4171. # Step 2: Count DAMN vs non‑DAMN by stage
  4172. stage_DAMN_counts = (
  4173. alpha_obs
  4174. .groupby(["stage", "DAMN_status"], observed=True)
  4175. .size()
  4176. .unstack(fill_value=0)
  4177. )
  4178. # Step 3: Convert to proportions within each stage
  4179. stage_DAMN_props = stage_DAMN_counts.div(stage_DAMN_counts.sum(axis=1), axis=0)
  4180. # Step 4: Ensure stage order
  4181. stage_order = ["Control", "Early", "Mid", "End"]
  4182. stage_DAMN_props = stage_DAMN_props.reindex(stage_order).fillna(0)
  4183. # 5) reorder to DAMN bottom, Non-DAMN top
  4184. stage_DAMN_props = stage_DAMN_props[["DAMN","Non-DAMN"]].fillna(0)
  4185. # 6) plot
  4186. fig, ax = plt.subplots(figsize=(1.625, 2.15))
  4187. stage_DAMN_props.plot(
  4188. kind="bar",
  4189. stacked=True,
  4190. color=["#1f77b4", "#8fbbd9"], # bottom=“DAMN”, top=“Non-DAMN”
  4191. edgecolor="black",
  4192. ax=ax
  4193. )
  4194. # Step 7: Labels, legend, title
  4195. ax.set_xlabel("") # no x-axis label
  4196. ax.set_ylabel("Proportion")
  4197. ax.set_xticklabels(stage_order, rotation=0)
  4198. ax.legend(
  4199. title="DM",
  4200. labels=["True", "False"],
  4201. loc="upper center",
  4202. bbox_to_anchor=(0.5, -0.12),
  4203. ncol=2,
  4204. handlelength=1, # shorten the legend color box length
  4205. handletextpad=0.4, # reduce space between color box and text
  4206. columnspacing=0.8, # reduce space between columns
  4207. borderpad=0.3 # reduce padding inside the legend box
  4208. )
  4209. ax.set_title("Alpha MNs")
  4210. plt.tight_layout()
  4211. out_path = os.path.join(figures_dir, "DM_proportion.svg")
  4212. plt.savefig(out_path, format="svg", bbox_inches="tight")
  4213. plt.show()
  4214. # %%
  4215. print("\nProportion DAMN_status == 'DAMN' by stage (percent):")
  4216. for stage, prop in stage_DAMN_props["DAMN"].items():
  4217. print(f"{stage}: {prop:.1%}")
  4218. # %% [markdown]
  4219. # ## Alpha MN subtype proportion plot
  4220. # %%
  4221. # Read in AnnData
  4222. adata = sc.read_h5ad(os.path.join(working_dir, f"adata_objects/alpha_anndata_label_transfer.h5ad"))
  4223. # %% [markdown]
  4224. # ## Fig. S7G
  4225. # %%
  4226. import pandas as pd
  4227. import seaborn as sns
  4228. import matplotlib.pyplot as plt
  4229. import os
  4230. # ------------------------------------------------------------------
  4231. # 0) Set all fonts to size 7 (match your other figure)
  4232. # ------------------------------------------------------------------
  4233. plt.rcParams.update({
  4234. "font.size": 7,
  4235. "axes.titlesize": 7,
  4236. "axes.labelsize": 7,
  4237. "xtick.labelsize": 7,
  4238. "ytick.labelsize": 7,
  4239. "legend.fontsize": 7,
  4240. "legend.title_fontsize": 7
  4241. })
  4242. # ------------------------------------------------------------------
  4243. # 1) Subset to nuclear cells (optional)
  4244. # ------------------------------------------------------------------
  4245. adata_sub = adata # (or: adata[adata.obs["nuclear"] == True])
  4246. # ------------------------------------------------------------------
  4247. # 2) Extract relevant metadata (now includes region)
  4248. # ------------------------------------------------------------------
  4249. df = adata_sub.obs[["stage", "predicted.id", "region"]].copy()
  4250. df = df.dropna(subset=["stage", "predicted.id", "region"])
  4251. stage_order = ["Control", "Early", "Mid", "End"]
  4252. pred_order = ["Slow-Firing", "Intermediate", "Fast-Firing", "Early DAMN", "Late DAMN"]
  4253. pred_order_stack = list(reversed(pred_order))
  4254. # Display-only legend labels
  4255. label_map = {"Early DAMN": "Early DM", "Late DAMN": "Late DM"}
  4256. df["stage"] = pd.Categorical(df["stage"], categories=stage_order, ordered=True)
  4257. df["predicted.id"] = pd.Categorical(df["predicted.id"], categories=pred_order, ordered=True)
  4258. # ------------------------------------------------------------------
  4259. # 3) Define colors
  4260. # ------------------------------------------------------------------
  4261. alpha_colors = {
  4262. "Slow-Firing": "#F47D2B",
  4263. "Intermediate": "#272E6A",
  4264. "Fast-Firing": "#208A42",
  4265. "Early DAMN": "#89288F",
  4266. "Late DAMN": "#D51F26"
  4267. }
  4268. # ------------------------------------------------------------------
  4269. # 4) Helper: compute proportions for a given region
  4270. # ------------------------------------------------------------------
  4271. def compute_prop_for_region(df_in: pd.DataFrame, region_name: str) -> pd.DataFrame:
  4272. d = df_in[df_in["region"] == region_name].copy()
  4273. counts = d.groupby(["stage", "predicted.id"]).size().to_frame("count")
  4274. counts["proportion"] = counts["count"] / counts.groupby(level=0)["count"].transform("sum")
  4275. prop = counts.reset_index()
  4276. # Ensure all stage x predicted.id combos exist (so bars stack cleanly even if missing)
  4277. full = (
  4278. pd.MultiIndex.from_product([stage_order, pred_order], names=["stage", "predicted.id"])
  4279. .to_frame(index=False)
  4280. )
  4281. prop = full.merge(prop, on=["stage", "predicted.id"], how="left").fillna({"proportion": 0, "count": 0})
  4282. prop["stage"] = pd.Categorical(prop["stage"], categories=stage_order, ordered=True)
  4283. prop["predicted.id"] = pd.Categorical(prop["predicted.id"], categories=pred_order, ordered=True)
  4284. return prop
  4285. # ------------------------------------------------------------------
  4286. # 5) Plot: two stacked barplots (Lumbar + Cervical)
  4287. # ------------------------------------------------------------------
  4288. regions = ["Lumbar", "Cervical"]
  4289. fig, axes = plt.subplots(
  4290. 1, 2,
  4291. figsize=(3.25, 1.625), # ~2x width of your single panel (1.625 -> 3.25)
  4292. sharey=True
  4293. )
  4294. legend_handles = None # we will grab once and use a single legend for the figure
  4295. for ax, region_name in zip(axes, regions):
  4296. prop = compute_prop_for_region(df, region_name)
  4297. bottom = pd.Series([0.0] * len(stage_order), index=stage_order)
  4298. handles = {}
  4299. for pred in pred_order_stack:
  4300. sub = prop[prop["predicted.id"] == pred].sort_values("stage")
  4301. bars = ax.bar(
  4302. sub["stage"].astype(str),
  4303. sub["proportion"].values,
  4304. bottom=bottom[sub["stage"].astype(str)].values if bottom.index.dtype == object else bottom[sub["stage"]].values,
  4305. color=alpha_colors[pred],
  4306. width=0.8,
  4307. edgecolor="none"
  4308. )
  4309. handles[pred] = bars[0]
  4310. # Update bottom by stage order
  4311. bottom.loc[sub["stage"].astype(str).values] = (
  4312. bottom.loc[sub["stage"].astype(str).values].values + sub["proportion"].values
  4313. )
  4314. ax.set_title(region_name)
  4315. ax.set_xlabel("")
  4316. ax.set_ylim(0, 1)
  4317. ax.set_xticklabels(stage_order, rotation=0)
  4318. sns.despine(ax=ax)
  4319. if legend_handles is None:
  4320. legend_handles = handles # save for figure-level legend
  4321. # Y label only on left
  4322. axes[0].set_ylabel("Proportion")
  4323. axes[1].set_ylabel("")
  4324. # One legend for the whole figure (no title)
  4325. fig.legend(
  4326. handles=[legend_handles[p] for p in pred_order],
  4327. labels=[label_map.get(p, p) for p in pred_order],
  4328. bbox_to_anchor=(1.02, 1),
  4329. loc="upper left",
  4330. frameon=False
  4331. )
  4332. plt.tight_layout()
  4333. # ------------------------------------------------------------------
  4334. # 6) Save figure
  4335. # ------------------------------------------------------------------
  4336. out_path = os.path.join(figures_dir, "alpha_mn_subtype_barplot_Lumbar_Cervical_DM.svg")
  4337. plt.savefig(out_path, format="svg", bbox_inches="tight")
  4338. plt.show()
  4339. # %% [markdown]
  4340. # ## Fig. S7H
  4341. # %%
  4342. import scanpy as sc
  4343. import matplotlib.pyplot as plt
  4344. import pandas as pd
  4345. import os
  4346. from matplotlib.collections import PathCollection
  4347. # ---- Enforce predicted.id order ----
  4348. pred_order = ["Slow-Firing", "Intermediate", "Fast-Firing", "Early DAMN", "Late DAMN"]
  4349. adata.obs["predicted.id"] = pd.Categorical(
  4350. adata.obs["predicted.id"],
  4351. categories=pred_order,
  4352. ordered=True
  4353. )
  4354. # ---- Display-only labels for plotting ----
  4355. label_map = {"Early DAMN": "Early DM", "Late DAMN": "Late DM"}
  4356. pred_order_plot = [label_map.get(c, c) for c in pred_order]
  4357. adata.obs["predicted.id_plot"] = adata.obs["predicted.id"].cat.rename_categories(label_map)
  4358. adata.obs["predicted.id_plot"] = pd.Categorical(
  4359. adata.obs["predicted.id_plot"],
  4360. categories=pred_order_plot,
  4361. ordered=True
  4362. )
  4363. # ---- Colors ----
  4364. alpha_colors = {
  4365. "Slow-Firing": "#F47D2B",
  4366. "Intermediate": "#272E6A",
  4367. "Fast-Firing": "#208A42",
  4368. "Early DM": "#89288F",
  4369. "Late DM": "#D51F26"
  4370. }
  4371. adata.uns["predicted.id_plot_colors"] = [alpha_colors[c] for c in pred_order_plot]
  4372. # Global font size = 7
  4373. plt.rcParams.update({
  4374. "font.size": 7,
  4375. "axes.titlesize": 7,
  4376. "axes.labelsize": 7,
  4377. "xtick.labelsize": 7,
  4378. "ytick.labelsize": 7,
  4379. "legend.fontsize": 7,
  4380. "legend.title_fontsize": 6
  4381. })
  4382. # Create UMAP and return figure
  4383. fig = sc.pl.umap(
  4384. adata,
  4385. color="predicted.id_plot",
  4386. title="",
  4387. size=5,
  4388. show=False,
  4389. return_fig=True
  4390. )
  4391. # Hard-remove any lingering title
  4392. for ax in fig.axes:
  4393. ax.set_title("")
  4394. # Set exact figure size AFTER creation
  4395. fig.set_size_inches(2.3, 1.525)
  4396. # ---- Force VECTOR points: turn OFF rasterization on the scatter collection(s)
  4397. for ax in fig.axes:
  4398. for coll in ax.collections:
  4399. if isinstance(coll, PathCollection):
  4400. coll.set_rasterized(False)
  4401. plt.tight_layout()
  4402. plt.show()
  4403. out_path = os.path.join(figures_dir, "alpha_mn_subtype_umap_DM.svg")
  4404. fig.savefig(out_path, format="svg", bbox_inches="tight")
  4405. # %% [markdown]
  4406. # ## Fig. 6I
  4407. # %%
  4408. import numpy as np
  4409. import matplotlib.pyplot as plt
  4410. import seaborn as sns
  4411. # ——————————————————————————————————
  4412. # Ensure all text is size 7
  4413. # ——————————————————————————————————
  4414. plt.rcParams.update({
  4415. "font.size": 7,
  4416. "axes.titlesize": 7,
  4417. "axes.labelsize": 7,
  4418. "xtick.labelsize": 7,
  4419. "ytick.labelsize": 7,
  4420. "legend.fontsize": 7,
  4421. "legend.title_fontsize": 7
  4422. })
  4423. # 1) Define the three groups in bottom→top stacking order
  4424. group_names = [
  4425. "Disease-Associated Interneurons", # bottom
  4426. "Cholinergic Interneurons", # middle
  4427. "Non-Cholinergic Interneurons", # top
  4428. ]
  4429. # 2) Filter and tag each cell into one of those groups
  4430. mask = (
  4431. (adata.obs["cell_class"] == "Disease-Associated Interneurons") |
  4432. (adata.obs["cholinergic_type"] == "Cholinergic Interneurons") |
  4433. (adata.obs["cell_class"] == "Non-Cholinergic Interneurons")
  4434. )
  4435. df = adata.obs.loc[mask].copy()
  4436. df["cell_group"] = np.select(
  4437. [
  4438. df["cell_class"] == "Disease-Associated Interneurons",
  4439. df["cholinergic_type"] == "Cholinergic Interneurons",
  4440. df["cell_class"] == "Non-Cholinergic Interneurons",
  4441. ],
  4442. group_names
  4443. )
  4444. # 3) Count per (stage, cell_group)
  4445. stage_order = ["Control", "Early", "Mid", "End"]
  4446. counts = (
  4447. df
  4448. .groupby(["stage", "cell_group"], observed=True)
  4449. .size()
  4450. .unstack(fill_value=0)
  4451. .reindex(stage_order)
  4452. .fillna(0)
  4453. )
  4454. # 4) Proportions within each stage
  4455. props = counts.div(counts.sum(axis=1), axis=0)
  4456. # 5) Reorder columns to match stacking bottom→top
  4457. props = props[group_names]
  4458. # 6) Define colors in the same bottom→top order
  4459. colors = [
  4460. "#2ca02c", # DAI (bottom)
  4461. "#ff7f0e", # CI (middle)
  4462. "#9467bd", # NCI (top)
  4463. ]
  4464. # 7) Plot
  4465. fig, ax = plt.subplots(figsize=(1.625, 2.2852))
  4466. props.plot(
  4467. kind="bar",
  4468. stacked=True,
  4469. color=colors,
  4470. edgecolor="black",
  4471. ax=ax
  4472. )
  4473. # 8) Labels & legend
  4474. ax.set_xlabel("")
  4475. ax.set_ylabel("Proportion")
  4476. ax.set_xticklabels(stage_order, rotation=0)
  4477. ax.set_title("Interneurons")
  4478. short_labels = ["DAI", "CI", "NCI"]
  4479. ax.legend(
  4480. title="",
  4481. labels=short_labels,
  4482. loc="lower center",
  4483. bbox_to_anchor=(0.5, -0.35),
  4484. ncol=3,
  4485. columnspacing=0.5, # ↓ Reduce space between columns
  4486. handletextpad=0.3, # ↓ Reduce space between handle and text
  4487. borderpad=0.2, # ↓ Reduce border padding inside legend box
  4488. handlelength=1.0 # ↓ Optional: reduce legend key size
  4489. )
  4490. plt.tight_layout()
  4491. out_path = os.path.join(figures_dir, "interneuron_proportions_small.svg")
  4492. plt.savefig(out_path, format="svg", bbox_inches="tight")
  4493. plt.show()
  4494. # %%
  4495. print("\nProportions of each interneuron type by stage (percent):")
  4496. for stage in stage_order:
  4497. print(f"{stage}:")
  4498. for group in group_names:
  4499. pct = props.loc[stage, group]
  4500. print(f" {group}: {pct:.1%}")
  4501. # %% [markdown]
  4502. # ## Add additional metadata to all_anndata_final.h5ad
  4503. # %%
  4504. adata = sc.read_h5ad(os.path.join(working_dir, f"adata_objects/all_anndata_final.h5ad"))
  4505. adata_w_anno = sc.read_h5ad(os.path.join(working_dir, "adata_objects/all_anndata_final_rotated_w_anno.h5ad"))
  4506. # %%
  4507. adata.obs["predicted.id"] = (
  4508. adata.obs["predicted.id"]
  4509. .astype("category")
  4510. .cat.add_categories(["Not_Alpha_MN"])
  4511. .fillna("Not_Alpha_MN")
  4512. )
  4513. adata.obs["DAMN_status"] = (
  4514. adata.obs["DAMN_status"]
  4515. .astype("category")
  4516. .cat.add_categories(["Not_Alpha_MN"])
  4517. .fillna("Not_Alpha_MN")
  4518. )
  4519. # %%
  4520. cols_to_add = ["reactive_microglia", "reactive_or_wm_astrocytes"]
  4521. adata.obs[cols_to_add] = adata_w_anno.obs[cols_to_add].to_numpy()
  4522. # %%
  4523. # Make sure the needed columns exist
  4524. need = ["cell_class", "reactive_microglia", "reactive_or_wm_astrocytes"]
  4525. missing = [c for c in need if c not in adata.obs.columns]
  4526. if missing:
  4527. raise ValueError(f"Missing required columns in adata.obs: {missing}")
  4528. # Start with default
  4529. adata.obs["cell_class_reactivity"] = "Other"
  4530. # Cholinergic neurons
  4531. mask_chol = adata.obs["cell_class"] == "Cholinergic Neurons"
  4532. adata.obs.loc[mask_chol, "cell_class_reactivity"] = "Cholinergic Neurons"
  4533. # Microglia/Macrophages → Reactive vs Non-Reactive
  4534. mask_mg = adata.obs["cell_class"] == "Microglia/Macrophages"
  4535. adata.obs.loc[mask_mg & (adata.obs["reactive_microglia"] == True), "cell_class_reactivity"] = "Reactive MG"
  4536. adata.obs.loc[mask_mg & (adata.obs["reactive_microglia"] == False), "cell_class_reactivity"] = "Non-Reactive MG"
  4537. # Astrocytes → Reactive vs Non-Reactive
  4538. mask_ast = adata.obs["cell_class"] == "Astrocytes"
  4539. adata.obs.loc[mask_ast & (adata.obs["reactive_or_wm_astrocytes"] == True), "cell_class_reactivity"] = "Reactive Astrocytes"
  4540. adata.obs.loc[mask_ast & (adata.obs["reactive_or_wm_astrocytes"] == False), "cell_class_reactivity"] = "Non-Reactive Astrocytes"
  4541. # %%
  4542. # -------------------------------------------------
  4543. # Create combined DAMN / Reactivity annotation
  4544. # -------------------------------------------------
  4545. # Make sure required columns exist
  4546. need = ["DAMN_status", "cell_class_reactivity"]
  4547. missing = [c for c in need if c not in adata.obs.columns]
  4548. if missing:
  4549. raise ValueError(f"Missing required columns in adata.obs: {missing}")
  4550. # Initialize with default
  4551. adata.obs["DAMN_reactivity_class"] = "Other"
  4552. # DAMN motor neurons
  4553. adata.obs.loc[
  4554. adata.obs["DAMN_status"] == "DAMN",
  4555. "DAMN_reactivity_class"
  4556. ] = "DAMN"
  4557. # Non-DAMN motor neurons
  4558. adata.obs.loc[
  4559. adata.obs["DAMN_status"] == "Non-DAMN",
  4560. "DAMN_reactivity_class"
  4561. ] = "Non-DAMN"
  4562. # Reactive Microglia
  4563. adata.obs.loc[
  4564. adata.obs["cell_class_reactivity"] == "Reactive MG",
  4565. "DAMN_reactivity_class"
  4566. ] = "Reactive MG"
  4567. # Non-Reactive Microglia
  4568. adata.obs.loc[
  4569. adata.obs["cell_class_reactivity"] == "Non-Reactive MG",
  4570. "DAMN_reactivity_class"
  4571. ] = "Non-Reactive MG"
  4572. # Convert to ordered categorical (optional but recommended for Vizualizer)
  4573. adata.obs["DAMN_reactivity_class"] = pd.Categorical(
  4574. adata.obs["DAMN_reactivity_class"],
  4575. categories=["DAMN", "Non-DAMN", "Reactive MG", "Non-Reactive MG", "Other"],
  4576. ordered=True
  4577. )
  4578. # %%
  4579. # Save anndata
  4580. save_name = f"adata_objects/all_anndata_final_vizualizer.h5ad"
  4581. adata.write_h5ad(os.path.join(working_dir, save_name))
  4582. # %% [markdown]
  4583. # ## Save files to use with MERSCOPE Vizualizer
  4584. # %%
  4585. # Paths
  4586. input_file = os.path.join(working_dir, "adata_objects/all_anndata_final_vizualizer.h5ad")
  4587. output_dir = os.path.join(working_dir, "adata_objects_vizualizer")
  4588. # load full AnnData
  4589. adata = sc.read_h5ad(input_file)
  4590. # get all unique slide IDs
  4591. slide_ids = adata.obs["slide"].unique()
  4592. # loop through and save one file per slide
  4593. for slide_id in slide_ids:
  4594. # subset
  4595. ad = adata[adata.obs["slide"] == slide_id].copy()
  4596. # construct filename
  4597. # e.g. "202405281416_MsSpinalCord-...-VMSC15302.hdf5"
  4598. fname = f"{slide_id}.hdf5"
  4599. out_path = os.path.join(output_dir, fname)
  4600. # write
  4601. ad.write_h5ad(out_path)
  4602. print(f"Wrote {out_path}")
  4603. # %% [markdown]
  4604. # ## MN segmentations from parquet files
  4605. # %%
  4606. adata = sc.read_h5ad(os.path.join(working_dir, f"adata_objects/all_anndata_final_vizualizer.h5ad"))
  4607. # %%
  4608. import numpy as np
  4609. import matplotlib.pyplot as plt
  4610. import seaborn as sns
  4611. from skimage.filters import threshold_otsu
  4612. # -----------------------------
  4613. # Restrict to Alpha MNs
  4614. # -----------------------------
  4615. alpha_mask = adata.obs["cholinergic_type"] == "Alpha MNs"
  4616. # Pull DAPI values for Alpha MNs only
  4617. vals = adata.obs.loc[alpha_mask, "DAPI_high_pass"].values
  4618. log_vals = np.log10(vals + 1)
  4619. # -----------------------------
  4620. # Otsu threshold (Alpha MNs only)
  4621. # -----------------------------
  4622. thresh = threshold_otsu(log_vals)
  4623. print("Otsu log10(DAPI) threshold (Alpha MNs) =", thresh)
  4624. # Initialize column (False by default)
  4625. adata.obs["nuclear_alphaMN"] = False
  4626. # Assign nuclear status ONLY for Alpha MNs
  4627. adata.obs.loc[alpha_mask, "nuclear_alphaMN"] = (
  4628. np.log10(adata.obs.loc[alpha_mask, "DAPI_high_pass"].values + 1) >= thresh
  4629. )
  4630. # -----------------------------
  4631. # QC plot
  4632. # -----------------------------
  4633. plt.figure(figsize=(6,4))
  4634. sns.histplot(log_vals, bins=300, color="steelblue", edgecolor=None)
  4635. plt.axvline(thresh, color="red", linestyle="--", label=f"Otsu={thresh:.2f}")
  4636. plt.xlabel("log10(DAPI_high_pass + 1)")
  4637. plt.ylabel("Alpha MN count")
  4638. plt.legend()
  4639. plt.tight_layout()
  4640. plt.show()
  4641. # %% [markdown]
  4642. # ## Fig. S9A
  4643. # %%
  4644. import os, glob
  4645. import numpy as np
  4646. import pandas as pd
  4647. import matplotlib.pyplot as plt
  4648. from shapely import wkb
  4649. from scipy.spatial import cKDTree
  4650. from matplotlib.patches import Polygon as MplPolygon
  4651. # -----------------------------
  4652. # Paths / inputs
  4653. # -----------------------------
  4654. base = "/home/users/ogautier/oak/Shared/SOD1_Paper/Vizgen/mn_nonmn_segmentation/202401241200_HuSpinalCord-VS119-Lubmar-Ctrl3-SOD10_VMSC10802/analysis_outputs_7000_900"
  4655. tile_files = sorted(glob.glob(os.path.join(base, "result_tiles", "cell_*.parquet")))
  4656. FIG_DIR = "/home/users/ogautier/oak/Shared/SOD1_Paper/Vizgen/mn_nonmn_segmentation/figures"
  4657. os.makedirs(FIG_DIR, exist_ok=True)
  4658. # -----------------------------
  4659. # Filters / columns
  4660. # -----------------------------
  4661. slide_section_col = "slide_section"
  4662. mn_filter_col = "cholinergic_type"
  4663. mn_filter_value = "Alpha MNs"
  4664. nuclear_col = "nuclear_alphaMN"
  4665. nuclear_value = True
  4666. color_by_col = "predicted.id"
  4667. tol = 1.7
  4668. # -----------------------------
  4669. # Plot settings
  4670. # -----------------------------
  4671. plt.rcParams.update({
  4672. "font.size": 7,
  4673. "axes.titlesize": 7,
  4674. "axes.labelsize": 7,
  4675. "xtick.labelsize": 7,
  4676. "ytick.labelsize": 7,
  4677. "legend.fontsize": 7,
  4678. "legend.title_fontsize": 7,
  4679. "font.family": "DejaVu Sans",
  4680. "svg.fonttype": "path",
  4681. })
  4682. FIG_W_IN, FIG_H_IN = 4.875, 3.5
  4683. LEFT, RIGHT, BOTTOM, TOP = 0.06, 0.98, 0.18, 0.98
  4684. WSPACE, HSPACE = -0.035, -0.035
  4685. SPINE_LW = 0.9
  4686. BOX_SIZE_UM = 600
  4687. HALF = BOX_SIZE_UM / 2.0
  4688. LEGEND_ORDER = ["Slow-Firing", "Intermediate", "Fast-Firing", "Early DAMN", "Late DAMN"]
  4689. LEGEND_LABEL_MAP = {"Early DAMN": "Early DM", "Late DAMN": "Late DM"}
  4690. COLOR_MAP = {
  4691. "Slow-Firing": "#f47d2b",
  4692. "Intermediate": "#272e6a",
  4693. "Fast-Firing": "#208a42",
  4694. "Early DAMN": "#89288f",
  4695. "Late DAMN": "#d51f26"
  4696. }
  4697. # -----------------------------
  4698. # Checks
  4699. # -----------------------------
  4700. for col in [slide_section_col, mn_filter_col, nuclear_col, color_by_col]:
  4701. if col not in adata.obs.columns:
  4702. raise ValueError(f"adata.obs missing '{col}'")
  4703. if "spatial" not in adata.obsm.keys():
  4704. raise ValueError("adata.obsm missing 'spatial'")
  4705. sp = np.asarray(adata.obsm["spatial"])
  4706. tree = cKDTree(sp)
  4707. # -----------------------------
  4708. # Helpers
  4709. # -----------------------------
  4710. def is_R_prefix(adata_slide_section_vals, prefix):
  4711. suffix = pd.Series(adata_slide_section_vals.astype(str)).str.split("_").str[-1]
  4712. return suffix.str.startswith(prefix).values
  4713. def nuclear_alpha_mask(adata):
  4714. return (
  4715. (adata.obs[mn_filter_col].astype(str) == mn_filter_value) &
  4716. (adata.obs[nuclear_col].astype(bool) == nuclear_value)
  4717. )
  4718. def compute_centroids_from_wkb_bytes(wkb_bytes_arr):
  4719. cx = np.empty(len(wkb_bytes_arr), dtype=float)
  4720. cy = np.empty(len(wkb_bytes_arr), dtype=float)
  4721. ok = np.ones(len(wkb_bytes_arr), dtype=bool)
  4722. for i, b in enumerate(wkb_bytes_arr):
  4723. try:
  4724. g = wkb.loads(b)
  4725. c = g.centroid
  4726. cx[i] = c.x
  4727. cy[i] = c.y
  4728. except Exception:
  4729. ok[i] = False
  4730. cx[i] = np.nan
  4731. cy[i] = np.nan
  4732. return cx, cy, ok
  4733. def refine_center_fixed_window(centroids, half, n_iter=2):
  4734. if len(centroids) == 0:
  4735. return None
  4736. cx, cy = np.median(centroids[:, 0]), np.median(centroids[:, 1])
  4737. for _ in range(n_iter):
  4738. m = (
  4739. (centroids[:, 0] >= cx-half) & (centroids[:, 0] <= cx+half) &
  4740. (centroids[:, 1] >= cy-half) & (centroids[:, 1] <= cy+half)
  4741. )
  4742. if m.sum() == 0:
  4743. break
  4744. xs = centroids[m, 0]
  4745. ys = centroids[m, 1]
  4746. cx = (xs.min() + xs.max()) / 2.0
  4747. cy = (ys.min() + ys.max()) / 2.0
  4748. return cx, cy
  4749. def iter_polygons(g):
  4750. if g.geom_type == "Polygon":
  4751. yield g
  4752. elif g.geom_type == "MultiPolygon":
  4753. for p in g.geoms:
  4754. yield p
  4755. def add_geom_as_patches(ax, geom, facecolor, edgecolor="black", lw=0.35):
  4756. for poly in iter_polygons(geom):
  4757. x, y = poly.exterior.xy
  4758. coords = np.column_stack([x, y])
  4759. ax.add_patch(MplPolygon(
  4760. coords, closed=True,
  4761. facecolor=facecolor, edgecolor=edgecolor,
  4762. linewidth=lw, joinstyle="miter", capstyle="butt",
  4763. antialiased=True
  4764. ))
  4765. def count_nuclear_alphas_in_tile_for_group(tile_path, group_prefix, na_mask_values):
  4766. df = pd.read_parquet(tile_path)
  4767. if df.empty:
  4768. return 0, 0
  4769. df = df[df["Type"] == "cell"]
  4770. if df.empty:
  4771. return 0, 0
  4772. cx, cy, ok = compute_centroids_from_wkb_bytes(df["Geometry"].values)
  4773. cent = np.column_stack([cx, cy])[ok]
  4774. if cent.shape[0] == 0:
  4775. return 0, 0
  4776. dists, idx = tree.query(cent, k=1)
  4777. idx = idx[dists <= tol]
  4778. if idx.size == 0:
  4779. return 0, 0
  4780. ss_vals = adata.obs[slide_section_col].values[idx]
  4781. in_group = is_R_prefix(ss_vals, group_prefix)
  4782. idx_group = idx[in_group]
  4783. n_group = int(idx_group.size)
  4784. if n_group == 0:
  4785. return 0, 0
  4786. n_nuclear_alpha = int(na_mask_values[idx_group].sum())
  4787. return n_nuclear_alpha, n_group
  4788. def get_top_tiles_for_group(group_prefix, top_k=3):
  4789. na_mask_values = nuclear_alpha_mask(adata).values
  4790. rows = []
  4791. for tf in tile_files:
  4792. n_na, n_group = count_nuclear_alphas_in_tile_for_group(tf, group_prefix, na_mask_values)
  4793. rows.append({"tile": tf, "n_nuclear_alpha": n_na, "n_group_cells": n_group})
  4794. summ = (
  4795. pd.DataFrame(rows)
  4796. .sort_values(["n_nuclear_alpha", "n_group_cells"], ascending=False)
  4797. .reset_index(drop=True)
  4798. )
  4799. top = summ.head(top_k).copy()
  4800. if top["n_nuclear_alpha"].max() == 0:
  4801. raise ValueError(f"No nuclear Alpha MNs found for group {group_prefix}.")
  4802. return top
  4803. def load_tile_geoms_cats_centroids(tile_path, group_prefix):
  4804. df = pd.read_parquet(tile_path)
  4805. df = df[df["Type"] == "cell"].copy()
  4806. if df.empty:
  4807. return [], [], np.zeros((0, 2))
  4808. df["geom"] = df["Geometry"].apply(lambda b: wkb.loads(b) if b is not None else None)
  4809. df = df.dropna(subset=["geom"]).copy()
  4810. if df.empty:
  4811. return [], [], np.zeros((0, 2))
  4812. cent = np.column_stack([
  4813. df["geom"].apply(lambda g: g.centroid.x).to_numpy(),
  4814. df["geom"].apply(lambda g: g.centroid.y).to_numpy()
  4815. ])
  4816. dists, idx = tree.query(cent, k=1)
  4817. keep = dists <= tol
  4818. df = df.iloc[keep].copy()
  4819. cent = cent[keep]
  4820. idx = idx[keep]
  4821. if df.empty:
  4822. return [], [], np.zeros((0, 2))
  4823. ss_vals = adata.obs[slide_section_col].values[idx]
  4824. keep = is_R_prefix(ss_vals, group_prefix)
  4825. df = df.iloc[keep].copy()
  4826. cent = cent[keep]
  4827. idx = idx[keep]
  4828. if df.empty:
  4829. return [], [], np.zeros((0, 2))
  4830. na = nuclear_alpha_mask(adata).values[idx]
  4831. df = df.iloc[na].copy()
  4832. cent = cent[na]
  4833. idx = idx[na]
  4834. if df.empty:
  4835. return [], [], np.zeros((0, 2))
  4836. cats = adata.obs[color_by_col].astype(str).values[idx]
  4837. return df["geom"].tolist(), cats.tolist(), cent
  4838. # -----------------------------
  4839. # Select top tiles
  4840. # -----------------------------
  4841. top_end = get_top_tiles_for_group("R1", top_k=3)
  4842. top_ctl = get_top_tiles_for_group("R2", top_k=3)
  4843. control_tiles = top_ctl["tile"].tolist()
  4844. end_tiles = top_end["tile"].tolist()
  4845. # -----------------------------
  4846. # Build panels
  4847. # -----------------------------
  4848. panels = []
  4849. for t in control_tiles:
  4850. geoms, cats, cent = load_tile_geoms_cats_centroids(t, "R2")
  4851. panels.append(("Control", geoms, cats, cent))
  4852. for t in end_tiles:
  4853. geoms, cats, cent = load_tile_geoms_cats_centroids(t, "R1")
  4854. panels.append(("End", geoms, cats, cent))
  4855. # -----------------------------
  4856. # Plot montage (SVG)
  4857. # -----------------------------
  4858. fig = plt.figure(figsize=(FIG_W_IN, FIG_H_IN))
  4859. gs = fig.add_gridspec(
  4860. 2, 3,
  4861. left=LEFT, right=RIGHT,
  4862. bottom=BOTTOM, top=TOP,
  4863. wspace=WSPACE, hspace=HSPACE
  4864. )
  4865. axes = [[fig.add_subplot(gs[r, c]) for c in range(3)] for r in range(2)]
  4866. for i, (row, geoms, cats, cent) in enumerate(panels):
  4867. r = 0 if row == "Control" else 1
  4868. c = i % 3
  4869. ax = axes[r][c]
  4870. for g, cat in zip(geoms, cats):
  4871. fc = COLOR_MAP.get(cat, "#999999")
  4872. add_geom_as_patches(ax, g, facecolor=fc, edgecolor="black", lw=0.35)
  4873. center = refine_center_fixed_window(cent, HALF, n_iter=2)
  4874. if center is not None:
  4875. cx, cy = center
  4876. ax.set_xlim(cx - HALF, cx + HALF)
  4877. ax.set_ylim(cy + HALF, cy - HALF)
  4878. ax.set_aspect("equal")
  4879. ax.set_xticks([]); ax.set_yticks([])
  4880. for s in ax.spines.values():
  4881. s.set_visible(True)
  4882. s.set_linewidth(SPINE_LW)
  4883. s.set_color("black")
  4884. axes[0][0].text(-0.12, 0.5, "Control", transform=axes[0][0].transAxes,
  4885. rotation=90, va="center", ha="right")
  4886. axes[1][0].text(-0.12, 0.5, "End", transform=axes[1][0].transAxes,
  4887. rotation=90, va="center", ha="right")
  4888. handles = [
  4889. plt.Line2D([0], [0], marker='s', linestyle='',
  4890. markersize=6, markerfacecolor=COLOR_MAP[k],
  4891. markeredgecolor='none', label=LEGEND_LABEL_MAP.get(k, k))
  4892. for k in LEGEND_ORDER
  4893. ]
  4894. fig.legend(handles=handles, loc="lower center", ncol=len(handles),
  4895. frameon=False, bbox_to_anchor=(0.5, 0.04))
  4896. out_svg = os.path.join(
  4897. FIG_DIR,
  4898. "Control_End_MN_segmentations_DM.svg"
  4899. )
  4900. fig.savefig(out_svg)
  4901. plt.show()
  4902. # %% [markdown]
  4903. # ## Alpha motor neuron morphological changes
  4904. # %%
  4905. # Read in AnnData
  4906. adata = sc.read_h5ad(os.path.join(working_dir, f"adata_objects/alpha_anndata_label_transfer.h5ad"))
  4907. # %%
  4908. import numpy as np
  4909. from skimage.filters import threshold_otsu
  4910. import matplotlib.pyplot as plt
  4911. import seaborn as sns
  4912. # log10-transform
  4913. vals = adata.obs["DAPI_high_pass"].values
  4914. log_vals = np.log10(vals + 1)
  4915. # Otsu threshold on log-values
  4916. thresh = threshold_otsu(log_vals)
  4917. print("Otsu log10(DAPI) threshold =", thresh)
  4918. adata.obs["nuclear"] = log_vals >= thresh
  4919. # Plot with threshold line
  4920. plt.figure(figsize=(6,4))
  4921. sns.histplot(log_vals, bins=300, color="steelblue", edgecolor=None)
  4922. plt.axvline(thresh, color="red", linestyle="--", label=f"Otsu={thresh:.2f}")
  4923. plt.xlabel("log10(DAPI_high_pass + 1)")
  4924. plt.ylabel("Count")
  4925. plt.legend()
  4926. plt.tight_layout()
  4927. plt.show()
  4928. # %% [markdown]
  4929. # ## Fig. S9B
  4930. # %% [markdown]
  4931. # #### Control FF vs. SF volume
  4932. # %%
  4933. import os
  4934. import numpy as np
  4935. import pandas as pd
  4936. import matplotlib.pyplot as plt
  4937. import seaborn as sns
  4938. from scipy.stats import wilcoxon
  4939. # -----------------------------
  4940. # 0. Aesthetics
  4941. # -----------------------------
  4942. sns.set_theme(
  4943. style="ticks",
  4944. rc={
  4945. "font.size": 7,
  4946. "axes.titlesize": 7,
  4947. "axes.labelsize": 7,
  4948. "xtick.labelsize": 7,
  4949. "ytick.labelsize": 7,
  4950. "legend.fontsize": 7,
  4951. "legend.title_fontsize": 7,
  4952. "axes.linewidth": 0.5,
  4953. "xtick.major.width": 0.5,
  4954. "ytick.major.width": 0.5,
  4955. "pdf.fonttype": 42,
  4956. "ps.fonttype": 42,
  4957. "svg.fonttype": "path"
  4958. }
  4959. )
  4960. group_order = ["Fast-Firing", "Slow-Firing"]
  4961. group_colors = {
  4962. "Fast-Firing": "#2ca02c", # green
  4963. "Slow-Firing": "#ff7f0e" # orange
  4964. }
  4965. # -----------------------------
  4966. # 1. Prepare dataframe (Control, nuclear-only)
  4967. # -----------------------------
  4968. need_cols = ["slide_stage", "slide_section", "stage", "predicted.id", "volume", "nuclear"]
  4969. df = adata.obs[need_cols].copy()
  4970. df = df[df["nuclear"] == True].dropna(
  4971. subset=["slide_stage", "slide_section", "stage", "predicted.id", "volume"]
  4972. )
  4973. df = df[df["stage"] == "Control"].copy()
  4974. df = df[df["predicted.id"].isin(group_order)].copy()
  4975. # -----------------------------
  4976. # 2. Option A: section means -> slide means
  4977. # -----------------------------
  4978. sec_mean = (
  4979. df.groupby(["slide_section", "slide_stage", "predicted.id"], observed=True)
  4980. .agg(sec_mean_volume=("volume", "mean"))
  4981. .reset_index()
  4982. )
  4983. slide_mean = (
  4984. sec_mean.groupby(["slide_stage", "predicted.id"], observed=True)
  4985. .agg(mean_volume=("sec_mean_volume", "mean"))
  4986. .reset_index()
  4987. )
  4988. wide = slide_mean.pivot(
  4989. index="slide_stage",
  4990. columns="predicted.id",
  4991. values="mean_volume"
  4992. )
  4993. paired = wide.dropna(subset=group_order).copy()
  4994. # -----------------------------
  4995. # 3. Paired Wilcoxon signed-rank test (one-sided: SF < FF)
  4996. # -----------------------------
  4997. ff = paired["Fast-Firing"].values
  4998. sf = paired["Slow-Firing"].values
  4999. if len(paired) >= 2:
  5000. res = wilcoxon(
  5001. sf,
  5002. ff,
  5003. alternative="less", # Slow-Firing < Fast-Firing
  5004. zero_method="wilcox"
  5005. )
  5006. p = res.pvalue
  5007. else:
  5008. p = np.nan
  5009. print(f"Paired Wilcoxon test (Slow-Firing < Fast-Firing): p = {p:.3e}")
  5010. # convert to star
  5011. if not np.isfinite(p):
  5012. star = "na"
  5013. elif p < 0.001:
  5014. star = "***"
  5015. elif p < 0.01:
  5016. star = "**"
  5017. elif p < 0.05:
  5018. star = "*"
  5019. else:
  5020. star = "ns"
  5021. # -----------------------------
  5022. # 4. Long format for plotting
  5023. # -----------------------------
  5024. long_means = (
  5025. paired.reset_index()
  5026. .melt(
  5027. id_vars="slide_stage",
  5028. value_vars=group_order,
  5029. var_name="group",
  5030. value_name="mean_volume"
  5031. )
  5032. )
  5033. x_positions = {g: i for i, g in enumerate(group_order)}
  5034. # -----------------------------
  5035. # 5. Boxplot + paired lines
  5036. # -----------------------------
  5037. fig, ax = plt.subplots(figsize=(1.625, 1.7))
  5038. sns.boxplot(
  5039. data=long_means,
  5040. x="group",
  5041. y="mean_volume",
  5042. order=group_order,
  5043. palette=[group_colors[g] for g in group_order],
  5044. showfliers=False,
  5045. width=0.6,
  5046. linewidth=0.8,
  5047. ax=ax
  5048. )
  5049. sns.stripplot(
  5050. data=long_means,
  5051. x="group",
  5052. y="mean_volume",
  5053. order=group_order,
  5054. color="black",
  5055. size=4,
  5056. jitter=0.12,
  5057. alpha=0.8,
  5058. ax=ax
  5059. )
  5060. # connect paired points
  5061. for slide, sub in long_means.groupby("slide_stage"):
  5062. if set(sub["group"]) == set(group_order):
  5063. x1 = x_positions["Fast-Firing"]
  5064. y1 = sub.loc[sub["group"] == "Fast-Firing", "mean_volume"].iloc[0]
  5065. x2 = x_positions["Slow-Firing"]
  5066. y2 = sub.loc[sub["group"] == "Slow-Firing", "mean_volume"].iloc[0]
  5067. ax.plot([x1, x2], [y1, y2], lw=1, c="gray", alpha=0.7)
  5068. # aesthetics
  5069. ax.grid(False)
  5070. for spine in ax.spines.values():
  5071. spine.set_color("black")
  5072. # ↓↓↓ Shorter ticks (Option 2 incorporated here) ↓↓↓
  5073. ax.tick_params(axis="both", which="major", length=2, width=0.5)
  5074. ax.set_xlabel("")
  5075. ax.set_ylabel("Volume")
  5076. ax.set_title("Control")
  5077. ax.set_xticklabels(["FF", "SF"])
  5078. # -----------------------------
  5079. # Stats bracket (ORIGINAL geometry)
  5080. # -----------------------------
  5081. data_min = long_means["mean_volume"].min()
  5082. data_max = long_means["mean_volume"].max()
  5083. data_range = max(1e-12, data_max - data_min)
  5084. y_base = data_max * 1.02
  5085. y_step = 0.12 * data_range
  5086. h = 0.25 * y_step
  5087. x1, x2 = x_positions["Fast-Firing"], x_positions["Slow-Firing"]
  5088. ax.plot(
  5089. [x1, x1, x2, x2],
  5090. [y_base, y_base + h, y_base + h, y_base],
  5091. lw=1.2, c="k"
  5092. )
  5093. ax.text(
  5094. (x1 + x2) / 2,
  5095. y_base + h * 1.05,
  5096. star,
  5097. ha="center",
  5098. va="bottom",
  5099. fontsize=7
  5100. )
  5101. ymin, ymax = ax.get_ylim()
  5102. ax.set_ylim(ymin, max(ymax, y_base + y_step * 1.3))
  5103. plt.tight_layout()
  5104. # -----------------------------
  5105. # 6. Save plot
  5106. # -----------------------------
  5107. out_svg = os.path.join(figures_dir, "control_FF_vs_SF_volume_paired_wilcoxon.svg")
  5108. out_png = os.path.join(figures_dir, "control_FF_vs_SF_volume_paired_wilcoxon.png")
  5109. fig.savefig(out_svg, format="svg", dpi=300, bbox_inches="tight", transparent=True)
  5110. fig.savefig(out_png, format="png", dpi=300, bbox_inches="tight", transparent=True)
  5111. plt.show()
  5112. # %% [markdown]
  5113. # ## Fig. S9C
  5114. # %% [markdown]
  5115. # #### Volume changes with disease stage
  5116. # %%
  5117. import os
  5118. import numpy as np
  5119. import pandas as pd
  5120. import matplotlib.pyplot as plt
  5121. import seaborn as sns
  5122. from scipy.stats import mannwhitneyu
  5123. from statsmodels.stats.multitest import multipletests
  5124. from statannotations.Annotator import Annotator
  5125. # ------------------ Config ------------------
  5126. order = ["Control", "Early", "Mid", "End"]
  5127. comparisons = [("Early", "Control"), ("Mid", "Control"), ("End", "Control")]
  5128. stage_colors = {
  5129. "Control": "#1f77b4",
  5130. "Early": "#ff7f0e",
  5131. "Mid": "#d62728",
  5132. "End": "#2ca02c"
  5133. }
  5134. # ------------------ Global aesthetics ------------------
  5135. sns.set_theme(
  5136. style="ticks",
  5137. rc={
  5138. "font.size": 7,
  5139. "axes.titlesize": 7,
  5140. "axes.labelsize": 7,
  5141. "xtick.labelsize": 7,
  5142. "ytick.labelsize": 7,
  5143. "legend.fontsize": 7,
  5144. "legend.title_fontsize": 7,
  5145. "axes.linewidth": 0.5,
  5146. "xtick.major.width": 0.5,
  5147. "ytick.major.width": 0.5,
  5148. "pdf.fonttype": 42,
  5149. "ps.fonttype": 42,
  5150. "svg.fonttype": "path"
  5151. }
  5152. )
  5153. # ------------------ Load & clean (NUCLEAR ONLY) ------------------
  5154. df = (
  5155. adata.obs[['slide_stage', 'slide_section', 'stage', 'volume', 'nuclear']]
  5156. .copy()
  5157. .dropna(subset=['slide_stage', 'slide_section', 'stage', 'volume', 'nuclear'])
  5158. )
  5159. df = df[df['nuclear'] == True].copy()
  5160. df = df.drop(columns=['nuclear'])
  5161. df['stage'] = df['stage'].astype(str).str.strip()
  5162. df['volume'] = pd.to_numeric(df['volume'], errors='coerce')
  5163. df = df.dropna(subset=['volume'])
  5164. df = df[df['stage'].isin(order)].copy()
  5165. # ------------------ Option A: slide_section means -> slide_stage means ------------------
  5166. sec_mean = (
  5167. df.groupby(['slide_section', 'slide_stage', 'stage'], observed=True)
  5168. .agg(sec_mean_volume=('volume', 'mean'))
  5169. .reset_index()
  5170. )
  5171. stage_per_slide = (
  5172. df.groupby('slide_stage')['stage']
  5173. .agg(['nunique', 'first'])
  5174. .rename(columns={'nunique': 'n_stage_labels', 'first': 'stage'})
  5175. .reset_index()
  5176. )
  5177. bad = stage_per_slide[stage_per_slide['n_stage_labels'] != 1]
  5178. if len(bad):
  5179. print("[WARN] Some slide_stage have multiple stage labels; using the first label:")
  5180. print(bad.head())
  5181. stage_per_slide = stage_per_slide[['slide_stage', 'stage']]
  5182. slide_mean = (
  5183. sec_mean.groupby('slide_stage', observed=True)
  5184. .agg(mean_volume=('sec_mean_volume', 'mean'))
  5185. .reset_index()
  5186. )
  5187. per_slide = slide_mean.merge(stage_per_slide, on='slide_stage', how='left')
  5188. per_slide = per_slide[per_slide['stage'].isin(order)].copy()
  5189. per_slide['stage'] = pd.Categorical(per_slide['stage'], categories=order, ordered=True)
  5190. print("Slides per stage:")
  5191. print(per_slide.groupby('stage')['slide_stage'].nunique(), "\n")
  5192. # ------------------ Build arrays for tests ------------------
  5193. stage_arrays = {
  5194. g: per_slide.loc[per_slide['stage'].eq(g), 'mean_volume'].to_numpy()
  5195. for g in order
  5196. }
  5197. for g in order:
  5198. print(f"{g}: n_slides = {len(stage_arrays[g])}")
  5199. # ------------------ Statistics (MWU one-sided + Bonferroni) ------------------
  5200. rows = []
  5201. for s_label, ctl_label in comparisons:
  5202. x = stage_arrays[s_label]
  5203. y = stage_arrays[ctl_label]
  5204. if len(x) >= 1 and len(y) >= 1:
  5205. try:
  5206. p = mannwhitneyu(x, y, alternative='less', method='asymptotic').pvalue
  5207. except TypeError:
  5208. p = mannwhitneyu(x, y, alternative='less').pvalue
  5209. else:
  5210. p = np.nan
  5211. rows.append((s_label, ctl_label, p))
  5212. res = pd.DataFrame(rows, columns=['stage', 'control', 'p_raw'])
  5213. mask = res['p_raw'].notna()
  5214. res['p_adj'] = np.nan
  5215. if mask.any():
  5216. _, p_adj, _, _ = multipletests(res.loc[mask, 'p_raw'], method='bonferroni')
  5217. res.loc[mask, 'p_adj'] = p_adj
  5218. print(res, "\n")
  5219. # p-values in the same order as `comparisons`
  5220. pvals_adj = [res.loc[i, 'p_adj'] for i in range(len(comparisons))]
  5221. # ------------------ Plot ------------------
  5222. fig, ax = plt.subplots(figsize=(1.625, 1.8))
  5223. sns.boxplot(
  5224. data=per_slide,
  5225. x='stage',
  5226. y='mean_volume',
  5227. order=order,
  5228. palette=[stage_colors[s] for s in order],
  5229. showfliers=False,
  5230. width=0.6,
  5231. linewidth=0.8,
  5232. ax=ax
  5233. )
  5234. sns.stripplot(
  5235. data=per_slide,
  5236. x='stage',
  5237. y='mean_volume',
  5238. order=order,
  5239. color='black',
  5240. size=4,
  5241. jitter=0.15,
  5242. alpha=0.8,
  5243. ax=ax
  5244. )
  5245. ax.grid(False)
  5246. for spine in ax.spines.values():
  5247. spine.set_color("black")
  5248. # shorter ticks (same as your other panels)
  5249. ax.tick_params(axis="both", which="major", length=2, width=0.5)
  5250. ax.set_xlabel("")
  5251. ax.set_ylabel("Volume")
  5252. ax.set_title("All")
  5253. for label in ax.get_xticklabels():
  5254. label.set_rotation(90)
  5255. # ------------------ Stat annotations (automatic bracket geometry) ------------------
  5256. annotator = Annotator(
  5257. ax,
  5258. comparisons,
  5259. data=per_slide,
  5260. x="stage",
  5261. y="mean_volume",
  5262. order=order
  5263. )
  5264. annotator.configure(
  5265. test=None,
  5266. text_format="star",
  5267. loc="inside", # same behavior as your good-looking example
  5268. fontsize=6 # your request
  5269. )
  5270. annotator.set_pvalues(pvals_adj)
  5271. annotator.annotate()
  5272. ax.set_yticks([7500, 10000, 12500, 15000, 17500])
  5273. plt.tight_layout()
  5274. # ------------------ Save ------------------
  5275. out_svg = os.path.join(figures_dir, "stage_volume_per_slide_nuclear_wilcoxon_bonferroni.svg")
  5276. out_png = os.path.join(figures_dir, "stage_volume_per_slide_nuclear_wilcoxon_bonferroni.png")
  5277. fig.savefig(out_svg, dpi=300, bbox_inches='tight', transparent=True)
  5278. fig.savefig(out_png, dpi=300, bbox_inches='tight', transparent=True)
  5279. plt.show()
  5280. print("Saved:", out_svg, "and", out_png)
  5281. # %% [markdown]
  5282. # ## Fig. S9D
  5283. # %%
  5284. import os
  5285. import numpy as np
  5286. import pandas as pd
  5287. import matplotlib.pyplot as plt
  5288. impo

14. MERFISH spatial transcriptomics analysis.ipynb at commit 10e89db, no license · at the source

Overview

Authors: Olivia Gautier1,2,3, Jacob A. Blum1,3, Thao P. Nguyen1, Shaolong Cao4, Sandy Klemm1, Mai Yamakawa5, Dann Huh4, Jessica A. Hurt4, Nasa Sinnott-Armstrong6,7,8, Yi Zeng1,9, Chung-ha O. Davis10, Juliane Bombosch1, Chang Liu1, Lisa N. Encarnacion1, Kevin A. Guttenplan11, Derek Chen1, Arwa Kathiria1, Luke Zhao1, Stephen Moore12, Alex Meng13
and 6 other authorsKailee Ong13, Don W. Cleveland12, John Ravits13, Jessica E. Rexach5,14, William J. Greenleaf1,15,9, Aaron D. Gitler1,9,16,17
17 affiliations
  1. Department of Genetics, Stanford University School of Medicine, Stanford, CA 94305, USA
  2. Stanford Neurosciences Graduate Program, Stanford University School of Medicine, Stanford, CA 94305, USA
  3. These authors contributed equally
  4. Research, Biogen Inc., Cambridge, MA 02142, USA
  5. Program in Neurogenetics, Department of Neurology, David Geffen School of Medicine, University of California, Los Angeles, Los Angeles, CA 90095, USA
  6. Herbold Computational Biology Program, Public Health Sciences Division, Fred Hutchinson Cancer Center, Seattle, WA 98195, USA
  7. Department of Genome Sciences, University of Washington, Seattle, WA 98195, USA
  8. Brotman Baty Institute, University of Washington, Seattle, WA 98195, USA
  9. Biohub – San Francisco, San Francisco, CA 94158, USA
  10. Department of Neurobiology, Stanford University, Stanford, CA 94305, USA
  11. Vollum Institute, Oregon Health and Sciences University, Portland, OR 97239, USA
  12. Departments of Cellular and Molecular Medicine, University of California, San Diego, La Jolla, CA 92093, USA
  13. Department of Neurosciences, University of California, San Diego, La Jolla, CA 92093, USA
  14. Department of Human Genetics, David Geffen School of Medicine, University of California, Los Angeles, Los Angeles, CA 90095, USA
  15. Department of Applied Physics, Stanford University, Stanford, CA 94305, USA
  16. The Phil and Penny Knight Initiative for Brain Resilience, Stanford University, Stanford, CA 94305, USA
  17. Lead contact
Institutions: Stanford Medicine (United States); Stanford University (United States); Biogen (United States) (United States); University of California, Los Angeles (United States); University of Washington (United States); Fred Hutch Cancer Center (United States); Brotman Baty Institute (United States); Oregon Health & Science University (United States); Vollum Institute (United States); University of California San Diego (United States)
Journal: Cell, volume 189, issue 16, pages 5044-5064.e12
Dates: published online 23 June 2026; in print 6 August 2026
Type: Research article · Language: English
License: CC BY
Identifiers: DOI 10.1016/j.cell.2026.05.047 · PMID 42335888 · PMCID PMC13446465 · OpenAlex W7165678572
Open access: hybrid, a free copy (OpenAlex)
Status: code verified
Categories: genetics / omics (modality), human (organism), mouse (organism), other condition (population), cellular / molecular (subfield)
Methods: Connectivity, Spectral & time-frequency, Statistics, Smoothing, state filtering, decompositions, Preprocessing, Machine learning
Keywords: Spinal cord, Motor neuron, Neurodegeneration, ALS, Microglial Activation, Selective Vulnerability, Cell States, Spatial Transcriptomics, Snrna-seq, Snatac-seq
MeSH: Amyotrophic Lateral Sclerosis*, Motor Neurons*, Animals, Cell Death, Disease Models, Animal, Female, Humans, Male, Mice, Mice, Transgenic, Spinal Cord, Superoxide Dismutase-1, Transcription Factors, Transcriptome (* major topic)
Topic: Amyotrophic Lateral Sclerosis Research (Neurology, Medicine), according to OpenAlex
Funding: NINDS NIH HHS (R35 NS137159, RF1 NS128800, U54 NS123743, R01 NS128028); National Institutes of Health; NCRR NIH HHS (S10 RR025518); NIA NIH HHS (R01 AG075802, R01 AG064690); NHGRI NIH HHS (RM1 HG010461)
Citations: cited by 3 papers (Europe PMC); 137 references in the paper
Research resources: RRID:AB_2687461, ATF-3 (D2Y5W) Rabbit Monoclonal Antibody RRID:AB_2799039, ATF3 plasmid (TFORF0801) RRID:Addgene_141707, CREB3-V5 plasmid (TFORF3311) RRID:Addgene_144787, mCherry plasmid (TFORF3550) RRID:Addgene_145026, SOD1-G93A mice RRID:IMSR_JAX:002726, ChAT-IRES- Cre mice RRID:IMSR_JAX:006410, ROSAnT-nG mice RRID:IMSR_JAX:023035, B6SJLF1/J mice RRID:IMSR_JAX:100012

Abstract

To define molecular determinants of motor neuron degeneration in amyotrophic lateral sclerosis (ALS), we generated longitudinal single-nucleus transcriptomes and chromatin accessibility profiles of spinal motor neurons together with spatial transcriptomics from the SOD1-G93A mouse model. Vulnerable alpha motor neurons showed thousands of molecular changes, marking a transition into a distinct cell state we named “disease-associated motor neurons” (DMs). We identified transcription factor networks that govern how healthy cells transition into DMs and those associated with motor neuron subtype-selective vulnerability. Upregulation of DM-associated transcription factors in human motor neurons induced key features of DMs, demonstrating an active regulatory component. Human ALS spinal cord single-nucleus RNA sequencing data demonstrated conservation of the DM signature in alpha motor neurons, and human orthologs of regions differentially accessible in SOD1-G93A mouse motor neurons were enriched for ALS genetic risk variants. Together, these findings establish a conserved, genetically linked motor neuron signature in ALS.

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

omgautier/Gautier_Blum_2026

License: none: the authors keep all their rights
State: the link answers, verified on 27 September 2026
Evidence: files inventoried
Commit: 10e89dba3fdbd6b3f9d642a161995d913d6bed0e, 23 June 2026
Languages: Jupyter (16)
Size: 17 files, 16 scripts
Software Heritage: not checked
Found in: “Data and code availability”
Holds: README, 16 notebooks
Not found: license file, CITATION.cff, environment file, tests, continuous integration, documentation
Tools: tidyverse (13 files), cowplot (12 files), ggpubr (12 files), pheatmap (12 files), Seurat (12 files), ggplot2 (7 files), DESeq2 (6 files), broom (4 files), anndata (1 file), Matplotlib (1 file), NumPy (1 file), pandas (1 file), Scanpy (1 file), scikit-image (1 file), SciPy (1 file), seaborn (1 file), Squidpy (1 file), statannotations (1 file), statsmodels (1 file)
Availability: 1 check, the latest on 27 September 2026: the link answers
  • 27 September 2026: the link answers
14 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:

  • 1 repository of the authors' code, each at its verified commit, with its license and how the link was found in the paper;
  • 14 scripts, each with its path and the digest of its content;
  • 40 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 and code availability

Raw and processed sequencing data have been deposited in the NCBI Gene Expression Omnibus (GEO) under accession numbers GEO: GSE306676 for the snRNA-seq data and GEO: GSE306675 for the multiome (paired snATAC/snRNA-seq) data. An interactive web portal for exploring the snRNA-seq dataset is available at http://www.spinalcordatlas.org. The processed MERFISH spatial transcriptomics data are available on Zenodo: https://doi.org/10.5281/zenodo.16938739.

Original code is available on GitHub: https://github.com/omgautier/Gautier_Blum_2026.

Any additional information required to reanalyze the data reported in this paper is available from the lead contact upon request.

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 2, 28 September 2026

  • Publisher: n/a → Cell Press

Version 1, 27 September 2026: the first record

Recorded: type, language, journal, volume, issue, pages, dates, 26 authors, 10 keywords, 14 MeSH terms, 5 funders, 137 references, 9 RRIDs.

Cite

This paper

Gautier, O., Blum, J. A., Nguyen, T. P., Cao, S., Klemm, S., Yamakawa, M., Huh, D., Hurt, J. A., Sinnott-Armstrong, N., Zeng, Y., Davis, C.-h. O., Bombosch, J., Liu, C., Encarnacion, L. N., Guttenplan, K. A., Chen, D., Kathiria, A., Zhao, L., Moore, S., . . . Gitler, A. D. (2026). An emergent disease-associated motor neuron state precedes cell death in ALS. Cell, 189(16), 5044-5064.e12. https://doi.org/10.1016/j.cell.2026.05.047

BibTeX

@article{gautier2026emergent,
author = {Gautier, Olivia and Blum, Jacob A. and Nguyen, Thao P. and Cao, Shaolong and Klemm, Sandy and Yamakawa, Mai and Huh, Dann and Hurt, Jessica A. and Sinnott-Armstrong, Nasa and Zeng, Yi and Davis, Chung-ha O. and Bombosch, Juliane and Liu, Chang and Encarnacion, Lisa N. and Guttenplan, Kevin A. and Chen, Derek and Kathiria, Arwa and Zhao, Luke and Moore, Stephen and Meng, Alex and Ong, Kailee and Cleveland, Don W. and Ravits, John and Rexach, Jessica E. and Greenleaf, William J. and Gitler, Aaron D.},
title = {{An emergent disease-associated motor neuron state precedes cell death in ALS}},
journal = {Cell},
year = {2026},
month = jun,
volume = {189},
number = {16},
pages = {5044--5064.e12},
publisher = {Cell Press},
issn = {0092-8674},
doi = {10.1016/j.cell.2026.05.047},
url = {https://doi.org/10.1016/j.cell.2026.05.047},
pmid = {42335888},
pmcid = {PMC13446465}
}

RIS

TY - JOUR
AU - Gautier, Olivia
AU - Blum, Jacob A.
AU - Nguyen, Thao P.
AU - Cao, Shaolong
AU - Klemm, Sandy
AU - Yamakawa, Mai
AU - Huh, Dann
AU - Hurt, Jessica A.
AU - Sinnott-Armstrong, Nasa
AU - Zeng, Yi
AU - Davis, Chung-ha O.
AU - Bombosch, Juliane
AU - Liu, Chang
AU - Encarnacion, Lisa N.
AU - Guttenplan, Kevin A.
AU - Chen, Derek
AU - Kathiria, Arwa
AU - Zhao, Luke
AU - Moore, Stephen
AU - Meng, Alex
AU - Ong, Kailee
AU - Cleveland, Don W.
AU - Ravits, John
AU - Rexach, Jessica E.
AU - Greenleaf, William J.
AU - Gitler, Aaron D.
TI - An emergent disease-associated motor neuron state precedes cell death in ALS
T2 - Cell
J2 - Cell
PY - 2026
DA - 2026/06/23
VL - 189
IS - 16
SP - 5044
EP - 5064.e12
SN - 0092-8674
PB - Cell Press
DO - 10.1016/j.cell.2026.05.047
UR - https://doi.org/10.1016/j.cell.2026.05.047
LA - en
ER -

CSL-JSON

{
"id": "10.1016/j.cell.2026.05.047",
"type": "article-journal",
"title": "An emergent disease-associated motor neuron state precedes cell death in ALS",
"container-title": "Cell",
"author": [
{
"family": "Gautier",
"given": "Olivia"
},
{
"family": "Blum",
"given": "Jacob A."
},
{
"family": "Nguyen",
"given": "Thao P."
},
{
"family": "Cao",
"given": "Shaolong"
},
{
"family": "Klemm",
"given": "Sandy"
},
{
"family": "Yamakawa",
"given": "Mai"
},
{
"family": "Huh",
"given": "Dann"
},
{
"family": "Hurt",
"given": "Jessica A."
},
{
"family": "Sinnott-Armstrong",
"given": "Nasa"
},
{
"family": "Zeng",
"given": "Yi"
},
{
"family": "Davis",
"given": "Chung-ha O."
},
{
"family": "Bombosch",
"given": "Juliane"
},
{
"family": "Liu",
"given": "Chang"
},
{
"family": "Encarnacion",
"given": "Lisa N."
},
{
"family": "Guttenplan",
"given": "Kevin A."
},
{
"family": "Chen",
"given": "Derek"
},
{
"family": "Kathiria",
"given": "Arwa"
},
{
"family": "Zhao",
"given": "Luke"
},
{
"family": "Moore",
"given": "Stephen"
},
{
"family": "Meng",
"given": "Alex"
},
{
"family": "Ong",
"given": "Kailee"
},
{
"family": "Cleveland",
"given": "Don W."
},
{
"family": "Ravits",
"given": "John"
},
{
"family": "Rexach",
"given": "Jessica E."
},
{
"family": "Greenleaf",
"given": "William J."
},
{
"family": "Gitler",
"given": "Aaron D."
}
],
"container-title-short": "Cell",
"volume": "189",
"issue": "16",
"page": "5044-5064.e12",
"DOI": "10.1016/j.cell.2026.05.047",
"PMID": "42335888",
"PMCID": "PMC13446465",
"ISSN": "0092-8674",
"publisher": "Cell Press",
"URL": "https://doi.org/10.1016/j.cell.2026.05.047",
"language": "en",
"issued": {
"date-parts": [
[
2026,
6,
23
]
]
}
}

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

Similar papers

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

[1] doi:10.1038/s41593-026-02300-5 [code]
Integrated single-cell and spatial transcriptomic profiling in ALS uncovers peripheral-to-central immune infiltration and reprogramming.
Journal: Nature neuroscience
In common: Squidpy, anndata, DESeq2, 11 other tools, genetics / omics, other condition, cellular / molecular, 6 references
[2] doi:10.1186/s13024-026-00944-2
TDP-43: [GU]-ardian of the transcriptome.
Journal: Molecular neurodegeneration
In common: genetics / omics, other condition, cellular / molecular, 15 references
[3] doi:10.1016/j.xcrm.2026.102766 [code]
A longitudinal single-cell and spatial multiomic atlas of pediatric high-grade glioma.
Journal: Cell reports. Medicine
In common: anndata, DESeq2, Scanpy, 12 other tools, genetics / omics, other condition, cellular / molecular, 5 references
[4] doi:10.1038/s42003-026-10034-0 [code]
Region- and cell type-specific changes in gene expression in the cerebellum after classical fear conditioning.
Journal: Communications biology
In common: Squidpy, anndata, DESeq2, 12 other tools, genetics / omics, mouse, cellular / molecular, 3 references
[5] doi:10.1038/s41467-026-71803-3 [code]
Charting the transition from in vitro gliogenesis to the in vivo maturation of human glial progenitor cells transplanted into the hypomyelinated mouse brain.
Journal: Nature communications
In common: Squidpy, anndata, DESeq2, 12 other tools, genetics / omics, mouse, cellular / molecular, 2 references
[6] doi:10.1038/s41586-026-10629-x [code]
Whole-genome duplication shaped cell-type evolution in the vertebrate brain.
Journal: Nature
In common: anndata, DESeq2, Scanpy, 12 other tools, genetics / omics, mouse, cellular / molecular, 3 references
[7] doi:10.1016/j.isci.2026.116906 [code]
Evaluating exon skipping in the central nervous system in Duchenne muscular dystrophy using spatial transcriptomics.
Journal: iScience
In common: Squidpy, statannotations, anndata, 10 other tools, genetics / omics, other condition, mouse, 3 references
[8] doi:10.1002/imt2.70163 [code]
Spatial multi-omics unveils sphingolipid metabolic reprogramming within the retinal pathological niche.
Journal: iMeta
In common: anndata, DESeq2, Scanpy, 12 other tools, genetics / omics, mouse, cellular / molecular, 2 references
[9] doi:10.1038/s41514-026-00391-9 [code]
Region-specific transcriptional signatures of brain aging in the absence of neuropathology at the single-cell level.
Journal: npj aging
In common: anndata, broom, Scanpy, 11 other tools, genetics / omics, cellular / molecular, 4 references
[10] doi:10.1186/s13059-026-04177-w [code]
Genomic sequence evolution underlying human neocortical interareal diversification.
Journal: Genome biology
In common: Squidpy, anndata, Scanpy, 10 other tools, genetics / omics, mouse, cellular / molecular, 3 references

Contribute

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

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

Request its removal

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

Discussion, reproductions, activity

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

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

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