|
| 1 | +from pathlib import Path |
| 2 | +import copy |
| 3 | +import time |
| 4 | +import os |
| 5 | +import subprocess |
| 6 | + |
| 7 | +import numpy as np |
| 8 | + |
| 9 | +import matplotlib.pyplot as plt |
| 10 | +import matplotlib.cm as cm |
| 11 | +from matplotlib.colors import ListedColormap |
| 12 | +from matplotlib.markers import MarkerStyle |
| 13 | +from scipy.stats import spearmanr |
| 14 | + |
| 15 | +import torch |
| 16 | +import torch.nn as nn |
| 17 | +from torch.func import functional_call, vmap, jacrev |
| 18 | + |
| 19 | +import scanpy as sc |
| 20 | +import anndata as ad |
| 21 | + |
| 22 | +from glass_box_umap import ParametricUMAP |
| 23 | + |
| 24 | +# ═══════════════════════════════════════════════════════════════════════════════ |
| 25 | +# Utils — move to utils.py and `from utils import *` |
| 26 | +# ═══════════════════════════════════════════════════════════════════════════════ |
| 27 | + |
| 28 | +# ── Data loading & preprocessing ────────────────────────────────────────────── |
| 29 | + |
| 30 | +def download_bone_marrow_data( |
| 31 | + url="ftp://ftp.ncbi.nlm.nih.gov/geo/series/GSE194nnn/GSE194122/suppl/" |
| 32 | + "GSE194122_openproblems_neurips2021_cite_BMMC_processed.h5ad.gz", |
| 33 | + filename="GSE194122_openproblems_neurips2021_cite_BMMC_processed.h5ad.gz", |
| 34 | +) -> ad.AnnData: |
| 35 | + """Download, unzip, and load the bone marrow dataset.""" |
| 36 | + unzipped = filename.replace(".gz", "") |
| 37 | + if not os.path.isfile(unzipped): |
| 38 | + if not os.path.isfile(filename): |
| 39 | + subprocess.run(["wget", url, "--no-verbose"]) |
| 40 | + subprocess.run(["gunzip", filename]) |
| 41 | + return sc.read_h5ad(unzipped) |
| 42 | + |
| 43 | + |
| 44 | +def preprocess_adata( |
| 45 | + adata: ad.AnnData, |
| 46 | + min_genes: int = 100, |
| 47 | + min_cells: int = 3, |
| 48 | + n_top_genes: int = 2000, |
| 49 | + n_pcs: int = 50, |
| 50 | + batch_key: str = "Samplename", |
| 51 | + run_scrublet: bool = False, |
| 52 | +) -> ad.AnnData: |
| 53 | + """Full scRNA-seq preprocessing: QC filtering, normalize, HVG selection, PCA.""" |
| 54 | + print("--- Starting Preprocessing ---") |
| 55 | + |
| 56 | + adata.obs_names_make_unique() |
| 57 | + adata.var_names_make_unique() |
| 58 | + |
| 59 | + # Flag QC gene categories |
| 60 | + adata.var["mt"] = adata.var_names.str.startswith("MT-") |
| 61 | + adata.var["ribo"] = adata.var_names.str.startswith(("RPS", "RPL")) |
| 62 | + adata.var["hb"] = adata.var_names.str.contains("^HB[^(P)]") |
| 63 | + adata.var["technical"] = adata.var_names.str.startswith( |
| 64 | + ("MALAT1", "NEAT1", "FOS", "JUN") |
| 65 | + ) |
| 66 | + |
| 67 | + sc.pp.calculate_qc_metrics( |
| 68 | + adata, qc_vars=["mt", "ribo", "hb", "technical"], inplace=True, log1p=True |
| 69 | + ) |
| 70 | + |
| 71 | + # Remove QC genes, filter cells/genes |
| 72 | + genes_to_remove = adata.var["mt"] | adata.var["ribo"] #| adata.var["technical"] |
| 73 | + adata._inplace_subset_var(~genes_to_remove) |
| 74 | + sc.pp.filter_cells(adata, min_genes=min_genes) |
| 75 | + sc.pp.filter_genes(adata, min_cells=min_cells) |
| 76 | + if run_scrublet: |
| 77 | + sc.pp.scrublet(adata, batch_key=batch_key) |
| 78 | + |
| 79 | + # Normalize, log-transform, HVGs |
| 80 | + adata.layers["counts"] = adata.X.copy() |
| 81 | + sc.pp.normalize_total(adata) |
| 82 | + sc.pp.log1p(adata) |
| 83 | + |
| 84 | + sc.pp.highly_variable_genes(adata, n_top_genes=n_top_genes, batch_key=batch_key) |
| 85 | + |
| 86 | + # Now regress out on the full matrix (or subset to HVGs first) |
| 87 | + sc.pp.regress_out(adata, ['total_counts']) |
| 88 | + |
| 89 | + # Store HVG mean for later gene-space reconstruction |
| 90 | + X_hvg = adata[:, adata.var.highly_variable].X |
| 91 | + if hasattr(X_hvg, "toarray"): |
| 92 | + X_hvg = X_hvg.toarray() |
| 93 | + adata.uns["pca_mean_hvg"] = X_hvg.mean(axis=0) |
| 94 | + |
| 95 | + # PCA |
| 96 | + sc.tl.pca(adata, n_comps=n_pcs, use_highly_variable=True) |
| 97 | + |
| 98 | + return adata |
| 99 | + |
| 100 | + |
| 101 | +def prepare_data( |
| 102 | + groupby_key: str = "cell_type", |
| 103 | + n_pcs: int = 50, |
| 104 | + batch_key: str = "Samplename", |
| 105 | + n_top_cell_types: int = 32, |
| 106 | +) -> ad.AnnData: |
| 107 | + """Load, preprocess, and subset to top cell types.""" |
| 108 | + adata_raw = download_bone_marrow_data() |
| 109 | + adata_raw.var_names_make_unique() |
| 110 | + |
| 111 | + adata_processed = preprocess_adata( |
| 112 | + adata_raw, n_top_genes=2000, n_pcs=n_pcs, batch_key=batch_key |
| 113 | + ) |
| 114 | + |
| 115 | + top_types = adata_processed.obs[groupby_key].value_counts().nlargest(n_top_cell_types).index |
| 116 | + adata_final = adata_processed[adata_processed.obs[groupby_key].isin(top_types)].copy() |
| 117 | + |
| 118 | + return adata_final |
| 119 | + |
| 120 | +def print_top_genes_per_cluster(importance_gene, gene_names_hvg, labels, label_names, n_top=8): |
| 121 | + """Print top contributing genes for each cluster.""" |
| 122 | + print(f"\n── Top {n_top} Genes Per Cluster ──") |
| 123 | + for cluster_id in range(labels.max() + 1): |
| 124 | + mask = labels == cluster_id |
| 125 | + mean_imp = importance_gene[mask].mean(axis=0) |
| 126 | + top_genes = gene_names_hvg[np.argsort(mean_imp)[::-1][:n_top]] |
| 127 | + print(f" {label_names[cluster_id]}: {list(top_genes)}") |
| 128 | + |
| 129 | + |
| 130 | +# ── Plotting helpers ────────────────────────────────────────────────────────── |
| 131 | + |
| 132 | +def make_tab40_cmap(seed=None): |
| 133 | + """Create a 40-color colormap with a randomized order.""" |
| 134 | + top = plt.get_cmap("tab20b").colors |
| 135 | + bottom = plt.get_cmap("tab20c").colors |
| 136 | + combined = np.vstack((top, bottom)) |
| 137 | + |
| 138 | + # Generate a random permutation |
| 139 | + rng = np.random.default_rng(seed) |
| 140 | + shuffled_indices = rng.permutation(len(combined)) |
| 141 | + shuffled_colors = combined[shuffled_indices] |
| 142 | + |
| 143 | + cmap = ListedColormap(shuffled_colors, name="tab40") |
| 144 | + # Optional: Register it if you want to call it by string later |
| 145 | + plt.colormaps.register(cmap=cmap) |
| 146 | + |
| 147 | + return cmap |
| 148 | + |
| 149 | +def plot_embedding(Z, labels, cmap, filename, title="Parametric UMAP"): |
| 150 | + """Scatter plot of embedding colored by cell type.""" |
| 151 | + fig, ax = plt.subplots(figsize=(10, 8)) |
| 152 | + scatter = ax.scatter( |
| 153 | + Z[:, 0], Z[:, 1], |
| 154 | + c=labels, |
| 155 | + cmap=cmap, |
| 156 | + s=0.1, |
| 157 | + marker=MarkerStyle("o", fillstyle="full"), |
| 158 | + alpha=0.5, |
| 159 | + rasterized=True, |
| 160 | + ) |
| 161 | + plt.colorbar(scatter, ax=ax, label="Cell Type") |
| 162 | + ax.set_title(title, fontsize=16) |
| 163 | + ax.set_xlabel("UMAP 1") |
| 164 | + ax.set_ylabel("UMAP 2") |
| 165 | + ax.grid(True, alpha=0.3) |
| 166 | + plt.savefig(filename, dpi=300, bbox_inches="tight") |
| 167 | + plt.close() |
| 168 | + |
| 169 | + |
| 170 | +def plot_top_gene_map(Z, top_gene_names, gene_names_hvg, filename, n_label=60): |
| 171 | + """UMAP colored by each cell's top contributing gene, with centroid labels.""" |
| 172 | + unique_genes, counts = np.unique(top_gene_names, return_counts=True) |
| 173 | + top_n_genes = unique_genes[np.argsort(counts)[::-1][:n_label]] |
| 174 | + cmap = cm.get_cmap("tab40", 40) |
| 175 | + |
| 176 | + fig, ax = plt.subplots(figsize=(10, 10)) |
| 177 | + |
| 178 | + for i, gene in enumerate(top_n_genes): |
| 179 | + mask = top_gene_names == gene |
| 180 | + ax.scatter( |
| 181 | + Z[mask, 0], Z[mask, 1], |
| 182 | + s=0.3, alpha=0.7, color=cmap(i % 40), |
| 183 | + label=gene, rasterized=True, |
| 184 | + ) |
| 185 | + |
| 186 | + # Centroid labels |
| 187 | + for gene in top_n_genes: |
| 188 | + mask = top_gene_names == gene |
| 189 | + cx, cy = Z[mask, 0].mean(), Z[mask, 1].mean() |
| 190 | + ax.annotate( |
| 191 | + gene, (cx, cy), |
| 192 | + fontsize=7, fontweight="bold", ha="center", va="center", |
| 193 | + bbox=dict(boxstyle="round,pad=0.2", fc="white", ec="gray", alpha=0.85), |
| 194 | + ) |
| 195 | + |
| 196 | + ax.set_title("UMAP — colored by top contributing gene") |
| 197 | + ax.set_xlabel("UMAP 1") |
| 198 | + ax.set_ylabel("UMAP 2") |
| 199 | + ax.legend(markerscale=8, fontsize=7, loc="best", ncol=2) |
| 200 | + plt.tight_layout() |
| 201 | + plt.savefig(filename, dpi=150, bbox_inches="tight") |
| 202 | + plt.close() |
0 commit comments