sync scripts (token removed)
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- scripts/__init__.py +0 -0
- scripts/__pycache__/__init__.cpython-310.pyc +0 -0
- scripts/analysis/100_primary_eden_discovery.py +205 -0
- scripts/analysis/101_primary_eden_derm_scoring.py +159 -0
- scripts/analysis/102_train_on_dingwall_variantA.py +317 -0
- scripts/analysis/103_replicate_dingwall_seurat_pipeline.py +261 -0
- scripts/analysis/104_train_on_dingwall_derm_labels.py +261 -0
- scripts/analysis/105_primary_eden_full_dermal.py +134 -0
- scripts/analysis/106_melanoblast_neural_crest.py +138 -0
- scripts/analysis/107_dingwall_class_deg_count.py +116 -0
- scripts/analysis/108_dahlin_lineage_metabolism.py +158 -0
- scripts/analysis/109_veres_mature_beta.py +113 -0
- scripts/analysis/110_veres_polyhormonal_alpha.py +125 -0
- scripts/analysis/44_en1_cko_contrast.py +117 -0
- scripts/analysis/45_marker_refinement.py +70 -0
- scripts/analysis/49_melanocyte_deep_dive.py +125 -0
- scripts/analysis/57_multiclass_pathway_analysis.py +123 -0
- scripts/analysis/57_pathway_analysis.py +529 -0
- scripts/analysis/62_time_course_analysis.py +112 -0
- scripts/analysis/63_nestorowa_zero_shot.py +142 -0
- scripts/analysis/66_dahlin_kit_mutant.py +142 -0
- scripts/analysis/67_dahlin_within_class.py +129 -0
- scripts/analysis/70_prototype_geometry.py +107 -0
- scripts/analysis/72_emergent_axes.py +74 -0
- scripts/analysis/73_novel_populations_dahlin.py +123 -0
- scripts/analysis/80_prototype_gene_attribution.py +219 -0
- scripts/analysis/81_counterfactual_knockouts.py +112 -0
- scripts/analysis/82_gene_coattribution_modules.py +72 -0
- scripts/analysis/83_prototype_training_trajectory.py +71 -0
- scripts/analysis/84_adversary_purification.py +125 -0
- scripts/analysis/85_hessian_gene_interactions.py +129 -0
- scripts/analysis/90_dingwall_marker_deep_dive.py +87 -0
- scripts/analysis/91_veres_marker_deep_dive.py +110 -0
- scripts/analysis/92_dahlin_marker_deep_dive.py +150 -0
- scripts/analysis/93_true_zero_shot_baron.py +90 -0
- scripts/analysis/94_true_zero_shot_nestorowa.py +107 -0
- scripts/analysis/95_adult_beta_validation.py +92 -0
- scripts/analysis/98_eden_posthoc_detection.py +149 -0
- scripts/analysis/99_en1_dual_role_analysis.py +137 -0
- scripts/analysis/README.md +20 -0
- scripts/common/README.md +14 -0
- scripts/common/__init__.py +0 -0
- scripts/common/cv_holdout.py +155 -0
- scripts/common/extract_prototypes_for_analysis.py +31 -0
- scripts/common/generate_missing_holdouts.py +65 -0
- scripts/common/nestorowa_zero_shot.py +168 -0
- scripts/common/phase_e_driver.sh +35 -0
- scripts/common/rerun_all_discovery.sh +31 -0
- scripts/common/run_all_retrains.sh +26 -0
- scripts/common/run_all_zero_shot.py +152 -0
scripts/__init__.py
ADDED
|
File without changes
|
scripts/__pycache__/__init__.cpython-310.pyc
ADDED
|
Binary file (131 Bytes). View file
|
|
|
scripts/analysis/100_primary_eden_discovery.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""primary EDEN discovery on Dingwall: PANDA-v3 dermal-fibro subset, Leiden res=1.5, wilcoxon markers + Fisher cKO depletion + module scoring."""
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
import warnings, json, sys, pickle, numpy as np, pandas as pd, anndata as ad, scanpy as sc, scipy.sparse as sp, torch, torch.nn.functional as F
|
| 4 |
+
from scipy.stats import fisher_exact, mannwhitneyu
|
| 5 |
+
warnings.filterwarnings("ignore"); sc.settings.verbosity = 0
|
| 6 |
+
sys.path.insert(0, "/home/bcheng/PRISM")
|
| 7 |
+
from panda import PANDAEncoder
|
| 8 |
+
|
| 9 |
+
ROOT = Path("/home/bcheng/PRISM")
|
| 10 |
+
DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 11 |
+
|
| 12 |
+
CKO_GSMS = {"GSM6833482", "GSM6833483"} # CORRECTED: 480/481 are rttaControl (WT), not cKO
|
| 13 |
+
WT_GSMS = {"GSM6833478", "GSM6833479", "GSM6833480", "GSM6833481"} # CORRECTED: 4 Cre-neg controls per GEO metadata
|
| 14 |
+
|
| 15 |
+
# secondary EDEN definition per Dingwall 2024
|
| 16 |
+
SECONDARY_EDEN_PANEL = ["S100a4", "Tnc", "Pdgfra"]
|
| 17 |
+
|
| 18 |
+
# En1-responsive eccrine program (from restored 57_pathway_analysis.py, En1 removed)
|
| 19 |
+
SWEAT_GLAND_PANEL_ENSMINUSEN1 = ["Foxi3", "Foxa1", "Krt8", "Krt18", "Krt19", "Muc5b", "Aqp5"]
|
| 20 |
+
# Eda pathway
|
| 21 |
+
EDA_PATHWAY_PANEL = ["Eda", "Edar", "Edaradd", "Nfkb1", "Nfkb2", "Rela"]
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def load_dingwall_with_v3_predictions():
|
| 25 |
+
print("[eden] loading Dingwall raw + v3 predictions", flush=True)
|
| 26 |
+
raw = ad.read_h5ad(ROOT / "data/raw/GSE220977_combined.h5ad")
|
| 27 |
+
pred = pd.read_csv(ROOT / "discovery/pan_skin/marker/dingwall_predictions.csv")
|
| 28 |
+
pred_map = dict(zip(pred["cell_id"].astype(str), pred["pred_label"]))
|
| 29 |
+
raw.obs["pred_label"] = np.array([pred_map.get(c, "unknown") for c in raw.obs_names.astype(str)])
|
| 30 |
+
raw.obs["genotype"] = np.where(raw.obs["sample"].astype(str).isin(list(CKO_GSMS)), "En1-cKO",
|
| 31 |
+
np.where(raw.obs["sample"].astype(str).isin(list(WT_GSMS)), "WT", "other"))
|
| 32 |
+
labeled = raw.obs["genotype"].isin(["WT", "En1-cKO"]).values
|
| 33 |
+
raw = raw[labeled].copy()
|
| 34 |
+
|
| 35 |
+
dermal_mask = np.isin(raw.obs["pred_label"], ["fibroblast-papillary", "fibroblast-reticular"])
|
| 36 |
+
dermal = raw[dermal_mask].copy()
|
| 37 |
+
print(f"[eden] {dermal.n_obs} dermal-fibroblast cells for sub-clustering", flush=True)
|
| 38 |
+
return dermal
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def subcluster_dermal(dermal, resolution=1.5):
|
| 42 |
+
print(f"[eden] preprocessing + PCA (Leiden resolution={resolution})", flush=True)
|
| 43 |
+
sc.pp.normalize_total(dermal, target_sum=1e4); sc.pp.log1p(dermal)
|
| 44 |
+
sc.pp.highly_variable_genes(dermal, n_top_genes=3000, flavor="seurat_v3",
|
| 45 |
+
inplace=True, batch_key=None)
|
| 46 |
+
dermal_hvg = dermal[:, dermal.var["highly_variable"]].copy() if "highly_variable" in dermal.var else dermal
|
| 47 |
+
sc.pp.scale(dermal_hvg, max_value=10)
|
| 48 |
+
sc.tl.pca(dermal_hvg, n_comps=30, random_state=0)
|
| 49 |
+
sc.pp.neighbors(dermal_hvg, n_neighbors=20, use_rep="X_pca")
|
| 50 |
+
sc.tl.leiden(dermal_hvg, resolution=resolution, random_state=0)
|
| 51 |
+
dermal.obs["leiden"] = dermal_hvg.obs["leiden"].astype(str)
|
| 52 |
+
print(f"[eden] {dermal.obs['leiden'].nunique()} sub-clusters found", flush=True)
|
| 53 |
+
return dermal
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def score_modules(dermal):
|
| 57 |
+
for name, genes in [("secondary_eden", SECONDARY_EDEN_PANEL),
|
| 58 |
+
("sweat_gland", SWEAT_GLAND_PANEL_ENSMINUSEN1),
|
| 59 |
+
("eda_pathway", EDA_PATHWAY_PANEL)]:
|
| 60 |
+
present = [g for g in genes if g in dermal.var_names]
|
| 61 |
+
if not present:
|
| 62 |
+
dermal.obs[f"score_{name}"] = 0.0
|
| 63 |
+
continue
|
| 64 |
+
sc.tl.score_genes(dermal, gene_list=present, score_name=f"score_{name}",
|
| 65 |
+
random_state=0, use_raw=False)
|
| 66 |
+
return dermal
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def per_subcluster_analysis(dermal):
|
| 70 |
+
n_wt_tot = int((dermal.obs["genotype"] == "WT").sum())
|
| 71 |
+
n_cko_tot = int((dermal.obs["genotype"] == "En1-cKO").sum())
|
| 72 |
+
baseline_cko_frac = n_cko_tot / (n_wt_tot + n_cko_tot)
|
| 73 |
+
print(f"[eden] baseline: WT={n_wt_tot} cKO={n_cko_tot} (baseline cKO frac = {baseline_cko_frac:.3f})", flush=True)
|
| 74 |
+
|
| 75 |
+
sc.tl.rank_genes_groups(dermal, "leiden", method="wilcoxon", n_genes=30, use_raw=False)
|
| 76 |
+
|
| 77 |
+
rows = []
|
| 78 |
+
for cls in sorted(dermal.obs["leiden"].unique(), key=int):
|
| 79 |
+
sub = dermal[dermal.obs["leiden"] == cls]
|
| 80 |
+
n_wt = int((sub.obs["genotype"] == "WT").sum())
|
| 81 |
+
n_cko = int((sub.obs["genotype"] == "En1-cKO").sum())
|
| 82 |
+
if n_wt + n_cko < 20:
|
| 83 |
+
continue
|
| 84 |
+
cko_frac = n_cko / (n_wt + n_cko)
|
| 85 |
+
|
| 86 |
+
# fisher 2x2: (n_wt_in, n_wt_out) vs (n_cko_in, n_cko_out) — cluster depletion in cKO
|
| 87 |
+
n_wt_elsewhere = n_wt_tot - n_wt
|
| 88 |
+
n_cko_elsewhere = n_cko_tot - n_cko
|
| 89 |
+
odds, p_fisher = fisher_exact([[n_wt, n_wt_elsewhere], [n_cko, n_cko_elsewhere]],
|
| 90 |
+
alternative="two-sided")
|
| 91 |
+
depletion_direction = "cKO-depleted" if cko_frac < baseline_cko_frac else "cKO-enriched"
|
| 92 |
+
|
| 93 |
+
s2eden_mean_wt = float(sub[sub.obs["genotype"] == "WT"].obs["score_secondary_eden"].mean()) if n_wt > 0 else 0.0
|
| 94 |
+
s2eden_mean_cko = float(sub[sub.obs["genotype"] == "En1-cKO"].obs["score_secondary_eden"].mean()) if n_cko > 0 else 0.0
|
| 95 |
+
sg_mean_wt = float(sub[sub.obs["genotype"] == "WT"].obs["score_sweat_gland"].mean()) if n_wt > 0 else 0.0
|
| 96 |
+
sg_mean_cko = float(sub[sub.obs["genotype"] == "En1-cKO"].obs["score_sweat_gland"].mean()) if n_cko > 0 else 0.0
|
| 97 |
+
eda_mean_wt = float(sub[sub.obs["genotype"] == "WT"].obs["score_eda_pathway"].mean()) if n_wt > 0 else 0.0
|
| 98 |
+
eda_mean_cko = float(sub[sub.obs["genotype"] == "En1-cKO"].obs["score_eda_pathway"].mean()) if n_cko > 0 else 0.0
|
| 99 |
+
|
| 100 |
+
genes_list = list(dermal.uns["rank_genes_groups"]["names"][cls][:10])
|
| 101 |
+
lfc_list = list(dermal.uns["rank_genes_groups"]["logfoldchanges"][cls][:10])
|
| 102 |
+
top_markers = ", ".join([f"{g}({lfc:+.1f})" for g, lfc in zip(genes_list, lfc_list)])
|
| 103 |
+
|
| 104 |
+
rows.append({
|
| 105 |
+
"leiden_cluster": cls,
|
| 106 |
+
"n_cells": n_wt + n_cko,
|
| 107 |
+
"n_WT": n_wt, "n_cKO": n_cko,
|
| 108 |
+
"cko_frac": cko_frac,
|
| 109 |
+
"baseline_cko_frac": baseline_cko_frac,
|
| 110 |
+
"depletion_direction": depletion_direction,
|
| 111 |
+
"fisher_p_two_sided": p_fisher,
|
| 112 |
+
"odds_ratio": odds,
|
| 113 |
+
"score_secondary_eden_WT_mean": s2eden_mean_wt,
|
| 114 |
+
"score_secondary_eden_cKO_mean": s2eden_mean_cko,
|
| 115 |
+
"score_sweat_gland_WT_mean": sg_mean_wt,
|
| 116 |
+
"score_sweat_gland_cKO_mean": sg_mean_cko,
|
| 117 |
+
"score_eda_pathway_WT_mean": eda_mean_wt,
|
| 118 |
+
"score_eda_pathway_cKO_mean": eda_mean_cko,
|
| 119 |
+
"top_wilcoxon_markers": top_markers,
|
| 120 |
+
})
|
| 121 |
+
return pd.DataFrame(rows), baseline_cko_frac
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def call_primary_and_secondary(df, baseline_cko_frac):
|
| 125 |
+
# secondary EDEN: highest score_secondary_eden_WT_mean AND cKO-depleted (Fisher p<0.05)
|
| 126 |
+
df_wt_ordered = df.sort_values("score_secondary_eden_WT_mean", ascending=False)
|
| 127 |
+
secondary_candidates = df_wt_ordered[
|
| 128 |
+
(df_wt_ordered["depletion_direction"] == "cKO-depleted") &
|
| 129 |
+
(df_wt_ordered["fisher_p_two_sided"] < 0.05)
|
| 130 |
+
]
|
| 131 |
+
secondary = secondary_candidates.iloc[0]["leiden_cluster"] if len(secondary_candidates) > 0 else None
|
| 132 |
+
|
| 133 |
+
# primary EDEN: cKO-depleted + LOW secondary_eden (S100a4-neg) + HIGH Eda_pathway (En1-responsive)
|
| 134 |
+
df_ranked = df.copy()
|
| 135 |
+
df_ranked["depletion_score"] = -np.log10(df_ranked["fisher_p_two_sided"].clip(lower=1e-300)) * \
|
| 136 |
+
(df_ranked["cko_frac"] < baseline_cko_frac).astype(int)
|
| 137 |
+
primary_score = df_ranked["depletion_score"] * \
|
| 138 |
+
(1.0 / (df_ranked["score_secondary_eden_WT_mean"].abs() + 0.01)) * \
|
| 139 |
+
(df_ranked["score_eda_pathway_WT_mean"] + 0.1)
|
| 140 |
+
df_ranked["primary_eden_composite_score"] = primary_score
|
| 141 |
+
df_ranked = df_ranked.sort_values("primary_eden_composite_score", ascending=False)
|
| 142 |
+
primary_candidates = df_ranked[
|
| 143 |
+
(df_ranked["depletion_direction"] == "cKO-depleted") &
|
| 144 |
+
(df_ranked["fisher_p_two_sided"] < 0.05) &
|
| 145 |
+
(df_ranked["leiden_cluster"] != secondary)
|
| 146 |
+
].head(3)
|
| 147 |
+
return secondary, primary_candidates, df_ranked
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def main():
|
| 151 |
+
dermal = load_dingwall_with_v3_predictions()
|
| 152 |
+
dermal = subcluster_dermal(dermal, resolution=1.5)
|
| 153 |
+
dermal = score_modules(dermal)
|
| 154 |
+
df, baseline_cko = per_subcluster_analysis(dermal)
|
| 155 |
+
secondary, primary_cands, df_ranked = call_primary_and_secondary(df, baseline_cko)
|
| 156 |
+
|
| 157 |
+
out = ROOT / "discovery/pan_skin/marker"
|
| 158 |
+
out.mkdir(parents=True, exist_ok=True)
|
| 159 |
+
df_ranked.to_csv(out / "100_primary_eden_discovery.csv", index=False)
|
| 160 |
+
|
| 161 |
+
summary = {
|
| 162 |
+
"target": "Dingwall_GSE220977",
|
| 163 |
+
"hypothesis": "Primary EDEN precedes Secondary EDEN (S100a4+/Tnc+ cluster 20/Derm10) in dermal lineage",
|
| 164 |
+
"method": "PANDA-v3 predicts dermal-fibroblast compartment; Leiden sub-clustering "
|
| 165 |
+
"(resolution=1.5) resolves substructure; Wilcoxon markers + Fisher-exact "
|
| 166 |
+
"cKO enrichment + module scoring (Secondary_EDEN, Sweat_gland, Eda_pathway) "
|
| 167 |
+
"rank sub-clusters for Primary EDEN candidacy",
|
| 168 |
+
"baseline_cko_frac": float(baseline_cko),
|
| 169 |
+
"n_subclusters": int(len(df)),
|
| 170 |
+
"secondary_eden_call": {
|
| 171 |
+
"leiden_cluster": str(secondary),
|
| 172 |
+
"criteria": "highest S100a4+Tnc+Pdgfra score AND Fisher cKO-depleted p<0.05",
|
| 173 |
+
"row": df[df["leiden_cluster"] == secondary].iloc[0].to_dict() if secondary else None,
|
| 174 |
+
},
|
| 175 |
+
"primary_eden_candidates_top3": primary_cands[[
|
| 176 |
+
"leiden_cluster", "n_cells", "n_WT", "n_cKO", "cko_frac",
|
| 177 |
+
"fisher_p_two_sided", "score_secondary_eden_WT_mean",
|
| 178 |
+
"score_sweat_gland_WT_mean", "score_eda_pathway_WT_mean",
|
| 179 |
+
"top_wilcoxon_markers", "primary_eden_composite_score",
|
| 180 |
+
]].to_dict("records") if len(primary_cands) > 0 else [],
|
| 181 |
+
}
|
| 182 |
+
(out / "100_primary_eden_summary.json").write_text(json.dumps(summary, indent=2, default=str))
|
| 183 |
+
|
| 184 |
+
print(f"\n[eden] wrote {out}/100_primary_eden_*", flush=True)
|
| 185 |
+
print(f"\n=== SECONDARY EDEN CALL ===", flush=True)
|
| 186 |
+
print(f" leiden cluster: {secondary}", flush=True)
|
| 187 |
+
if secondary:
|
| 188 |
+
row = df[df["leiden_cluster"] == secondary].iloc[0]
|
| 189 |
+
print(f" n={row['n_cells']} (WT {row['n_WT']} / cKO {row['n_cKO']}), "
|
| 190 |
+
f"cko_frac={row['cko_frac']:.3f} vs baseline {baseline_cko:.3f}", flush=True)
|
| 191 |
+
print(f" Fisher p={row['fisher_p_two_sided']:.2e}, "
|
| 192 |
+
f"score_secondary_eden WT={row['score_secondary_eden_WT_mean']:.3f}", flush=True)
|
| 193 |
+
|
| 194 |
+
print(f"\n=== PRIMARY EDEN CANDIDATES (top 3) ===", flush=True)
|
| 195 |
+
for _, row in primary_cands.iterrows():
|
| 196 |
+
print(f" leiden {row['leiden_cluster']} n={row['n_cells']} (WT {row['n_WT']} / cKO {row['n_cKO']}), "
|
| 197 |
+
f"cko_frac={row['cko_frac']:.3f}, Fisher p={row['fisher_p_two_sided']:.2e}", flush=True)
|
| 198 |
+
print(f" S2EDEN_WT={row['score_secondary_eden_WT_mean']:.3f}, "
|
| 199 |
+
f"Sweat_WT={row['score_sweat_gland_WT_mean']:.3f}, "
|
| 200 |
+
f"Eda_WT={row['score_eda_pathway_WT_mean']:.3f}", flush=True)
|
| 201 |
+
print(f" top markers: {row['top_wilcoxon_markers']}", flush=True)
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
if __name__ == "__main__":
|
| 205 |
+
main()
|
scripts/analysis/101_primary_eden_derm_scoring.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""primary + secondary EDEN on Dingwall via score_genes against dingwall's own Derm0-11 markers (Data S1C top-30); argmax identity + Fisher cKO depletion."""
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
import warnings, json, sys, numpy as np, pandas as pd, anndata as ad, scanpy as sc, scipy.sparse as sp
|
| 4 |
+
from scipy.stats import fisher_exact
|
| 5 |
+
warnings.filterwarnings("ignore"); sc.settings.verbosity = 0
|
| 6 |
+
|
| 7 |
+
ROOT = Path("/home/bcheng/PRISM")
|
| 8 |
+
CKO_GSMS = {"GSM6833482", "GSM6833483"} # CORRECTED: 480/481 are rttaControl (WT), not cKO
|
| 9 |
+
WT_GSMS = {"GSM6833478", "GSM6833479", "GSM6833480", "GSM6833481"} # CORRECTED: 4 Cre-neg controls per GEO metadata
|
| 10 |
+
DERM_MARKERS = ROOT / "data/external_labels/dingwall_supp/biorxiv_media-3.xlsx"
|
| 11 |
+
TOP_N = 30 # top N markers per Derm cluster for scoring
|
| 12 |
+
|
| 13 |
+
# EDEN identity map based on Data S1C + Data S2 CellChat
|
| 14 |
+
EDEN_IDENTITY = {
|
| 15 |
+
10: "Secondary_EDEN",
|
| 16 |
+
2: "Primary_EDEN_candidate_1",
|
| 17 |
+
9: "Primary_EDEN_candidate_2",
|
| 18 |
+
3: "EDEN_signalling",
|
| 19 |
+
6: "EDEN_signalling",
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def load_derm_marker_panels():
|
| 24 |
+
df = pd.read_excel(DERM_MARKERS)
|
| 25 |
+
df = df.sort_values(["cluster", "avg_log2FC"], ascending=[True, False])
|
| 26 |
+
panels = {}
|
| 27 |
+
for cl in sorted(df["cluster"].unique()):
|
| 28 |
+
genes = df[df["cluster"] == cl].head(TOP_N)["gene"].tolist()
|
| 29 |
+
panels[int(cl)] = genes
|
| 30 |
+
return panels
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def load_dingwall_v3_dermal():
|
| 34 |
+
raw = ad.read_h5ad(ROOT / "data/raw/GSE220977_combined.h5ad")
|
| 35 |
+
pred = pd.read_csv(ROOT / "discovery/pan_skin/marker/dingwall_predictions.csv")
|
| 36 |
+
pred_map = dict(zip(pred["cell_id"].astype(str), pred["pred_label"]))
|
| 37 |
+
raw.obs["pred_label"] = np.array([pred_map.get(c, "unknown") for c in raw.obs_names.astype(str)])
|
| 38 |
+
raw.obs["genotype"] = np.where(raw.obs["sample"].astype(str).isin(list(CKO_GSMS)), "En1-cKO",
|
| 39 |
+
np.where(raw.obs["sample"].astype(str).isin(list(WT_GSMS)), "WT", "other"))
|
| 40 |
+
labeled = raw.obs["genotype"].isin(["WT", "En1-cKO"]).values
|
| 41 |
+
raw = raw[labeled].copy()
|
| 42 |
+
dermal_mask = np.isin(raw.obs["pred_label"], ["fibroblast-papillary", "fibroblast-reticular"])
|
| 43 |
+
dermal = raw[dermal_mask].copy()
|
| 44 |
+
return dermal
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def score_derm_identities(a, panels):
|
| 48 |
+
for cl, genes in panels.items():
|
| 49 |
+
present = [g for g in genes if g in a.var_names]
|
| 50 |
+
if not present:
|
| 51 |
+
a.obs[f"derm{cl}_score"] = 0.0
|
| 52 |
+
continue
|
| 53 |
+
sc.tl.score_genes(a, gene_list=present, score_name=f"derm{cl}_score",
|
| 54 |
+
random_state=0, use_raw=False)
|
| 55 |
+
return a
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def main():
|
| 59 |
+
print("[eden] loading Data S1C marker panels", flush=True)
|
| 60 |
+
panels = load_derm_marker_panels()
|
| 61 |
+
print(f"[eden] loaded {len(panels)} Derm panels", flush=True)
|
| 62 |
+
|
| 63 |
+
print("[eden] loading Dingwall + PANDA-v3 dermal predictions", flush=True)
|
| 64 |
+
dermal = load_dingwall_v3_dermal()
|
| 65 |
+
print(f"[eden] {dermal.n_obs} dermal-fibroblast cells", flush=True)
|
| 66 |
+
|
| 67 |
+
print("[eden] normalise + log1p", flush=True)
|
| 68 |
+
sc.pp.normalize_total(dermal, target_sum=1e4); sc.pp.log1p(dermal)
|
| 69 |
+
|
| 70 |
+
print("[eden] scoring cells on all 12 Derm identity panels", flush=True)
|
| 71 |
+
dermal = score_derm_identities(dermal, panels)
|
| 72 |
+
|
| 73 |
+
score_cols = [f"derm{cl}_score" for cl in sorted(panels.keys())]
|
| 74 |
+
score_mat = dermal.obs[score_cols].values
|
| 75 |
+
argmax = np.argmax(score_mat, axis=1)
|
| 76 |
+
derm_ids = [int(score_cols[i].replace("derm", "").replace("_score", "")) for i in argmax]
|
| 77 |
+
dermal.obs["derm_identity"] = derm_ids
|
| 78 |
+
dermal.obs["max_derm_score"] = score_mat.max(axis=1)
|
| 79 |
+
|
| 80 |
+
n_wt_tot = int((dermal.obs["genotype"] == "WT").sum())
|
| 81 |
+
n_cko_tot = int((dermal.obs["genotype"] == "En1-cKO").sum())
|
| 82 |
+
baseline_cko_frac = n_cko_tot / (n_wt_tot + n_cko_tot)
|
| 83 |
+
print(f"[eden] baseline WT={n_wt_tot} cKO={n_cko_tot} (baseline cKO={baseline_cko_frac:.3f})", flush=True)
|
| 84 |
+
|
| 85 |
+
rows = []
|
| 86 |
+
for cl in sorted(panels.keys()):
|
| 87 |
+
sub = dermal[dermal.obs["derm_identity"] == cl]
|
| 88 |
+
n_wt = int((sub.obs["genotype"] == "WT").sum())
|
| 89 |
+
n_cko = int((sub.obs["genotype"] == "En1-cKO").sum())
|
| 90 |
+
if n_wt + n_cko == 0:
|
| 91 |
+
continue
|
| 92 |
+
cko_frac = n_cko / (n_wt + n_cko) if (n_wt + n_cko) > 0 else 0
|
| 93 |
+
n_wt_else = n_wt_tot - n_wt
|
| 94 |
+
n_cko_else = n_cko_tot - n_cko
|
| 95 |
+
try:
|
| 96 |
+
odds, p_fisher = fisher_exact([[n_wt, n_wt_else], [n_cko, n_cko_else]],
|
| 97 |
+
alternative="two-sided")
|
| 98 |
+
except ValueError:
|
| 99 |
+
odds, p_fisher = 1.0, 1.0
|
| 100 |
+
rows.append({
|
| 101 |
+
"derm_id": cl,
|
| 102 |
+
"identity": EDEN_IDENTITY.get(cl, "other"),
|
| 103 |
+
"n_cells": n_wt + n_cko,
|
| 104 |
+
"n_WT": n_wt, "n_cKO": n_cko,
|
| 105 |
+
"cko_frac": cko_frac,
|
| 106 |
+
"baseline_cko_frac": baseline_cko_frac,
|
| 107 |
+
"cko_delta": cko_frac - baseline_cko_frac,
|
| 108 |
+
"fisher_p": float(p_fisher),
|
| 109 |
+
"odds_ratio": float(odds) if not np.isnan(odds) else None,
|
| 110 |
+
"depletion_direction": "cKO-depleted" if cko_frac < baseline_cko_frac
|
| 111 |
+
else "cKO-enriched",
|
| 112 |
+
"top10_markers_dingwall": ", ".join(panels[cl][:10]),
|
| 113 |
+
})
|
| 114 |
+
df = pd.DataFrame(rows).sort_values("cko_delta")
|
| 115 |
+
|
| 116 |
+
out = ROOT / "discovery/pan_skin/marker"
|
| 117 |
+
out.mkdir(parents=True, exist_ok=True)
|
| 118 |
+
df.to_csv(out / "101_derm_subcluster_scores.csv", index=False)
|
| 119 |
+
|
| 120 |
+
secondary_row = df[df["derm_id"] == 10].iloc[0].to_dict() if 10 in df["derm_id"].values else None
|
| 121 |
+
primary_1_row = df[df["derm_id"] == 2].iloc[0].to_dict() if 2 in df["derm_id"].values else None
|
| 122 |
+
primary_2_row = df[df["derm_id"] == 9].iloc[0].to_dict() if 9 in df["derm_id"].values else None
|
| 123 |
+
|
| 124 |
+
summary = {
|
| 125 |
+
"target": "Dingwall_GSE220977",
|
| 126 |
+
"method": "Score PANDA-v3 dermal-fibroblast predictions on Dingwall's own Derm0-11 "
|
| 127 |
+
"marker panels (Data S1C top-30 genes each); argmax identity per cell; "
|
| 128 |
+
"Fisher-exact cKO depletion per Derm identity",
|
| 129 |
+
"baseline_cko_frac": baseline_cko_frac,
|
| 130 |
+
"n_dermal_cells_total": int(dermal.n_obs),
|
| 131 |
+
"secondary_eden_Derm10": secondary_row,
|
| 132 |
+
"primary_eden_Derm2": primary_1_row,
|
| 133 |
+
"primary_eden_Derm9": primary_2_row,
|
| 134 |
+
"all_derm_summary": df.to_dict("records"),
|
| 135 |
+
}
|
| 136 |
+
(out / "101_derm_identity_summary.json").write_text(json.dumps(summary, indent=2, default=str))
|
| 137 |
+
|
| 138 |
+
print(f"\n[eden] wrote {out}/101_derm_*", flush=True)
|
| 139 |
+
print(f"\n=== SECONDARY EDEN (Derm10) ===", flush=True)
|
| 140 |
+
if secondary_row:
|
| 141 |
+
print(f" n={secondary_row['n_cells']} (WT {secondary_row['n_WT']} / cKO {secondary_row['n_cKO']}), "
|
| 142 |
+
f"cko_frac={secondary_row['cko_frac']:.3f} vs baseline {baseline_cko_frac:.3f}", flush=True)
|
| 143 |
+
print(f" {secondary_row['depletion_direction']}, Fisher p={secondary_row['fisher_p']:.2e}", flush=True)
|
| 144 |
+
|
| 145 |
+
print(f"\n=== PRIMARY EDEN candidate 1 (Derm2) ===", flush=True)
|
| 146 |
+
if primary_1_row:
|
| 147 |
+
print(f" n={primary_1_row['n_cells']} (WT {primary_1_row['n_WT']} / cKO {primary_1_row['n_cKO']}), "
|
| 148 |
+
f"cko_frac={primary_1_row['cko_frac']:.3f}", flush=True)
|
| 149 |
+
print(f" {primary_1_row['depletion_direction']}, Fisher p={primary_1_row['fisher_p']:.2e}", flush=True)
|
| 150 |
+
|
| 151 |
+
print(f"\n=== PRIMARY EDEN candidate 2 (Derm9) ===", flush=True)
|
| 152 |
+
if primary_2_row:
|
| 153 |
+
print(f" n={primary_2_row['n_cells']} (WT {primary_2_row['n_WT']} / cKO {primary_2_row['n_cKO']}), "
|
| 154 |
+
f"cko_frac={primary_2_row['cko_frac']:.3f}", flush=True)
|
| 155 |
+
print(f" {primary_2_row['depletion_direction']}, Fisher p={primary_2_row['fisher_p']:.2e}", flush=True)
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
if __name__ == "__main__":
|
| 159 |
+
main()
|
scripts/analysis/102_train_on_dingwall_variantA.py
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""variant A — semi-supervised panda on dingwall using data s3 marker panels with score+margin gate."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
import warnings, json, sys, time
|
| 5 |
+
warnings.filterwarnings("ignore")
|
| 6 |
+
|
| 7 |
+
import numpy as np
|
| 8 |
+
import pandas as pd
|
| 9 |
+
import anndata as ad
|
| 10 |
+
import scanpy as sc
|
| 11 |
+
import scipy.sparse as sp
|
| 12 |
+
from scipy.stats import fisher_exact
|
| 13 |
+
import torch
|
| 14 |
+
import torch.nn.functional as F
|
| 15 |
+
from torch.utils.data import Dataset, DataLoader
|
| 16 |
+
|
| 17 |
+
sys.path.insert(0, "/home/bcheng/PRISM")
|
| 18 |
+
from panda.model import (
|
| 19 |
+
PANDAEncoder, supcon_loss, vicreg_loss, hsic_biased, subcenter_angular_infonce
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
ROOT = Path("/home/bcheng/PRISM")
|
| 23 |
+
RAW_H5 = ROOT / "data/raw/GSE220977_combined.h5ad"
|
| 24 |
+
DERM_MARKERS = ROOT / "data/external_labels/dingwall_supp/biorxiv_media-3.xlsx"
|
| 25 |
+
OUT_DIR = ROOT / "discovery/pan_skin/marker"
|
| 26 |
+
CK_DIR = ROOT / "checkpoints/pan_skin_dingwall_variantA"
|
| 27 |
+
|
| 28 |
+
# Dingwall GSM -> genotype (see 101_primary_eden_derm_scoring)
|
| 29 |
+
CKO_GSMS = {"GSM6833482", "GSM6833483"} # CORRECTED: 480/481 are rttaControl (WT), not cKO
|
| 30 |
+
WT_GSMS = {"GSM6833478", "GSM6833479", "GSM6833480", "GSM6833481"} # CORRECTED: 4 Cre-neg controls per GEO metadata
|
| 31 |
+
|
| 32 |
+
TOP_N = 30 # markers per Derm panel for scoring
|
| 33 |
+
SCORE_MIN = 0.10 # min score to accept a pseudo-label
|
| 34 |
+
MARGIN_MIN = 0.05 # min gap best - runner-up
|
| 35 |
+
N_HVG = 2000 # matches paper
|
| 36 |
+
N_PCA = 40 # matches paper
|
| 37 |
+
DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 38 |
+
|
| 39 |
+
# Training config (mirrors 20_train_panda.py)
|
| 40 |
+
GUARANTEED_PER_CLASS = 6
|
| 41 |
+
NATURAL_SLOTS = 96
|
| 42 |
+
STAGE_EPOCHS = [15, 25, 40, 40]
|
| 43 |
+
BALANCE_MIX = 0.5
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
# ---------- data prep ----------
|
| 47 |
+
|
| 48 |
+
def load_derm_panels(top_n: int = TOP_N) -> dict[int, list[str]]:
|
| 49 |
+
df = pd.read_excel(DERM_MARKERS)
|
| 50 |
+
df = df.sort_values(["cluster", "avg_log2FC"], ascending=[True, False])
|
| 51 |
+
return {int(c): df[df["cluster"] == c].head(top_n)["gene"].tolist()
|
| 52 |
+
for c in sorted(df["cluster"].unique())}
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def load_dingwall_dermal() -> ad.AnnData:
|
| 56 |
+
a = ad.read_h5ad(RAW_H5)
|
| 57 |
+
sample = a.obs["sample"].astype(str)
|
| 58 |
+
a.obs["genotype"] = np.where(sample.isin(list(CKO_GSMS)), "En1-cKO",
|
| 59 |
+
np.where(sample.isin(list(WT_GSMS)), "WT", "other"))
|
| 60 |
+
a = a[a.obs["genotype"].isin(["WT", "En1-cKO"])].copy()
|
| 61 |
+
return a
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def preprocess_paper_style(a: ad.AnnData) -> ad.AnnData:
|
| 65 |
+
"""lognormalize + hvg(2000) + pca(40) + harmony per-sample, matches dingwall STAR methods."""
|
| 66 |
+
sc.pp.filter_genes(a, min_cells=10)
|
| 67 |
+
sc.pp.normalize_total(a, target_sum=1e4)
|
| 68 |
+
sc.pp.log1p(a)
|
| 69 |
+
sc.pp.highly_variable_genes(a, n_top_genes=N_HVG, flavor="seurat", batch_key="sample")
|
| 70 |
+
sc.pp.scale(a, max_value=10, zero_center=False)
|
| 71 |
+
sc.tl.pca(a, n_comps=N_PCA, use_highly_variable=True, zero_center=False)
|
| 72 |
+
try:
|
| 73 |
+
import harmonypy as hm # noqa
|
| 74 |
+
sc.external.pp.harmony_integrate(a, key="sample", basis="X_pca",
|
| 75 |
+
adjusted_basis="X_pca_harmony", max_iter_harmony=20)
|
| 76 |
+
a.obsm["X_train"] = a.obsm["X_pca_harmony"]
|
| 77 |
+
except Exception as exc:
|
| 78 |
+
print(f"[preprocess] harmony skipped ({exc}); using raw PCA", flush=True)
|
| 79 |
+
a.obsm["X_train"] = a.obsm["X_pca"]
|
| 80 |
+
return a
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
# ---------- pseudo-labelling ----------
|
| 84 |
+
|
| 85 |
+
def score_and_gate(a: ad.AnnData, panels: dict[int, list[str]],
|
| 86 |
+
score_min: float = SCORE_MIN,
|
| 87 |
+
margin_min: float = MARGIN_MIN) -> ad.AnnData:
|
| 88 |
+
"""score cells on 12 derm panels; accept label if best>score_min and margin>margin_min."""
|
| 89 |
+
for cl, genes in panels.items():
|
| 90 |
+
present = [g for g in genes if g in a.var_names]
|
| 91 |
+
if not present:
|
| 92 |
+
a.obs[f"derm{cl}_score"] = 0.0
|
| 93 |
+
else:
|
| 94 |
+
sc.tl.score_genes(a, gene_list=present, score_name=f"derm{cl}_score",
|
| 95 |
+
random_state=0, use_raw=False)
|
| 96 |
+
cols = [f"derm{cl}_score" for cl in sorted(panels)]
|
| 97 |
+
S = a.obs[cols].values
|
| 98 |
+
top1_ix = S.argmax(axis=1)
|
| 99 |
+
top1 = S[np.arange(len(S)), top1_ix]
|
| 100 |
+
S_copy = S.copy(); S_copy[np.arange(len(S)), top1_ix] = -np.inf
|
| 101 |
+
top2 = S_copy.max(axis=1)
|
| 102 |
+
margin = top1 - top2
|
| 103 |
+
accept = (top1 > score_min) & (margin > margin_min)
|
| 104 |
+
|
| 105 |
+
ids = np.array([int(cols[i].replace("derm", "").replace("_score", "")) for i in top1_ix])
|
| 106 |
+
a.obs["derm_pseudo"] = ids
|
| 107 |
+
a.obs["derm_pseudo_top1"] = top1
|
| 108 |
+
a.obs["derm_pseudo_margin"] = margin
|
| 109 |
+
a.obs["derm_pseudo_accept"] = accept
|
| 110 |
+
return a
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
# ---------- PANDA training (mirrors 20_train_panda.py) ----------
|
| 114 |
+
|
| 115 |
+
class CorpusDataset(Dataset):
|
| 116 |
+
def __init__(self, X, y, d, aux):
|
| 117 |
+
self.X = X.astype(np.float32); self.y = y.astype(np.int64)
|
| 118 |
+
self.d = d.astype(np.int64); self.aux = aux.astype(np.float32)
|
| 119 |
+
def __len__(self): return self.X.shape[0]
|
| 120 |
+
def __getitem__(self, i):
|
| 121 |
+
return (torch.from_numpy(self.X[i]), torch.tensor(self.y[i]),
|
| 122 |
+
torch.tensor(self.d[i]), torch.from_numpy(self.aux[i]))
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
class HybridSampler:
|
| 126 |
+
def __init__(self, y, n_batches=100, seed=0):
|
| 127 |
+
self.y = np.asarray(y); self.n_batches = n_batches
|
| 128 |
+
self.rng = np.random.default_rng(seed)
|
| 129 |
+
self.classes = np.unique(self.y)
|
| 130 |
+
self.by_cls = {int(c): np.where(self.y == c)[0] for c in self.classes}
|
| 131 |
+
counts = np.bincount(self.y, minlength=int(self.classes.max()) + 1).astype(float)
|
| 132 |
+
self.natural_p = counts / counts.sum()
|
| 133 |
+
def __iter__(self):
|
| 134 |
+
for _ in range(self.n_batches):
|
| 135 |
+
batch = []
|
| 136 |
+
for c in self.classes:
|
| 137 |
+
idx = self.by_cls[int(c)]
|
| 138 |
+
take = min(GUARANTEED_PER_CLASS, len(idx))
|
| 139 |
+
if take > 0:
|
| 140 |
+
batch.extend(self.rng.choice(idx, size=take, replace=(len(idx) < take)).tolist())
|
| 141 |
+
for _ in range(NATURAL_SLOTS):
|
| 142 |
+
c = self.rng.choice(len(self.natural_p), p=self.natural_p)
|
| 143 |
+
idx = self.by_cls.get(int(c), self.by_cls[int(self.classes[0])])
|
| 144 |
+
batch.append(int(self.rng.choice(idx)))
|
| 145 |
+
yield batch
|
| 146 |
+
def __len__(self): return self.n_batches
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def train_panda(X_tr, y_tr, d_tr, aux_tr, n_classes, n_datasets, ck_out: Path):
|
| 150 |
+
ck_out.mkdir(parents=True, exist_ok=True)
|
| 151 |
+
counts = np.bincount(y_tr, minlength=n_classes)
|
| 152 |
+
inv_sqrt = 1.0 / np.sqrt(counts + 1); inv_sqrt = inv_sqrt / inv_sqrt.mean()
|
| 153 |
+
class_w = BALANCE_MIX * inv_sqrt + (1 - BALANCE_MIX) * np.ones_like(inv_sqrt)
|
| 154 |
+
class_w = torch.tensor(class_w, dtype=torch.float32, device=DEVICE)
|
| 155 |
+
|
| 156 |
+
ds = CorpusDataset(X_tr, y_tr, d_tr, aux_tr)
|
| 157 |
+
loader = DataLoader(ds, batch_sampler=HybridSampler(y_tr, n_batches=100), num_workers=0)
|
| 158 |
+
|
| 159 |
+
model = PANDAEncoder(variant="pca", n_pca=X_tr.shape[1], n_classes=n_classes,
|
| 160 |
+
n_datasets=n_datasets).to(DEVICE)
|
| 161 |
+
opt = torch.optim.AdamW(model.parameters(), lr=3e-4, weight_decay=1e-4)
|
| 162 |
+
|
| 163 |
+
for stage, n_ep in enumerate(STAGE_EPOCHS):
|
| 164 |
+
print(f"[panda-A] stage {stage} ({n_ep} epochs)", flush=True)
|
| 165 |
+
for e in range(n_ep):
|
| 166 |
+
t0 = time.time(); losses = []
|
| 167 |
+
for X_b, y_b, d_b, aux_b in loader:
|
| 168 |
+
X_b = X_b.to(DEVICE); y_b = y_b.to(DEVICE); d_b = d_b.to(DEVICE); aux_b = aux_b.to(DEVICE)
|
| 169 |
+
lam = 1.0 if stage >= 2 else 0.0
|
| 170 |
+
out = model(X_b, aux_b, lam_dann=lam)
|
| 171 |
+
L_supcon = supcon_loss(out["z"], y_b)
|
| 172 |
+
L_vic = vicreg_loss(out["z"])
|
| 173 |
+
L_ce = F.cross_entropy(out["logits"], y_b, weight=class_w, label_smoothing=0.05)
|
| 174 |
+
total = L_supcon + 1.0 * L_vic + 0.4 * L_ce
|
| 175 |
+
if stage >= 1:
|
| 176 |
+
proto_ref = model.prototypes.detach().clone()
|
| 177 |
+
total = total + 0.6 * subcenter_angular_infonce(out["z"], y_b, proto_ref)
|
| 178 |
+
if stage >= 2:
|
| 179 |
+
total = total + F.cross_entropy(out["dom"], d_b)
|
| 180 |
+
total = total + 0.3 * F.mse_loss(out["depth"].squeeze(1), aux_b[:, 1])
|
| 181 |
+
total = total + 0.05 * hsic_biased(out["repr"], aux_b[:, 1:2])
|
| 182 |
+
opt.zero_grad(); total.backward()
|
| 183 |
+
torch.nn.utils.clip_grad_norm_(model.parameters(), 5.0)
|
| 184 |
+
opt.step()
|
| 185 |
+
if stage >= 1:
|
| 186 |
+
model.update_prototypes(out["z"].detach(), y_b)
|
| 187 |
+
losses.append(float(total.item()))
|
| 188 |
+
if (e + 1) % 5 == 0:
|
| 189 |
+
print(f" ep {e+1}/{n_ep} loss={np.mean(losses):.3f} dt={time.time()-t0:.1f}s", flush=True)
|
| 190 |
+
torch.save({"model": model.state_dict()}, ck_out / f"panda_stage{stage}.pt")
|
| 191 |
+
|
| 192 |
+
torch.save({"model": model.state_dict(),
|
| 193 |
+
"prototypes": model.prototypes.detach().cpu().numpy()},
|
| 194 |
+
ck_out / "panda_final.pt")
|
| 195 |
+
return model
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
@torch.no_grad()
|
| 199 |
+
def infer(model, X, aux):
|
| 200 |
+
model.eval()
|
| 201 |
+
Xt = torch.from_numpy(X.astype(np.float32)).to(DEVICE)
|
| 202 |
+
at = torch.from_numpy(aux.astype(np.float32)).to(DEVICE)
|
| 203 |
+
B = 4096; preds = []; confs = []
|
| 204 |
+
for i in range(0, len(Xt), B):
|
| 205 |
+
out = model(Xt[i:i+B], at[i:i+B])
|
| 206 |
+
p = F.softmax(out["logits"], dim=1)
|
| 207 |
+
preds.append(p.argmax(dim=1).cpu().numpy())
|
| 208 |
+
confs.append(p.max(dim=1).values.cpu().numpy())
|
| 209 |
+
return np.concatenate(preds), np.concatenate(confs)
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
# ---------- reporting ----------
|
| 213 |
+
|
| 214 |
+
def report_depletion(labels: np.ndarray, genotype: np.ndarray, n_classes: int) -> pd.DataFrame:
|
| 215 |
+
n_wt = int((genotype == "WT").sum()); n_cko = int((genotype == "En1-cKO").sum())
|
| 216 |
+
base = n_cko / max(n_wt + n_cko, 1)
|
| 217 |
+
rows = []
|
| 218 |
+
for c in range(n_classes):
|
| 219 |
+
m = labels == c
|
| 220 |
+
w = int(((genotype == "WT") & m).sum()); k = int(((genotype == "En1-cKO") & m).sum())
|
| 221 |
+
if w + k == 0: continue
|
| 222 |
+
try:
|
| 223 |
+
odds, p = fisher_exact([[w, n_wt - w], [k, n_cko - k]], alternative="two-sided")
|
| 224 |
+
except ValueError:
|
| 225 |
+
odds, p = 1.0, 1.0
|
| 226 |
+
rows.append({"derm_id": c, "n": w + k, "n_WT": w, "n_cKO": k,
|
| 227 |
+
"cko_frac": k / (w + k), "baseline": base,
|
| 228 |
+
"odds_ratio": float(odds), "fisher_p": float(p)})
|
| 229 |
+
return pd.DataFrame(rows).sort_values("cko_frac")
|
| 230 |
+
|
| 231 |
+
|
| 232 |
+
def main():
|
| 233 |
+
OUT_DIR.mkdir(parents=True, exist_ok=True); CK_DIR.mkdir(parents=True, exist_ok=True)
|
| 234 |
+
|
| 235 |
+
print("[A] load panels + dermal Dingwall", flush=True)
|
| 236 |
+
panels = load_derm_panels()
|
| 237 |
+
a = load_dingwall_dermal()
|
| 238 |
+
# reuse existing panda-v3 fibroblast calls if present, else all cells
|
| 239 |
+
pred_csv = ROOT / "discovery/pan_skin/marker/dingwall_predictions.csv"
|
| 240 |
+
if pred_csv.exists():
|
| 241 |
+
pred = pd.read_csv(pred_csv)
|
| 242 |
+
pred_map = dict(zip(pred["cell_id"].astype(str), pred["pred_label"]))
|
| 243 |
+
a.obs["v3_label"] = [pred_map.get(c, "unknown") for c in a.obs_names.astype(str)]
|
| 244 |
+
a = a[np.isin(a.obs["v3_label"], ["fibroblast-papillary", "fibroblast-reticular"])].copy()
|
| 245 |
+
print(f"[A] restricted to PANDA-v3 fibroblasts: n={a.n_obs}", flush=True)
|
| 246 |
+
|
| 247 |
+
print("[A] paper-style preprocess", flush=True)
|
| 248 |
+
a = preprocess_paper_style(a)
|
| 249 |
+
|
| 250 |
+
print("[A] score + gate pseudo-labels", flush=True)
|
| 251 |
+
a = score_and_gate(a, panels)
|
| 252 |
+
n_acc = int(a.obs["derm_pseudo_accept"].sum())
|
| 253 |
+
print(f"[A] pseudo-label acceptance: {n_acc}/{a.n_obs} ({100*n_acc/a.n_obs:.1f}%)", flush=True)
|
| 254 |
+
|
| 255 |
+
# train/heldout split (gate = train; rest = infer)
|
| 256 |
+
train_mask = a.obs["derm_pseudo_accept"].values.astype(bool)
|
| 257 |
+
X_all = np.asarray(a.obsm["X_train"])
|
| 258 |
+
y_all = a.obs["derm_pseudo"].astype(int).values
|
| 259 |
+
sample_ix = {s: i for i, s in enumerate(sorted(a.obs["sample"].astype(str).unique()))}
|
| 260 |
+
d_all = np.array([sample_ix[s] for s in a.obs["sample"].astype(str)])
|
| 261 |
+
aux_all = np.stack([np.zeros(a.n_obs, dtype=np.float32),
|
| 262 |
+
np.log10(np.asarray(a.X.sum(axis=1)).ravel() + 1)], axis=1)
|
| 263 |
+
aux_all[:, 1] = (aux_all[:, 1] - aux_all[:, 1].mean()) / (aux_all[:, 1].std() + 1e-6)
|
| 264 |
+
|
| 265 |
+
classes = sorted(np.unique(y_all[train_mask]).tolist())
|
| 266 |
+
if len(classes) < 2:
|
| 267 |
+
print("[A] not enough classes accepted; abort", flush=True); return
|
| 268 |
+
cls_ix = {c: i for i, c in enumerate(classes)}
|
| 269 |
+
y_all_ix = np.array([cls_ix.get(int(c), -1) for c in y_all])
|
| 270 |
+
y_tr = y_all_ix[train_mask]
|
| 271 |
+
X_tr = X_all[train_mask]; d_tr = d_all[train_mask]; aux_tr = aux_all[train_mask]
|
| 272 |
+
|
| 273 |
+
print(f"[A] train n={train_mask.sum()} on {len(classes)} classes: {classes}", flush=True)
|
| 274 |
+
model = train_panda(X_tr, y_tr, d_tr, aux_tr, n_classes=len(classes),
|
| 275 |
+
n_datasets=len(sample_ix), ck_out=CK_DIR)
|
| 276 |
+
|
| 277 |
+
# inference on held-out
|
| 278 |
+
infer_mask = ~train_mask
|
| 279 |
+
preds_ix, confs = infer(model, X_all[infer_mask], aux_all[infer_mask])
|
| 280 |
+
preds_derm = np.array([classes[p] for p in preds_ix])
|
| 281 |
+
|
| 282 |
+
# combine: use pseudo-label on train, prediction on inference
|
| 283 |
+
final = np.where(train_mask, y_all,
|
| 284 |
+
np.concatenate([y_all[train_mask].astype(int) * 0 - 1, # placeholder
|
| 285 |
+
preds_derm.astype(int)])[:a.n_obs] if False else 0)
|
| 286 |
+
# simpler: assemble directly
|
| 287 |
+
final = y_all.astype(int).copy()
|
| 288 |
+
final[infer_mask] = preds_derm.astype(int)
|
| 289 |
+
|
| 290 |
+
df = pd.DataFrame({
|
| 291 |
+
"cell_id": a.obs_names.astype(str).values,
|
| 292 |
+
"sample": a.obs["sample"].astype(str).values,
|
| 293 |
+
"genotype": a.obs["genotype"].astype(str).values,
|
| 294 |
+
"pseudo_derm": y_all,
|
| 295 |
+
"pseudo_accept": train_mask,
|
| 296 |
+
"final_derm": final,
|
| 297 |
+
})
|
| 298 |
+
df.to_csv(OUT_DIR / "102_variantA_predictions.csv", index=False)
|
| 299 |
+
|
| 300 |
+
dep = report_depletion(final, a.obs["genotype"].values, n_classes=12)
|
| 301 |
+
dep.to_csv(OUT_DIR / "102_variantA_depletion.csv", index=False)
|
| 302 |
+
summary = {
|
| 303 |
+
"variant": "A_semi_supervised_S3_scoring",
|
| 304 |
+
"score_min": SCORE_MIN, "margin_min": MARGIN_MIN, "top_n": TOP_N,
|
| 305 |
+
"n_total": int(a.n_obs), "n_train_pseudo": int(train_mask.sum()),
|
| 306 |
+
"classes_trained": classes,
|
| 307 |
+
"derm10": dep[dep["derm_id"] == 10].to_dict("records"),
|
| 308 |
+
"derm2": dep[dep["derm_id"] == 2].to_dict("records"),
|
| 309 |
+
"derm9": dep[dep["derm_id"] == 9].to_dict("records"),
|
| 310 |
+
"all": dep.to_dict("records"),
|
| 311 |
+
}
|
| 312 |
+
(OUT_DIR / "102_variantA_summary.json").write_text(json.dumps(summary, indent=2, default=str))
|
| 313 |
+
print(f"[A] done -> {OUT_DIR}/102_variantA_*", flush=True)
|
| 314 |
+
|
| 315 |
+
|
| 316 |
+
if __name__ == "__main__":
|
| 317 |
+
main()
|
scripts/analysis/103_replicate_dingwall_seurat_pipeline.py
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""replicate dingwall's seurat clustering (QC, harmony, PCA, leiden) to derive Derm0..Derm11 labels."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
import warnings, json, sys
|
| 5 |
+
warnings.filterwarnings("ignore")
|
| 6 |
+
|
| 7 |
+
import numpy as np
|
| 8 |
+
import pandas as pd
|
| 9 |
+
import anndata as ad
|
| 10 |
+
import scanpy as sc
|
| 11 |
+
|
| 12 |
+
ROOT = Path("/home/bcheng/PRISM")
|
| 13 |
+
RAW_H5 = ROOT / "data/raw/GSE220977_combined.h5ad"
|
| 14 |
+
DERM_MARKERS = ROOT / "data/external_labels/dingwall_supp/biorxiv_media-3.xlsx"
|
| 15 |
+
TOP_MARKERS = ROOT / "data/external_labels/dingwall_supp/biorxiv_media-1.xlsx"
|
| 16 |
+
OUT_DIR = ROOT / "data/processed/dingwall_replica"
|
| 17 |
+
|
| 18 |
+
CKO_GSMS = {"GSM6833482", "GSM6833483"} # CORRECTED: 480/481 are rttaControl (WT), not cKO
|
| 19 |
+
WT_GSMS = {"GSM6833478", "GSM6833479", "GSM6833480", "GSM6833481"} # CORRECTED: 4 Cre-neg controls per GEO metadata
|
| 20 |
+
|
| 21 |
+
# Paper values
|
| 22 |
+
DERMAL_TOP_CLUSTERS = {0, 1, 3, 4, 5, 8, 11, 20} # from STAR Methods
|
| 23 |
+
N_HVG = 2000
|
| 24 |
+
N_PCA = 40
|
| 25 |
+
RES = 0.7
|
| 26 |
+
N_LEIDEN_DERM = 12 # target: Derm0..Derm11
|
| 27 |
+
JACCARD_TOP_N = 50 # top-N markers for label mapping
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
# ---------- QC + preprocessing ----------
|
| 31 |
+
|
| 32 |
+
def qc_filter(a: ad.AnnData) -> ad.AnnData:
|
| 33 |
+
a.var["mt"] = a.var_names.str.upper().str.startswith("MT-") | \
|
| 34 |
+
a.var_names.str.startswith("mt-")
|
| 35 |
+
sc.pp.calculate_qc_metrics(a, qc_vars=["mt"], inplace=True, percent_top=None,
|
| 36 |
+
log1p=False)
|
| 37 |
+
sc.pp.filter_cells(a, min_genes=300)
|
| 38 |
+
a = a[a.obs["n_genes_by_counts"] < 6000].copy()
|
| 39 |
+
a = a[a.obs["pct_counts_mt"] < 5].copy()
|
| 40 |
+
sc.pp.filter_genes(a, min_cells=10)
|
| 41 |
+
return a
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def lognorm(a: ad.AnnData) -> ad.AnnData:
|
| 45 |
+
a.layers["counts"] = a.X.copy() if not hasattr(a.X, "toarray") or True else a.X.copy()
|
| 46 |
+
sc.pp.normalize_total(a, target_sum=1e4)
|
| 47 |
+
sc.pp.log1p(a)
|
| 48 |
+
return a
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def hvg_pca_harmony(a: ad.AnnData, n_hvg=N_HVG, n_pca=N_PCA, batch_key="sample") -> ad.AnnData:
|
| 52 |
+
sc.pp.highly_variable_genes(a, n_top_genes=n_hvg, flavor="seurat", batch_key=batch_key)
|
| 53 |
+
a_use = a[:, a.var["highly_variable"]].copy()
|
| 54 |
+
sc.pp.scale(a_use, max_value=10, zero_center=False)
|
| 55 |
+
sc.tl.pca(a_use, n_comps=n_pca, use_highly_variable=False, zero_center=False)
|
| 56 |
+
# copy PCA back — a_use has same obs rows as a
|
| 57 |
+
a.obsm["X_pca"] = a_use.obsm["X_pca"].copy()
|
| 58 |
+
rep = "X_pca"
|
| 59 |
+
try:
|
| 60 |
+
# harmonypy directly on the pca matrix to avoid scanpy wrapper obsm-shape bug
|
| 61 |
+
import harmonypy as hm
|
| 62 |
+
pca_mat = a.obsm["X_pca"].copy()
|
| 63 |
+
meta = a.obs[[batch_key]].reset_index(drop=True)
|
| 64 |
+
ho = hm.run_harmony(pca_mat, meta, batch_key, max_iter_harmony=20)
|
| 65 |
+
# ho.Z_corr is (pcs, cells); make it (cells, pcs)
|
| 66 |
+
z = ho.Z_corr
|
| 67 |
+
if z.shape[1] == a.n_obs:
|
| 68 |
+
harm_mat = np.ascontiguousarray(z.T)
|
| 69 |
+
elif z.shape[0] == a.n_obs:
|
| 70 |
+
harm_mat = np.ascontiguousarray(z)
|
| 71 |
+
else:
|
| 72 |
+
raise RuntimeError(f"unknown harmony shape {z.shape}, n_obs={a.n_obs}")
|
| 73 |
+
if harm_mat.shape[0] == a.n_obs and harm_mat.shape[1] == pca_mat.shape[1]:
|
| 74 |
+
a.obsm["X_pca_harmony"] = harm_mat
|
| 75 |
+
rep = "X_pca_harmony"
|
| 76 |
+
print(f"[replica] Harmony ok, X_pca_harmony shape={harm_mat.shape}", flush=True)
|
| 77 |
+
else:
|
| 78 |
+
print(f"[replica] Harmony output shape mismatch ({harm_mat.shape}); using X_pca", flush=True)
|
| 79 |
+
except Exception as exc:
|
| 80 |
+
print(f"[replica] Harmony skipped ({exc}); using X_pca", flush=True)
|
| 81 |
+
a.uns["_replica_rep"] = rep
|
| 82 |
+
return a
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def leiden_cluster(a: ad.AnnData, res=RES) -> ad.AnnData:
|
| 86 |
+
rep = a.uns.get("_replica_rep", "X_pca")
|
| 87 |
+
sc.pp.neighbors(a, n_neighbors=20, use_rep=rep, n_pcs=N_PCA)
|
| 88 |
+
sc.tl.leiden(a, resolution=res, key_added="leiden")
|
| 89 |
+
return a
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
# ---------- 23-cluster stage (map dermal identity) ----------
|
| 93 |
+
|
| 94 |
+
def call_dermal_23(a: ad.AnnData) -> ad.AnnData:
|
| 95 |
+
"""first-pass clustering; mark cells whose leiden id maps to DERMAL_TOP_CLUSTERS."""
|
| 96 |
+
print("[23] preprocess", flush=True)
|
| 97 |
+
a = qc_filter(a); a = lognorm(a); a = hvg_pca_harmony(a)
|
| 98 |
+
print("[23] leiden res=0.7", flush=True)
|
| 99 |
+
a = leiden_cluster(a, res=RES)
|
| 100 |
+
|
| 101 |
+
# rank markers per top-level cluster
|
| 102 |
+
sc.tl.rank_genes_groups(a, "leiden", method="wilcoxon", n_genes=100)
|
| 103 |
+
df_tl = pd.read_excel(TOP_MARKERS) # Data S1 all-cluster markers
|
| 104 |
+
tl_panels = {int(c): df_tl[df_tl["cluster"] == c].sort_values("avg_log2FC", ascending=False)
|
| 105 |
+
.head(JACCARD_TOP_N)["gene"].tolist() for c in sorted(df_tl["cluster"].unique())}
|
| 106 |
+
tl_map = map_leiden_to_paper(a, "leiden", tl_panels, top_n=JACCARD_TOP_N)
|
| 107 |
+
a.obs["paper_cluster_23"] = a.obs["leiden"].map(lambda c: tl_map.get(str(c), -1))
|
| 108 |
+
a.obs["is_dermal_paper"] = a.obs["paper_cluster_23"].isin(DERMAL_TOP_CLUSTERS)
|
| 109 |
+
print(f"[23] cells matched to paper dermal set: {int(a.obs['is_dermal_paper'].sum())}",
|
| 110 |
+
flush=True)
|
| 111 |
+
return a
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def map_leiden_to_paper(a: ad.AnnData, key: str, paper_panels: dict[int, list[str]],
|
| 115 |
+
top_n: int = JACCARD_TOP_N) -> dict[str, int]:
|
| 116 |
+
"""best-matching paper cluster per leiden id via jaccard on top-N markers."""
|
| 117 |
+
ranks = a.uns["rank_genes_groups"]
|
| 118 |
+
names = pd.DataFrame(ranks["names"])
|
| 119 |
+
out = {}
|
| 120 |
+
used = set()
|
| 121 |
+
scores = []
|
| 122 |
+
for lc in names.columns:
|
| 123 |
+
my_top = set(names[lc].dropna().tolist()[:top_n])
|
| 124 |
+
best_pc, best_j = None, -1.0
|
| 125 |
+
for pc, panel in paper_panels.items():
|
| 126 |
+
j = len(my_top & set(panel[:top_n])) / max(len(my_top | set(panel[:top_n])), 1)
|
| 127 |
+
if j > best_j:
|
| 128 |
+
best_pc, best_j = pc, j
|
| 129 |
+
scores.append({"leiden": lc, "best_paper": best_pc, "jaccard": best_j})
|
| 130 |
+
out[lc] = best_pc
|
| 131 |
+
# convert to json-safe strings for h5ad serialization
|
| 132 |
+
a.uns[f"_map_scores_{key}"] = json.dumps(scores, default=str)
|
| 133 |
+
return out
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
# ---------- dermal subclustering stage (Derm0..Derm11) ----------
|
| 137 |
+
|
| 138 |
+
def subcluster_dermal(a: ad.AnnData) -> ad.AnnData:
|
| 139 |
+
dermal = a[a.obs["is_dermal_paper"]].copy()
|
| 140 |
+
# start again from raw counts on the subset
|
| 141 |
+
if "counts" in dermal.layers:
|
| 142 |
+
dermal.X = dermal.layers["counts"]
|
| 143 |
+
print(f"[derm] subset n={dermal.n_obs}", flush=True)
|
| 144 |
+
dermal = lognorm(dermal)
|
| 145 |
+
dermal = hvg_pca_harmony(dermal)
|
| 146 |
+
# tune res to hit ~12 clusters; res=0.7 is the paper value but scanpy Leiden can
|
| 147 |
+
# differ from Seurat FindClusters, so we sweep if the exact-res doesn't give 12
|
| 148 |
+
dermal = leiden_cluster(dermal, res=RES)
|
| 149 |
+
# paper uses seurat FindClusters at res=0.7; scanpy leiden can differ so sweep to hit 12
|
| 150 |
+
if len(dermal.obs["leiden"].unique()) != N_LEIDEN_DERM:
|
| 151 |
+
for r in [0.5, 0.6, 0.7, 0.8, 0.9, 1.0, 1.1, 1.2]:
|
| 152 |
+
sc.tl.leiden(dermal, resolution=r, key_added=f"leiden_r{r}")
|
| 153 |
+
if len(dermal.obs[f"leiden_r{r}"].unique()) == N_LEIDEN_DERM:
|
| 154 |
+
dermal.obs["leiden"] = dermal.obs[f"leiden_r{r}"]
|
| 155 |
+
dermal.uns["_replica_derm_res"] = r
|
| 156 |
+
break
|
| 157 |
+
print(f"[derm] n_leiden = {len(dermal.obs['leiden'].unique())}", flush=True)
|
| 158 |
+
|
| 159 |
+
# rank markers + map to Derm0..Derm11
|
| 160 |
+
sc.tl.rank_genes_groups(dermal, "leiden", method="wilcoxon", n_genes=100)
|
| 161 |
+
df_s3 = pd.read_excel(DERM_MARKERS)
|
| 162 |
+
derm_panels = {int(c): df_s3[df_s3["cluster"] == c].sort_values("avg_log2FC", ascending=False)
|
| 163 |
+
.head(JACCARD_TOP_N)["gene"].tolist() for c in sorted(df_s3["cluster"].unique())}
|
| 164 |
+
derm_map = map_leiden_to_paper(dermal, "leiden", derm_panels, top_n=JACCARD_TOP_N)
|
| 165 |
+
dermal.obs["derm_label"] = dermal.obs["leiden"].map(lambda c: f"Derm{derm_map.get(str(c), -1)}")
|
| 166 |
+
|
| 167 |
+
# push labels back into full object
|
| 168 |
+
labels = pd.Series("non_dermal", index=a.obs_names)
|
| 169 |
+
labels.loc[dermal.obs_names] = dermal.obs["derm_label"].values
|
| 170 |
+
a.obs["derm_label"] = labels
|
| 171 |
+
a.obs["leiden_derm"] = ""
|
| 172 |
+
a.obs.loc[dermal.obs_names, "leiden_derm"] = dermal.obs["leiden"].astype(str).values
|
| 173 |
+
# h5py-safe: stringify keys AND serialize dicts to json
|
| 174 |
+
a.uns["derm_leiden_to_paper"] = json.dumps({str(k): int(v) if v is not None else -1
|
| 175 |
+
for k, v in derm_map.items()}, default=str)
|
| 176 |
+
a.uns["derm_panels_used"] = json.dumps({str(k): [str(g) for g in v]
|
| 177 |
+
for k, v in derm_panels.items()}, default=str)
|
| 178 |
+
return a, dermal
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
# ---------- QC of the replica: cluster 20 fractions ----------
|
| 182 |
+
|
| 183 |
+
def qc_cluster_20(a: ad.AnnData) -> dict:
|
| 184 |
+
a.obs["genotype"] = a.obs.get("genotype", pd.Series("unknown", index=a.obs_names))
|
| 185 |
+
if "genotype" not in a.obs or a.obs["genotype"].nunique() < 2:
|
| 186 |
+
s = a.obs["sample"].astype(str)
|
| 187 |
+
a.obs["genotype"] = np.where(s.isin(list(CKO_GSMS)), "En1-cKO",
|
| 188 |
+
np.where(s.isin(list(WT_GSMS)), "WT", "other"))
|
| 189 |
+
|
| 190 |
+
dermal_mask = a.obs["is_dermal_paper"].values
|
| 191 |
+
wt_derm = int(((a.obs["genotype"] == "WT") & dermal_mask).sum())
|
| 192 |
+
ck_derm = int(((a.obs["genotype"] == "En1-cKO") & dermal_mask).sum())
|
| 193 |
+
|
| 194 |
+
# top-level cluster 20 replica
|
| 195 |
+
c20 = a.obs["paper_cluster_23"] == 20
|
| 196 |
+
wt_c20 = int(((a.obs["genotype"] == "WT") & c20).sum())
|
| 197 |
+
ck_c20 = int(((a.obs["genotype"] == "En1-cKO") & c20).sum())
|
| 198 |
+
|
| 199 |
+
# derm10 replica
|
| 200 |
+
d10 = a.obs["derm_label"] == "Derm10"
|
| 201 |
+
wt_d10 = int(((a.obs["genotype"] == "WT") & d10).sum())
|
| 202 |
+
ck_d10 = int(((a.obs["genotype"] == "En1-cKO") & d10).sum())
|
| 203 |
+
|
| 204 |
+
return {
|
| 205 |
+
"expected_paper": {"wt_dermal_total": 17398, "cko_dermal_total": 8461,
|
| 206 |
+
"wt_c20_pct": 1.99, "cko_c20_pct": 0.08,
|
| 207 |
+
"wt_c20_abs": 346, "cko_c20_abs": 7},
|
| 208 |
+
"replica": {
|
| 209 |
+
"wt_dermal_total": wt_derm, "cko_dermal_total": ck_derm,
|
| 210 |
+
"wt_c20": wt_c20, "cko_c20": ck_c20,
|
| 211 |
+
"wt_c20_pct": 100 * wt_c20 / max(wt_derm, 1),
|
| 212 |
+
"cko_c20_pct": 100 * ck_c20 / max(ck_derm, 1),
|
| 213 |
+
"wt_derm10": wt_d10, "cko_derm10": ck_d10,
|
| 214 |
+
"wt_derm10_pct": 100 * wt_d10 / max(wt_derm, 1),
|
| 215 |
+
"cko_derm10_pct": 100 * ck_d10 / max(ck_derm, 1),
|
| 216 |
+
},
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
def main():
|
| 221 |
+
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
| 222 |
+
print("[replica] load raw", flush=True)
|
| 223 |
+
a = ad.read_h5ad(RAW_H5)
|
| 224 |
+
# inject genotype
|
| 225 |
+
s = a.obs["sample"].astype(str)
|
| 226 |
+
a.obs["genotype"] = np.where(s.isin(list(CKO_GSMS)), "En1-cKO",
|
| 227 |
+
np.where(s.isin(list(WT_GSMS)), "WT", "other"))
|
| 228 |
+
a = a[a.obs["genotype"].isin(["WT", "En1-cKO"])].copy()
|
| 229 |
+
print(f"[replica] n={a.n_obs}", flush=True)
|
| 230 |
+
|
| 231 |
+
print("[replica] 23-cluster stage", flush=True)
|
| 232 |
+
a = call_dermal_23(a)
|
| 233 |
+
|
| 234 |
+
print("[replica] dermal subcluster stage", flush=True)
|
| 235 |
+
a, dermal = subcluster_dermal(a)
|
| 236 |
+
|
| 237 |
+
print("[replica] QC vs paper", flush=True)
|
| 238 |
+
qc = qc_cluster_20(a)
|
| 239 |
+
(OUT_DIR / "replica_cluster_20_qc.json").write_text(json.dumps(qc, indent=2, default=str))
|
| 240 |
+
print(json.dumps(qc, indent=2, default=str), flush=True)
|
| 241 |
+
|
| 242 |
+
# write per-Leiden -> paper mapping (parse json-back)
|
| 243 |
+
derm_map_parsed = json.loads(a.uns["derm_leiden_to_paper"])
|
| 244 |
+
mm = pd.DataFrame([{"leiden_derm": k, "paper_derm": v}
|
| 245 |
+
for k, v in derm_map_parsed.items()])
|
| 246 |
+
mm.to_csv(OUT_DIR / "replica_marker_matches.csv", index=False)
|
| 247 |
+
|
| 248 |
+
# save — first stringify any datetime/complex obs cols to survive h5ad serialization
|
| 249 |
+
for col in list(a.obs.columns):
|
| 250 |
+
dt = a.obs[col].dtype
|
| 251 |
+
if pd.api.types.is_datetime64_any_dtype(dt) or dt == object:
|
| 252 |
+
try:
|
| 253 |
+
a.obs[col] = a.obs[col].astype(str)
|
| 254 |
+
except Exception:
|
| 255 |
+
del a.obs[col]
|
| 256 |
+
a.write_h5ad(OUT_DIR / "dingwall_replica.h5ad")
|
| 257 |
+
print(f"[replica] wrote {OUT_DIR}/dingwall_replica.h5ad", flush=True)
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
if __name__ == "__main__":
|
| 261 |
+
main()
|
scripts/analysis/104_train_on_dingwall_derm_labels.py
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""variant B — fully supervised panda on replicated dingwall Derm0..Derm11 labels from script 103."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
import warnings, json, sys, time
|
| 5 |
+
warnings.filterwarnings("ignore")
|
| 6 |
+
|
| 7 |
+
import numpy as np
|
| 8 |
+
import pandas as pd
|
| 9 |
+
import anndata as ad
|
| 10 |
+
import torch
|
| 11 |
+
import torch.nn.functional as F
|
| 12 |
+
from torch.utils.data import Dataset, DataLoader
|
| 13 |
+
from scipy.stats import fisher_exact
|
| 14 |
+
|
| 15 |
+
sys.path.insert(0, "/home/bcheng/PRISM")
|
| 16 |
+
from panda.model import (
|
| 17 |
+
PANDAEncoder, supcon_loss, vicreg_loss, hsic_biased, subcenter_angular_infonce
|
| 18 |
+
)
|
| 19 |
+
|
| 20 |
+
ROOT = Path("/home/bcheng/PRISM")
|
| 21 |
+
REPLICA_H5 = ROOT / "data/processed/dingwall_replica/dingwall_replica.h5ad"
|
| 22 |
+
OUT_DIR = ROOT / "discovery/pan_skin/marker"
|
| 23 |
+
CK_DIR = ROOT / "checkpoints/pan_skin_dingwall_derm"
|
| 24 |
+
|
| 25 |
+
TRAIN_FRAC = 0.7
|
| 26 |
+
SEED = 0
|
| 27 |
+
N_PCA = 40
|
| 28 |
+
DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 29 |
+
|
| 30 |
+
# Training config (mirrors 20_train_panda.py)
|
| 31 |
+
GUARANTEED_PER_CLASS = 6
|
| 32 |
+
NATURAL_SLOTS = 96
|
| 33 |
+
STAGE_EPOCHS = [15, 25, 40, 40]
|
| 34 |
+
BALANCE_MIX = 0.5
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
# ---------- split ----------
|
| 38 |
+
|
| 39 |
+
def genotype_stratified_split(labels: np.ndarray, genotypes: np.ndarray,
|
| 40 |
+
frac_train: float = TRAIN_FRAC, seed: int = SEED
|
| 41 |
+
) -> tuple[np.ndarray, np.ndarray]:
|
| 42 |
+
"""stratified 70/30 within each (label, genotype) group; preserves cKO/WT ratio per class."""
|
| 43 |
+
rng = np.random.default_rng(seed)
|
| 44 |
+
n = len(labels); train = np.zeros(n, dtype=bool); test = np.zeros(n, dtype=bool)
|
| 45 |
+
for lab in np.unique(labels):
|
| 46 |
+
for g in np.unique(genotypes):
|
| 47 |
+
idx = np.where((labels == lab) & (genotypes == g))[0]
|
| 48 |
+
if len(idx) == 0: continue
|
| 49 |
+
rng.shuffle(idx)
|
| 50 |
+
k = max(1, int(len(idx) * frac_train)) if len(idx) > 1 else len(idx)
|
| 51 |
+
train[idx[:k]] = True
|
| 52 |
+
if len(idx) > 1:
|
| 53 |
+
test[idx[k:]] = True
|
| 54 |
+
return train, test
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
# ---------- PANDA training (identical to variant A) ----------
|
| 58 |
+
|
| 59 |
+
class CorpusDataset(Dataset):
|
| 60 |
+
def __init__(self, X, y, d, aux):
|
| 61 |
+
self.X = X.astype(np.float32); self.y = y.astype(np.int64)
|
| 62 |
+
self.d = d.astype(np.int64); self.aux = aux.astype(np.float32)
|
| 63 |
+
def __len__(self): return self.X.shape[0]
|
| 64 |
+
def __getitem__(self, i):
|
| 65 |
+
return (torch.from_numpy(self.X[i]), torch.tensor(self.y[i]),
|
| 66 |
+
torch.tensor(self.d[i]), torch.from_numpy(self.aux[i]))
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
class HybridSampler:
|
| 70 |
+
def __init__(self, y, n_batches=100, seed=0):
|
| 71 |
+
self.y = np.asarray(y); self.n_batches = n_batches
|
| 72 |
+
self.rng = np.random.default_rng(seed)
|
| 73 |
+
self.classes = np.unique(self.y)
|
| 74 |
+
self.by_cls = {int(c): np.where(self.y == c)[0] for c in self.classes}
|
| 75 |
+
counts = np.bincount(self.y, minlength=int(self.classes.max()) + 1).astype(float)
|
| 76 |
+
self.natural_p = counts / counts.sum()
|
| 77 |
+
def __iter__(self):
|
| 78 |
+
for _ in range(self.n_batches):
|
| 79 |
+
batch = []
|
| 80 |
+
for c in self.classes:
|
| 81 |
+
idx = self.by_cls[int(c)]
|
| 82 |
+
take = min(GUARANTEED_PER_CLASS, len(idx))
|
| 83 |
+
if take > 0:
|
| 84 |
+
batch.extend(self.rng.choice(idx, size=take, replace=(len(idx) < take)).tolist())
|
| 85 |
+
for _ in range(NATURAL_SLOTS):
|
| 86 |
+
c = self.rng.choice(len(self.natural_p), p=self.natural_p)
|
| 87 |
+
idx = self.by_cls.get(int(c), self.by_cls[int(self.classes[0])])
|
| 88 |
+
batch.append(int(self.rng.choice(idx)))
|
| 89 |
+
yield batch
|
| 90 |
+
def __len__(self): return self.n_batches
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def train_panda(X_tr, y_tr, d_tr, aux_tr, n_classes, n_datasets, ck_out: Path):
|
| 94 |
+
ck_out.mkdir(parents=True, exist_ok=True)
|
| 95 |
+
counts = np.bincount(y_tr, minlength=n_classes)
|
| 96 |
+
inv_sqrt = 1.0 / np.sqrt(counts + 1); inv_sqrt = inv_sqrt / inv_sqrt.mean()
|
| 97 |
+
class_w = BALANCE_MIX * inv_sqrt + (1 - BALANCE_MIX) * np.ones_like(inv_sqrt)
|
| 98 |
+
class_w = torch.tensor(class_w, dtype=torch.float32, device=DEVICE)
|
| 99 |
+
|
| 100 |
+
ds = CorpusDataset(X_tr, y_tr, d_tr, aux_tr)
|
| 101 |
+
loader = DataLoader(ds, batch_sampler=HybridSampler(y_tr, n_batches=100), num_workers=0)
|
| 102 |
+
|
| 103 |
+
model = PANDAEncoder(variant="pca", n_pca=X_tr.shape[1], n_classes=n_classes,
|
| 104 |
+
n_datasets=n_datasets).to(DEVICE)
|
| 105 |
+
opt = torch.optim.AdamW(model.parameters(), lr=3e-4, weight_decay=1e-4)
|
| 106 |
+
|
| 107 |
+
for stage, n_ep in enumerate(STAGE_EPOCHS):
|
| 108 |
+
print(f"[panda-B] stage {stage} ({n_ep} epochs)", flush=True)
|
| 109 |
+
for e in range(n_ep):
|
| 110 |
+
t0 = time.time(); losses = []
|
| 111 |
+
for X_b, y_b, d_b, aux_b in loader:
|
| 112 |
+
X_b = X_b.to(DEVICE); y_b = y_b.to(DEVICE); d_b = d_b.to(DEVICE); aux_b = aux_b.to(DEVICE)
|
| 113 |
+
lam = 1.0 if stage >= 2 else 0.0
|
| 114 |
+
out = model(X_b, aux_b, lam_dann=lam)
|
| 115 |
+
L_supcon = supcon_loss(out["z"], y_b)
|
| 116 |
+
L_vic = vicreg_loss(out["z"])
|
| 117 |
+
L_ce = F.cross_entropy(out["logits"], y_b, weight=class_w, label_smoothing=0.05)
|
| 118 |
+
total = L_supcon + 1.0 * L_vic + 0.4 * L_ce
|
| 119 |
+
if stage >= 1:
|
| 120 |
+
proto_ref = model.prototypes.detach().clone()
|
| 121 |
+
total = total + 0.6 * subcenter_angular_infonce(out["z"], y_b, proto_ref)
|
| 122 |
+
if stage >= 2:
|
| 123 |
+
total = total + F.cross_entropy(out["dom"], d_b)
|
| 124 |
+
total = total + 0.3 * F.mse_loss(out["depth"].squeeze(1), aux_b[:, 1])
|
| 125 |
+
total = total + 0.05 * hsic_biased(out["repr"], aux_b[:, 1:2])
|
| 126 |
+
opt.zero_grad(); total.backward()
|
| 127 |
+
torch.nn.utils.clip_grad_norm_(model.parameters(), 5.0)
|
| 128 |
+
opt.step()
|
| 129 |
+
if stage >= 1:
|
| 130 |
+
model.update_prototypes(out["z"].detach(), y_b)
|
| 131 |
+
losses.append(float(total.item()))
|
| 132 |
+
if (e + 1) % 5 == 0:
|
| 133 |
+
print(f" ep {e+1}/{n_ep} loss={np.mean(losses):.3f} dt={time.time()-t0:.1f}s", flush=True)
|
| 134 |
+
torch.save({"model": model.state_dict()}, ck_out / f"panda_stage{stage}.pt")
|
| 135 |
+
torch.save({"model": model.state_dict(),
|
| 136 |
+
"prototypes": model.prototypes.detach().cpu().numpy()},
|
| 137 |
+
ck_out / "panda_final.pt")
|
| 138 |
+
return model
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
@torch.no_grad()
|
| 142 |
+
def infer(model, X, aux):
|
| 143 |
+
model.eval()
|
| 144 |
+
Xt = torch.from_numpy(X.astype(np.float32)).to(DEVICE)
|
| 145 |
+
at = torch.from_numpy(aux.astype(np.float32)).to(DEVICE)
|
| 146 |
+
B = 4096; preds = []; confs = []
|
| 147 |
+
for i in range(0, len(Xt), B):
|
| 148 |
+
out = model(Xt[i:i+B], at[i:i+B])
|
| 149 |
+
p = F.softmax(out["logits"], dim=1)
|
| 150 |
+
preds.append(p.argmax(dim=1).cpu().numpy())
|
| 151 |
+
confs.append(p.max(dim=1).values.cpu().numpy())
|
| 152 |
+
return np.concatenate(preds), np.concatenate(confs)
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
# ---------- reporting ----------
|
| 156 |
+
|
| 157 |
+
def depletion_table(true_or_pred: np.ndarray, genotype: np.ndarray, class_names: list[str]
|
| 158 |
+
) -> pd.DataFrame:
|
| 159 |
+
n_wt = int((genotype == "WT").sum()); n_cko = int((genotype == "En1-cKO").sum())
|
| 160 |
+
base = n_cko / max(n_wt + n_cko, 1)
|
| 161 |
+
rows = []
|
| 162 |
+
for i, cn in enumerate(class_names):
|
| 163 |
+
m = true_or_pred == i
|
| 164 |
+
w = int(((genotype == "WT") & m).sum()); k = int(((genotype == "En1-cKO") & m).sum())
|
| 165 |
+
if w + k == 0: continue
|
| 166 |
+
try:
|
| 167 |
+
odds, p = fisher_exact([[w, n_wt - w], [k, n_cko - k]], alternative="two-sided")
|
| 168 |
+
except ValueError:
|
| 169 |
+
odds, p = 1.0, 1.0
|
| 170 |
+
rows.append({"derm_label": cn, "n": w + k, "n_WT": w, "n_cKO": k,
|
| 171 |
+
"cko_frac": k / (w + k), "baseline_cko": base,
|
| 172 |
+
"odds_ratio": float(odds), "fisher_p": float(p)})
|
| 173 |
+
return pd.DataFrame(rows).sort_values("cko_frac")
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def main():
|
| 177 |
+
OUT_DIR.mkdir(parents=True, exist_ok=True); CK_DIR.mkdir(parents=True, exist_ok=True)
|
| 178 |
+
|
| 179 |
+
print("[B] load replica", flush=True)
|
| 180 |
+
if not REPLICA_H5.exists():
|
| 181 |
+
raise FileNotFoundError(f"Run 103 first — {REPLICA_H5} missing")
|
| 182 |
+
a = ad.read_h5ad(REPLICA_H5)
|
| 183 |
+
|
| 184 |
+
dermal = a[a.obs["derm_label"].astype(str) != "non_dermal"].copy()
|
| 185 |
+
print(f"[B] dermal n={dermal.n_obs}", flush=True)
|
| 186 |
+
labels_str = dermal.obs["derm_label"].astype(str).values
|
| 187 |
+
classes = sorted(set(labels_str))
|
| 188 |
+
cls_ix = {c: i for i, c in enumerate(classes)}
|
| 189 |
+
y_all = np.array([cls_ix[c] for c in labels_str])
|
| 190 |
+
genotype = dermal.obs["genotype"].astype(str).values
|
| 191 |
+
|
| 192 |
+
# get embedding from replica (harmony-corrected PCA)
|
| 193 |
+
rep_key = dermal.uns.get("_replica_rep", "X_pca_harmony")
|
| 194 |
+
if rep_key not in dermal.obsm:
|
| 195 |
+
rep_key = "X_pca_harmony" if "X_pca_harmony" in dermal.obsm else "X_pca"
|
| 196 |
+
X_all = np.asarray(dermal.obsm[rep_key])
|
| 197 |
+
print(f"[B] using {rep_key} (d={X_all.shape[1]})", flush=True)
|
| 198 |
+
|
| 199 |
+
sample_ix = {s: i for i, s in enumerate(sorted(dermal.obs["sample"].astype(str).unique()))}
|
| 200 |
+
d_all = np.array([sample_ix[s] for s in dermal.obs["sample"].astype(str)])
|
| 201 |
+
total_counts = np.asarray(dermal.X.sum(axis=1)).ravel()
|
| 202 |
+
logc = np.log10(total_counts + 1); logc = (logc - logc.mean()) / (logc.std() + 1e-6)
|
| 203 |
+
aux_all = np.stack([np.zeros(dermal.n_obs, dtype=np.float32), logc.astype(np.float32)], axis=1)
|
| 204 |
+
|
| 205 |
+
print("[B] genotype-stratified 70/30 split", flush=True)
|
| 206 |
+
tr, te = genotype_stratified_split(labels_str, genotype, frac_train=TRAIN_FRAC, seed=SEED)
|
| 207 |
+
print(f"[B] train={tr.sum()} test={te.sum()}", flush=True)
|
| 208 |
+
|
| 209 |
+
manifest = pd.DataFrame({
|
| 210 |
+
"cell_id": dermal.obs_names.astype(str).values,
|
| 211 |
+
"derm_label": labels_str,
|
| 212 |
+
"genotype": genotype,
|
| 213 |
+
"split": np.where(tr, "train", np.where(te, "test", "unassigned")),
|
| 214 |
+
})
|
| 215 |
+
manifest.to_csv(OUT_DIR / "104_dingwall_derm_split_manifest.csv", index=False)
|
| 216 |
+
|
| 217 |
+
print("[B] train PANDA", flush=True)
|
| 218 |
+
model = train_panda(X_all[tr], y_all[tr], d_all[tr], aux_all[tr],
|
| 219 |
+
n_classes=len(classes), n_datasets=len(sample_ix), ck_out=CK_DIR)
|
| 220 |
+
|
| 221 |
+
print("[B] infer on held-out", flush=True)
|
| 222 |
+
pred_ix, conf = infer(model, X_all[te], aux_all[te])
|
| 223 |
+
pred = pd.DataFrame({
|
| 224 |
+
"cell_id": dermal.obs_names.astype(str).values[te],
|
| 225 |
+
"derm_true": labels_str[te],
|
| 226 |
+
"derm_pred": [classes[p] for p in pred_ix],
|
| 227 |
+
"confidence": conf,
|
| 228 |
+
"genotype": genotype[te],
|
| 229 |
+
})
|
| 230 |
+
pred.to_csv(OUT_DIR / "104_dingwall_derm_predictions.csv", index=False)
|
| 231 |
+
|
| 232 |
+
# depletion — reported for TEST set only, using PANDA predictions
|
| 233 |
+
pred_ix_full = np.array([cls_ix[c] for c in pred["derm_pred"].values])
|
| 234 |
+
dep_pred = depletion_table(pred_ix_full, genotype[te], classes)
|
| 235 |
+
dep_true = depletion_table(y_all[te], genotype[te], classes)
|
| 236 |
+
dep_pred.to_csv(OUT_DIR / "104_dingwall_derm_depletion_pred.csv", index=False)
|
| 237 |
+
dep_true.to_csv(OUT_DIR / "104_dingwall_derm_depletion_true.csv", index=False)
|
| 238 |
+
|
| 239 |
+
d10_true = dep_true[dep_true["derm_label"] == "Derm10"].to_dict("records")
|
| 240 |
+
d10_pred = dep_pred[dep_pred["derm_label"] == "Derm10"].to_dict("records")
|
| 241 |
+
acc = float((pred_ix == y_all[te]).mean())
|
| 242 |
+
|
| 243 |
+
summary = {
|
| 244 |
+
"variant": "B_fully_supervised_replica_labels",
|
| 245 |
+
"n_dermal_total": int(dermal.n_obs),
|
| 246 |
+
"n_train": int(tr.sum()), "n_test": int(te.sum()),
|
| 247 |
+
"classes": classes,
|
| 248 |
+
"test_accuracy": acc,
|
| 249 |
+
"expected_paper_derm10": {"wt_pct": 1.99, "cko_pct": 0.08,
|
| 250 |
+
"or_approx": 24.5, "wt_n_approx": 346, "cko_n_approx": 7},
|
| 251 |
+
"test_derm10_true": d10_true,
|
| 252 |
+
"test_derm10_pred": d10_pred,
|
| 253 |
+
"test_depletion_true": dep_true.to_dict("records"),
|
| 254 |
+
"test_depletion_pred": dep_pred.to_dict("records"),
|
| 255 |
+
}
|
| 256 |
+
(OUT_DIR / "104_dingwall_derm_summary.json").write_text(json.dumps(summary, indent=2, default=str))
|
| 257 |
+
print(f"[B] done -> {OUT_DIR}/104_dingwall_derm_*", flush=True)
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
if __name__ == "__main__":
|
| 261 |
+
main()
|
scripts/analysis/105_primary_eden_full_dermal.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""primary EDEN discovery on the full dingwall-defined dermal set (not the panda-v3 fibroblast subset)."""
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
import warnings, json, sys, numpy as np, pandas as pd, anndata as ad, scanpy as sc, scipy.sparse as sp
|
| 4 |
+
from scipy.stats import fisher_exact
|
| 5 |
+
warnings.filterwarnings("ignore"); sc.settings.verbosity = 0
|
| 6 |
+
|
| 7 |
+
ROOT = Path("/home/bcheng/PRISM")
|
| 8 |
+
REPLICA = ROOT / "data/processed/dingwall_replica/dingwall_replica.h5ad"
|
| 9 |
+
DERM_MARKERS = ROOT / "data/external_labels/dingwall_supp/biorxiv_media-3.xlsx"
|
| 10 |
+
TOP_N = 30
|
| 11 |
+
|
| 12 |
+
# EDEN identity map from Dingwall paper + Data S2 CellChat
|
| 13 |
+
EDEN_IDENTITY = {
|
| 14 |
+
10: "Secondary_EDEN_(Dingwall_cluster_20)",
|
| 15 |
+
2: "Primary_EDEN_candidate_1_(Derm2_-_immediate_precursor)",
|
| 16 |
+
9: "Primary_EDEN_candidate_2_(Derm9)",
|
| 17 |
+
6: "EDEN-signalling_(Derm6)",
|
| 18 |
+
3: "EDEN-signalling_(Derm3)",
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def main():
|
| 23 |
+
print("[eden] loading replica dermal set (Dingwall-defined)", flush=True)
|
| 24 |
+
a = ad.read_h5ad(REPLICA)
|
| 25 |
+
# keep only cells the Seurat replica classified as belonging to Dingwall's dermal clusters
|
| 26 |
+
if "is_dermal_paper" in a.obs.columns:
|
| 27 |
+
dermal = a[a.obs["is_dermal_paper"] == True].copy()
|
| 28 |
+
elif "derm_label" in a.obs.columns:
|
| 29 |
+
dermal = a[a.obs["derm_label"] != "non_dermal"].copy()
|
| 30 |
+
else:
|
| 31 |
+
raise RuntimeError("no dermal indicator in replica")
|
| 32 |
+
print(f"[eden] Seurat replica dermal cells: {dermal.n_obs}", flush=True)
|
| 33 |
+
|
| 34 |
+
# verify our Derm-label distribution matches the replica
|
| 35 |
+
if "derm_label" in dermal.obs.columns:
|
| 36 |
+
print(f"[eden] Derm label distribution (from replica):", flush=True)
|
| 37 |
+
for k, v in dermal.obs["derm_label"].value_counts().sort_index().items():
|
| 38 |
+
print(f" {k}: {v}", flush=True)
|
| 39 |
+
|
| 40 |
+
# load Data S1C panels
|
| 41 |
+
print(f"\n[eden] loading Data S1C marker panels", flush=True)
|
| 42 |
+
df = pd.read_excel(DERM_MARKERS)
|
| 43 |
+
df = df.sort_values(["cluster", "avg_log2FC"], ascending=[True, False])
|
| 44 |
+
panels = {}
|
| 45 |
+
for cl in sorted(df["cluster"].unique()):
|
| 46 |
+
genes = df[df["cluster"] == cl].head(TOP_N)["gene"].astype(str).tolist()
|
| 47 |
+
panels[int(cl)] = genes
|
| 48 |
+
|
| 49 |
+
# replica may or may not have log1p applied; reset from counts layer if present
|
| 50 |
+
if "counts" in dermal.layers:
|
| 51 |
+
dermal.X = dermal.layers["counts"]
|
| 52 |
+
if dermal.X.max() > 30: # raw counts
|
| 53 |
+
sc.pp.normalize_total(dermal, target_sum=1e4); sc.pp.log1p(dermal)
|
| 54 |
+
|
| 55 |
+
# score each cell on all 12 Derm identity panels
|
| 56 |
+
print(f"\n[eden] scoring cells on all 12 Derm panels (top-30 markers each)", flush=True)
|
| 57 |
+
for cl, genes in panels.items():
|
| 58 |
+
present = [g for g in genes if g in dermal.var_names]
|
| 59 |
+
if len(present) < 3:
|
| 60 |
+
dermal.obs[f"derm{cl}_score"] = 0.0
|
| 61 |
+
continue
|
| 62 |
+
sc.tl.score_genes(dermal, gene_list=present, score_name=f"derm{cl}_score",
|
| 63 |
+
random_state=0, use_raw=False)
|
| 64 |
+
|
| 65 |
+
# global baseline
|
| 66 |
+
n_wt = int((dermal.obs["genotype"] == "WT").sum())
|
| 67 |
+
n_cko = int((dermal.obs["genotype"] == "En1-cKO").sum())
|
| 68 |
+
baseline = n_cko / max(n_wt + n_cko, 1)
|
| 69 |
+
print(f"\n[eden] baseline: WT={n_wt} cKO={n_cko} (baseline cKO frac = {baseline:.3f})", flush=True)
|
| 70 |
+
|
| 71 |
+
# use the replica's derm_label directly, not argmax of scores
|
| 72 |
+
rows = []
|
| 73 |
+
print(f"\n[eden] per-Derm Fisher exact on replica-assigned identities:", flush=True)
|
| 74 |
+
for cl in sorted(panels.keys()):
|
| 75 |
+
derm_label = f"Derm{cl}"
|
| 76 |
+
if derm_label not in dermal.obs["derm_label"].values:
|
| 77 |
+
continue
|
| 78 |
+
sub = dermal[dermal.obs["derm_label"] == derm_label]
|
| 79 |
+
n_wt_c = int((sub.obs["genotype"] == "WT").sum())
|
| 80 |
+
n_cko_c = int((sub.obs["genotype"] == "En1-cKO").sum())
|
| 81 |
+
if n_wt_c + n_cko_c == 0:
|
| 82 |
+
continue
|
| 83 |
+
cko_frac = n_cko_c / (n_wt_c + n_cko_c)
|
| 84 |
+
n_wt_else = n_wt - n_wt_c
|
| 85 |
+
n_cko_else = n_cko - n_cko_c
|
| 86 |
+
try:
|
| 87 |
+
odds, p_f = fisher_exact([[n_wt_c, n_wt_else], [n_cko_c, n_cko_else]],
|
| 88 |
+
alternative="two-sided")
|
| 89 |
+
except ValueError:
|
| 90 |
+
odds, p_f = 1.0, 1.0
|
| 91 |
+
rows.append({
|
| 92 |
+
"derm_id": cl,
|
| 93 |
+
"identity": EDEN_IDENTITY.get(cl, "other"),
|
| 94 |
+
"n_cells": n_wt_c + n_cko_c,
|
| 95 |
+
"n_WT": n_wt_c, "n_cKO": n_cko_c,
|
| 96 |
+
"cko_frac": cko_frac,
|
| 97 |
+
"baseline_cko_frac": baseline,
|
| 98 |
+
"cko_delta": cko_frac - baseline,
|
| 99 |
+
"wt_enrichment_odds_ratio": float(1.0/odds) if odds > 0 else None,
|
| 100 |
+
"fisher_p_two_sided": float(p_f),
|
| 101 |
+
"depletion_direction": "cKO-depleted" if cko_frac < baseline else "cKO-enriched",
|
| 102 |
+
"top10_markers_dingwall_S1C": ", ".join(panels[cl][:10]),
|
| 103 |
+
})
|
| 104 |
+
|
| 105 |
+
result_df = pd.DataFrame(rows).sort_values("cko_delta")
|
| 106 |
+
out = ROOT / "discovery/pan_skin/marker"
|
| 107 |
+
out.mkdir(parents=True, exist_ok=True)
|
| 108 |
+
result_df.to_csv(out / "105_primary_eden_full_dermal.csv", index=False)
|
| 109 |
+
|
| 110 |
+
print(f"\n{'Derm':<8}{'Identity':<50}{'n':<7}{'WT':<6}{'cKO':<6}{'cKO_frac':<10}"
|
| 111 |
+
f"{'OR (WT enrich)':<16}{'Fisher p':<12}", flush=True)
|
| 112 |
+
print("-" * 130, flush=True)
|
| 113 |
+
for _, r in result_df.iterrows():
|
| 114 |
+
print(f"Derm{r['derm_id']:<5}{r['identity'][:47]:<50}{r['n_cells']:<7}"
|
| 115 |
+
f"{r['n_WT']:<6}{r['n_cKO']:<6}{r['cko_frac']:<10.3f}"
|
| 116 |
+
f"{r['wt_enrichment_odds_ratio']:<16.2f}{r['fisher_p_two_sided']:<12.2e}", flush=True)
|
| 117 |
+
|
| 118 |
+
# summary json
|
| 119 |
+
summary = {
|
| 120 |
+
"target": "Dingwall_GSE220977",
|
| 121 |
+
"method": "Seurat-replica-identified 14,251 dermal cells (Dingwall clusters {0,1,3,4,5,8,11,20}); "
|
| 122 |
+
"per-Derm identity Fisher-exact cKO depletion using replica-assigned Derm labels "
|
| 123 |
+
"(mapped via Jaccard on top-50 markers to Dingwall Data S1C)",
|
| 124 |
+
"n_dermal_cells_total": int(dermal.n_obs),
|
| 125 |
+
"baseline_cko_frac": float(baseline),
|
| 126 |
+
"n_WT_dermal": n_wt, "n_cKO_dermal": n_cko,
|
| 127 |
+
"per_derm": rows,
|
| 128 |
+
}
|
| 129 |
+
(out / "105_primary_eden_full_dermal.json").write_text(json.dumps(summary, indent=2, default=str))
|
| 130 |
+
print(f"\n[eden] wrote {out}/105_primary_eden_full_dermal.{{csv,json}}", flush=True)
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
if __name__ == "__main__":
|
| 134 |
+
main()
|
scripts/analysis/106_melanoblast_neural_crest.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""test whether En1-cKO melanoblasts with sweat/eda derepression revert to neural crest vs disrupted melanocyte."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import json
|
| 5 |
+
import warnings
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
import anndata as ad
|
| 9 |
+
import numpy as np
|
| 10 |
+
import pandas as pd
|
| 11 |
+
import scanpy as sc
|
| 12 |
+
from scipy.stats import mannwhitneyu
|
| 13 |
+
|
| 14 |
+
warnings.filterwarnings("ignore")
|
| 15 |
+
sc.settings.verbosity = 0
|
| 16 |
+
|
| 17 |
+
ROOT = Path("/home/bcheng/PRISM")
|
| 18 |
+
OUT = ROOT / "discovery/pan_skin/marker"
|
| 19 |
+
OUT.mkdir(parents=True, exist_ok=True)
|
| 20 |
+
|
| 21 |
+
RAW = ROOT / "data/raw/GSE220977_combined.h5ad"
|
| 22 |
+
PRED = ROOT / "discovery/pan_skin/marker/dingwall_predictions.csv"
|
| 23 |
+
CKO_GSMS = {"GSM6833482", "GSM6833483"}
|
| 24 |
+
WT_GSMS = {"GSM6833478", "GSM6833479", "GSM6833480", "GSM6833481"}
|
| 25 |
+
|
| 26 |
+
MODULES = {
|
| 27 |
+
"Neural_crest": ["Sox10", "Sox9", "Sox2", "Pax3", "Foxd3", "Nes", "Tfap2a"],
|
| 28 |
+
"Melanogenesis_late": ["Tyrp1", "Slc45a2", "Oca2", "Gpnmb", "Pmel", "Silv",
|
| 29 |
+
"Mlph", "Rab27a", "Melana"],
|
| 30 |
+
"MITF_regulon": ["Mitf", "Dct", "Tyr", "Pmel", "Mlana", "Tyrp1",
|
| 31 |
+
"Slc24a5", "Slc45a2", "Sox10", "Pax3", "Kit", "Ednrb"],
|
| 32 |
+
"Sweat_gland": ["Foxi3", "Foxa1", "En1", "Krt8", "Krt18", "Krt19",
|
| 33 |
+
"Muc5b", "Aqp5", "Cutl1"],
|
| 34 |
+
"Eda_ectodysplasin": ["Eda", "Edar", "Edaradd", "Nfkb1", "Nfkb2", "Rela"],
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def score(sub, name, genes):
|
| 39 |
+
present = [g for g in genes if g in sub.var_names]
|
| 40 |
+
if not present:
|
| 41 |
+
sub.obs[f"pw_{name}"] = 0.0
|
| 42 |
+
return
|
| 43 |
+
sc.tl.score_genes(sub, gene_list=present, score_name=f"pw_{name}",
|
| 44 |
+
random_state=0, use_raw=False)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def main():
|
| 48 |
+
print("[load] Dingwall + predictions", flush=True)
|
| 49 |
+
a = ad.read_h5ad(RAW)
|
| 50 |
+
pred = pd.read_csv(PRED)
|
| 51 |
+
pred_map = dict(zip(pred["cell_id"].astype(str), pred["pred_label"]))
|
| 52 |
+
a.obs["pred_label"] = pd.Categorical(
|
| 53 |
+
[pred_map.get(c, "unknown") for c in a.obs_names])
|
| 54 |
+
samp = a.obs["sample"].astype(str)
|
| 55 |
+
a.obs["group"] = np.where(samp.isin(list(CKO_GSMS)), "En1-cKO",
|
| 56 |
+
np.where(samp.isin(list(WT_GSMS)), "WT", "other"))
|
| 57 |
+
a = a[a.obs["group"].isin(["En1-cKO", "WT"])].copy()
|
| 58 |
+
sub = a[a.obs["pred_label"].astype(str) == "melanoblast"].copy()
|
| 59 |
+
print(f"[load] {sub.n_obs} melanoblast cells "
|
| 60 |
+
f"(cKO={(sub.obs['group']=='En1-cKO').sum()}, "
|
| 61 |
+
f"WT={(sub.obs['group']=='WT').sum()})", flush=True)
|
| 62 |
+
|
| 63 |
+
sc.pp.normalize_total(sub, target_sum=1e4)
|
| 64 |
+
sc.pp.log1p(sub)
|
| 65 |
+
|
| 66 |
+
for name, genes in MODULES.items():
|
| 67 |
+
score(sub, name, genes)
|
| 68 |
+
|
| 69 |
+
sub.obs["derepression"] = (sub.obs["pw_Sweat_gland"].astype(float)
|
| 70 |
+
+ sub.obs["pw_Eda_ectodysplasin"].astype(float))
|
| 71 |
+
|
| 72 |
+
df = sub.obs[[
|
| 73 |
+
"group", "pw_Neural_crest", "pw_Melanogenesis_late", "pw_MITF_regulon",
|
| 74 |
+
"pw_Sweat_gland", "pw_Eda_ectodysplasin", "derepression"
|
| 75 |
+
]].copy()
|
| 76 |
+
df.reset_index().rename(columns={"index": "cell_id"}).to_csv(
|
| 77 |
+
OUT / "106_melanoblast_nc_scores.csv", index=False)
|
| 78 |
+
|
| 79 |
+
# baseline cKO vs WT per module
|
| 80 |
+
tests = {}
|
| 81 |
+
for m in ["Neural_crest", "Melanogenesis_late", "MITF_regulon",
|
| 82 |
+
"Sweat_gland", "Eda_ectodysplasin"]:
|
| 83 |
+
v1 = df.loc[df["group"] == "En1-cKO", f"pw_{m}"].astype(float).values
|
| 84 |
+
v0 = df.loc[df["group"] == "WT", f"pw_{m}"].astype(float).values
|
| 85 |
+
_, p = mannwhitneyu(v1, v0, alternative="two-sided")
|
| 86 |
+
tests[m] = {
|
| 87 |
+
"delta_cKO_minus_WT": float(v1.mean() - v0.mean()),
|
| 88 |
+
"mean_cKO": float(v1.mean()), "mean_WT": float(v0.mean()),
|
| 89 |
+
"n_cKO": int(len(v1)), "n_WT": int(len(v0)),
|
| 90 |
+
"mannu_p": float(p),
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
# within cKO, split by derepression quartile
|
| 94 |
+
cko = df[df["group"] == "En1-cKO"].copy()
|
| 95 |
+
q1 = cko["derepression"].quantile(0.25)
|
| 96 |
+
q4 = cko["derepression"].quantile(0.75)
|
| 97 |
+
top = cko[cko["derepression"] >= q4]
|
| 98 |
+
bot = cko[cko["derepression"] <= q1]
|
| 99 |
+
print(f"[q] top-derep cKO: n={len(top)}, bot-derep cKO: n={len(bot)}",
|
| 100 |
+
flush=True)
|
| 101 |
+
|
| 102 |
+
within = {}
|
| 103 |
+
for m in ["Neural_crest", "Melanogenesis_late", "MITF_regulon"]:
|
| 104 |
+
vt = top[f"pw_{m}"].astype(float).values
|
| 105 |
+
vb = bot[f"pw_{m}"].astype(float).values
|
| 106 |
+
_, p = mannwhitneyu(vt, vb, alternative="two-sided")
|
| 107 |
+
within[m] = {
|
| 108 |
+
"mean_top_derep": float(vt.mean()),
|
| 109 |
+
"mean_bot_derep": float(vb.mean()),
|
| 110 |
+
"delta_top_minus_bot": float(vt.mean() - vb.mean()),
|
| 111 |
+
"n_top": int(len(vt)), "n_bot": int(len(vb)),
|
| 112 |
+
"mannu_p": float(p),
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
nc_delta = within["Neural_crest"]["delta_top_minus_bot"]
|
| 116 |
+
mel_delta = within["Melanogenesis_late"]["delta_top_minus_bot"]
|
| 117 |
+
verdict = (
|
| 118 |
+
"novel_neural_crest_reversion" if nc_delta > 0.03 and mel_delta > -0.02
|
| 119 |
+
else "basic_melanocyte_disruption" if nc_delta < 0.01 and mel_delta < -0.02
|
| 120 |
+
else "mixed_or_orthogonal"
|
| 121 |
+
)
|
| 122 |
+
summary = {
|
| 123 |
+
"n_melanoblast_cells": int(sub.n_obs),
|
| 124 |
+
"n_cKO_melanoblasts": int((df["group"] == "En1-cKO").sum()),
|
| 125 |
+
"n_WT_melanoblasts": int((df["group"] == "WT").sum()),
|
| 126 |
+
"baseline_cKO_vs_WT": tests,
|
| 127 |
+
"within_cKO_top_vs_bot_derepression_quartile": within,
|
| 128 |
+
"verdict": verdict,
|
| 129 |
+
}
|
| 130 |
+
with open(OUT / "106_melanoblast_nc_summary.json", "w") as f:
|
| 131 |
+
json.dump(summary, f, indent=2)
|
| 132 |
+
|
| 133 |
+
print("[done] verdict:", verdict, flush=True)
|
| 134 |
+
print(json.dumps(summary, indent=2))
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
if __name__ == "__main__":
|
| 138 |
+
main()
|
scripts/analysis/107_dingwall_class_deg_count.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""rank dingwall cell types by En1-cKO vs WT DEG count (|log2FC|>1, padj<0.05)."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import json
|
| 5 |
+
import warnings
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
import anndata as ad
|
| 9 |
+
import numpy as np
|
| 10 |
+
import pandas as pd
|
| 11 |
+
import scanpy as sc
|
| 12 |
+
|
| 13 |
+
warnings.filterwarnings("ignore")
|
| 14 |
+
sc.settings.verbosity = 0
|
| 15 |
+
|
| 16 |
+
ROOT = Path("/home/bcheng/PRISM")
|
| 17 |
+
OUT = ROOT / "discovery/pan_skin/marker"
|
| 18 |
+
OUT.mkdir(parents=True, exist_ok=True)
|
| 19 |
+
|
| 20 |
+
RAW = ROOT / "data/raw/GSE220977_combined.h5ad"
|
| 21 |
+
PRED = ROOT / "discovery/pan_skin/marker/dingwall_predictions.csv"
|
| 22 |
+
CKO_GSMS = {"GSM6833482", "GSM6833483"}
|
| 23 |
+
WT_GSMS = {"GSM6833478", "GSM6833479", "GSM6833480", "GSM6833481"}
|
| 24 |
+
MIN_PER_GROUP = 15
|
| 25 |
+
LFC_THRESH = 1.0
|
| 26 |
+
PADJ_THRESH = 0.05
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def main():
|
| 30 |
+
print("[load]", flush=True)
|
| 31 |
+
a = ad.read_h5ad(RAW)
|
| 32 |
+
pred = pd.read_csv(PRED)
|
| 33 |
+
pred_map = dict(zip(pred["cell_id"].astype(str), pred["pred_label"]))
|
| 34 |
+
a.obs["pred_label"] = pd.Categorical(
|
| 35 |
+
[pred_map.get(c, "unknown") for c in a.obs_names])
|
| 36 |
+
samp = a.obs["sample"].astype(str)
|
| 37 |
+
a.obs["group"] = np.where(samp.isin(list(CKO_GSMS)), "En1-cKO",
|
| 38 |
+
np.where(samp.isin(list(WT_GSMS)), "WT", "other"))
|
| 39 |
+
a = a[a.obs["group"].isin(["En1-cKO", "WT"])].copy()
|
| 40 |
+
print(f"[load] {a.n_obs} cells, {a.n_vars} genes", flush=True)
|
| 41 |
+
|
| 42 |
+
sc.pp.normalize_total(a, target_sum=1e4)
|
| 43 |
+
sc.pp.log1p(a)
|
| 44 |
+
|
| 45 |
+
rows = []
|
| 46 |
+
for cls in sorted(a.obs["pred_label"].astype(str).unique()):
|
| 47 |
+
mask = (a.obs["pred_label"].astype(str) == cls)
|
| 48 |
+
n_cko = int((mask & (a.obs["group"] == "En1-cKO")).sum())
|
| 49 |
+
n_wt = int((mask & (a.obs["group"] == "WT")).sum())
|
| 50 |
+
if n_cko < MIN_PER_GROUP or n_wt < MIN_PER_GROUP:
|
| 51 |
+
print(f"[skip] {cls}: n_cKO={n_cko} n_WT={n_wt}", flush=True)
|
| 52 |
+
continue
|
| 53 |
+
sub = a[mask].copy()
|
| 54 |
+
sub.obs["group"] = pd.Categorical(sub.obs["group"].values,
|
| 55 |
+
categories=["En1-cKO", "WT"])
|
| 56 |
+
# reference=WT so positive LFC means up in cKO
|
| 57 |
+
try:
|
| 58 |
+
sc.tl.rank_genes_groups(sub, "group", reference="WT",
|
| 59 |
+
groups=["En1-cKO"], method="wilcoxon",
|
| 60 |
+
n_genes=sub.n_vars, use_raw=False,
|
| 61 |
+
pts=True)
|
| 62 |
+
rgg = sub.uns["rank_genes_groups"]
|
| 63 |
+
df = pd.DataFrame({
|
| 64 |
+
"gene": [x[0] for x in rgg["names"]],
|
| 65 |
+
"lfc": [x[0] for x in rgg["logfoldchanges"]],
|
| 66 |
+
"padj": [x[0] for x in rgg["pvals_adj"]],
|
| 67 |
+
})
|
| 68 |
+
up = int(((df["lfc"] > LFC_THRESH) & (df["padj"] < PADJ_THRESH)).sum())
|
| 69 |
+
down = int(((df["lfc"] < -LFC_THRESH) & (df["padj"] < PADJ_THRESH)).sum())
|
| 70 |
+
total = up + down
|
| 71 |
+
top_up = df[(df["lfc"] > LFC_THRESH) & (df["padj"] < PADJ_THRESH)] \
|
| 72 |
+
.sort_values("lfc", ascending=False)["gene"].head(10).tolist()
|
| 73 |
+
top_down = df[(df["lfc"] < -LFC_THRESH) & (df["padj"] < PADJ_THRESH)] \
|
| 74 |
+
.sort_values("lfc", ascending=True)["gene"].head(10).tolist()
|
| 75 |
+
except Exception as e:
|
| 76 |
+
print(f"[fail] {cls}: {e}", flush=True)
|
| 77 |
+
continue
|
| 78 |
+
rows.append({
|
| 79 |
+
"class": cls,
|
| 80 |
+
"n_cKO": n_cko,
|
| 81 |
+
"n_WT": n_wt,
|
| 82 |
+
"n_DEG": total,
|
| 83 |
+
"n_up": up,
|
| 84 |
+
"n_down": down,
|
| 85 |
+
"top_up": ";".join(top_up),
|
| 86 |
+
"top_down": ";".join(top_down),
|
| 87 |
+
})
|
| 88 |
+
print(f"[ok] {cls}: n_cKO={n_cko} n_WT={n_wt} DEG={total} "
|
| 89 |
+
f"(up={up}, down={down})", flush=True)
|
| 90 |
+
|
| 91 |
+
df_out = pd.DataFrame(rows).sort_values("n_DEG", ascending=False)
|
| 92 |
+
df_out.to_csv(OUT / "107_dingwall_class_deg_count.csv", index=False)
|
| 93 |
+
print("\n[rank] classes ordered by DEG count (|LFC|>1 padj<0.05):")
|
| 94 |
+
print(df_out[["class", "n_cKO", "n_WT", "n_DEG", "n_up", "n_down"]]
|
| 95 |
+
.to_string(index=False))
|
| 96 |
+
|
| 97 |
+
if not df_out.empty:
|
| 98 |
+
winner = df_out.iloc[0]
|
| 99 |
+
summary = {
|
| 100 |
+
"lfc_threshold": LFC_THRESH, "padj_threshold": PADJ_THRESH,
|
| 101 |
+
"n_classes_tested": int(len(df_out)),
|
| 102 |
+
"top_class": str(winner["class"]),
|
| 103 |
+
"top_n_DEG": int(winner["n_DEG"]),
|
| 104 |
+
"top_up": winner["top_up"],
|
| 105 |
+
"top_down": winner["top_down"],
|
| 106 |
+
"ranking": df_out[["class", "n_DEG"]].to_dict("records"),
|
| 107 |
+
}
|
| 108 |
+
else:
|
| 109 |
+
summary = {"error": "no eligible classes"}
|
| 110 |
+
with open(OUT / "107_dingwall_class_deg_count.json", "w") as f:
|
| 111 |
+
json.dump(summary, f, indent=2)
|
| 112 |
+
print("[done]", flush=True)
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
if __name__ == "__main__":
|
| 116 |
+
main()
|
scripts/analysis/108_dahlin_lineage_metabolism.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""rank dahlin lineages by Kit_W41 vs WT metabolic shift across 4 modules."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import json
|
| 5 |
+
import warnings
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
import anndata as ad
|
| 9 |
+
import numpy as np
|
| 10 |
+
import pandas as pd
|
| 11 |
+
import scanpy as sc
|
| 12 |
+
import scipy.sparse as sp
|
| 13 |
+
from scipy.stats import mannwhitneyu
|
| 14 |
+
|
| 15 |
+
warnings.filterwarnings("ignore")
|
| 16 |
+
sc.settings.verbosity = 0
|
| 17 |
+
|
| 18 |
+
ROOT = Path("/home/bcheng/PRISM")
|
| 19 |
+
OUT = ROOT / "discovery/hematopoiesis/marker"
|
| 20 |
+
OUT.mkdir(parents=True, exist_ok=True)
|
| 21 |
+
|
| 22 |
+
D_DIR = ROOT / "data/corpus/hematopoiesis/held_out_unlabeled/dahlin_extract"
|
| 23 |
+
PRED = ROOT / "discovery/hematopoiesis/marker/dahlin_predictions.csv"
|
| 24 |
+
GT = {"SIGAB1": "WT", "SIGAC1": "WT", "SIGAD1": "WT", "SIGAF1": "WT",
|
| 25 |
+
"SIGAG1": "WT", "SIGAH1": "WT",
|
| 26 |
+
"SIGAG8": "Kit_W41", "SIGAH8": "Kit_W41"}
|
| 27 |
+
|
| 28 |
+
MIN_PER_GROUP = 15
|
| 29 |
+
|
| 30 |
+
MODULES = {
|
| 31 |
+
"OXPHOS_ETC": ["Ndufa1", "Ndufa2", "Ndufb1", "Ndufb2", "Sdha", "Sdhb",
|
| 32 |
+
"Cox4i1", "Cox5a", "Cox6a1", "Atp5a1", "Atp5b", "Uqcrq"],
|
| 33 |
+
"Glycolysis": ["Hk1", "Hk2", "Pfkm", "Pfkl", "Aldoa", "Gapdh", "Pgk1",
|
| 34 |
+
"Pkm", "Ldha", "Eno1", "Tpi1", "Pgam1"],
|
| 35 |
+
"Fatty_acid_oxidation":["Cpt1a", "Acadm", "Acadl", "Acadvl", "Hadha", "Hadhb",
|
| 36 |
+
"Ppara", "Ppargc1a", "Ucp2"],
|
| 37 |
+
"Redox_glutathione": ["Gpx1", "Gpx2", "Gpx3", "Gpx4", "Gsr", "Prdx1", "Prdx2",
|
| 38 |
+
"Prdx3", "Prdx4", "Prdx5", "Prdx6", "Sod1", "Sod2", "Cat"],
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def load_dahlin():
|
| 43 |
+
print("[load] Dahlin raw counts", flush=True)
|
| 44 |
+
parts = []
|
| 45 |
+
for f in sorted(D_DIR.glob("*.txt.gz")):
|
| 46 |
+
sample = f.name.split("_")[1].split(".")[0]
|
| 47 |
+
df = pd.read_csv(f, sep="\t", compression="gzip", index_col=0)
|
| 48 |
+
X = sp.csr_matrix(df.values.T.astype(np.float32))
|
| 49 |
+
obs = pd.DataFrame(index=[f"{sample}_{bc}" for bc in df.columns.astype(str)])
|
| 50 |
+
obs["sample"] = sample
|
| 51 |
+
obs["group"] = GT.get(sample, "unknown")
|
| 52 |
+
var = pd.DataFrame(index=df.index.astype(str))
|
| 53 |
+
parts.append(ad.AnnData(X=X, obs=obs, var=var))
|
| 54 |
+
print(f"[load] {sample}: {X.shape}", flush=True)
|
| 55 |
+
a = ad.concat(parts, join="outer", label="_batch")
|
| 56 |
+
print(f"[load] concat: {a.shape}", flush=True)
|
| 57 |
+
|
| 58 |
+
import mygene
|
| 59 |
+
mg = mygene.MyGeneInfo()
|
| 60 |
+
res = mg.querymany(a.var_names.astype(str).tolist(), scopes="ensembl.gene",
|
| 61 |
+
fields="symbol", species="mouse", verbose=False)
|
| 62 |
+
id2sym = {r["query"]: r["symbol"] for r in res if "symbol" in r}
|
| 63 |
+
syms = pd.Series(a.var_names.astype(str)).map(id2sym).values
|
| 64 |
+
keep = pd.notna(syms)
|
| 65 |
+
a = a[:, keep].copy()
|
| 66 |
+
a.var_names = syms[keep]
|
| 67 |
+
a.var_names_make_unique()
|
| 68 |
+
print(f"[map] {a.n_vars} genes with symbols", flush=True)
|
| 69 |
+
a = a[a.obs["group"].isin(["Kit_W41", "WT"])].copy()
|
| 70 |
+
return a
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def main():
|
| 74 |
+
a = load_dahlin()
|
| 75 |
+
pred = pd.read_csv(PRED)
|
| 76 |
+
common = a.obs_names.intersection(pd.Index(pred["cell_id"].astype(str)))
|
| 77 |
+
a = a[list(common)].copy()
|
| 78 |
+
pred_map = dict(zip(pred["cell_id"].astype(str), pred["pred_label"]))
|
| 79 |
+
a.obs["pred_label"] = pd.Categorical(
|
| 80 |
+
[pred_map.get(c, "unknown") for c in a.obs_names])
|
| 81 |
+
print(f"[join] {a.n_obs} cells, "
|
| 82 |
+
f"Kit_W41={int((a.obs['group']=='Kit_W41').sum())}, "
|
| 83 |
+
f"WT={int((a.obs['group']=='WT').sum())}", flush=True)
|
| 84 |
+
|
| 85 |
+
sc.pp.normalize_total(a, target_sum=1e4)
|
| 86 |
+
sc.pp.log1p(a)
|
| 87 |
+
|
| 88 |
+
rows = []
|
| 89 |
+
for cls in sorted(a.obs["pred_label"].astype(str).unique()):
|
| 90 |
+
mask = (a.obs["pred_label"].astype(str) == cls).values
|
| 91 |
+
n_k = int((mask & (a.obs["group"].values == "Kit_W41")).sum())
|
| 92 |
+
n_w = int((mask & (a.obs["group"].values == "WT")).sum())
|
| 93 |
+
if n_k < MIN_PER_GROUP or n_w < MIN_PER_GROUP:
|
| 94 |
+
print(f"[skip] {cls}: n_Kit={n_k} n_WT={n_w}", flush=True)
|
| 95 |
+
continue
|
| 96 |
+
sub = a[mask].copy()
|
| 97 |
+
for name, genes in MODULES.items():
|
| 98 |
+
present = [g for g in genes if g in sub.var_names]
|
| 99 |
+
if not present:
|
| 100 |
+
sub.obs[f"pw_{name}"] = 0.0
|
| 101 |
+
continue
|
| 102 |
+
sc.tl.score_genes(sub, gene_list=present,
|
| 103 |
+
score_name=f"pw_{name}", random_state=0,
|
| 104 |
+
use_raw=False)
|
| 105 |
+
|
| 106 |
+
grp = sub.obs["group"].values
|
| 107 |
+
for name in MODULES:
|
| 108 |
+
v_k = sub.obs[f"pw_{name}"].astype(float).values[grp == "Kit_W41"]
|
| 109 |
+
v_w = sub.obs[f"pw_{name}"].astype(float).values[grp == "WT"]
|
| 110 |
+
try:
|
| 111 |
+
_, p = mannwhitneyu(v_k, v_w, alternative="two-sided")
|
| 112 |
+
except Exception:
|
| 113 |
+
p = 1.0
|
| 114 |
+
rows.append({
|
| 115 |
+
"class": cls,
|
| 116 |
+
"module": name,
|
| 117 |
+
"delta": float(v_k.mean() - v_w.mean()),
|
| 118 |
+
"mean_Kit_W41": float(v_k.mean()),
|
| 119 |
+
"mean_WT": float(v_w.mean()),
|
| 120 |
+
"n_Kit_W41": n_k,
|
| 121 |
+
"n_WT": n_w,
|
| 122 |
+
"mannu_p": float(p),
|
| 123 |
+
})
|
| 124 |
+
print(f"[ok] {cls}: n_Kit={n_k} n_WT={n_w}", flush=True)
|
| 125 |
+
|
| 126 |
+
df = pd.DataFrame(rows)
|
| 127 |
+
n_tests = len(df)
|
| 128 |
+
df["mannu_p_adj_bonferroni"] = np.minimum(df["mannu_p"] * n_tests, 1.0)
|
| 129 |
+
df.to_csv(OUT / "108_dahlin_lineage_metabolism.csv", index=False)
|
| 130 |
+
|
| 131 |
+
# per-class composite magnitude
|
| 132 |
+
piv = df.pivot(index="class", columns="module", values="delta").fillna(0.0)
|
| 133 |
+
piv["sum_abs_delta"] = piv.abs().sum(axis=1)
|
| 134 |
+
piv["l2_delta"] = np.sqrt((piv[list(MODULES)] ** 2).sum(axis=1))
|
| 135 |
+
piv_sorted = piv.sort_values("sum_abs_delta", ascending=False)
|
| 136 |
+
piv_sorted.to_csv(OUT / "108_dahlin_lineage_metabolism_ranked.csv")
|
| 137 |
+
|
| 138 |
+
print("\n[ranked] lineages by sum |delta| across 4 metabolic modules:")
|
| 139 |
+
print(piv_sorted.round(4).to_string())
|
| 140 |
+
|
| 141 |
+
top_cls = piv_sorted.index[0]
|
| 142 |
+
top_row = piv_sorted.iloc[0]
|
| 143 |
+
summary = {
|
| 144 |
+
"modules": list(MODULES),
|
| 145 |
+
"top_lineage": str(top_cls),
|
| 146 |
+
"top_sum_abs_delta": float(top_row["sum_abs_delta"]),
|
| 147 |
+
"top_deltas_per_module": {
|
| 148 |
+
m: float(top_row[m]) for m in MODULES
|
| 149 |
+
},
|
| 150 |
+
"ranking": piv_sorted[["sum_abs_delta"]].reset_index().to_dict("records"),
|
| 151 |
+
}
|
| 152 |
+
with open(OUT / "108_dahlin_lineage_metabolism.json", "w") as f:
|
| 153 |
+
json.dump(summary, f, indent=2)
|
| 154 |
+
print("[done] top lineage:", top_cls, flush=True)
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
if __name__ == "__main__":
|
| 158 |
+
main()
|
scripts/analysis/109_veres_mature_beta.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""split veres beta-predicted cells into mature (MAFA/UCN3 hi) vs SC-beta by INS x MAT quadrants."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import json
|
| 5 |
+
import warnings
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
import anndata as ad
|
| 9 |
+
import numpy as np
|
| 10 |
+
import pandas as pd
|
| 11 |
+
|
| 12 |
+
warnings.filterwarnings("ignore")
|
| 13 |
+
|
| 14 |
+
ROOT = Path("/home/bcheng/PRISM")
|
| 15 |
+
OUT = ROOT / "discovery/pancreas/marker"
|
| 16 |
+
OUT.mkdir(parents=True, exist_ok=True)
|
| 17 |
+
|
| 18 |
+
VERES = ROOT / "data/corpus/pancreas/held_out_labeled/veres_GSE114412_test.h5ad"
|
| 19 |
+
PRED = ROOT / "discovery/pancreas/marker/veres_predictions.csv"
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def z(x):
|
| 23 |
+
x = np.asarray(x, dtype=float)
|
| 24 |
+
s = x.std()
|
| 25 |
+
return (x - x.mean()) / (s if s > 0 else 1.0)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def main():
|
| 29 |
+
print("[load]", flush=True)
|
| 30 |
+
a = ad.read_h5ad(VERES)
|
| 31 |
+
pred = pd.read_csv(PRED)
|
| 32 |
+
pred_map = dict(zip(pred["cell_id"].astype(str), pred["pred_label"]))
|
| 33 |
+
a.obs["pred_label"] = pd.Categorical(
|
| 34 |
+
[pred_map.get(c, "unknown") for c in a.obs_names])
|
| 35 |
+
|
| 36 |
+
print("[filter] pred_label == beta", flush=True)
|
| 37 |
+
sub = a[a.obs["pred_label"].astype(str) == "beta"].copy()
|
| 38 |
+
print(f"[filter] {sub.n_obs} beta-predicted cells", flush=True)
|
| 39 |
+
|
| 40 |
+
# veres X is already log-normalised (range 0..9)
|
| 41 |
+
def col(g):
|
| 42 |
+
if g not in sub.var_names:
|
| 43 |
+
return np.zeros(sub.n_obs)
|
| 44 |
+
j = sub.var_names.get_loc(g)
|
| 45 |
+
x = sub.X[:, j]
|
| 46 |
+
if hasattr(x, "toarray"):
|
| 47 |
+
x = x.toarray()
|
| 48 |
+
return np.asarray(x).ravel()
|
| 49 |
+
|
| 50 |
+
ins1 = col("Ins1"); ins2 = col("Ins2"); iapp = col("Iapp")
|
| 51 |
+
mafa = col("Mafa"); ucn3 = col("Ucn3")
|
| 52 |
+
insulin = ins1 + ins2
|
| 53 |
+
mature = z(mafa) + z(ucn3)
|
| 54 |
+
|
| 55 |
+
# split thresholds are the median within the veres beta-predicted set
|
| 56 |
+
ins_thr = float(np.median(insulin))
|
| 57 |
+
mat_thr = float(np.median(mature))
|
| 58 |
+
ins_hi = insulin > ins_thr
|
| 59 |
+
mat_hi = mature > mat_thr
|
| 60 |
+
|
| 61 |
+
quadrant = np.array(
|
| 62 |
+
["INS+/MAT+" if (ih and mh) else
|
| 63 |
+
"INS+/MAT-" if (ih and not mh) else
|
| 64 |
+
"INS-/MAT+" if (not ih and mh) else
|
| 65 |
+
"INS-/MAT-"
|
| 66 |
+
for ih, mh in zip(ins_hi, mat_hi)])
|
| 67 |
+
sub.obs["INS_level"] = insulin
|
| 68 |
+
sub.obs["MAT_score"] = mature
|
| 69 |
+
sub.obs["quadrant"] = pd.Categorical(quadrant)
|
| 70 |
+
|
| 71 |
+
df = sub.obs[["quadrant", "INS_level", "MAT_score"]].copy()
|
| 72 |
+
df["Ins1"] = ins1; df["Ins2"] = ins2; df["Iapp"] = iapp
|
| 73 |
+
df["Mafa"] = mafa; df["Ucn3"] = ucn3
|
| 74 |
+
df["paper_label"] = sub.obs["paper_label"].astype(str).values
|
| 75 |
+
df.reset_index().rename(columns={"index": "cell_id"}).to_csv(
|
| 76 |
+
OUT / "109_veres_mature_beta_scores.csv", index=False)
|
| 77 |
+
|
| 78 |
+
counts = df["quadrant"].value_counts().to_dict()
|
| 79 |
+
paper_by_quad = df.groupby(["quadrant", "paper_label"], observed=True) \
|
| 80 |
+
.size().unstack(fill_value=0)
|
| 81 |
+
paper_by_quad.to_csv(OUT / "109_veres_mature_beta_paper_x_quadrant.csv")
|
| 82 |
+
|
| 83 |
+
n_total = int(sub.n_obs)
|
| 84 |
+
n_mature = int((df["quadrant"] == "INS+/MAT+").sum())
|
| 85 |
+
n_scbeta = int((df["quadrant"] == "INS+/MAT-").sum())
|
| 86 |
+
|
| 87 |
+
fraction_mature = n_mature / n_total if n_total else 0.0
|
| 88 |
+
|
| 89 |
+
means = df.groupby("quadrant", observed=True)[
|
| 90 |
+
["Mafa", "Ucn3", "Ins1", "Ins2", "Iapp"]].mean().round(3).to_dict()
|
| 91 |
+
|
| 92 |
+
summary = {
|
| 93 |
+
"n_beta_predicted": n_total,
|
| 94 |
+
"ins_threshold": ins_thr,
|
| 95 |
+
"mat_threshold": mat_thr,
|
| 96 |
+
"quadrant_counts": counts,
|
| 97 |
+
"n_mature_INS+MAT+": n_mature,
|
| 98 |
+
"n_SCbeta_INS+MAT-": n_scbeta,
|
| 99 |
+
"fraction_mature": round(fraction_mature, 4),
|
| 100 |
+
"mean_expression_per_quadrant": means,
|
| 101 |
+
"paper_label_x_quadrant": {
|
| 102 |
+
q: paper_by_quad.loc[q].to_dict()
|
| 103 |
+
for q in paper_by_quad.index
|
| 104 |
+
} if len(paper_by_quad) else {},
|
| 105 |
+
}
|
| 106 |
+
with open(OUT / "109_veres_mature_beta_summary.json", "w") as f:
|
| 107 |
+
json.dump(summary, f, indent=2)
|
| 108 |
+
print("\n[done]")
|
| 109 |
+
print(json.dumps(summary, indent=2))
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
if __name__ == "__main__":
|
| 113 |
+
main()
|
scripts/analysis/110_veres_polyhormonal_alpha.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""test if veres alpha-pool polyhormonal (Ins+/Gcg+/Sst+) cells form a distinct sub-cluster vs graded."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import json
|
| 5 |
+
import warnings
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
import anndata as ad
|
| 9 |
+
import numpy as np
|
| 10 |
+
import pandas as pd
|
| 11 |
+
import scanpy as sc
|
| 12 |
+
|
| 13 |
+
warnings.filterwarnings("ignore")
|
| 14 |
+
sc.settings.verbosity = 0
|
| 15 |
+
|
| 16 |
+
ROOT = Path("/home/bcheng/PRISM")
|
| 17 |
+
OUT = ROOT / "discovery/pancreas/marker"
|
| 18 |
+
OUT.mkdir(parents=True, exist_ok=True)
|
| 19 |
+
|
| 20 |
+
VERES = ROOT / "data/corpus/pancreas/held_out_labeled/veres_GSE114412_test.h5ad"
|
| 21 |
+
PRED = ROOT / "discovery/pancreas/marker/veres_predictions.csv"
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def col(sub, g):
|
| 25 |
+
if g not in sub.var_names:
|
| 26 |
+
return np.zeros(sub.n_obs)
|
| 27 |
+
j = sub.var_names.get_loc(g)
|
| 28 |
+
x = sub.X[:, j]
|
| 29 |
+
if hasattr(x, "toarray"):
|
| 30 |
+
x = x.toarray()
|
| 31 |
+
return np.asarray(x).ravel()
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def main():
|
| 35 |
+
print("[load]", flush=True)
|
| 36 |
+
a = ad.read_h5ad(VERES)
|
| 37 |
+
pred = pd.read_csv(PRED)
|
| 38 |
+
pred_map = dict(zip(pred["cell_id"].astype(str), pred["pred_label"]))
|
| 39 |
+
a.obs["pred_label"] = pd.Categorical(
|
| 40 |
+
[pred_map.get(c, "unknown") for c in a.obs_names])
|
| 41 |
+
|
| 42 |
+
sub = a[a.obs["pred_label"].astype(str).isin(
|
| 43 |
+
["alpha_progenitor", "alpha"])].copy()
|
| 44 |
+
print(f"[filter] {sub.n_obs} alpha-pool cells", flush=True)
|
| 45 |
+
|
| 46 |
+
ins1 = col(sub, "Ins1"); ins2 = col(sub, "Ins2")
|
| 47 |
+
gcg = col(sub, "Gcg"); sst = col(sub, "Sst"); iapp = col(sub, "Iapp")
|
| 48 |
+
|
| 49 |
+
ins_level = ins1 + ins2
|
| 50 |
+
q_ins = np.quantile(ins_level, 0.75)
|
| 51 |
+
q_gcg = np.quantile(gcg, 0.75)
|
| 52 |
+
q_sst = np.quantile(sst, 0.75)
|
| 53 |
+
|
| 54 |
+
n_pos = (
|
| 55 |
+
(ins_level >= q_ins).astype(int)
|
| 56 |
+
+ (gcg >= q_gcg).astype(int)
|
| 57 |
+
+ (sst >= q_sst).astype(int)
|
| 58 |
+
)
|
| 59 |
+
sub.obs["INS_level"] = ins_level
|
| 60 |
+
sub.obs["GCG_level"] = gcg
|
| 61 |
+
sub.obs["SST_level"] = sst
|
| 62 |
+
sub.obs["n_hormones_positive"] = n_pos
|
| 63 |
+
|
| 64 |
+
print("[cluster] PCA + Leiden", flush=True)
|
| 65 |
+
sc.pp.highly_variable_genes(sub, n_top_genes=2000, flavor="seurat_v3",
|
| 66 |
+
subset=False, batch_key=None)
|
| 67 |
+
sc.pp.pca(sub, n_comps=30)
|
| 68 |
+
sc.pp.neighbors(sub, n_neighbors=15, n_pcs=30)
|
| 69 |
+
sc.tl.leiden(sub, resolution=0.5, random_state=0, key_added="leiden_alpha")
|
| 70 |
+
|
| 71 |
+
df = sub.obs[[
|
| 72 |
+
"pred_label", "paper_label", "leiden_alpha",
|
| 73 |
+
"INS_level", "GCG_level", "SST_level", "n_hormones_positive"
|
| 74 |
+
]].copy()
|
| 75 |
+
df["Ins1"] = ins1; df["Ins2"] = ins2; df["Gcg"] = gcg
|
| 76 |
+
df["Sst"] = sst; df["Iapp"] = iapp
|
| 77 |
+
df.reset_index().rename(columns={"index": "cell_id"}).to_csv(
|
| 78 |
+
OUT / "110_veres_polyhormonal_alpha_scores.csv", index=False)
|
| 79 |
+
|
| 80 |
+
baseline_polyhormonal = float((df["n_hormones_positive"] >= 2).mean())
|
| 81 |
+
per_clus = df.groupby("leiden_alpha", observed=True).agg(
|
| 82 |
+
n_cells=("n_hormones_positive", "size"),
|
| 83 |
+
frac_polyhormonal=("n_hormones_positive",
|
| 84 |
+
lambda s: float((s >= 2).mean())),
|
| 85 |
+
frac_gcg_hi=("GCG_level",
|
| 86 |
+
lambda s: float((s >= q_gcg).mean())),
|
| 87 |
+
frac_ins_hi=("INS_level",
|
| 88 |
+
lambda s: float((s >= q_ins).mean())),
|
| 89 |
+
frac_sst_hi=("SST_level",
|
| 90 |
+
lambda s: float((s >= q_sst).mean())),
|
| 91 |
+
mean_gcg=("GCG_level", "mean"),
|
| 92 |
+
mean_ins=("INS_level", "mean"),
|
| 93 |
+
mean_sst=("SST_level", "mean"),
|
| 94 |
+
).sort_values("frac_polyhormonal", ascending=False).reset_index()
|
| 95 |
+
per_clus["enrichment_vs_baseline"] = per_clus["frac_polyhormonal"] \
|
| 96 |
+
/ max(baseline_polyhormonal, 1e-6)
|
| 97 |
+
per_clus.to_csv(OUT / "110_veres_polyhormonal_alpha_per_cluster.csv",
|
| 98 |
+
index=False)
|
| 99 |
+
|
| 100 |
+
n_2x_clusters = int((per_clus["enrichment_vs_baseline"] >= 2.0).sum())
|
| 101 |
+
verdict = ("distinct_polyhormonal_subcluster" if n_2x_clusters in (1, 2)
|
| 102 |
+
else "graded_phenotype" if n_2x_clusters == 0
|
| 103 |
+
else "diffuse_enrichment")
|
| 104 |
+
|
| 105 |
+
summary = {
|
| 106 |
+
"n_alpha_pool": int(sub.n_obs),
|
| 107 |
+
"baseline_polyhormonal_frac": round(baseline_polyhormonal, 4),
|
| 108 |
+
"q75_thresholds": {"Ins": float(q_ins),
|
| 109 |
+
"Gcg": float(q_gcg),
|
| 110 |
+
"Sst": float(q_sst)},
|
| 111 |
+
"leiden_resolution": 0.5,
|
| 112 |
+
"n_clusters": int(per_clus["leiden_alpha"].nunique()),
|
| 113 |
+
"n_clusters_enriched_2x": n_2x_clusters,
|
| 114 |
+
"verdict": verdict,
|
| 115 |
+
"per_cluster": per_clus.to_dict("records"),
|
| 116 |
+
}
|
| 117 |
+
with open(OUT / "110_veres_polyhormonal_alpha_summary.json", "w") as f:
|
| 118 |
+
json.dump(summary, f, indent=2)
|
| 119 |
+
|
| 120 |
+
print("\n[done] verdict:", verdict, flush=True)
|
| 121 |
+
print(per_clus.round(3).to_string(index=False))
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
if __name__ == "__main__":
|
| 125 |
+
main()
|
scripts/analysis/44_en1_cko_contrast.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""en1-cKO vs WT contrast on aldrich predictions."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
import warnings
|
| 5 |
+
warnings.filterwarnings("ignore")
|
| 6 |
+
|
| 7 |
+
import numpy as np
|
| 8 |
+
import pandas as pd
|
| 9 |
+
import anndata as ad
|
| 10 |
+
import scanpy as sc
|
| 11 |
+
from scipy import stats
|
| 12 |
+
|
| 13 |
+
TARGET = Path("/home/bcheng/PRISM/data/processed/skin/adata_processed.h5ad")
|
| 14 |
+
PROJ = Path("/home/bcheng/PRISM/discovery/pan_skin/marker/50_aldrich_projections.h5ad")
|
| 15 |
+
NOVEL = Path("/home/bcheng/PRISM/discovery/pan_skin/marker/51_aldrich_novel_annotation.csv")
|
| 16 |
+
OUT = Path("/home/bcheng/PRISM/discovery/pan_skin/marker")
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def main():
|
| 20 |
+
a = ad.read_h5ad(TARGET)
|
| 21 |
+
p = ad.read_h5ad(PROJ)
|
| 22 |
+
for c in ["pred_label", "pred_conf", "abstain", "pred_bbse_label"]:
|
| 23 |
+
if c in p.obs.columns:
|
| 24 |
+
a.obs[c] = p.obs[c].values
|
| 25 |
+
if NOVEL.exists():
|
| 26 |
+
nov = pd.read_csv(NOVEL, index_col=0)
|
| 27 |
+
a.obs["novel_cluster"] = nov.reindex(a.obs_names)["novel_cluster"].values
|
| 28 |
+
|
| 29 |
+
print(f"[cko] target: {a.shape}, genotype: {a.obs['genotype'].value_counts().to_dict()}",
|
| 30 |
+
flush=True)
|
| 31 |
+
|
| 32 |
+
rows = []
|
| 33 |
+
for cls in a.obs["pred_bbse_label"].unique():
|
| 34 |
+
mask = a.obs["pred_bbse_label"] == cls
|
| 35 |
+
n_cko = int((mask & (a.obs["genotype"] == "En1-cKO")).sum())
|
| 36 |
+
n_wt = int((mask & (a.obs["genotype"] == "WT")).sum())
|
| 37 |
+
total_cko = (a.obs["genotype"] == "En1-cKO").sum()
|
| 38 |
+
total_wt = (a.obs["genotype"] == "WT").sum()
|
| 39 |
+
contingency = np.array([[n_cko, total_cko - n_cko],
|
| 40 |
+
[n_wt, total_wt - n_wt]])
|
| 41 |
+
odds, p_val = stats.fisher_exact(contingency)
|
| 42 |
+
f_cko = (n_cko + 1) / (total_cko + 2)
|
| 43 |
+
f_wt = (n_wt + 1) / (total_wt + 2)
|
| 44 |
+
log2_fc = np.log2(f_cko / f_wt)
|
| 45 |
+
rows.append({
|
| 46 |
+
"class": cls,
|
| 47 |
+
"n_En1cKO": n_cko,
|
| 48 |
+
"n_WT": n_wt,
|
| 49 |
+
"pct_En1cKO": round(100 * n_cko / total_cko, 2),
|
| 50 |
+
"pct_WT": round(100 * n_wt / total_wt, 2),
|
| 51 |
+
"log2_fold_enrich_cKO_vs_WT": round(log2_fc, 3),
|
| 52 |
+
"fisher_pvalue": p_val,
|
| 53 |
+
})
|
| 54 |
+
df = pd.DataFrame(rows).sort_values("log2_fold_enrich_cKO_vs_WT")
|
| 55 |
+
print("\n[cko] class enrichment (cKO vs WT):")
|
| 56 |
+
print(df.to_string(index=False))
|
| 57 |
+
|
| 58 |
+
df.to_csv(OUT / "53_en1_cko_class_enrichment.csv", index=False)
|
| 59 |
+
|
| 60 |
+
de_rows = []
|
| 61 |
+
for cls in sorted(a.obs["pred_bbse_label"].unique()):
|
| 62 |
+
cls_mask = a.obs["pred_bbse_label"] == cls
|
| 63 |
+
if cls_mask.sum() < 50:
|
| 64 |
+
continue
|
| 65 |
+
sub = a[cls_mask].copy()
|
| 66 |
+
vc = sub.obs["genotype"].value_counts()
|
| 67 |
+
if not {"En1-cKO", "WT"}.issubset(vc.index) or vc.min() < 15:
|
| 68 |
+
continue
|
| 69 |
+
try:
|
| 70 |
+
sc.tl.rank_genes_groups(sub, "genotype", method="wilcoxon",
|
| 71 |
+
n_genes=40, use_raw=False)
|
| 72 |
+
for grp in ["En1-cKO", "WT"]:
|
| 73 |
+
if grp not in sub.uns["rank_genes_groups"]["names"].dtype.names:
|
| 74 |
+
continue
|
| 75 |
+
names = list(sub.uns["rank_genes_groups"]["names"][grp][:15])
|
| 76 |
+
lfcs = list(sub.uns["rank_genes_groups"]["logfoldchanges"][grp][:15])
|
| 77 |
+
for g, lf in zip(names, lfcs):
|
| 78 |
+
de_rows.append({
|
| 79 |
+
"class": cls, "up_in": grp, "gene": g, "logfc": round(float(lf), 3),
|
| 80 |
+
})
|
| 81 |
+
except Exception as exc:
|
| 82 |
+
print(f"[cko] DE failed for {cls}: {exc}")
|
| 83 |
+
continue
|
| 84 |
+
de_df = pd.DataFrame(de_rows)
|
| 85 |
+
de_df.to_csv(OUT / "53_en1_cko_wilcoxon_within_class.csv", index=False)
|
| 86 |
+
|
| 87 |
+
md = ["# Aldrich En1-cKO vs WT contrast on PANDA-MLP predictions\n",
|
| 88 |
+
f"Total cells: {a.n_obs:,} ({int((a.obs['genotype']=='En1-cKO').sum())} En1-cKO, "
|
| 89 |
+
f"{int((a.obs['genotype']=='WT').sum())} WT).\n",
|
| 90 |
+
"## Class-level cKO/WT enrichment (BBSE-corrected predictions)\n",
|
| 91 |
+
df.to_markdown(index=False), "",
|
| 92 |
+
"The direction of `log2_fold_enrich_cKO_vs_WT` indicates whether a class is over-represented",
|
| 93 |
+
"in En1-cKO (positive) or WT (negative). Fisher exact p-value tests significance vs the",
|
| 94 |
+
"background genotype ratio (~40% cKO / 60% WT).\n",
|
| 95 |
+
"## Per-class Wilcoxon DE (En1-cKO vs WT within each class)\n",
|
| 96 |
+
"Top genes differentially expressed BETWEEN genotypes WITHIN a predicted class. Genes up in",
|
| 97 |
+
"cKO reveal En1-loss-responsive programs specific to that cell type; genes up in WT are the",
|
| 98 |
+
"opposite.\n",
|
| 99 |
+
]
|
| 100 |
+
if len(de_df):
|
| 101 |
+
for cls in sorted(de_df["class"].unique()):
|
| 102 |
+
md.append(f"\n### {cls}\n")
|
| 103 |
+
for grp in ["En1-cKO", "WT"]:
|
| 104 |
+
sub = de_df[(de_df["class"] == cls) & (de_df["up_in"] == grp)]
|
| 105 |
+
if not len(sub):
|
| 106 |
+
continue
|
| 107 |
+
md.append(f"**Up in {grp}**: " + ", ".join(sub["gene"].tolist()))
|
| 108 |
+
md += ["", "## Novel population x genotype cross-tab\n"]
|
| 109 |
+
if "novel_cluster" in a.obs.columns:
|
| 110 |
+
xt = pd.crosstab(a.obs["novel_cluster"], a.obs["genotype"])
|
| 111 |
+
md.append(xt.to_markdown())
|
| 112 |
+
(OUT / "53_en1_cko_contrast.md").write_text("\n".join(md))
|
| 113 |
+
print(f"[cko] wrote {OUT}/53_en1_cko_contrast.md")
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
if __name__ == "__main__":
|
| 117 |
+
main()
|
scripts/analysis/45_marker_refinement.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""marker refinement on aldrich zero-shot predictions: per-class wilcoxon DE vs canonical list."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
import warnings, yaml
|
| 5 |
+
warnings.filterwarnings("ignore")
|
| 6 |
+
|
| 7 |
+
import numpy as np
|
| 8 |
+
import pandas as pd
|
| 9 |
+
import anndata as ad
|
| 10 |
+
import scanpy as sc
|
| 11 |
+
|
| 12 |
+
TARGET = Path("/home/bcheng/PRISM/data/processed/skin/adata_processed.h5ad")
|
| 13 |
+
PROJ = Path("/home/bcheng/PRISM/discovery/pan_skin/marker/50_aldrich_projections.h5ad")
|
| 14 |
+
TFS = Path("/home/bcheng/PRISM/discovery/pan_skin/marker/known_skin_tfs.yaml")
|
| 15 |
+
OUT = Path("/home/bcheng/PRISM/discovery/pan_skin/marker")
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def main():
|
| 19 |
+
a = ad.read_h5ad(TARGET)
|
| 20 |
+
p = ad.read_h5ad(PROJ)
|
| 21 |
+
a.obs["pred_label"] = p.obs["pred_label"].values
|
| 22 |
+
a.obs["pred_conf"] = p.obs["pred_conf"].astype(float).values
|
| 23 |
+
|
| 24 |
+
with open(TFS) as f:
|
| 25 |
+
tf_cfg = yaml.safe_load(f)
|
| 26 |
+
canon = tf_cfg["classes"]
|
| 27 |
+
|
| 28 |
+
keep = a[~a.obs["pred_label"].str.startswith("UNK", na=False)].copy()
|
| 29 |
+
sc.tl.rank_genes_groups(keep, "pred_label", method="wilcoxon",
|
| 30 |
+
n_genes=100, use_raw=False)
|
| 31 |
+
|
| 32 |
+
rows = []
|
| 33 |
+
for cls in keep.obs["pred_label"].unique():
|
| 34 |
+
try:
|
| 35 |
+
names = keep.uns["rank_genes_groups"]["names"][cls]
|
| 36 |
+
lfc = keep.uns["rank_genes_groups"]["logfoldchanges"][cls]
|
| 37 |
+
padj = keep.uns["rank_genes_groups"]["pvals_adj"][cls]
|
| 38 |
+
except Exception:
|
| 39 |
+
continue
|
| 40 |
+
top100 = list(names[:100])
|
| 41 |
+
canon_set = set(canon.get(cls, []))
|
| 42 |
+
retained = [g for g in canon_set if g in top100]
|
| 43 |
+
novel = [g for g in top100 if g not in canon_set]
|
| 44 |
+
mask = keep.obs["pred_label"] == cls
|
| 45 |
+
n_cells = int(mask.sum())
|
| 46 |
+
mean_conf = float(keep.obs.loc[mask, "pred_conf"].mean())
|
| 47 |
+
rows.append({
|
| 48 |
+
"class": cls,
|
| 49 |
+
"n_cells_predicted": n_cells,
|
| 50 |
+
"mean_conf": round(mean_conf, 3),
|
| 51 |
+
"n_canonical": len(canon_set),
|
| 52 |
+
"n_canonical_retained_top100": len(retained),
|
| 53 |
+
"retained_canonical": ",".join(retained[:15]),
|
| 54 |
+
"novel_top20": ",".join(novel[:20]),
|
| 55 |
+
})
|
| 56 |
+
df = pd.DataFrame(rows).sort_values("n_cells_predicted", ascending=False)
|
| 57 |
+
df.to_csv(OUT / "52_refined_markers.csv", index=False)
|
| 58 |
+
print(df.to_string(index=False))
|
| 59 |
+
|
| 60 |
+
md = ["# Refined pan-skin markers for Aldrich zero-shot predictions\n"]
|
| 61 |
+
md.append("Marker refinement is Wilcoxon DE of each predicted class against all other")
|
| 62 |
+
md.append("predicted cells on raw Aldrich gene expression. `retained_canonical` are canonical")
|
| 63 |
+
md.append("markers recovered in the top-100; `novel_top20` are DE genes not in the canonical list.\n")
|
| 64 |
+
md.append(df.to_markdown(index=False))
|
| 65 |
+
(OUT / "52_refined_markers.md").write_text("\n".join(md))
|
| 66 |
+
print(f"[refine] wrote {OUT}/52_refined_markers.csv and .md")
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
if __name__ == "__main__":
|
| 70 |
+
main()
|
scripts/analysis/49_melanocyte_deep_dive.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""melanocyte deep dive on aldrich: within-melanocyte cKO vs WT DE and pathway scoring."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
import warnings
|
| 5 |
+
warnings.filterwarnings("ignore")
|
| 6 |
+
|
| 7 |
+
import numpy as np
|
| 8 |
+
import pandas as pd
|
| 9 |
+
import anndata as ad
|
| 10 |
+
import scanpy as sc
|
| 11 |
+
from scipy.stats import mannwhitneyu
|
| 12 |
+
|
| 13 |
+
TARGET = Path("/home/bcheng/PRISM/data/processed/skin/adata_processed.h5ad")
|
| 14 |
+
PROJ = Path("/home/bcheng/PRISM/discovery/pan_skin/marker/50_aldrich_projections.h5ad")
|
| 15 |
+
OUT = Path("/home/bcheng/PRISM/discovery/pan_skin/marker")
|
| 16 |
+
|
| 17 |
+
PATHWAYS = {
|
| 18 |
+
"MITF_regulon": ["Mitf", "Dct", "Tyr", "Pmel", "Mlana", "Tyrp1", "Slc24a5",
|
| 19 |
+
"Slc45a2", "Silv", "Sox10", "Pax3", "Kit", "Ednrb"],
|
| 20 |
+
"Wnt_signaling": ["Wnt3", "Wnt5a", "Wnt7a", "Wnt10b", "Ctnnb1", "Lef1", "Tcf4",
|
| 21 |
+
"Tcf7", "Axin2", "Dkk1", "Sfrp1", "Fzd7", "Lrp5", "Lrp6"],
|
| 22 |
+
"BMP_TGF_signaling": ["Bmp2", "Bmp4", "Bmp5", "Bmp7", "Bmpr1a", "Bmpr1b", "Bmpr2",
|
| 23 |
+
"Smad1", "Smad3", "Smad5", "Tgfb1", "Tgfb2", "Tgfbr1",
|
| 24 |
+
"Tgfbr2", "Id1", "Id2", "Id3"],
|
| 25 |
+
"FGF_signaling": ["Fgf1", "Fgf2", "Fgf7", "Fgf9", "Fgf10", "Fgf20", "Fgfr1",
|
| 26 |
+
"Fgfr2", "Fgfr3", "Fgfr4", "Etv1", "Etv4", "Etv5", "Spry2",
|
| 27 |
+
"Spry4", "Dusp6"],
|
| 28 |
+
"Notch_signaling": ["Notch1", "Notch2", "Notch3", "Jag1", "Jag2", "Dll1", "Dll3",
|
| 29 |
+
"Dll4", "Hes1", "Hes5", "Hey1", "Hey2", "Rbpj", "Maml1"],
|
| 30 |
+
"Hedgehog": ["Shh", "Ihh", "Ptch1", "Ptch2", "Smo", "Gli1", "Gli2", "Gli3",
|
| 31 |
+
"Hhip", "Sufu"],
|
| 32 |
+
"EMT": ["Zeb1", "Zeb2", "Snai1", "Snai2", "Twist1", "Twist2", "Vim",
|
| 33 |
+
"Cdh2", "Fn1", "Mmp2", "Mmp9", "Prrx1"],
|
| 34 |
+
"Cell_cycle": ["Ccnd1", "Ccnd2", "Ccne1", "Ccna2", "Ccnb1", "Cdk1", "Cdk2",
|
| 35 |
+
"Cdk4", "Cdk6", "Mki67", "Top2a", "Pcna", "Mcm2", "Mcm3"],
|
| 36 |
+
"Neural_crest": ["Sox10", "Sox9", "Sox2", "Pax3", "Foxd3", "Nes", "Ngfr",
|
| 37 |
+
"Ednrb", "Kit", "Tfap2a"],
|
| 38 |
+
"Apoptosis": ["Bax", "Bak1", "Bad", "Bid", "Bcl2", "Bcl2l1", "Casp3",
|
| 39 |
+
"Casp9", "Casp8", "Fas", "Fasl", "Trp53", "Cdkn1a"],
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def pathway_scoring(sub, pathway_dict):
|
| 44 |
+
for name, genes in pathway_dict.items():
|
| 45 |
+
present = [g for g in genes if g in sub.var_names]
|
| 46 |
+
if not present:
|
| 47 |
+
sub.obs[f"{name}"] = 0.0
|
| 48 |
+
continue
|
| 49 |
+
sc.tl.score_genes(sub, gene_list=present, score_name=f"{name}",
|
| 50 |
+
random_state=0, use_raw=False)
|
| 51 |
+
return sub
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def main():
|
| 55 |
+
a = ad.read_h5ad(TARGET)
|
| 56 |
+
p = ad.read_h5ad(PROJ)
|
| 57 |
+
a.obs["pred_label"] = p.obs["pred_bbse_label"].values
|
| 58 |
+
|
| 59 |
+
mask = a.obs["pred_label"] == "melanocyte"
|
| 60 |
+
print(f"[mel] melanocyte cells: {int(mask.sum())} "
|
| 61 |
+
f"({int((mask & (a.obs['genotype']=='En1-cKO')).sum())} cKO, "
|
| 62 |
+
f"{int((mask & (a.obs['genotype']=='WT')).sum())} WT)", flush=True)
|
| 63 |
+
if mask.sum() < 30:
|
| 64 |
+
print("[mel] too few melanocyte cells")
|
| 65 |
+
return
|
| 66 |
+
|
| 67 |
+
sub = a[mask].copy()
|
| 68 |
+
sub = pathway_scoring(sub, PATHWAYS)
|
| 69 |
+
|
| 70 |
+
print("\n[mel] pathway score cKO vs WT (positive = up in cKO):")
|
| 71 |
+
rows = []
|
| 72 |
+
for name in PATHWAYS.keys():
|
| 73 |
+
s = sub.obs[name].astype(float).values
|
| 74 |
+
g = sub.obs["genotype"].values
|
| 75 |
+
c_scores = s[g == "En1-cKO"]
|
| 76 |
+
w_scores = s[g == "WT"]
|
| 77 |
+
stat, pval = mannwhitneyu(c_scores, w_scores, alternative="two-sided")
|
| 78 |
+
delta = c_scores.mean() - w_scores.mean()
|
| 79 |
+
rows.append({"pathway": name, "delta_cKO_minus_WT": round(delta, 4),
|
| 80 |
+
"MannU_p": pval,
|
| 81 |
+
"cKO_mean": round(c_scores.mean(), 4),
|
| 82 |
+
"WT_mean": round(w_scores.mean(), 4)})
|
| 83 |
+
star = "***" if pval < 0.001 else "**" if pval < 0.01 else "*" if pval < 0.05 else ""
|
| 84 |
+
print(f" {name:20s} delta={delta:+.4f} p={pval:.2e} {star}")
|
| 85 |
+
|
| 86 |
+
pd.DataFrame(rows).sort_values("MannU_p").to_csv(OUT / "56_melanocyte_pathways.csv", index=False)
|
| 87 |
+
|
| 88 |
+
print("\n[mel] within-melanocyte cKO vs WT DE:")
|
| 89 |
+
sc.tl.rank_genes_groups(sub, "genotype", method="wilcoxon", n_genes=50, use_raw=False)
|
| 90 |
+
de = pd.DataFrame({
|
| 91 |
+
"cKO_up_gene": sub.uns["rank_genes_groups"]["names"]["En1-cKO"][:20],
|
| 92 |
+
"cKO_up_lfc": sub.uns["rank_genes_groups"]["logfoldchanges"]["En1-cKO"][:20],
|
| 93 |
+
"WT_up_gene": sub.uns["rank_genes_groups"]["names"]["WT"][:20],
|
| 94 |
+
"WT_up_lfc": sub.uns["rank_genes_groups"]["logfoldchanges"]["WT"][:20],
|
| 95 |
+
})
|
| 96 |
+
print(de.to_string(index=False))
|
| 97 |
+
de.to_csv(OUT / "56_melanocyte_wilcoxon_cko_vs_wt.csv", index=False)
|
| 98 |
+
|
| 99 |
+
lines = ["# Melanocyte deep dive — Aldrich En1-cKO vs WT",
|
| 100 |
+
"",
|
| 101 |
+
f"PANDA-MLP-predicted melanocyte cells: **{int(mask.sum())} total** "
|
| 102 |
+
f"({int((mask & (a.obs['genotype']=='En1-cKO')).sum())} cKO, "
|
| 103 |
+
f"{int((mask & (a.obs['genotype']=='WT')).sum())} WT).",
|
| 104 |
+
"",
|
| 105 |
+
"The v3b+v3c cross-model replication showed ~2× melanocyte enrichment in cKO "
|
| 106 |
+
"(log2fc +1.04, Fisher p ≈ 4×10⁻⁶). Here we probe what's happening WITHIN the "
|
| 107 |
+
"melanocyte compartment.",
|
| 108 |
+
"",
|
| 109 |
+
"## Pathway score comparisons (MannU cKO vs WT)",
|
| 110 |
+
""]
|
| 111 |
+
dfp = pd.DataFrame(rows).sort_values("MannU_p")
|
| 112 |
+
lines.append(dfp.to_markdown(index=False))
|
| 113 |
+
lines += ["",
|
| 114 |
+
"## Within-melanocyte Wilcoxon DE (top 20 each direction)",
|
| 115 |
+
""]
|
| 116 |
+
de_show = de.copy()
|
| 117 |
+
de_show["cKO_up_lfc"] = de_show["cKO_up_lfc"].round(3)
|
| 118 |
+
de_show["WT_up_lfc"] = de_show["WT_up_lfc"].round(3)
|
| 119 |
+
lines.append(de_show.to_markdown(index=False))
|
| 120 |
+
(OUT / "56_melanocyte_deep_dive.md").write_text("\n".join(lines))
|
| 121 |
+
print(f"[mel] wrote {OUT}/56_melanocyte_deep_dive.md")
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
if __name__ == "__main__":
|
| 125 |
+
main()
|
scripts/analysis/57_multiclass_pathway_analysis.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""per-class pathway scoring cKO vs WT on aldrich, MannU per (class, pathway)."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
import warnings
|
| 5 |
+
warnings.filterwarnings("ignore")
|
| 6 |
+
|
| 7 |
+
import numpy as np
|
| 8 |
+
import pandas as pd
|
| 9 |
+
import anndata as ad
|
| 10 |
+
import scanpy as sc
|
| 11 |
+
from scipy.stats import mannwhitneyu
|
| 12 |
+
|
| 13 |
+
TARGET = Path("/home/bcheng/PRISM/data/processed/skin/adata_processed.h5ad")
|
| 14 |
+
PROJ = Path("/home/bcheng/PRISM/discovery/pan_skin/marker/50_aldrich_projections.h5ad")
|
| 15 |
+
OUT = Path("/home/bcheng/PRISM/discovery/pan_skin/marker")
|
| 16 |
+
|
| 17 |
+
CLASSES_OF_INTEREST = [
|
| 18 |
+
"basal-IFE", "spinous", "granular",
|
| 19 |
+
"fibroblast-papillary", "fibroblast-reticular",
|
| 20 |
+
"endothelial", "immune", "melanocyte",
|
| 21 |
+
]
|
| 22 |
+
|
| 23 |
+
PATHWAYS = {
|
| 24 |
+
"MITF_regulon": ["Mitf", "Dct", "Tyr", "Pmel", "Mlana", "Tyrp1", "Slc24a5",
|
| 25 |
+
"Slc45a2", "Sox10", "Pax3", "Kit", "Ednrb"],
|
| 26 |
+
"Wnt_signaling": ["Wnt3", "Wnt5a", "Wnt7a", "Wnt10b", "Ctnnb1", "Lef1", "Tcf4",
|
| 27 |
+
"Tcf7", "Axin2", "Dkk1", "Sfrp1", "Fzd7", "Lrp5"],
|
| 28 |
+
"BMP_signaling": ["Bmp2", "Bmp4", "Bmp5", "Bmp7", "Bmpr1a", "Bmpr1b", "Bmpr2",
|
| 29 |
+
"Smad1", "Smad5", "Id1", "Id2", "Id3"],
|
| 30 |
+
"TGFB_signaling": ["Tgfb1", "Tgfb2", "Tgfbr1", "Tgfbr2", "Smad3", "Smad7"],
|
| 31 |
+
"FGF_signaling": ["Fgf1", "Fgf2", "Fgf7", "Fgf9", "Fgf10", "Fgfr1", "Fgfr2",
|
| 32 |
+
"Etv1", "Etv4", "Etv5", "Spry2", "Dusp6"],
|
| 33 |
+
"Notch_signaling": ["Notch1", "Notch2", "Notch3", "Jag1", "Dll1", "Hes1", "Hes5",
|
| 34 |
+
"Hey1", "Hey2", "Rbpj"],
|
| 35 |
+
"Hedgehog": ["Shh", "Ptch1", "Smo", "Gli1", "Gli2", "Gli3"],
|
| 36 |
+
"Eda_ectodysplasin": ["Eda", "Edar", "Edaradd", "Nfkb1", "Nfkb2", "Rela"],
|
| 37 |
+
"EMT": ["Zeb1", "Zeb2", "Snai1", "Snai2", "Twist1", "Twist2", "Vim",
|
| 38 |
+
"Cdh2", "Fn1", "Prrx1"],
|
| 39 |
+
"Cell_cycle": ["Ccnd1", "Ccne1", "Ccna2", "Ccnb1", "Cdk1", "Cdk2", "Cdk4",
|
| 40 |
+
"Mki67", "Top2a", "Pcna", "Mcm2", "Mcm3"],
|
| 41 |
+
"KC_differentiation": ["Krt1", "Krt10", "Ivl", "Lor", "Flg", "Flg2", "Klk5", "Klk7",
|
| 42 |
+
"Cdsn"],
|
| 43 |
+
"Basal_keratinocyte": ["Krt5", "Krt14", "Krt15", "Trp63", "Itga6", "Itgb1", "Itga3"],
|
| 44 |
+
"Sweat_gland": ["Foxi3", "Foxa1", "En1", "Krt8", "Krt18", "Krt19", "Muc5b",
|
| 45 |
+
"Aqp5", "Cutl1"],
|
| 46 |
+
"Hair_placode": ["Wnt10b", "Shh", "Lef1", "Foxi3", "Edar", "Bmp4", "Msx2"],
|
| 47 |
+
"Neural_crest": ["Sox10", "Sox9", "Sox2", "Pax3", "Foxd3", "Nes", "Tfap2a"],
|
| 48 |
+
"Apoptosis": ["Bax", "Bak1", "Bad", "Bcl2", "Casp3", "Casp9", "Trp53",
|
| 49 |
+
"Cdkn1a"],
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def pathway_scoring(sub, pathway_dict):
|
| 54 |
+
for name, genes in pathway_dict.items():
|
| 55 |
+
present = [g for g in genes if g in sub.var_names]
|
| 56 |
+
if not present:
|
| 57 |
+
sub.obs[f"pw_{name}"] = 0.0
|
| 58 |
+
continue
|
| 59 |
+
sc.tl.score_genes(sub, gene_list=present, score_name=f"pw_{name}",
|
| 60 |
+
random_state=0, use_raw=False)
|
| 61 |
+
return sub
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def main():
|
| 65 |
+
a = ad.read_h5ad(TARGET)
|
| 66 |
+
p = ad.read_h5ad(PROJ)
|
| 67 |
+
a.obs["pred_label"] = p.obs["pred_bbse_label"].values
|
| 68 |
+
print(f"[pw] classes in target: {a.obs['pred_label'].value_counts().to_dict()}", flush=True)
|
| 69 |
+
|
| 70 |
+
rows = []
|
| 71 |
+
for cls in CLASSES_OF_INTEREST:
|
| 72 |
+
mask = a.obs["pred_label"] == cls
|
| 73 |
+
n_c = int((mask & (a.obs["genotype"]=="En1-cKO")).sum())
|
| 74 |
+
n_w = int((mask & (a.obs["genotype"]=="WT")).sum())
|
| 75 |
+
if n_c < 15 or n_w < 15:
|
| 76 |
+
print(f"[pw] {cls}: skip (n_cKO={n_c}, n_WT={n_w})")
|
| 77 |
+
continue
|
| 78 |
+
sub = a[mask].copy()
|
| 79 |
+
sub = pathway_scoring(sub, PATHWAYS)
|
| 80 |
+
for pw in PATHWAYS.keys():
|
| 81 |
+
s = sub.obs[f"pw_{pw}"].astype(float).values
|
| 82 |
+
g = sub.obs["genotype"].values
|
| 83 |
+
cvals = s[g=="En1-cKO"]; wvals = s[g=="WT"]
|
| 84 |
+
try:
|
| 85 |
+
_, pval = mannwhitneyu(cvals, wvals, alternative="two-sided")
|
| 86 |
+
except Exception:
|
| 87 |
+
pval = 1.0
|
| 88 |
+
delta = cvals.mean() - wvals.mean()
|
| 89 |
+
rows.append({"class": cls, "pathway": pw,
|
| 90 |
+
"n_cKO": n_c, "n_WT": n_w,
|
| 91 |
+
"delta_cKO_minus_WT": round(delta, 4),
|
| 92 |
+
"MannU_p": pval})
|
| 93 |
+
print(f"[pw] {cls}: {n_c} cKO, {n_w} WT — scored")
|
| 94 |
+
|
| 95 |
+
df = pd.DataFrame(rows)
|
| 96 |
+
df.to_csv(OUT / "57_pathway_class_by_pathway.csv", index=False)
|
| 97 |
+
|
| 98 |
+
pivot_delta = df.pivot(index="pathway", columns="class", values="delta_cKO_minus_WT")
|
| 99 |
+
pivot_p = df.pivot(index="pathway", columns="class", values="MannU_p")
|
| 100 |
+
def stars(p): return "***" if p<0.001 else "**" if p<0.01 else "*" if p<0.05 else ""
|
| 101 |
+
disp = pivot_delta.copy().astype(object)
|
| 102 |
+
for pw in disp.index:
|
| 103 |
+
for c in disp.columns:
|
| 104 |
+
d = pivot_delta.loc[pw, c]; p = pivot_p.loc[pw, c]
|
| 105 |
+
if pd.isna(d): disp.loc[pw, c] = ""
|
| 106 |
+
else: disp.loc[pw, c] = f"{d:+.3f}{stars(p)}"
|
| 107 |
+
|
| 108 |
+
lines = ["# Pathway score contrasts by class — Aldrich En1-cKO vs WT",
|
| 109 |
+
"",
|
| 110 |
+
"Delta = cKO mean − WT mean of `sc.tl.score_genes` pathway score.",
|
| 111 |
+
"Sig: * p<0.05, ** p<0.01, *** p<0.001 (MannU two-sided).\n",
|
| 112 |
+
disp.to_markdown()]
|
| 113 |
+
(OUT / "57_pathway_class_by_pathway.md").write_text("\n".join(lines))
|
| 114 |
+
print(f"[pw] wrote {OUT}/57_pathway_class_by_pathway.md")
|
| 115 |
+
|
| 116 |
+
df_sig = df[df["MannU_p"] < 0.01].sort_values("MannU_p")
|
| 117 |
+
print("\n[pw] Strongest cKO/WT pathway shifts (p<0.01):")
|
| 118 |
+
print(df_sig[["class","pathway","delta_cKO_minus_WT","MannU_p"]].to_string(index=False))
|
| 119 |
+
df_sig.to_csv(OUT / "57_pathway_top_hits.csv", index=False)
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
if __name__ == "__main__":
|
| 123 |
+
main()
|
scripts/analysis/57_pathway_analysis.py
ADDED
|
@@ -0,0 +1,529 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""per-class pathway module scoring cKO/mutant vs WT across pan_skin, hematopoiesis, pancreas.
|
| 2 |
+
pancreas contrast is Veres stage 6 vs stage 5 (HUMAN gene symbols)."""
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import argparse
|
| 6 |
+
import warnings
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
import anndata as ad
|
| 10 |
+
import numpy as np
|
| 11 |
+
import pandas as pd
|
| 12 |
+
import scanpy as sc
|
| 13 |
+
import scipy.sparse as sp
|
| 14 |
+
from scipy.stats import mannwhitneyu
|
| 15 |
+
|
| 16 |
+
warnings.filterwarnings("ignore")
|
| 17 |
+
sc.settings.verbosity = 0
|
| 18 |
+
|
| 19 |
+
ROOT = Path("/home/bcheng/PRISM")
|
| 20 |
+
MIN_PER_GROUP = 15
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _M(genes, type_, citation, direction=None):
|
| 24 |
+
return {"genes": list(genes), "type": type_, "citation": citation,
|
| 25 |
+
"direction": direction}
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
# pan-skin: mouse symbols
|
| 29 |
+
SKIN_MODULES = {
|
| 30 |
+
"MITF_regulon": _M(["Mitf","Dct","Tyr","Pmel","Mlana","Tyrp1","Slc24a5",
|
| 31 |
+
"Slc45a2","Sox10","Pax3","Kit","Ednrb"],
|
| 32 |
+
"lineage", "Steingrimsson-2004"),
|
| 33 |
+
"Wnt_signaling": _M(["Wnt3","Wnt5a","Wnt7a","Wnt10b","Ctnnb1","Lef1",
|
| 34 |
+
"Tcf4","Tcf7","Axin2","Dkk1","Sfrp1","Fzd7","Lrp5"],
|
| 35 |
+
"signaling", "Nusse-2017"),
|
| 36 |
+
"BMP_signaling": _M(["Bmp2","Bmp4","Bmp5","Bmp7","Bmpr1a","Bmpr1b",
|
| 37 |
+
"Bmpr2","Smad1","Smad5","Id1","Id2","Id3"],
|
| 38 |
+
"signaling", "Botchkarev-2003"),
|
| 39 |
+
"TGFB_signaling": _M(["Tgfb1","Tgfb2","Tgfbr1","Tgfbr2","Smad3","Smad7"],
|
| 40 |
+
"signaling", "Massague-2012"),
|
| 41 |
+
"FGF_signaling": _M(["Fgf1","Fgf2","Fgf7","Fgf9","Fgf10","Fgfr1","Fgfr2",
|
| 42 |
+
"Etv1","Etv4","Etv5","Spry2","Dusp6"],
|
| 43 |
+
"signaling", "Ornitz-2015"),
|
| 44 |
+
"Notch_signaling": _M(["Notch1","Notch2","Notch3","Jag1","Dll1","Hes1",
|
| 45 |
+
"Hes5","Hey1","Hey2","Rbpj"],
|
| 46 |
+
"signaling", "Andersson-2011"),
|
| 47 |
+
"Hedgehog": _M(["Shh","Ptch1","Smo","Gli1","Gli2","Gli3"],
|
| 48 |
+
"signaling", "St-Jacques-1998"),
|
| 49 |
+
"Eda_ectodysplasin": _M(["Eda","Edar","Edaradd","Nfkb1","Nfkb2","Rela"],
|
| 50 |
+
"signaling", "Mikkola-2009"),
|
| 51 |
+
"EMT": _M(["Zeb1","Zeb2","Snai1","Snai2","Twist1","Twist2",
|
| 52 |
+
"Vim","Cdh2","Fn1","Prrx1"],
|
| 53 |
+
"lineage", "Thiery-2009"),
|
| 54 |
+
"Cell_cycle": _M(["Ccnd1","Ccne1","Ccna2","Ccnb1","Cdk1","Cdk2",
|
| 55 |
+
"Cdk4","Mki67","Top2a","Pcna","Mcm2","Mcm3"],
|
| 56 |
+
"cycle", "Whitfield-2002"),
|
| 57 |
+
"KC_differentiation":_M(["Krt1","Krt10","Ivl","Lor","Flg","Flg2","Klk5",
|
| 58 |
+
"Klk7","Cdsn"],
|
| 59 |
+
"lineage", "Fuchs-2007"),
|
| 60 |
+
"Basal_keratinocyte":_M(["Krt5","Krt14","Krt15","Trp63","Itga6","Itgb1",
|
| 61 |
+
"Itga3"],
|
| 62 |
+
"lineage", "Blanpain-2007"),
|
| 63 |
+
"Sweat_gland": _M(["Foxi3","Foxa1","En1","Krt8","Krt18","Krt19",
|
| 64 |
+
"Muc5b","Aqp5","Cutl1"],
|
| 65 |
+
"lineage", "Lu-2016"),
|
| 66 |
+
"Hair_placode": _M(["Wnt10b","Shh","Lef1","Foxi3","Edar","Bmp4","Msx2"],
|
| 67 |
+
"lineage", "Millar-2002"),
|
| 68 |
+
"Neural_crest": _M(["Sox10","Sox9","Sox2","Pax3","Foxd3","Nes","Tfap2a"],
|
| 69 |
+
"lineage", "Simoes-Costa-2015"),
|
| 70 |
+
"Apoptosis": _M(["Bax","Bak1","Bad","Bcl2","Casp3","Casp9","Trp53",
|
| 71 |
+
"Cdkn1a"],
|
| 72 |
+
"stress", "Youle-2008"),
|
| 73 |
+
"Melanogenesis_late":_M(["Tyrp1","Slc45a2","Oca2","Gpnmb","Pmel","Silv",
|
| 74 |
+
"Mlph","Rab27a","Melana"],
|
| 75 |
+
"lineage", "Raposo-2007"),
|
| 76 |
+
"Sebogenesis": _M(["Elovl3","Awat2","Scd1","Scd3","Adipoq","Fasn",
|
| 77 |
+
"Mgst1","Srebf1","Pparg"],
|
| 78 |
+
"metabolism", "Zouboulis-2016"),
|
| 79 |
+
"Immune_Th1_Th2_Th17":_M(["Tbx21","Gata3","Rorc","Ifng","Il4","Il13","Il17a",
|
| 80 |
+
"Il17f","Il22","Foxp3"],
|
| 81 |
+
"immune", "Zhu-2010"),
|
| 82 |
+
"DNA_damage": _M(["Trp53","Cdkn1a","Atm","Atr","Brca1","Chek1",
|
| 83 |
+
"Chek2","Rad51","Mre11a","H2ax","Nbn"],
|
| 84 |
+
"stress", "Ciccia-2010"),
|
| 85 |
+
"Autophagy": _M(["Atg5","Atg7","Atg12","Becn1","Map1lc3b","Sqstm1",
|
| 86 |
+
"Ulk1","Atg3","Atg16l1"],
|
| 87 |
+
"stress", "Mizushima-2011"),
|
| 88 |
+
"Senescence": _M(["Cdkn2a","Cdkn2b","Cdkn1a","Il6","Cxcl1","Serpine1",
|
| 89 |
+
"Glb1","Lmnb1"],
|
| 90 |
+
"stress", "Coppe-2010"),
|
| 91 |
+
"Epidermal_junction":_M(["Cdh1","Dsg1a","Dsg1b","Dsg2","Dsg3","Cldn1",
|
| 92 |
+
"Cldn4","Ocln","Tjp1","Cldn23","Dsp","Pkp1"],
|
| 93 |
+
"junction", "Green-2010"),
|
| 94 |
+
"ECM_collagen": _M(["Col1a1","Col1a2","Col3a1","Col4a1","Col6a1",
|
| 95 |
+
"Col17a1","Lum","Dcn","Fbn1","Postn","Fbln1"],
|
| 96 |
+
"ecm", "Ricard-Blum-2011"),
|
| 97 |
+
"Endothelial_tip_stalk":_M(["Dll4","Notch1","Hes1","Kdr","Kit","Cxcr4",
|
| 98 |
+
"Angpt2","Nrp1","Flt1","Cdh5","Pecam1"],
|
| 99 |
+
"lineage", "Blanco-2013"),
|
| 100 |
+
"Fibroblast_wound": _M(["Postn","Fap","Aspn","Tnc","Acta2","Prrx1","Pdgfra",
|
| 101 |
+
"Ly6a"],
|
| 102 |
+
"lineage", "Rinkevich-2015"),
|
| 103 |
+
"Fatty_acid_oxidation":_M(["Cpt1a","Acadm","Acadl","Acadvl","Hadha","Hadhb",
|
| 104 |
+
"Ppara","Ppargc1a","Ucp2"],
|
| 105 |
+
"metabolism", "Houten-2010"),
|
| 106 |
+
"Nrf2_oxidative_stress":_M(["Nfe2l2","Nqo1","Gclc","Hmox1","Slc7a11",
|
| 107 |
+
"Txnrd1","Gsta3","Gpx2","Keap1"],
|
| 108 |
+
"stress", "Ma-2013"),
|
| 109 |
+
"IFN_gamma": _M(["Ifng","Stat1","Ifit1","Ifit2","Ifit3","Isg15",
|
| 110 |
+
"Irf1","Cxcl9","Cxcl10","Gbp2"],
|
| 111 |
+
"immune", "Schoggins-2011"),
|
| 112 |
+
"IL6_JAK_STAT": _M(["Il6","Stat3","Socs3","Jak1","Jak2","Il6ra",
|
| 113 |
+
"Il6st","Osm"],
|
| 114 |
+
"signaling", "Heinrich-2003"),
|
| 115 |
+
"Pigment_regulation":_M(["Asip","Kitl","Kit","Bcl2","Mc1r","Pomc","Adcy8",
|
| 116 |
+
"Ednrb","Edn3"],
|
| 117 |
+
"signaling", "Slominski-2004"),
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
# hematopoiesis: mouse symbols
|
| 122 |
+
HSC_MODULES = {
|
| 123 |
+
"Kit_signaling": _M(["Kit","Kitl","Sox4","Gata2","Runx1","Meis1"],
|
| 124 |
+
"signaling", "Lennartsson-2012"),
|
| 125 |
+
"Kit_ligand": _M(["Kit","Kitl"], "signaling", "Broudy-1997"),
|
| 126 |
+
"MYC_targets": _M(["Myc","Nolc1","Nop58","Ncl","Npm1","Fbl","Eif4e",
|
| 127 |
+
"Nop56","Ldha","Odc1"],
|
| 128 |
+
"lineage", "Dang-2012"),
|
| 129 |
+
"Cell_cycle": _M(["Ccnd1","Ccne1","Ccna2","Ccnb1","Cdk1","Cdk2",
|
| 130 |
+
"Cdk4","Mki67","Top2a","Pcna","Mcm2","Mcm3","Mcm5"],
|
| 131 |
+
"cycle", "Whitfield-2002"),
|
| 132 |
+
"DNA_replication": _M(["Mcm2","Mcm3","Mcm4","Mcm5","Mcm6","Mcm7","Pcna",
|
| 133 |
+
"Rfc4","Pola1","Pole","Rpa1","Rpa2"],
|
| 134 |
+
"cycle", "Bell-2002"),
|
| 135 |
+
"Integrated_stress": _M(["Atf4","Ddit3","Ppp1r15a","Ppp1r15b","Eif2ak3",
|
| 136 |
+
"Eif2s1","Atf3","Gadd45a"],
|
| 137 |
+
"stress", "Pakos-Zebrucka-2016"),
|
| 138 |
+
"Apoptosis_pro": _M(["Bax","Bak1","Bid","Bad","Bim","Puma","Noxa",
|
| 139 |
+
"Casp3","Casp9"],
|
| 140 |
+
"stress", "Youle-2008"),
|
| 141 |
+
"Apoptosis_anti": _M(["Bcl2","Bcl2l1","Mcl1","Bcl2l2","Bcl2l10","Xiap"],
|
| 142 |
+
"stress", "Adams-2018"),
|
| 143 |
+
"Erythropoiesis_early":_M(["Gata1","Klf1","Epo","Epor","Tal1","Zfpm1",
|
| 144 |
+
"Gypa","Lmo2"],
|
| 145 |
+
"lineage", "Palis-2014"),
|
| 146 |
+
"Erythropoiesis_late":_M(["Alas2","Hba-a1","Hba-a2","Hbb-b1","Hbb-b2","Slc4a1",
|
| 147 |
+
"Ank1","Blvrb","Car1","Car2"],
|
| 148 |
+
"lineage", "Palis-2014"),
|
| 149 |
+
"Granulopoiesis": _M(["Cebpa","Cebpe","Elane","Mpo","Prtn3","Csf3r",
|
| 150 |
+
"S100a8","S100a9","Ctsg","Ltf","Lcn2","Mmp8"],
|
| 151 |
+
"lineage", "Rosenbauer-2007"),
|
| 152 |
+
"Lymphopoiesis_B": _M(["Rag1","Rag2","Dntt","Vpreb1","Vpreb3","Igll1",
|
| 153 |
+
"Cd19","Pax5","Ebf1"],
|
| 154 |
+
"lineage", "Nutt-2011"),
|
| 155 |
+
"Lymphopoiesis_T": _M(["Il7r","Cd3d","Cd3e","Cd3g","Lck","Zap70","Gata3",
|
| 156 |
+
"Tcf7","Runx3"],
|
| 157 |
+
"lineage", "Rothenberg-2014"),
|
| 158 |
+
"Megakaryopoiesis": _M(["Nfe2","Gata1","Fli1","Runx1","Itga2b","Pf4",
|
| 159 |
+
"Gp1bb","Gp9","Mpl","Vwf"],
|
| 160 |
+
"lineage", "Tijssen-2013"),
|
| 161 |
+
"Basophil_mast": _M(["Cpa3","Ms4a2","Gata2","Hdc","Mcpt8","Prss34",
|
| 162 |
+
"Fcer1a","Il4","Il6"],
|
| 163 |
+
"lineage", "Voehringer-2013"),
|
| 164 |
+
"Hemostasis": _M(["Vwf","F5","F13a1","Fga","Fgb","Fgg","Serpine1",
|
| 165 |
+
"Plat","Plau","Plg"],
|
| 166 |
+
"signaling", "Furie-2008"),
|
| 167 |
+
"OXPHOS_ETC": _M(["Ndufa1","Ndufa2","Ndufb1","Ndufb2","Sdha","Sdhb",
|
| 168 |
+
"Cox4i1","Cox5a","Cox6a1","Atp5a1","Atp5b","Uqcrq"],
|
| 169 |
+
"metabolism", "Mishra-2016"),
|
| 170 |
+
"Glycolysis": _M(["Hk1","Hk2","Pfkm","Pfkl","Aldoa","Gapdh","Pgk1",
|
| 171 |
+
"Pkm","Ldha","Eno1","Tpi1","Pgam1"],
|
| 172 |
+
"metabolism", "Vander-Heiden-2009"),
|
| 173 |
+
"TCA": _M(["Cs","Aco2","Idh2","Idh3a","Sdha","Fh1","Mdh2",
|
| 174 |
+
"Ogdh","Sucla2"],
|
| 175 |
+
"metabolism", "Chandel-2015"),
|
| 176 |
+
"Redox_glutathione": _M(["Gpx1","Gpx2","Gpx3","Gpx4","Gsr","Prdx1","Prdx2",
|
| 177 |
+
"Prdx3","Prdx4","Prdx5","Prdx6","Sod1","Sod2","Cat"],
|
| 178 |
+
"stress", "Ho-2007"),
|
| 179 |
+
"Wnt_hemato": _M(["Wnt3a","Wnt5a","Ctnnb1","Lef1","Tcf7","Axin2",
|
| 180 |
+
"Fzd4","Fzd7"],
|
| 181 |
+
"signaling", "Reya-2003"),
|
| 182 |
+
"Notch_hemato": _M(["Notch1","Notch2","Jag1","Hes1","Dll1","Dll4",
|
| 183 |
+
"Rbpj","Hey1"],
|
| 184 |
+
"signaling", "Bigas-2018"),
|
| 185 |
+
"TGFb_hemato": _M(["Tgfb1","Tgfb2","Tgfbr1","Tgfbr2","Smad2","Smad3",
|
| 186 |
+
"Smad4","Smad7"],
|
| 187 |
+
"signaling", "Blank-2015"),
|
| 188 |
+
"IFN_signaling": _M(["Ifnar1","Ifnar2","Stat1","Stat2","Ifit1","Ifit2",
|
| 189 |
+
"Ifit3","Isg15","Irf7","Mx1"],
|
| 190 |
+
"immune", "Essers-2009"),
|
| 191 |
+
"Complement": _M(["C1qa","C1qb","C1qc","C3","C4b","Cfp","Cfh","Cfd"],
|
| 192 |
+
"immune", "Ricklin-2016"),
|
| 193 |
+
"NK_cytotoxicity": _M(["Ncr1","Klrk1","Prf1","Gzmb","Gzmk","Nkg7","Klrd1",
|
| 194 |
+
"Klrb1c","Klra8"],
|
| 195 |
+
"immune", "Vivier-2011"),
|
| 196 |
+
"Mast_cell_degran": _M(["Ms4a2","Fcer1a","Cpa3","Kit","Hdc","Tpsb2",
|
| 197 |
+
"Prss34","Mcpt4"],
|
| 198 |
+
"immune", "Galli-2011"),
|
| 199 |
+
"Autophagy": _M(["Atg5","Atg7","Atg12","Becn1","Map1lc3b","Sqstm1",
|
| 200 |
+
"Ulk1","Atg3","Atg16l1"],
|
| 201 |
+
"stress", "Warr-2013"),
|
| 202 |
+
"Senescence": _M(["Cdkn2a","Cdkn2b","Cdkn1a","Il6","Cxcl1","Serpine1",
|
| 203 |
+
"Glb1","Lmnb1"],
|
| 204 |
+
"stress", "Chang-2016"),
|
| 205 |
+
"LT_HSC_quiescence": _M(["Hlf","Meis1","Mecom","Procr","Fgd5","Mllt3","Egr1",
|
| 206 |
+
"Rgs1","Cdkn1c","Ndn","Mpl"],
|
| 207 |
+
"lineage", "Cabezas-Wallscheid-2017"),
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
# pancreas: HUMAN symbols (Veres is hPSC)
|
| 212 |
+
PANCREAS_MODULES = {
|
| 213 |
+
"Insulin_secretion": _M(["INS","IAPP","CHGA","CHGB","SCG5","ERO1B","PCSK1",
|
| 214 |
+
"PCSK2","SLC30A8","G6PC2"],
|
| 215 |
+
"hormone", "Rorsman-2013"),
|
| 216 |
+
"Glucose_sensing": _M(["SLC2A2","GCK","KCNJ11","ABCC8","SIRT1","GLUT1",
|
| 217 |
+
"SLC2A1"],
|
| 218 |
+
"signaling", "Matschinsky-2013"),
|
| 219 |
+
"Alpha_master_TF": _M(["ARX","IRX1","IRX2","MAFB","POU3F4","GCG","TTR"],
|
| 220 |
+
"lineage", "Collombat-2003"),
|
| 221 |
+
"Beta_master_TF_embryonic":_M(["NKX6-1","MNX1","NEUROD1","PDX1","NKX2-2",
|
| 222 |
+
"HNF1B"],
|
| 223 |
+
"lineage", "Gu-2004"),
|
| 224 |
+
"Beta_master_TF_adult":_M(["MAFA","UCN3","SIX3","INS","IAPP","G6PC2"],
|
| 225 |
+
"lineage", "Blum-2012"),
|
| 226 |
+
"Neurog3_EP_cascade":_M(["NEUROG3","PAX4","FEV","INSM1","NEUROD1","SOX4",
|
| 227 |
+
"CBFA2T3","BTBD17"],
|
| 228 |
+
"lineage", "Gradwohl-2000"),
|
| 229 |
+
"Endocrine_maturation":_M(["RFX3","RFX6","ISL1","FOXA2","PAX6","NKX2-2"],
|
| 230 |
+
"lineage", "Piccand-2014"),
|
| 231 |
+
"Exocrine_acinar": _M(["PRSS1","PRSS2","CEL","CPA1","CTRB1","AMY2A",
|
| 232 |
+
"ELOVL5","PTF1A","CELA1"],
|
| 233 |
+
"lineage", "Kawaguchi-2002"),
|
| 234 |
+
"Ductal_epithelial": _M(["KRT19","KRT7","SOX9","MUC1","ONECUT1","HES1",
|
| 235 |
+
"HNF1B","CFTR"],
|
| 236 |
+
"lineage", "Solar-2009"),
|
| 237 |
+
"Foregut_endoderm": _M(["SOX17","FOXA1","FOXA2","ONECUT1","PROX1","HNF1A",
|
| 238 |
+
"HNF1B","GATA4","GATA6"],
|
| 239 |
+
"lineage", "Zorn-2009"),
|
| 240 |
+
"Cilium_Foxj1": _M(["FOXJ1","CFAP43","CFAP157","NPHP1","IFT88","DNAH5",
|
| 241 |
+
"TEKT1","SPAG6"],
|
| 242 |
+
"lineage", "Choksi-2014"),
|
| 243 |
+
"Delta_master": _M(["SST","HHEX","LEPR","GHSR"],
|
| 244 |
+
"hormone", "Rorsman-2018"),
|
| 245 |
+
"Gamma_master": _M(["PPY","PYY","SLC38A4"],
|
| 246 |
+
"hormone", "Wang-2016"),
|
| 247 |
+
"Epsilon_ghrelin": _M(["GHRL","ACSL1"],
|
| 248 |
+
"hormone", "Prado-2004"),
|
| 249 |
+
"ER_stress_pancreas":_M(["ATF6","XBP1","ERN1","DDIT3","HSPA5","HSPA1A",
|
| 250 |
+
"HSPA1B","EIF2AK3"],
|
| 251 |
+
"stress", "Back-2012"),
|
| 252 |
+
"Unfolded_protein_response":_M(["ATF4","ATF6","XBP1","HERPUD1","BAK1","BAX",
|
| 253 |
+
"EDEM1","DERL1"],
|
| 254 |
+
"stress", "Walter-2011"),
|
| 255 |
+
"Hormone_processing":_M(["PCSK1","PCSK2","CPE","CHGA","CHGB","SCG2","SCG5",
|
| 256 |
+
"PAM"],
|
| 257 |
+
"hormone", "Docherty-1997"),
|
| 258 |
+
"Insulin_receptor_signaling":_M(["INSR","IRS1","IRS2","AKT2","PDX1","FOXO1",
|
| 259 |
+
"GSK3B","MTOR"],
|
| 260 |
+
"signaling", "Kulkarni-1999"),
|
| 261 |
+
"Mesenchyme_pancreatic":_M(["NKX3-2","BMP4","SOX9","FGF10","COL1A1","COL3A1",
|
| 262 |
+
"DCN"],
|
| 263 |
+
"lineage", "Landsman-2011"),
|
| 264 |
+
"Fatty_acid_oxidation":_M(["CPT1A","ACADM","ACADL","HADHA","PPARA","PPARGC1A",
|
| 265 |
+
"ACOX1"],
|
| 266 |
+
"metabolism", "Houten-2010"),
|
| 267 |
+
"Glycolysis": _M(["HK1","HK2","PFKM","PFKL","ALDOA","GAPDH","PGK1",
|
| 268 |
+
"PKM","LDHA","ENO1","TPI1"],
|
| 269 |
+
"metabolism", "Vander-Heiden-2009"),
|
| 270 |
+
"TCA": _M(["CS","ACO2","IDH2","IDH3A","SDHA","FH","MDH2",
|
| 271 |
+
"OGDH","SUCLA2"],
|
| 272 |
+
"metabolism", "Chandel-2015"),
|
| 273 |
+
"OXPHOS_ETC": _M(["NDUFA1","NDUFA2","NDUFB1","SDHA","SDHB","COX4I1",
|
| 274 |
+
"COX5A","COX6A1","ATP5A1","ATP5B","UQCRQ"],
|
| 275 |
+
"metabolism", "Mishra-2016"),
|
| 276 |
+
"Redox_glutathione": _M(["GPX1","GPX2","GPX3","GPX4","GSR","PRDX1","PRDX2",
|
| 277 |
+
"PRDX3","PRDX4","PRDX5","PRDX6","SOD1","SOD2","CAT"],
|
| 278 |
+
"stress", "Ho-2007"),
|
| 279 |
+
"Wnt_pancreas": _M(["WNT3A","WNT5A","CTNNB1","LEF1","TCF7","AXIN2",
|
| 280 |
+
"FZD7"],
|
| 281 |
+
"signaling", "Murtaugh-2008"),
|
| 282 |
+
"Notch_pancreas": _M(["NOTCH1","NOTCH2","JAG1","HES1","DLL1","DLL4",
|
| 283 |
+
"RBPJ","HEY1"],
|
| 284 |
+
"signaling", "Apelqvist-1999"),
|
| 285 |
+
"TGFb_pancreas": _M(["TGFB1","TGFB2","TGFBR1","TGFBR2","SMAD2","SMAD3",
|
| 286 |
+
"SMAD4","SMAD7"],
|
| 287 |
+
"signaling", "Sanvito-1994"),
|
| 288 |
+
"Immune_pancreas": _M(["PTPRC","CD68","ADGRE1","CD3D","CD3E","CD4","CD8A",
|
| 289 |
+
"CD19"],
|
| 290 |
+
"immune", "Homo-2015"),
|
| 291 |
+
"Endothelial_pancreas":_M(["PECAM1","CDH5","KDR","VWF","PLVAP","FLT1",
|
| 292 |
+
"TEK","ENG"],
|
| 293 |
+
"lineage", "Cleaver-2019"),
|
| 294 |
+
"Cell_cycle": _M(["CCND1","CCNE1","CCNA2","CCNB1","CDK1","CDK2",
|
| 295 |
+
"CDK4","MKI67","TOP2A","PCNA","MCM2","MCM3"],
|
| 296 |
+
"cycle", "Whitfield-2002"),
|
| 297 |
+
}
|
| 298 |
+
|
| 299 |
+
|
| 300 |
+
def load_pan_skin():
|
| 301 |
+
RAW = ROOT / "data/raw/GSE220977_combined.h5ad"
|
| 302 |
+
PRED = ROOT / "discovery/pan_skin/marker/dingwall_predictions.csv"
|
| 303 |
+
|
| 304 |
+
CKO_GSMS = {"GSM6833482", "GSM6833483"} # 480/481 are rttaControl (WT), not cKO — per GEO metadata
|
| 305 |
+
WT_GSMS = {"GSM6833478", "GSM6833479", "GSM6833480", "GSM6833481"} # 4 Cre-neg controls per GEO metadata
|
| 306 |
+
|
| 307 |
+
a = ad.read_h5ad(RAW)
|
| 308 |
+
pred = pd.read_csv(PRED)
|
| 309 |
+
common = a.obs_names.intersection(pd.Index(pred["cell_id"].astype(str)))
|
| 310 |
+
a = a[list(common)].copy()
|
| 311 |
+
pred_map = dict(zip(pred["cell_id"].astype(str), pred["pred_label"]))
|
| 312 |
+
a.obs["pred_label"] = pd.Categorical([pred_map.get(c,"unknown")
|
| 313 |
+
for c in a.obs_names])
|
| 314 |
+
samp = a.obs["sample"].astype(str)
|
| 315 |
+
a.obs["group"] = np.where(samp.isin(list(CKO_GSMS)), "En1-cKO",
|
| 316 |
+
np.where(samp.isin(list(WT_GSMS)), "WT", "other"))
|
| 317 |
+
a = a[a.obs["group"].isin(["En1-cKO","WT"])].copy()
|
| 318 |
+
return a, "En1-cKO", "WT", SKIN_MODULES
|
| 319 |
+
|
| 320 |
+
|
| 321 |
+
def load_hematopoiesis():
|
| 322 |
+
D_DIR = ROOT / "data/corpus/hematopoiesis/held_out_unlabeled/dahlin_extract"
|
| 323 |
+
PRED = ROOT / "discovery/hematopoiesis/marker/nestorowa_anchor_predictions.csv"
|
| 324 |
+
if not PRED.exists():
|
| 325 |
+
PRED = ROOT / "discovery/hematopoiesis/marker/97_nestorowa_anchor_predictions.csv"
|
| 326 |
+
|
| 327 |
+
# Dahlin lacks a nestorowa-style anchor csv; fall back to its own predictions
|
| 328 |
+
DAHLIN_PRED_CANDIDATES = [
|
| 329 |
+
ROOT / "discovery/hematopoiesis/marker/dahlin_predictions.csv",
|
| 330 |
+
ROOT / "discovery/hematopoiesis/marker/92_dahlin_predictions.csv",
|
| 331 |
+
]
|
| 332 |
+
for p in DAHLIN_PRED_CANDIDATES:
|
| 333 |
+
if p.exists():
|
| 334 |
+
PRED = p
|
| 335 |
+
break
|
| 336 |
+
|
| 337 |
+
GT = {"SIGAB1":"WT","SIGAC1":"WT","SIGAD1":"WT","SIGAF1":"WT","SIGAG1":"WT",
|
| 338 |
+
"SIGAH1":"WT","SIGAG8":"Kit_W41","SIGAH8":"Kit_W41"}
|
| 339 |
+
|
| 340 |
+
parts = []
|
| 341 |
+
for f in sorted(D_DIR.glob("*.txt.gz")):
|
| 342 |
+
sample = f.name.split("_")[1].split(".")[0]
|
| 343 |
+
df = pd.read_csv(f, sep="\t", compression="gzip", index_col=0)
|
| 344 |
+
X = sp.csr_matrix(df.values.T.astype(np.float32))
|
| 345 |
+
obs = pd.DataFrame(index=[f"{sample}_{bc}" for bc in df.columns.astype(str)])
|
| 346 |
+
obs["sample"] = sample
|
| 347 |
+
obs["group"] = GT.get(sample, "unknown")
|
| 348 |
+
var = pd.DataFrame(index=df.index.astype(str))
|
| 349 |
+
parts.append(ad.AnnData(X=X, obs=obs, var=var))
|
| 350 |
+
a = ad.concat(parts, join="outer", label="_batch")
|
| 351 |
+
|
| 352 |
+
try:
|
| 353 |
+
import mygene
|
| 354 |
+
mg = mygene.MyGeneInfo()
|
| 355 |
+
res = mg.querymany(a.var_names.astype(str).tolist(), scopes="ensembl.gene",
|
| 356 |
+
fields="symbol", species="mouse", verbose=False)
|
| 357 |
+
id2sym = {r["query"]: r["symbol"] for r in res if "symbol" in r}
|
| 358 |
+
syms = pd.Series(a.var_names.astype(str)).map(id2sym).values
|
| 359 |
+
keep = pd.notna(syms)
|
| 360 |
+
a = a[:, keep].copy()
|
| 361 |
+
a.var_names = syms[keep]
|
| 362 |
+
a.var_names_make_unique()
|
| 363 |
+
except Exception as e:
|
| 364 |
+
print(f"[warn] mygene mapping failed: {e}")
|
| 365 |
+
|
| 366 |
+
a = a[a.obs["group"].isin(["Kit_W41","WT"])].copy()
|
| 367 |
+
|
| 368 |
+
if PRED.exists():
|
| 369 |
+
pred = pd.read_csv(PRED)
|
| 370 |
+
common = a.obs_names.intersection(pd.Index(pred["cell_id"].astype(str)))
|
| 371 |
+
a = a[list(common)].copy()
|
| 372 |
+
pred_map = dict(zip(pred["cell_id"].astype(str), pred["pred_label"]))
|
| 373 |
+
a.obs["pred_label"] = pd.Categorical([pred_map.get(c,"unknown")
|
| 374 |
+
for c in a.obs_names])
|
| 375 |
+
else:
|
| 376 |
+
# assign a single class so module scoring still runs
|
| 377 |
+
a.obs["pred_label"] = pd.Categorical(["all"] * a.n_obs)
|
| 378 |
+
print(f"[warn] no Dahlin prediction file found; using pred_label='all'")
|
| 379 |
+
|
| 380 |
+
return a, "Kit_W41", "WT", HSC_MODULES
|
| 381 |
+
|
| 382 |
+
|
| 383 |
+
def load_pancreas():
|
| 384 |
+
SHARON_DIR = ROOT / "data/corpus/pancreas/held_out_unlabeled/sharon_extract"
|
| 385 |
+
PRED = ROOT / "discovery/pancreas/marker/veres_predictions.csv"
|
| 386 |
+
|
| 387 |
+
parts = []
|
| 388 |
+
for meta_file in sorted(SHARON_DIR.glob("*.cell_metadata.tsv.gz")):
|
| 389 |
+
counts_file = str(meta_file).replace("cell_metadata", "processed_counts")
|
| 390 |
+
if not Path(counts_file).exists():
|
| 391 |
+
continue
|
| 392 |
+
meta = pd.read_csv(meta_file, sep="\t", compression="gzip")
|
| 393 |
+
counts = pd.read_csv(counts_file, sep="\t", compression="gzip", index_col=0)
|
| 394 |
+
obs = meta.set_index("library.barcode")
|
| 395 |
+
obs = obs.loc[obs.index.intersection(counts.index)]
|
| 396 |
+
counts_al = counts.loc[obs.index]
|
| 397 |
+
X = sp.csr_matrix(counts_al.values.astype(np.float32))
|
| 398 |
+
aa = ad.AnnData(X=X, obs=obs,
|
| 399 |
+
var=pd.DataFrame(index=counts_al.columns))
|
| 400 |
+
aa.var_names_make_unique()
|
| 401 |
+
parts.append(aa)
|
| 402 |
+
a = ad.concat(parts, join="outer")
|
| 403 |
+
|
| 404 |
+
pred = pd.read_csv(PRED)
|
| 405 |
+
# veres cell_ids are prefixed with "veres_" — strip to match sharon obs_names
|
| 406 |
+
pred["cell_id_stripped"] = pred["cell_id"].astype(str).str.replace(r"^veres_", "", regex=True)
|
| 407 |
+
common = a.obs_names.intersection(pd.Index(pred["cell_id_stripped"]))
|
| 408 |
+
a = a[list(common)].copy()
|
| 409 |
+
pred_map = dict(zip(pred["cell_id_stripped"], pred["pred_label"]))
|
| 410 |
+
a.obs["pred_label"] = pd.Categorical([pred_map.get(c,"unknown")
|
| 411 |
+
for c in a.obs_names])
|
| 412 |
+
|
| 413 |
+
# canonical Veres contrast: Stage 6 (mature) vs Stage 5 (immature)
|
| 414 |
+
stage = a.obs["Stage"].astype(str)
|
| 415 |
+
a.obs["group"] = np.where(stage == "6", "Stage6",
|
| 416 |
+
np.where(stage == "5", "Stage5", "other"))
|
| 417 |
+
a = a[a.obs["group"].isin(["Stage6","Stage5"])].copy()
|
| 418 |
+
return a, "Stage6", "Stage5", PANCREAS_MODULES
|
| 419 |
+
|
| 420 |
+
|
| 421 |
+
LOADERS = {
|
| 422 |
+
"pan_skin": load_pan_skin,
|
| 423 |
+
"hematopoiesis": load_hematopoiesis,
|
| 424 |
+
"pancreas": load_pancreas,
|
| 425 |
+
}
|
| 426 |
+
|
| 427 |
+
|
| 428 |
+
def score_modules(sub, modules):
|
| 429 |
+
var_set = set(sub.var_names.astype(str))
|
| 430 |
+
for name, spec in modules.items():
|
| 431 |
+
present = [g for g in spec["genes"] if g in var_set]
|
| 432 |
+
if not present:
|
| 433 |
+
sub.obs[f"pw_{name}"] = 0.0
|
| 434 |
+
continue
|
| 435 |
+
try:
|
| 436 |
+
sc.tl.score_genes(sub, gene_list=present, score_name=f"pw_{name}",
|
| 437 |
+
random_state=0, use_raw=False)
|
| 438 |
+
except Exception:
|
| 439 |
+
sub.obs[f"pw_{name}"] = 0.0
|
| 440 |
+
return sub
|
| 441 |
+
|
| 442 |
+
|
| 443 |
+
def run_system(system_name):
|
| 444 |
+
print(f"[load] {system_name}", flush=True)
|
| 445 |
+
a, g1, g2, modules = LOADERS[system_name]()
|
| 446 |
+
print(f"[load] {a.n_obs} cells, {sum(a.obs['group']==g1)} {g1}, "
|
| 447 |
+
f"{sum(a.obs['group']==g2)} {g2}, {len(modules)} modules", flush=True)
|
| 448 |
+
|
| 449 |
+
sc.pp.normalize_total(a, target_sum=1e4)
|
| 450 |
+
sc.pp.log1p(a)
|
| 451 |
+
|
| 452 |
+
OUT = ROOT / f"discovery/{system_name}/marker"
|
| 453 |
+
OUT.mkdir(parents=True, exist_ok=True)
|
| 454 |
+
|
| 455 |
+
classes = sorted(a.obs["pred_label"].astype(str).unique())
|
| 456 |
+
rows = []
|
| 457 |
+
for cls in classes:
|
| 458 |
+
mask = (a.obs["pred_label"].astype(str) == cls).values
|
| 459 |
+
n1 = int((mask & (a.obs["group"].values == g1)).sum())
|
| 460 |
+
n2 = int((mask & (a.obs["group"].values == g2)).sum())
|
| 461 |
+
if n1 < MIN_PER_GROUP or n2 < MIN_PER_GROUP:
|
| 462 |
+
print(f"[pw] {cls}: skip (n_{g1}={n1}, n_{g2}={n2})")
|
| 463 |
+
continue
|
| 464 |
+
sub = a[mask].copy()
|
| 465 |
+
sub = score_modules(sub, modules)
|
| 466 |
+
grp = sub.obs["group"].values
|
| 467 |
+
for mod_name, spec in modules.items():
|
| 468 |
+
s = sub.obs[f"pw_{mod_name}"].astype(float).values
|
| 469 |
+
v1 = s[grp == g1]; v2 = s[grp == g2]
|
| 470 |
+
try:
|
| 471 |
+
_, pval = mannwhitneyu(v1, v2, alternative="two-sided")
|
| 472 |
+
except Exception:
|
| 473 |
+
pval = 1.0
|
| 474 |
+
delta = float(v1.mean() - v2.mean())
|
| 475 |
+
rows.append({
|
| 476 |
+
"class": cls,
|
| 477 |
+
"module_name": mod_name,
|
| 478 |
+
"module_type": spec["type"],
|
| 479 |
+
"citation": spec["citation"],
|
| 480 |
+
"direction": spec["direction"],
|
| 481 |
+
"n_g1": n1,
|
| 482 |
+
"n_g2": n2,
|
| 483 |
+
"group_g1": g1,
|
| 484 |
+
"group_g2": g2,
|
| 485 |
+
"delta": round(delta, 4),
|
| 486 |
+
"mannu_p": float(pval),
|
| 487 |
+
})
|
| 488 |
+
print(f"[pw] {cls}: {n1} {g1}, {n2} {g2} — scored")
|
| 489 |
+
|
| 490 |
+
df = pd.DataFrame(rows)
|
| 491 |
+
if df.empty:
|
| 492 |
+
print("[pw] no eligible classes — done")
|
| 493 |
+
return
|
| 494 |
+
|
| 495 |
+
n_tests = len(df)
|
| 496 |
+
df["mannu_p_adj_bonferroni"] = np.minimum(df["mannu_p"] * n_tests, 1.0)
|
| 497 |
+
|
| 498 |
+
csv_path = OUT / "57_pathway_analysis.csv"
|
| 499 |
+
df.to_csv(csv_path, index=False)
|
| 500 |
+
print(f"[pw] wrote {csv_path} ({len(df)} rows, n_tests={n_tests})",
|
| 501 |
+
flush=True)
|
| 502 |
+
|
| 503 |
+
pivot_delta = df.pivot(index="module_name", columns="class", values="delta")
|
| 504 |
+
pivot_padj = df.pivot(index="module_name", columns="class",
|
| 505 |
+
values="mannu_p_adj_bonferroni")
|
| 506 |
+
pivot_delta.to_csv(OUT / "57_pathway_class_by_module_delta.tsv", sep="\t")
|
| 507 |
+
pivot_padj.to_csv(OUT / "57_pathway_class_by_module_padj.tsv", sep="\t")
|
| 508 |
+
print(f"[pw] wrote heatmap TSVs to {OUT}")
|
| 509 |
+
|
| 510 |
+
sig = df[df["mannu_p_adj_bonferroni"] < 0.01].sort_values(
|
| 511 |
+
"mannu_p_adj_bonferroni")
|
| 512 |
+
print(f"\n[pw] top Bonferroni-significant shifts (padj<0.01, "
|
| 513 |
+
f"n={len(sig)}):")
|
| 514 |
+
if len(sig):
|
| 515 |
+
print(sig[["class","module_name","module_type","delta",
|
| 516 |
+
"mannu_p_adj_bonferroni"]].head(30).to_string(index=False))
|
| 517 |
+
|
| 518 |
+
|
| 519 |
+
def main():
|
| 520 |
+
p = argparse.ArgumentParser()
|
| 521 |
+
p.add_argument("--system", required=True,
|
| 522 |
+
choices=list(LOADERS.keys()),
|
| 523 |
+
help="pan_skin | hematopoiesis | pancreas")
|
| 524 |
+
args = p.parse_args()
|
| 525 |
+
run_system(args.system)
|
| 526 |
+
|
| 527 |
+
|
| 528 |
+
if __name__ == "__main__":
|
| 529 |
+
main()
|
scripts/analysis/62_time_course_analysis.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""PANDA lineage distribution across LARRY time points d2/d9/d16 + clonal purity + per-lineage DE."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
import warnings, json, sys
|
| 5 |
+
warnings.filterwarnings("ignore")
|
| 6 |
+
import numpy as np, pandas as pd, anndata as ad, scanpy as sc, torch
|
| 7 |
+
from scipy import stats
|
| 8 |
+
|
| 9 |
+
sys.path.insert(0, "/home/bcheng/PRISM")
|
| 10 |
+
from panda.model import PANDAEncoder
|
| 11 |
+
|
| 12 |
+
DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 13 |
+
CKPT = Path("/home/bcheng/PRISM/checkpoints/hematopoiesis")
|
| 14 |
+
CORPUS = Path("/home/bcheng/PRISM/data/corpus/hematopoiesis/harmonized/corpus.h5ad")
|
| 15 |
+
OUT = Path("/home/bcheng/PRISM/discovery/hematopoiesis/marker")
|
| 16 |
+
OUT.mkdir(parents=True, exist_ok=True)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def main():
|
| 20 |
+
ck = torch.load(CKPT / "panda_final.pt", map_location=DEVICE, weights_only=False)
|
| 21 |
+
classes = ck["classes"]; datasets = ck["datasets"]
|
| 22 |
+
model = PANDAEncoder(n_pca=50, n_classes=len(classes),
|
| 23 |
+
n_datasets=len(datasets)).to(DEVICE).eval()
|
| 24 |
+
model.load_state_dict(ck["model"])
|
| 25 |
+
protos = ck["prototypes"]
|
| 26 |
+
protos = protos / (np.linalg.norm(protos, axis=1, keepdims=True) + 1e-8)
|
| 27 |
+
|
| 28 |
+
# predict on all corpus cells (this is training-set prediction — for analysis only)
|
| 29 |
+
a = ad.read_h5ad(CORPUS)
|
| 30 |
+
print(f"[hsc-mech] corpus: {a.shape}", flush=True)
|
| 31 |
+
X = np.asarray(a.obsm["X_pca"]).astype(np.float32)
|
| 32 |
+
|
| 33 |
+
all_z = []
|
| 34 |
+
with torch.no_grad():
|
| 35 |
+
for i in range(0, X.shape[0], 8192):
|
| 36 |
+
xb = torch.from_numpy(X[i:i+8192]).to(DEVICE)
|
| 37 |
+
aux = torch.zeros(len(xb), 2, device=DEVICE)
|
| 38 |
+
all_z.append(model(xb, aux, lam_dann=0.0)["z"].cpu().numpy())
|
| 39 |
+
Z = np.concatenate(all_z, axis=0)
|
| 40 |
+
cos = Z @ protos.T
|
| 41 |
+
pred_ix = cos.argmax(axis=1)
|
| 42 |
+
pred = np.array([classes[i] for i in pred_ix], dtype=object)
|
| 43 |
+
a.obs["pred_label"] = pred
|
| 44 |
+
a.obs["pred_conf"] = cos.max(axis=1)
|
| 45 |
+
|
| 46 |
+
if "Time point" not in a.obs.columns:
|
| 47 |
+
print("[hsc-mech] no Time point column; skipping time analysis")
|
| 48 |
+
else:
|
| 49 |
+
tp = a.obs["Time point"].astype(int)
|
| 50 |
+
xt = pd.crosstab(pred, tp, normalize="columns")
|
| 51 |
+
print("\n[hsc-mech] Fraction per predicted class per time point:")
|
| 52 |
+
print(xt.round(3))
|
| 53 |
+
xt.to_csv(OUT / "62_time_course_class_fractions.csv")
|
| 54 |
+
|
| 55 |
+
rows = []
|
| 56 |
+
n_d2 = int((tp == 2).sum()); n_d16 = int((tp == 16).sum())
|
| 57 |
+
for c in classes:
|
| 58 |
+
n_c_d16 = int(((pred == c) & (tp == 16)).sum())
|
| 59 |
+
n_c_d2 = int(((pred == c) & (tp == 2)).sum())
|
| 60 |
+
contingency = np.array([[n_c_d16, n_d16 - n_c_d16], [n_c_d2, n_d2 - n_c_d2]])
|
| 61 |
+
odds, p = stats.fisher_exact(contingency)
|
| 62 |
+
f16 = (n_c_d16 + 1) / (n_d16 + 2); f2 = (n_c_d2 + 1) / (n_d2 + 2)
|
| 63 |
+
rows.append({"class": c, "n_d16": n_c_d16, "n_d2": n_c_d2,
|
| 64 |
+
"log2_fold_d16_vs_d2": round(np.log2(f16 / f2), 3),
|
| 65 |
+
"fisher_p": p})
|
| 66 |
+
df = pd.DataFrame(rows).sort_values("log2_fold_d16_vs_d2", ascending=False)
|
| 67 |
+
print("\n[hsc-mech] class enrichment d16 vs d2 (positive = expanded at late time):")
|
| 68 |
+
print(df.to_string(index=False))
|
| 69 |
+
df.to_csv(OUT / "62_time_course_enrichment.csv", index=False)
|
| 70 |
+
|
| 71 |
+
# sibling-fate concordance: Library = clonal barcode
|
| 72 |
+
if "Library" in a.obs.columns:
|
| 73 |
+
libs = a.obs["Library"].astype(str)
|
| 74 |
+
top_lib = libs.value_counts().head(200).index # top 200 largest clones
|
| 75 |
+
clone_purity = []
|
| 76 |
+
for L in top_lib:
|
| 77 |
+
m = libs == L
|
| 78 |
+
if m.sum() < 3: continue
|
| 79 |
+
pl = pd.Series(pred[m.values]).value_counts(normalize=True)
|
| 80 |
+
clone_purity.append({
|
| 81 |
+
"library": L, "n": int(m.sum()),
|
| 82 |
+
"dominant_class": pl.index[0],
|
| 83 |
+
"purity": float(pl.iloc[0]),
|
| 84 |
+
})
|
| 85 |
+
cp = pd.DataFrame(clone_purity)
|
| 86 |
+
print(f"\n[hsc-mech] clonal purity (dominant-class fraction) — {len(cp)} clones:")
|
| 87 |
+
print(f" median: {cp['purity'].median():.3f}, mean: {cp['purity'].mean():.3f}, "
|
| 88 |
+
f"n_clones_pure_>0.9: {(cp['purity'] > 0.9).sum()}/{len(cp)}")
|
| 89 |
+
cp.to_csv(OUT / "62_clonal_purity.csv", index=False)
|
| 90 |
+
|
| 91 |
+
a.obs["pred_label"] = pd.Categorical(pred)
|
| 92 |
+
keep_classes = [c for c in classes if (pred == c).sum() >= 100]
|
| 93 |
+
a_sub = a[np.isin(pred, keep_classes)].copy()
|
| 94 |
+
if a_sub.n_obs >= 500:
|
| 95 |
+
sc.tl.rank_genes_groups(a_sub, "pred_label", method="wilcoxon",
|
| 96 |
+
n_genes=30, use_raw=False)
|
| 97 |
+
rows = []
|
| 98 |
+
for cls in keep_classes:
|
| 99 |
+
try:
|
| 100 |
+
names = a_sub.uns["rank_genes_groups"]["names"][cls]
|
| 101 |
+
lfc = a_sub.uns["rank_genes_groups"]["logfoldchanges"][cls]
|
| 102 |
+
for g, l in zip(names[:15], lfc[:15]):
|
| 103 |
+
rows.append({"class": cls, "gene": g, "logfc": round(float(l), 3)})
|
| 104 |
+
except Exception: pass
|
| 105 |
+
pd.DataFrame(rows).to_csv(OUT / "62_lineage_markers.csv", index=False)
|
| 106 |
+
print(f"\n[hsc-mech] wrote lineage markers to 62_lineage_markers.csv")
|
| 107 |
+
|
| 108 |
+
print(f"\n[hsc-mech] complete. Outputs in {OUT}/")
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
if __name__ == "__main__":
|
| 112 |
+
main()
|
scripts/analysis/63_nestorowa_zero_shot.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""zero-shot HSC PANDA on nestorowa 2016 smart-seq2 as unlabeled discovery target."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
import warnings, json, sys, pickle
|
| 5 |
+
warnings.filterwarnings("ignore")
|
| 6 |
+
import numpy as np, pandas as pd, anndata as ad, scanpy as sc, scipy.sparse as sp
|
| 7 |
+
import torch
|
| 8 |
+
sys.path.insert(0, "/home/bcheng/PRISM")
|
| 9 |
+
from panda.model import PANDAEncoder
|
| 10 |
+
|
| 11 |
+
DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 12 |
+
CKPT = Path("/home/bcheng/PRISM/checkpoints/hematopoiesis")
|
| 13 |
+
HARM = Path("/home/bcheng/PRISM/data/corpus/hematopoiesis/harmonized")
|
| 14 |
+
OUT = Path("/home/bcheng/PRISM/discovery/hematopoiesis/marker")
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def load_nestorowa():
|
| 18 |
+
p = Path("/home/bcheng/PRISM/data/corpus/hematopoiesis/tier_a/nestorowa_GSE81682_counts.txt.gz")
|
| 19 |
+
df = pd.read_csv(p, sep="\t", index_col=0, compression="gzip")
|
| 20 |
+
# rows = ENSMUSG, cols = cells; convert IDs to symbols via mygene
|
| 21 |
+
import mygene
|
| 22 |
+
print(f"[nestorowa] converting {len(df)} ENSMUSG IDs to symbols via mygene …", flush=True)
|
| 23 |
+
mg = mygene.MyGeneInfo()
|
| 24 |
+
ids = df.index.astype(str).tolist()
|
| 25 |
+
res = mg.querymany(ids, scopes="ensembl.gene", fields="symbol",
|
| 26 |
+
species="mouse", returnall=False, verbose=False)
|
| 27 |
+
id2sym = {}
|
| 28 |
+
for r in res:
|
| 29 |
+
if "symbol" in r and "query" in r and not r.get("notfound", False):
|
| 30 |
+
id2sym[r["query"]] = r["symbol"]
|
| 31 |
+
syms_series = pd.Series(df.index.astype(str)).map(id2sym).values
|
| 32 |
+
syms = pd.Series(syms_series, index=df.index)
|
| 33 |
+
keep = syms.notna()
|
| 34 |
+
print(f"[nestorowa] mapped {int(keep.sum())}/{len(df)} genes", flush=True)
|
| 35 |
+
df = df.loc[keep]
|
| 36 |
+
df.index = syms[keep].values
|
| 37 |
+
df = df.groupby(df.index).sum()
|
| 38 |
+
X = sp.csr_matrix(df.values.T.astype(np.float32))
|
| 39 |
+
obs = pd.DataFrame(index=df.columns.astype(str))
|
| 40 |
+
obs["dataset"] = "nestorowa_GSE81682"
|
| 41 |
+
obs["sample"] = obs.index.str.split("_").str[0]
|
| 42 |
+
var = pd.DataFrame({"gene_symbol": df.index.astype(str).values},
|
| 43 |
+
index=df.index.astype(str))
|
| 44 |
+
a = ad.AnnData(X=X, obs=obs, var=var)
|
| 45 |
+
a.var_names_make_unique()
|
| 46 |
+
return a
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def project(a, shared_hvgs, mu, sig):
|
| 50 |
+
G = len(shared_hvgs); n = a.n_obs
|
| 51 |
+
hvg2i = {g: i for i, g in enumerate(shared_hvgs)}
|
| 52 |
+
common = [g for g in a.var_names.astype(str) if g in hvg2i]
|
| 53 |
+
frac = len(common) / G
|
| 54 |
+
print(f"[proj] {len(common)}/{G} HVGs present ({frac:.1%})", flush=True)
|
| 55 |
+
a_c = a[:, common].copy()
|
| 56 |
+
sc.pp.normalize_total(a_c, target_sum=1e4)
|
| 57 |
+
sc.pp.log1p(a_c)
|
| 58 |
+
X = a_c.X.toarray().astype(np.float32) if sp.issparse(a_c.X) else a_c.X.astype(np.float32)
|
| 59 |
+
Xf = np.zeros((n, G), dtype=np.float32)
|
| 60 |
+
cols = [hvg2i[g] for g in common]
|
| 61 |
+
Xf[:, cols] = X
|
| 62 |
+
Xz = np.clip((Xf - mu.astype(np.float32)) / sig.astype(np.float32), -10, 10)
|
| 63 |
+
return Xz, frac
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def main():
|
| 67 |
+
ck = torch.load(CKPT / "panda_final.pt", map_location=DEVICE, weights_only=False)
|
| 68 |
+
classes = ck["classes"]; datasets = ck["datasets"]
|
| 69 |
+
model = PANDAEncoder(n_pca=50, n_classes=len(classes),
|
| 70 |
+
n_datasets=len(datasets)).to(DEVICE).eval()
|
| 71 |
+
model.load_state_dict(ck["model"])
|
| 72 |
+
protos = ck["prototypes"]
|
| 73 |
+
protos = protos / (np.linalg.norm(protos, axis=1, keepdims=True) + 1e-8)
|
| 74 |
+
print(f"[model] classes: {classes}", flush=True)
|
| 75 |
+
|
| 76 |
+
stats = np.load(HARM / "corpus_stats.npz", allow_pickle=True)
|
| 77 |
+
shared_hvgs = [str(g) for g in stats["shared_hvgs"]]
|
| 78 |
+
mu, sig = stats["mean"], stats["std"]
|
| 79 |
+
with open(HARM / "pca_basis.pkl", "rb") as f: pca = pickle.load(f)
|
| 80 |
+
|
| 81 |
+
a = load_nestorowa()
|
| 82 |
+
print(f"[target] Nestorowa shape: {a.shape}", flush=True)
|
| 83 |
+
|
| 84 |
+
Xz, frac = project(a, shared_hvgs, mu, sig)
|
| 85 |
+
Xpca = pca.transform(Xz).astype(np.float32)
|
| 86 |
+
|
| 87 |
+
all_z = []
|
| 88 |
+
with torch.no_grad():
|
| 89 |
+
for i in range(0, a.n_obs, 4096):
|
| 90 |
+
xb = torch.from_numpy(Xpca[i:i+4096]).to(DEVICE)
|
| 91 |
+
aux = torch.zeros(len(xb), 2, device=DEVICE)
|
| 92 |
+
out = model(xb, aux, lam_dann=0.0)
|
| 93 |
+
all_z.append(out["z"].cpu().numpy())
|
| 94 |
+
Z = np.concatenate(all_z, axis=0)
|
| 95 |
+
cos = Z @ protos.T
|
| 96 |
+
pred_ix = cos.argmax(axis=1)
|
| 97 |
+
conf = cos.max(axis=1)
|
| 98 |
+
entropy = -(np.exp(cos / 0.07) / np.exp(cos / 0.07).sum(axis=1, keepdims=True) *
|
| 99 |
+
np.log(np.exp(cos / 0.07) / np.exp(cos / 0.07).sum(axis=1, keepdims=True) + 1e-12)
|
| 100 |
+
).sum(axis=1)
|
| 101 |
+
|
| 102 |
+
a.obs["pred_label"] = np.array([classes[i] for i in pred_ix], dtype=object)
|
| 103 |
+
a.obs["pred_conf"] = conf.astype(np.float32)
|
| 104 |
+
a.obs["pred_entropy"] = entropy.astype(np.float32)
|
| 105 |
+
|
| 106 |
+
print(f"\n[nestorowa] predicted class distribution:")
|
| 107 |
+
print(a.obs["pred_label"].value_counts())
|
| 108 |
+
print(f"\n[nestorowa] pred_conf: p10={np.percentile(conf,10):.3f}, "
|
| 109 |
+
f"p50={np.percentile(conf,50):.3f}, p90={np.percentile(conf,90):.3f}")
|
| 110 |
+
print(f"[nestorowa] shared-HVG fraction: {frac:.1%}")
|
| 111 |
+
|
| 112 |
+
a.obs.to_csv(OUT / "63_nestorowa_predictions.csv")
|
| 113 |
+
print(f"\n[nestorowa] wrote predictions to 63_nestorowa_predictions.csv")
|
| 114 |
+
|
| 115 |
+
# cluster bottom-decile confidence cells for novel-population DE
|
| 116 |
+
thr = np.percentile(conf, 10)
|
| 117 |
+
mask = conf <= thr
|
| 118 |
+
print(f"\n[nestorowa] bottom-decile confidence: {int(mask.sum())} cells (thr={thr:.3f})",
|
| 119 |
+
flush=True)
|
| 120 |
+
if mask.sum() >= 30:
|
| 121 |
+
sub_low = ad.AnnData(X=Z[mask].astype(np.float32))
|
| 122 |
+
sc.pp.neighbors(sub_low, use_rep="X", n_neighbors=10)
|
| 123 |
+
sc.tl.leiden(sub_low, resolution=0.5, key_added="cluster")
|
| 124 |
+
print(f"[nestorowa] novel clusters: {sub_low.obs['cluster'].nunique()}")
|
| 125 |
+
a_low = a[mask].copy()
|
| 126 |
+
a_low.obs["cluster"] = sub_low.obs["cluster"].values
|
| 127 |
+
try:
|
| 128 |
+
sc.tl.rank_genes_groups(a_low, "cluster", method="wilcoxon", n_genes=10, use_raw=False)
|
| 129 |
+
rows = []
|
| 130 |
+
for cl in sorted(a_low.obs["cluster"].unique()):
|
| 131 |
+
names = a_low.uns["rank_genes_groups"]["names"][cl]
|
| 132 |
+
lfc = a_low.uns["rank_genes_groups"]["logfoldchanges"][cl]
|
| 133 |
+
for g, l in zip(names[:8], lfc[:8]):
|
| 134 |
+
rows.append({"cluster": cl, "gene": g, "logfc": round(float(l), 3)})
|
| 135 |
+
pd.DataFrame(rows).to_csv(OUT / "63_nestorowa_novel_markers.csv", index=False)
|
| 136 |
+
print(f"[nestorowa] novel markers saved")
|
| 137 |
+
except Exception as e:
|
| 138 |
+
print(f"[nestorowa] DE failed: {e}")
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
if __name__ == "__main__":
|
| 142 |
+
main()
|
scripts/analysis/66_dahlin_kit_mutant.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""zero-shot HSC PANDA on dahlin 2018: WT vs Kit W41/W41 class enrichment."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
import warnings, json, sys, pickle
|
| 5 |
+
warnings.filterwarnings("ignore")
|
| 6 |
+
import numpy as np, pandas as pd, anndata as ad, scanpy as sc, scipy.sparse as sp
|
| 7 |
+
import torch
|
| 8 |
+
from scipy import stats
|
| 9 |
+
sys.path.insert(0, "/home/bcheng/PRISM")
|
| 10 |
+
from panda.model import PANDAEncoder
|
| 11 |
+
|
| 12 |
+
DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 13 |
+
CKPT = Path("/home/bcheng/PRISM/checkpoints/hematopoiesis")
|
| 14 |
+
HARM = Path("/home/bcheng/PRISM/data/corpus/hematopoiesis/harmonized")
|
| 15 |
+
DAHLIN = Path("/home/bcheng/PRISM/data/corpus/hematopoiesis/held_out_unlabeled/dahlin_extract")
|
| 16 |
+
OUT = Path("/home/bcheng/PRISM/discovery/hematopoiesis/marker")
|
| 17 |
+
|
| 18 |
+
GENOTYPE_MAP = {
|
| 19 |
+
"SIGAB1": "WT", "SIGAC1": "WT", "SIGAD1": "WT",
|
| 20 |
+
"SIGAF1": "WT", "SIGAG1": "WT", "SIGAH1": "WT",
|
| 21 |
+
"SIGAG8": "Kit_W41", "SIGAH8": "Kit_W41",
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def load_dahlin_all():
|
| 26 |
+
print("[dahlin] loading 8 samples …", flush=True)
|
| 27 |
+
parts = []
|
| 28 |
+
for f in sorted(DAHLIN.glob("*.txt.gz")):
|
| 29 |
+
gsm = f.name.split("_")[0]
|
| 30 |
+
sample = f.name.split("_")[1].split(".")[0]
|
| 31 |
+
genotype = GENOTYPE_MAP.get(sample, "unknown")
|
| 32 |
+
print(f" {sample} ({genotype})", flush=True)
|
| 33 |
+
df = pd.read_csv(f, sep="\t", compression="gzip", index_col=0)
|
| 34 |
+
X = sp.csr_matrix(df.values.T.astype(np.float32))
|
| 35 |
+
obs = pd.DataFrame(index=[f"{sample}_{bc}" for bc in df.columns.astype(str)])
|
| 36 |
+
obs["sample"] = sample
|
| 37 |
+
obs["genotype"] = genotype
|
| 38 |
+
obs["dataset"] = "dahlin_GSE107727"
|
| 39 |
+
var = pd.DataFrame(index=df.index.astype(str))
|
| 40 |
+
var["ensmusg"] = var.index.values
|
| 41 |
+
a = ad.AnnData(X=X, obs=obs, var=var)
|
| 42 |
+
parts.append(a)
|
| 43 |
+
return ad.concat(parts, join="outer", label="_batch")
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def convert_ensembl_to_symbol(a):
|
| 47 |
+
import mygene
|
| 48 |
+
mg = mygene.MyGeneInfo()
|
| 49 |
+
ids = a.var_names.astype(str).tolist()
|
| 50 |
+
print(f"[dahlin] querying {len(ids)} ENSMUSG IDs …", flush=True)
|
| 51 |
+
res = mg.querymany(ids, scopes="ensembl.gene", fields="symbol",
|
| 52 |
+
species="mouse", verbose=False)
|
| 53 |
+
id2sym = {r["query"]: r["symbol"] for r in res if "symbol" in r}
|
| 54 |
+
syms = pd.Series(a.var_names.astype(str)).map(id2sym).values
|
| 55 |
+
keep = pd.notna(syms)
|
| 56 |
+
print(f"[dahlin] mapped {int(keep.sum())}/{len(a.var_names)} genes", flush=True)
|
| 57 |
+
a = a[:, keep].copy()
|
| 58 |
+
a.var_names = syms[keep]
|
| 59 |
+
a.var_names_make_unique()
|
| 60 |
+
return a
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def main():
|
| 64 |
+
a = load_dahlin_all()
|
| 65 |
+
print(f"[dahlin] concat shape: {a.shape}", flush=True)
|
| 66 |
+
a = convert_ensembl_to_symbol(a)
|
| 67 |
+
print(f"[dahlin] after symbol conversion: {a.shape}", flush=True)
|
| 68 |
+
|
| 69 |
+
ck = torch.load(CKPT / "panda_final.pt", map_location=DEVICE, weights_only=False)
|
| 70 |
+
classes = ck["classes"]; datasets = ck["datasets"]
|
| 71 |
+
model = PANDAEncoder(n_pca=50, n_classes=len(classes),
|
| 72 |
+
n_datasets=len(datasets)).to(DEVICE).eval()
|
| 73 |
+
model.load_state_dict(ck["model"])
|
| 74 |
+
|
| 75 |
+
stats_ = np.load(HARM / "corpus_stats.npz", allow_pickle=True)
|
| 76 |
+
shared_hvgs = [str(g) for g in stats_["shared_hvgs"]]
|
| 77 |
+
mu, sig = stats_["mean"], stats_["std"]
|
| 78 |
+
with open(HARM / "pca_basis.pkl", "rb") as f: pca = pickle.load(f)
|
| 79 |
+
|
| 80 |
+
G = len(shared_hvgs)
|
| 81 |
+
hvg2i = {g: i for i, g in enumerate(shared_hvgs)}
|
| 82 |
+
common = [g for g in a.var_names.astype(str) if g in hvg2i]
|
| 83 |
+
frac = len(common) / G
|
| 84 |
+
print(f"[proj] {len(common)}/{G} HVGs present ({frac:.1%})", flush=True)
|
| 85 |
+
a_c = a[:, common].copy()
|
| 86 |
+
sc.pp.normalize_total(a_c, target_sum=1e4); sc.pp.log1p(a_c)
|
| 87 |
+
X = a_c.X.toarray().astype(np.float32) if sp.issparse(a_c.X) else a_c.X.astype(np.float32)
|
| 88 |
+
Xf = np.zeros((a.n_obs, G), dtype=np.float32)
|
| 89 |
+
cols = [hvg2i[g] for g in common]; Xf[:, cols] = X
|
| 90 |
+
Xz = np.clip((Xf - mu.astype(np.float32)) / sig.astype(np.float32), -10, 10)
|
| 91 |
+
Xpca = pca.transform(Xz).astype(np.float32)
|
| 92 |
+
|
| 93 |
+
all_z = []
|
| 94 |
+
with torch.no_grad():
|
| 95 |
+
for i in range(0, a.n_obs, 4096):
|
| 96 |
+
xb = torch.from_numpy(Xpca[i:i+4096]).to(DEVICE)
|
| 97 |
+
aux = torch.zeros(len(xb), 2, device=DEVICE)
|
| 98 |
+
all_z.append(model(xb, aux, lam_dann=0.0)["z"].cpu().numpy())
|
| 99 |
+
Z = np.concatenate(all_z, axis=0)
|
| 100 |
+
protos = ck["prototypes"]
|
| 101 |
+
protos = protos / (np.linalg.norm(protos, axis=1, keepdims=True) + 1e-8)
|
| 102 |
+
cos = Z @ protos.T
|
| 103 |
+
pred = np.array([classes[i] for i in cos.argmax(axis=1)], dtype=object)
|
| 104 |
+
conf = cos.max(axis=1)
|
| 105 |
+
a.obs["pred_label"] = pred
|
| 106 |
+
a.obs["pred_conf"] = conf.astype(np.float32)
|
| 107 |
+
|
| 108 |
+
print(f"\n[dahlin] predicted class distribution overall:")
|
| 109 |
+
print(a.obs["pred_label"].value_counts())
|
| 110 |
+
print(f"\n[dahlin] per genotype:")
|
| 111 |
+
xt = pd.crosstab(a.obs["pred_label"], a.obs["genotype"], normalize="columns")
|
| 112 |
+
print(xt.round(4))
|
| 113 |
+
xt.to_csv(OUT / "66_dahlin_class_per_genotype.csv")
|
| 114 |
+
|
| 115 |
+
print(f"\n[dahlin] Fisher exact class enrichment Kit-mutant vs WT:")
|
| 116 |
+
rows = []
|
| 117 |
+
n_wt = int((a.obs["genotype"] == "WT").sum())
|
| 118 |
+
n_kit = int((a.obs["genotype"] == "Kit_W41").sum())
|
| 119 |
+
print(f" WT n={n_wt}, Kit_W41 n={n_kit}")
|
| 120 |
+
for c in classes:
|
| 121 |
+
n_c_kit = int(((pred == c) & (a.obs["genotype"] == "Kit_W41")).sum())
|
| 122 |
+
n_c_wt = int(((pred == c) & (a.obs["genotype"] == "WT")).sum())
|
| 123 |
+
contingency = np.array([[n_c_kit, n_kit - n_c_kit],
|
| 124 |
+
[n_c_wt, n_wt - n_c_wt]])
|
| 125 |
+
odds, p = stats.fisher_exact(contingency)
|
| 126 |
+
f_kit = (n_c_kit + 1) / (n_kit + 2)
|
| 127 |
+
f_wt = (n_c_wt + 1) / (n_wt + 2)
|
| 128 |
+
rows.append({"class": c, "n_Kit_W41": n_c_kit, "n_WT": n_c_wt,
|
| 129 |
+
"pct_Kit": round(100 * n_c_kit / n_kit, 3),
|
| 130 |
+
"pct_WT": round(100 * n_c_wt / n_wt, 3),
|
| 131 |
+
"log2_fold_Kit_vs_WT": round(np.log2(f_kit / f_wt), 3),
|
| 132 |
+
"fisher_p": p})
|
| 133 |
+
df = pd.DataFrame(rows).sort_values("log2_fold_Kit_vs_WT")
|
| 134 |
+
print(df.to_string(index=False))
|
| 135 |
+
df.to_csv(OUT / "66_dahlin_enrichment.csv", index=False)
|
| 136 |
+
|
| 137 |
+
a.obs.to_csv(OUT / "66_dahlin_predictions.csv")
|
| 138 |
+
print(f"\n[dahlin] complete. Outputs in {OUT}/")
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
if __name__ == "__main__":
|
| 142 |
+
main()
|
scripts/analysis/67_dahlin_within_class.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""within-class wilcoxon DE WT vs Kit_W41 + module scoring; checks dahlin paper claims (Myc/ISR)."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
import warnings, json, sys
|
| 5 |
+
warnings.filterwarnings("ignore")
|
| 6 |
+
import numpy as np, pandas as pd, anndata as ad, scanpy as sc, scipy.sparse as sp
|
| 7 |
+
from scipy.stats import mannwhitneyu
|
| 8 |
+
|
| 9 |
+
DAHLIN = Path("/home/bcheng/PRISM/data/corpus/hematopoiesis/held_out_unlabeled/dahlin_extract")
|
| 10 |
+
PRED = Path("/home/bcheng/PRISM/discovery/hematopoiesis/marker/66_dahlin_predictions.csv")
|
| 11 |
+
OUT = Path("/home/bcheng/PRISM/discovery/hematopoiesis/marker")
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def load_dahlin_all():
|
| 15 |
+
parts = []
|
| 16 |
+
for f in sorted(DAHLIN.glob("*.txt.gz")):
|
| 17 |
+
gsm = f.name.split("_")[0]
|
| 18 |
+
sample = f.name.split("_")[1].split(".")[0]
|
| 19 |
+
df = pd.read_csv(f, sep="\t", compression="gzip", index_col=0)
|
| 20 |
+
X = sp.csr_matrix(df.values.T.astype(np.float32))
|
| 21 |
+
obs = pd.DataFrame(index=[f"{sample}_{bc}" for bc in df.columns.astype(str)])
|
| 22 |
+
obs["sample"] = sample
|
| 23 |
+
var = pd.DataFrame(index=df.index.astype(str))
|
| 24 |
+
var["ensmusg"] = var.index.values
|
| 25 |
+
a = ad.AnnData(X=X, obs=obs, var=var)
|
| 26 |
+
parts.append(a)
|
| 27 |
+
return ad.concat(parts, join="outer", label="_batch")
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def main():
|
| 31 |
+
print("[dahlin-mech] loading Dahlin …", flush=True)
|
| 32 |
+
a = load_dahlin_all()
|
| 33 |
+
|
| 34 |
+
import mygene
|
| 35 |
+
mg = mygene.MyGeneInfo()
|
| 36 |
+
ids = a.var_names.astype(str).tolist()
|
| 37 |
+
print(f"[dahlin-mech] mapping {len(ids)} genes …", flush=True)
|
| 38 |
+
res = mg.querymany(ids, scopes="ensembl.gene", fields="symbol", species="mouse",
|
| 39 |
+
verbose=False)
|
| 40 |
+
id2sym = {r["query"]: r["symbol"] for r in res if "symbol" in r}
|
| 41 |
+
syms = pd.Series(a.var_names.astype(str)).map(id2sym).values
|
| 42 |
+
keep = pd.notna(syms)
|
| 43 |
+
a = a[:, keep].copy(); a.var_names = syms[keep]
|
| 44 |
+
a.var_names_make_unique()
|
| 45 |
+
print(f"[dahlin-mech] converted, shape now {a.shape}", flush=True)
|
| 46 |
+
|
| 47 |
+
preds = pd.read_csv(PRED, index_col=0)
|
| 48 |
+
a.obs = a.obs.join(preds[["pred_label", "pred_conf", "genotype"]], how="left")
|
| 49 |
+
print(f"[dahlin-mech] joined preds; genotype counts {a.obs['genotype'].value_counts().to_dict()}",
|
| 50 |
+
flush=True)
|
| 51 |
+
|
| 52 |
+
a_de = a.copy()
|
| 53 |
+
sc.pp.normalize_total(a_de, target_sum=1e4)
|
| 54 |
+
sc.pp.log1p(a_de)
|
| 55 |
+
|
| 56 |
+
rows = []
|
| 57 |
+
lg_class_min = 300
|
| 58 |
+
class_counts = a.obs["pred_label"].value_counts()
|
| 59 |
+
for cls in class_counts[class_counts >= lg_class_min].index:
|
| 60 |
+
mask = a.obs["pred_label"] == cls
|
| 61 |
+
sub = a_de[mask].copy()
|
| 62 |
+
if len(sub.obs["genotype"].unique()) < 2:
|
| 63 |
+
continue
|
| 64 |
+
gc = sub.obs["genotype"].value_counts()
|
| 65 |
+
if gc.min() < 30:
|
| 66 |
+
continue
|
| 67 |
+
try:
|
| 68 |
+
sc.tl.rank_genes_groups(sub, "genotype", method="wilcoxon", n_genes=40,
|
| 69 |
+
use_raw=False)
|
| 70 |
+
for grp in ["Kit_W41", "WT"]:
|
| 71 |
+
if grp not in sub.uns["rank_genes_groups"]["names"].dtype.names:
|
| 72 |
+
continue
|
| 73 |
+
names = list(sub.uns["rank_genes_groups"]["names"][grp][:15])
|
| 74 |
+
lfcs = list(sub.uns["rank_genes_groups"]["logfoldchanges"][grp][:15])
|
| 75 |
+
padjs = list(sub.uns["rank_genes_groups"]["pvals_adj"][grp][:15])
|
| 76 |
+
for g, l, p in zip(names, lfcs, padjs):
|
| 77 |
+
rows.append({"class": cls, "up_in": grp, "gene": g,
|
| 78 |
+
"logfc": round(float(l), 3),
|
| 79 |
+
"padj": float(p)})
|
| 80 |
+
except Exception as exc:
|
| 81 |
+
print(f"[dahlin-mech] DE failed for {cls}: {exc}")
|
| 82 |
+
pd.DataFrame(rows).to_csv(OUT / "67_dahlin_within_class_de.csv", index=False)
|
| 83 |
+
|
| 84 |
+
MODULES = {
|
| 85 |
+
"MYC_targets": ["Myc", "Mycn", "Nme1", "Odc1", "Ncl", "Ppat", "Cad",
|
| 86 |
+
"Ldha", "Nop58"],
|
| 87 |
+
"Integrated_stress": ["Atf4", "Ddit3", "Chac1", "Trib3", "Asns", "Gdf15",
|
| 88 |
+
"Sesn2", "Slc7a11"],
|
| 89 |
+
"Apoptosis_pro": ["Bax", "Bak1", "Bad", "Bid", "Bbc3", "Pmaip1", "Casp3",
|
| 90 |
+
"Casp9", "Trp53"],
|
| 91 |
+
"Apoptosis_anti": ["Bcl2", "Bcl2l1", "Mcl1", "Bcl2l2"],
|
| 92 |
+
"Erythroid_dev": ["Klf1", "Gata1", "Gata2", "Epor", "Hba-a1", "Hbb-b1"],
|
| 93 |
+
"Cell_cycle": ["Ccnd1", "Ccnd2", "Ccne1", "Ccna2", "Ccnb1", "Cdk1",
|
| 94 |
+
"Mki67", "Top2a", "Pcna", "Mcm2", "Mcm3", "Mcm4"],
|
| 95 |
+
"Kit_signaling": ["Kit", "Kitl", "Sos1", "Rasgrf1", "Grb2", "Pik3ca"],
|
| 96 |
+
}
|
| 97 |
+
for name, genes in MODULES.items():
|
| 98 |
+
present = [g for g in genes if g in a_de.var_names]
|
| 99 |
+
if not present: continue
|
| 100 |
+
sc.tl.score_genes(a_de, gene_list=present, score_name=f"pw_{name}",
|
| 101 |
+
random_state=0, use_raw=False)
|
| 102 |
+
|
| 103 |
+
print("\n[dahlin-mech] Kit_W41 vs WT module score contrast per class:", flush=True)
|
| 104 |
+
mod_rows = []
|
| 105 |
+
for cls in class_counts[class_counts >= lg_class_min].index:
|
| 106 |
+
mask = a_de.obs["pred_label"] == cls
|
| 107 |
+
for name in MODULES.keys():
|
| 108 |
+
col = f"pw_{name}"
|
| 109 |
+
if col not in a_de.obs: continue
|
| 110 |
+
s = a_de.obs.loc[mask, col].astype(float).values
|
| 111 |
+
g = a_de.obs.loc[mask, "genotype"].astype(str).values
|
| 112 |
+
cv = s[g == "Kit_W41"]; wv = s[g == "WT"]
|
| 113 |
+
if len(cv) < 20 or len(wv) < 20: continue
|
| 114 |
+
try:
|
| 115 |
+
_, p = mannwhitneyu(cv, wv, alternative="two-sided")
|
| 116 |
+
except Exception: p = 1.0
|
| 117 |
+
delta = cv.mean() - wv.mean()
|
| 118 |
+
star = "***" if p < 1e-6 else "**" if p < 1e-3 else "*" if p < 0.05 else ""
|
| 119 |
+
print(f" {cls:15s} {name:20s} delta={delta:+.4f} p={p:.2e} {star}")
|
| 120 |
+
mod_rows.append({"class": cls, "module": name,
|
| 121 |
+
"delta_Kit_minus_WT": round(delta, 4),
|
| 122 |
+
"MannU_p": p})
|
| 123 |
+
pd.DataFrame(mod_rows).to_csv(OUT / "67_dahlin_module_scores.csv", index=False)
|
| 124 |
+
|
| 125 |
+
print(f"\n[dahlin-mech] complete. Outputs in {OUT}/")
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
if __name__ == "__main__":
|
| 129 |
+
main()
|
scripts/analysis/70_prototype_geometry.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""cross-system prototype geometry: intra/cross cosine + participation-ratio effective dim."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
import json, numpy as np, pandas as pd
|
| 5 |
+
|
| 6 |
+
CKPT = Path("/home/bcheng/PRISM/checkpoints")
|
| 7 |
+
OUT = Path("/home/bcheng/PRISM/discovery")
|
| 8 |
+
OUT.mkdir(exist_ok=True)
|
| 9 |
+
|
| 10 |
+
SYSTEMS = ["pan_skin", "hematopoiesis", "pancreas"]
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def load_prototypes(sys):
|
| 14 |
+
"""prototypes shape (K, n_sub, D) — sub-centers averaged per class."""
|
| 15 |
+
import torch
|
| 16 |
+
ck = torch.load(CKPT / sys / "marker" / "panda_final.pt", map_location="cpu", weights_only=False)
|
| 17 |
+
P = ck["prototypes"]
|
| 18 |
+
classes = ck["classes"]
|
| 19 |
+
if P.ndim == 3:
|
| 20 |
+
P = P.mean(axis=1)
|
| 21 |
+
elif P.ndim == 2 and P.shape[0] != len(classes):
|
| 22 |
+
n_sub = P.shape[0] // len(classes)
|
| 23 |
+
P = P.reshape(len(classes), n_sub, -1).mean(axis=1)
|
| 24 |
+
P = P / (np.linalg.norm(P, axis=1, keepdims=True) + 1e-8)
|
| 25 |
+
return P, classes
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def effective_dim(P):
|
| 29 |
+
"""participation ratio Tr(Sigma)^2 / Tr(Sigma^2)."""
|
| 30 |
+
G = P @ P.T
|
| 31 |
+
eig = np.linalg.eigvalsh(G)
|
| 32 |
+
eig = np.clip(eig, 0, None)
|
| 33 |
+
return float((eig.sum() ** 2) / (eig ** 2).sum() + 1e-12)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
all_P, all_C, all_S = [], [], []
|
| 37 |
+
eff_dims = {}
|
| 38 |
+
|
| 39 |
+
for sys in SYSTEMS:
|
| 40 |
+
P, classes = load_prototypes(sys)
|
| 41 |
+
print(f"[{sys}] K={len(classes)} classes={classes}", flush=True)
|
| 42 |
+
C = P @ P.T
|
| 43 |
+
off = C[~np.eye(len(classes), dtype=bool)]
|
| 44 |
+
ed = effective_dim(P)
|
| 45 |
+
eff_dims[sys] = {
|
| 46 |
+
"K": len(classes),
|
| 47 |
+
"effective_dim": ed,
|
| 48 |
+
"cos_offdiag_mean": float(off.mean()),
|
| 49 |
+
"cos_offdiag_std": float(off.std()),
|
| 50 |
+
"cos_offdiag_max": float(off.max()),
|
| 51 |
+
"cos_offdiag_min": float(off.min()),
|
| 52 |
+
}
|
| 53 |
+
pd.DataFrame(C, index=classes, columns=classes).to_csv(
|
| 54 |
+
OUT / f"70_prototype_intra_cosine_{sys}.csv"
|
| 55 |
+
)
|
| 56 |
+
all_P.append(P); all_C.extend(classes); all_S.extend([sys] * len(classes))
|
| 57 |
+
print(f"[{sys}] effective_dim={ed:.2f} K={len(classes)} off-diag cos mean={off.mean():+.3f} max={off.max():+.3f}", flush=True)
|
| 58 |
+
|
| 59 |
+
json.dump(eff_dims, open(OUT / "70_prototype_effective_dim.json", "w"), indent=2)
|
| 60 |
+
|
| 61 |
+
P_all = np.vstack(all_P)
|
| 62 |
+
cos_all = P_all @ P_all.T
|
| 63 |
+
labels_full = [f"{s}:{c}" for s, c in zip(all_S, all_C)]
|
| 64 |
+
pd.DataFrame(cos_all, index=labels_full, columns=labels_full).to_csv(
|
| 65 |
+
OUT / "70_prototype_full_29x29.csv"
|
| 66 |
+
)
|
| 67 |
+
|
| 68 |
+
# cross-system pairs: mask same-system, rank by cosine
|
| 69 |
+
rows = []
|
| 70 |
+
for i in range(len(labels_full)):
|
| 71 |
+
for j in range(i + 1, len(labels_full)):
|
| 72 |
+
if all_S[i] == all_S[j]:
|
| 73 |
+
continue
|
| 74 |
+
rows.append({
|
| 75 |
+
"sys_a": all_S[i], "class_a": all_C[i],
|
| 76 |
+
"sys_b": all_S[j], "class_b": all_C[j],
|
| 77 |
+
"cos": float(cos_all[i, j]),
|
| 78 |
+
})
|
| 79 |
+
pairs = pd.DataFrame(rows).sort_values("cos", ascending=False)
|
| 80 |
+
pairs.to_csv(OUT / "70_prototype_cross_system_pairs.csv", index=False)
|
| 81 |
+
print(f"[cross] top-10 positive cross-system pairs:", flush=True)
|
| 82 |
+
print(pairs.head(10).to_string(index=False), flush=True)
|
| 83 |
+
print(f"[cross] top-10 negative cross-system pairs:", flush=True)
|
| 84 |
+
print(pairs.tail(10).to_string(index=False), flush=True)
|
| 85 |
+
|
| 86 |
+
# null: random K=29 128-d unit vectors
|
| 87 |
+
rng = np.random.default_rng(0)
|
| 88 |
+
R = rng.standard_normal((29, 128))
|
| 89 |
+
R = R / np.linalg.norm(R, axis=1, keepdims=True)
|
| 90 |
+
Rc = R @ R.T
|
| 91 |
+
null = Rc[~np.eye(29, dtype=bool)]
|
| 92 |
+
print(f"\n[null] random 29x128 unit vectors: cos mean={null.mean():+.3f} std={null.std():.3f} max={null.max():+.3f}", flush=True)
|
| 93 |
+
print(f"[obs] observed cross-system cos: mean={pairs['cos'].mean():+.3f} std={pairs['cos'].std():.3f} max={pairs['cos'].max():+.3f}", flush=True)
|
| 94 |
+
|
| 95 |
+
summary = {
|
| 96 |
+
"systems": SYSTEMS,
|
| 97 |
+
"eff_dims": eff_dims,
|
| 98 |
+
"null_cross_cos_mean": float(null.mean()),
|
| 99 |
+
"null_cross_cos_std": float(null.std()),
|
| 100 |
+
"obs_cross_cos_mean": float(pairs["cos"].mean()),
|
| 101 |
+
"obs_cross_cos_std": float(pairs["cos"].std()),
|
| 102 |
+
"obs_cross_cos_max": float(pairs["cos"].max()),
|
| 103 |
+
"top10_pos_pairs": pairs.head(10).to_dict("records"),
|
| 104 |
+
"top10_neg_pairs": pairs.tail(10).to_dict("records"),
|
| 105 |
+
}
|
| 106 |
+
json.dump(summary, open(OUT / "70_prototype_summary.json", "w"), indent=2, default=str)
|
| 107 |
+
print(f"\nwrote {OUT}/70_prototype_summary.json", flush=True)
|
scripts/analysis/72_emergent_axes.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""per-class-residual PCA on the 128d projections; skin only (only cached)."""
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
import json, warnings, numpy as np, pandas as pd, anndata as ad
|
| 4 |
+
warnings.filterwarnings("ignore")
|
| 5 |
+
from sklearn.decomposition import PCA
|
| 6 |
+
|
| 7 |
+
OUT = Path("/home/bcheng/PRISM/discovery"); OUT.mkdir(exist_ok=True)
|
| 8 |
+
|
| 9 |
+
P = ad.read_h5ad("/home/bcheng/PRISM/discovery/pan_skin/marker/50_aldrich_projections.h5ad")
|
| 10 |
+
Z = np.asarray(P.obsm["Z_projection"])
|
| 11 |
+
pred = (P.obs["pred_bbse_label"] if "pred_bbse_label" in P.obs
|
| 12 |
+
else P.obs["pred_label"]).astype(str).values
|
| 13 |
+
print(f"[skin] Z shape {Z.shape}, n_pred_classes={len(np.unique(pred))}", flush=True)
|
| 14 |
+
|
| 15 |
+
Zres = np.zeros_like(Z)
|
| 16 |
+
for cls in np.unique(pred):
|
| 17 |
+
m = pred == cls
|
| 18 |
+
if m.sum() < 2: continue
|
| 19 |
+
Zres[m] = Z[m] - Z[m].mean(0, keepdims=True)
|
| 20 |
+
|
| 21 |
+
pca = PCA(n_components=20, random_state=0).fit(Zres)
|
| 22 |
+
ev = pca.explained_variance_ratio_
|
| 23 |
+
print(f"[skin] top-10 residual PC EV: {[f'{e:.4f}' for e in ev[:10]]}", flush=True)
|
| 24 |
+
print(f"[skin] cumulative top-10: {ev[:10].cumsum()[-1]:.3f}", flush=True)
|
| 25 |
+
print(f"[skin] cumulative top-20: {ev.cumsum()[-1]:.3f}", flush=True)
|
| 26 |
+
|
| 27 |
+
scores = pca.transform(Zres) # (N, 20)
|
| 28 |
+
aux_cols = {}
|
| 29 |
+
if "log10_counts" in P.obs:
|
| 30 |
+
aux_cols["log10_counts"] = P.obs["log10_counts"].astype(float).values
|
| 31 |
+
elif "n_counts" in P.obs:
|
| 32 |
+
aux_cols["log10_counts"] = np.log10(P.obs["n_counts"].astype(float).values + 1)
|
| 33 |
+
if "missing_hvg_frac" in P.obs:
|
| 34 |
+
aux_cols["missing_hvg_frac"] = P.obs["missing_hvg_frac"].astype(float).values
|
| 35 |
+
if "max_cos" in P.obs:
|
| 36 |
+
aux_cols["max_cos"] = P.obs["max_cos"].astype(float).values
|
| 37 |
+
else:
|
| 38 |
+
import torch
|
| 39 |
+
ck = torch.load("/home/bcheng/PRISM/checkpoints/pan_skin/panda_final.pt",
|
| 40 |
+
map_location="cpu", weights_only=False)
|
| 41 |
+
protos = ck["prototypes"]
|
| 42 |
+
protos = protos / (np.linalg.norm(protos, axis=1, keepdims=True) + 1e-8)
|
| 43 |
+
Zn = Z / (np.linalg.norm(Z, axis=1, keepdims=True) + 1e-8)
|
| 44 |
+
aux_cols["max_cos"] = (Zn @ protos.T).max(axis=1)
|
| 45 |
+
|
| 46 |
+
if "genotype" in P.obs:
|
| 47 |
+
genotype_bin = (P.obs["genotype"] == "En1-cKO").astype(int).values
|
| 48 |
+
aux_cols["genotype_cKO"] = genotype_bin.astype(float)
|
| 49 |
+
|
| 50 |
+
print(f"[skin] auxiliaries: {list(aux_cols.keys())}", flush=True)
|
| 51 |
+
|
| 52 |
+
rows = []
|
| 53 |
+
for pc in range(10):
|
| 54 |
+
row = {"PC": f"PC{pc+1}", "EV": float(ev[pc])}
|
| 55 |
+
for aux_name, aux_vals in aux_cols.items():
|
| 56 |
+
r = float(np.corrcoef(scores[:, pc], aux_vals)[0, 1])
|
| 57 |
+
row[f"corr_{aux_name}"] = r
|
| 58 |
+
rows.append(row)
|
| 59 |
+
axes_df = pd.DataFrame(rows)
|
| 60 |
+
axes_df.to_csv(OUT / "72_emergent_axes_skin.csv", index=False)
|
| 61 |
+
print(axes_df.to_string(index=False), flush=True)
|
| 62 |
+
|
| 63 |
+
loadings = pca.components_[:5] # (5, 128)
|
| 64 |
+
np.save(OUT / "72_emergent_axes_skin_pc_loadings.npy", loadings)
|
| 65 |
+
|
| 66 |
+
summary = {
|
| 67 |
+
"system": "pan_skin",
|
| 68 |
+
"total_ev_top10": float(ev[:10].sum()),
|
| 69 |
+
"total_ev_top20": float(ev.sum()),
|
| 70 |
+
"top10_ev": [float(e) for e in ev[:10]],
|
| 71 |
+
"auxiliaries": list(aux_cols.keys()),
|
| 72 |
+
}
|
| 73 |
+
json.dump(summary, open(OUT / "72_emergent_axes_summary.json", "w"), indent=2)
|
| 74 |
+
print(f"\nwrote {OUT}/72_emergent_axes_summary.json", flush=True)
|
scripts/analysis/73_novel_populations_dahlin.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""novel-population discovery on Dahlin via PANDA's abstain gate. mirrors 71_."""
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
import json, warnings, sys, pickle, numpy as np, pandas as pd, anndata as ad, scanpy as sc, torch
|
| 4 |
+
import scipy.sparse as sp
|
| 5 |
+
warnings.filterwarnings("ignore")
|
| 6 |
+
from pathlib import Path as _P_root
|
| 7 |
+
ROOT = _P_root(__file__).resolve().parents[2]
|
| 8 |
+
ROOT_STR = str(ROOT)
|
| 9 |
+
sys.path.insert(0, ROOT_STR)
|
| 10 |
+
from panda import PANDAEncoder
|
| 11 |
+
|
| 12 |
+
sc.settings.verbosity = 1
|
| 13 |
+
OUT = Path(f"{ROOT_STR}/discovery"); OUT.mkdir(exist_ok=True)
|
| 14 |
+
DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def load_dahlin_raw():
|
| 18 |
+
"""load Dahlin counts and convert ENSMUSG ids to gene symbols."""
|
| 19 |
+
D_DIR = Path(f"{ROOT_STR}/data/corpus/hematopoiesis/held_out_unlabeled/dahlin_extract")
|
| 20 |
+
GT = {"SIGAB1":"WT","SIGAC1":"WT","SIGAD1":"WT","SIGAF1":"WT","SIGAG1":"WT",
|
| 21 |
+
"SIGAH1":"WT","SIGAG8":"Kit_W41","SIGAH8":"Kit_W41"}
|
| 22 |
+
parts = []
|
| 23 |
+
for f in sorted(D_DIR.glob("*.txt.gz")):
|
| 24 |
+
sample = f.name.split("_")[1].split(".")[0]
|
| 25 |
+
df = pd.read_csv(f, sep="\t", compression="gzip", index_col=0)
|
| 26 |
+
X = sp.csr_matrix(df.values.T.astype(np.float32))
|
| 27 |
+
obs = pd.DataFrame(index=[f"{sample}_{bc}" for bc in df.columns.astype(str)])
|
| 28 |
+
obs["sample"] = sample; obs["genotype"] = GT.get(sample, "unknown")
|
| 29 |
+
var = pd.DataFrame(index=df.index.astype(str))
|
| 30 |
+
parts.append(ad.AnnData(X=X, obs=obs, var=var))
|
| 31 |
+
a = ad.concat(parts, join="outer", label="_batch")
|
| 32 |
+
import mygene
|
| 33 |
+
mg = mygene.MyGeneInfo()
|
| 34 |
+
res = mg.querymany(a.var_names.astype(str).tolist(), scopes="ensembl.gene",
|
| 35 |
+
fields="symbol", species="mouse", verbose=False)
|
| 36 |
+
id2sym = {r["query"]: r["symbol"] for r in res if "symbol" in r}
|
| 37 |
+
syms = pd.Series(a.var_names.astype(str)).map(id2sym).values
|
| 38 |
+
keep = pd.notna(syms)
|
| 39 |
+
a = a[:, keep].copy(); a.var_names = syms[keep]; a.var_names_make_unique()
|
| 40 |
+
return a
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def project(adata, ckpt_path, shared_hvgs, mu, sig, pca):
|
| 44 |
+
ck = torch.load(ckpt_path, map_location=DEVICE, weights_only=False)
|
| 45 |
+
classes = ck["classes"]; datasets = ck["datasets"]
|
| 46 |
+
m = PANDAEncoder(n_pca=50, n_classes=len(classes), n_datasets=len(datasets)).to(DEVICE).eval()
|
| 47 |
+
m.load_state_dict(ck["model"])
|
| 48 |
+
protos = ck["prototypes"]
|
| 49 |
+
protos = protos / (np.linalg.norm(protos, axis=1, keepdims=True) + 1e-8)
|
| 50 |
+
|
| 51 |
+
G = len(shared_hvgs); hvg2i = {g: i for i, g in enumerate(shared_hvgs)}
|
| 52 |
+
common = [g for g in adata.var_names.astype(str) if g in hvg2i]
|
| 53 |
+
a_c = adata[:, common].copy()
|
| 54 |
+
sc.pp.normalize_total(a_c, target_sum=1e4); sc.pp.log1p(a_c)
|
| 55 |
+
X = a_c.X.toarray().astype(np.float32) if sp.issparse(a_c.X) else a_c.X.astype(np.float32)
|
| 56 |
+
Xf = np.zeros((adata.n_obs, G), dtype=np.float32)
|
| 57 |
+
cols = [hvg2i[g] for g in common]; Xf[:, cols] = X
|
| 58 |
+
Xz = np.clip((Xf - mu.astype(np.float32)) / sig.astype(np.float32), -10, 10)
|
| 59 |
+
Xpca = pca.transform(Xz).astype(np.float32)
|
| 60 |
+
|
| 61 |
+
all_z = []
|
| 62 |
+
with torch.no_grad():
|
| 63 |
+
for i in range(0, adata.n_obs, 4096):
|
| 64 |
+
xb = torch.from_numpy(Xpca[i:i+4096]).to(DEVICE)
|
| 65 |
+
aux = torch.zeros(len(xb), 2, device=DEVICE)
|
| 66 |
+
all_z.append(m(xb, aux, lam_dann=0.0)["z"].cpu().numpy())
|
| 67 |
+
Z = np.concatenate(all_z)
|
| 68 |
+
Zn = Z / (np.linalg.norm(Z, axis=1, keepdims=True) + 1e-8)
|
| 69 |
+
cos = Zn @ protos.T
|
| 70 |
+
return Z, cos, classes
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
print("[load] Dahlin ENSMUSG→symbol", flush=True)
|
| 74 |
+
raw = load_dahlin_raw()
|
| 75 |
+
print(f"[load] {raw.shape}", flush=True)
|
| 76 |
+
|
| 77 |
+
stats_h = np.load(f"{ROOT_STR}/data/corpus/hematopoiesis/harmonized/corpus_stats.npz",
|
| 78 |
+
allow_pickle=True)
|
| 79 |
+
shared_hvgs = [str(g) for g in stats_h["shared_hvgs"]]
|
| 80 |
+
pca_h = pickle.load(open(f"{ROOT_STR}/data/corpus/hematopoiesis/harmonized/pca_basis.pkl","rb"))
|
| 81 |
+
print(f"[project] running PANDA on Dahlin", flush=True)
|
| 82 |
+
Z, cos, classes = project(raw, f"{ROOT_STR}/checkpoints/hematopoiesis/panda_final.pt",
|
| 83 |
+
shared_hvgs, stats_h["mean"], stats_h["std"], pca_h)
|
| 84 |
+
max_cos = cos.max(axis=1); pred = np.array([classes[i] for i in cos.argmax(axis=1)])
|
| 85 |
+
print(f"[cos] q: 5%={np.quantile(max_cos,0.05):.3f} 50%={np.quantile(max_cos,0.5):.3f} 95%={np.quantile(max_cos,0.95):.3f}", flush=True)
|
| 86 |
+
print(f"[cos] cos<0.5 n={int((max_cos<0.5).sum())} cos<0.3 n={int((max_cos<0.3).sum())}", flush=True)
|
| 87 |
+
|
| 88 |
+
thr = max(0.5, float(np.quantile(max_cos, 0.05)))
|
| 89 |
+
mask = max_cos < thr
|
| 90 |
+
print(f"[novel] threshold cos<{thr:.3f}: {int(mask.sum())} cells", flush=True)
|
| 91 |
+
|
| 92 |
+
sub = raw[mask].copy()
|
| 93 |
+
sub.obsm["Z"] = Z[mask]
|
| 94 |
+
sub.obs["max_cos"] = max_cos[mask]
|
| 95 |
+
sub.obs["pred"] = pred[mask]
|
| 96 |
+
sc.pp.neighbors(sub, use_rep="Z", n_neighbors=20)
|
| 97 |
+
sc.tl.leiden(sub, resolution=0.8, random_state=0)
|
| 98 |
+
print(f"[cluster] {sub.obs['leiden'].nunique()} clusters", flush=True)
|
| 99 |
+
|
| 100 |
+
sc.pp.normalize_total(sub, target_sum=1e4); sc.pp.log1p(sub)
|
| 101 |
+
sc.tl.rank_genes_groups(sub, "leiden", method="wilcoxon", n_genes=25)
|
| 102 |
+
|
| 103 |
+
rows = []
|
| 104 |
+
for cl in sub.obs["leiden"].unique():
|
| 105 |
+
genes = list(sub.uns["rank_genes_groups"]["names"][cl][:15])
|
| 106 |
+
pvals = list(sub.uns["rank_genes_groups"]["pvals_adj"][cl][:15])
|
| 107 |
+
logfc = list(sub.uns["rank_genes_groups"]["logfoldchanges"][cl][:15])
|
| 108 |
+
pre = sub.obs.loc[sub.obs["leiden"] == cl, "pred"].value_counts()
|
| 109 |
+
gt = sub.obs.loc[sub.obs["leiden"] == cl, "genotype"].value_counts()
|
| 110 |
+
rows.append({
|
| 111 |
+
"cluster": cl,
|
| 112 |
+
"n_cells": int((sub.obs["leiden"] == cl).sum()),
|
| 113 |
+
"top_markers": ",".join(genes[:10]),
|
| 114 |
+
"top_pvals": ",".join(f"{p:.1e}" for p in pvals[:10]),
|
| 115 |
+
"top_logfc": ",".join(f"{f:+.2f}" for f in logfc[:10]),
|
| 116 |
+
"pred_pre_abstain": pre.index[0] if len(pre) else "",
|
| 117 |
+
"genotype_wt_frac": float(gt.get("WT", 0) / gt.sum()) if len(gt) else 0,
|
| 118 |
+
})
|
| 119 |
+
|
| 120 |
+
df = pd.DataFrame(rows).sort_values("n_cells", ascending=False)
|
| 121 |
+
df.to_csv(OUT / "73_dahlin_novel_populations.csv", index=False)
|
| 122 |
+
print(df.to_string(index=False), flush=True)
|
| 123 |
+
print(f"\nwrote {OUT}/73_dahlin_novel_populations.csv", flush=True)
|
scripts/analysis/80_prototype_gene_attribution.py
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""integrated-gradients attribution of prototype cosine to input genes, per class."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
import warnings, json, pickle, sys, numpy as np, pandas as pd
|
| 5 |
+
warnings.filterwarnings("ignore")
|
| 6 |
+
import torch
|
| 7 |
+
import anndata as ad
|
| 8 |
+
import yaml
|
| 9 |
+
from pathlib import Path as _P_root
|
| 10 |
+
ROOT = _P_root(__file__).resolve().parents[2]
|
| 11 |
+
ROOT_STR = str(ROOT)
|
| 12 |
+
sys.path.insert(0, ROOT_STR)
|
| 13 |
+
from panda import PANDAEncoder
|
| 14 |
+
|
| 15 |
+
CKPT_ROOT = Path(f"{ROOT_STR}/checkpoints")
|
| 16 |
+
CORP_ROOT = Path(f"{ROOT_STR}/data/corpus")
|
| 17 |
+
OUT = Path(f"{ROOT_STR}/discovery"); OUT.mkdir(exist_ok=True)
|
| 18 |
+
DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 19 |
+
IG_STEPS = 64
|
| 20 |
+
TOP_K_GENES = 30
|
| 21 |
+
|
| 22 |
+
TF_YAML = {
|
| 23 |
+
"pan_skin": f"{ROOT_STR}/scripts/pan_skin/known_skin_tfs.yaml",
|
| 24 |
+
"hematopoiesis": f"{ROOT_STR}/scripts/hematopoiesis/known_hsc_markers.yaml",
|
| 25 |
+
"pancreas": f"{ROOT_STR}/scripts/pancreas/known_pancreas_markers.yaml",
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def load_pca_and_hvgs(sys):
|
| 30 |
+
stats = np.load(CORP_ROOT / sys / "harmonized/corpus_stats.npz", allow_pickle=True)
|
| 31 |
+
hvgs = [str(g) for g in stats["shared_hvgs"]]
|
| 32 |
+
pca = pickle.load(open(CORP_ROOT / sys / "harmonized/pca_basis.pkl", "rb"))
|
| 33 |
+
mu = np.asarray(stats["mean"], dtype=np.float32)
|
| 34 |
+
sig = np.asarray(stats["std"], dtype=np.float32)
|
| 35 |
+
return hvgs, pca, mu, sig
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def load_model(sys):
|
| 39 |
+
ck = torch.load(CKPT_ROOT / sys / "marker" / "panda_final.pt", map_location=DEVICE, weights_only=False)
|
| 40 |
+
m = PANDAEncoder(variant="marker", n_pca=50, n_markers=len(ck.get("marker_genes",[])), n_classes=len(ck["classes"]), n_sub=3, n_datasets=len(ck["datasets"])).to(DEVICE).eval()
|
| 41 |
+
m.load_state_dict(ck["model"])
|
| 42 |
+
protos = torch.from_numpy(ck["prototypes"]).to(DEVICE)
|
| 43 |
+
protos = protos / (protos.norm(dim=1, keepdim=True) + 1e-8)
|
| 44 |
+
return m, ck["classes"], protos
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def load_corpus_pca(sys, model_hvgs):
|
| 48 |
+
corp = ad.read_h5ad(CORP_ROOT / sys / "harmonized/corpus.h5ad")
|
| 49 |
+
return corp
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def per_class_pca_mean(corp, sys, hvgs, mu, sig, pca):
|
| 53 |
+
import scipy.sparse as sp
|
| 54 |
+
import scanpy as sc
|
| 55 |
+
hvg2i = {g: i for i, g in enumerate(hvgs)}
|
| 56 |
+
# old builds used cell_type instead of canonical_label
|
| 57 |
+
label_key = "canonical_label" if "canonical_label" in corp.obs else "cell_type"
|
| 58 |
+
y = corp.obs[label_key].astype(str).values
|
| 59 |
+
common = [g for g in corp.var_names.astype(str) if g in hvg2i]
|
| 60 |
+
a = corp[:, common].copy()
|
| 61 |
+
if "log1p" not in a.uns.get("uns_keys", []):
|
| 62 |
+
sc.pp.normalize_total(a, target_sum=1e4); sc.pp.log1p(a)
|
| 63 |
+
X = a.X.toarray().astype(np.float32) if sp.issparse(a.X) else a.X.astype(np.float32)
|
| 64 |
+
Xf = np.zeros((corp.n_obs, len(hvgs)), dtype=np.float32)
|
| 65 |
+
cols = np.array([hvg2i[g] for g in common])
|
| 66 |
+
Xf[:, cols] = X
|
| 67 |
+
Xz = np.clip((Xf - mu) / sig, -10, 10)
|
| 68 |
+
Xpca = pca.transform(Xz).astype(np.float32)
|
| 69 |
+
cls_to_mean_pca = {}
|
| 70 |
+
cls_to_n = {}
|
| 71 |
+
for cls in sorted(set(y)):
|
| 72 |
+
mask = y == cls
|
| 73 |
+
if mask.sum() == 0: continue
|
| 74 |
+
cls_to_mean_pca[cls] = Xpca[mask].mean(axis=0)
|
| 75 |
+
cls_to_n[cls] = int(mask.sum())
|
| 76 |
+
return cls_to_mean_pca, cls_to_n
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def integrated_gradient_pc(model, protos, class_idx, x_class_pca, n_steps=64):
|
| 80 |
+
baseline = torch.zeros_like(x_class_pca)
|
| 81 |
+
alphas = torch.linspace(1.0 / (2*n_steps), 1 - 1.0/(2*n_steps), n_steps, device=DEVICE)
|
| 82 |
+
x_class_pca = x_class_pca.unsqueeze(0)
|
| 83 |
+
baseline = baseline.unsqueeze(0)
|
| 84 |
+
grad_accum = torch.zeros(1, x_class_pca.shape[-1], device=DEVICE)
|
| 85 |
+
for a in alphas:
|
| 86 |
+
interp = (baseline + a * (x_class_pca - baseline)).requires_grad_(True)
|
| 87 |
+
aux = torch.zeros(1, 2, device=DEVICE)
|
| 88 |
+
h = model.trunk(interp)
|
| 89 |
+
z_raw = model.projection(h)
|
| 90 |
+
z = torch.nn.functional.normalize(z_raw, dim=1)
|
| 91 |
+
score = (z * protos[class_idx].unsqueeze(0)).sum()
|
| 92 |
+
grad = torch.autograd.grad(score, interp)[0]
|
| 93 |
+
grad_accum = grad_accum + grad
|
| 94 |
+
grad_accum = grad_accum / n_steps
|
| 95 |
+
ig = ((x_class_pca - baseline) * grad_accum).squeeze(0).detach().cpu().numpy()
|
| 96 |
+
return ig
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def load_tf_list(sys):
|
| 100 |
+
path = TF_YAML.get(sys)
|
| 101 |
+
if path is None or not Path(path).exists():
|
| 102 |
+
return {}
|
| 103 |
+
with open(path) as f:
|
| 104 |
+
obj = yaml.safe_load(f)
|
| 105 |
+
tf_dict = {}
|
| 106 |
+
# YAMLs nest under "classes"
|
| 107 |
+
root = obj.get("classes", obj) if isinstance(obj, dict) else obj
|
| 108 |
+
if isinstance(root, dict):
|
| 109 |
+
for k, v in root.items():
|
| 110 |
+
if isinstance(v, list):
|
| 111 |
+
tf_dict[k] = [str(x) for x in v]
|
| 112 |
+
elif isinstance(v, dict) and "genes" in v:
|
| 113 |
+
tf_dict[k] = [str(x) for x in v["genes"]]
|
| 114 |
+
return tf_dict
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def main_system(sys):
|
| 118 |
+
print(f"\n===== {sys} =====", flush=True)
|
| 119 |
+
hvgs, pca, mu, sig = load_pca_and_hvgs(sys)
|
| 120 |
+
print(f"[load] {len(hvgs)} HVGs, PCA components {pca.components_.shape}", flush=True)
|
| 121 |
+
|
| 122 |
+
model, classes, protos = load_model(sys)
|
| 123 |
+
print(f"[load] model K={len(classes)} classes", flush=True)
|
| 124 |
+
|
| 125 |
+
corp = load_corpus_pca(sys, hvgs)
|
| 126 |
+
print(f"[corp] {corp.shape}", flush=True)
|
| 127 |
+
|
| 128 |
+
cls_to_mean_pca, cls_to_n = per_class_pca_mean(corp, sys, hvgs, mu, sig, pca)
|
| 129 |
+
print(f"[centroids] {len(cls_to_mean_pca)} class means computed", flush=True)
|
| 130 |
+
|
| 131 |
+
# model class names aren't always 1:1 with corpus labels; take best match
|
| 132 |
+
pc_att = np.zeros((len(classes), 50), dtype=np.float32)
|
| 133 |
+
used_class_map = {}
|
| 134 |
+
for ci, cls_name in enumerate(classes):
|
| 135 |
+
if cls_name in cls_to_mean_pca:
|
| 136 |
+
src = cls_name
|
| 137 |
+
else:
|
| 138 |
+
best = None
|
| 139 |
+
for k in cls_to_mean_pca:
|
| 140 |
+
if k.lower() == cls_name.lower():
|
| 141 |
+
best = k; break
|
| 142 |
+
if best is None:
|
| 143 |
+
# fall back to the overall mean
|
| 144 |
+
src = None
|
| 145 |
+
x_pca = np.mean(list(cls_to_mean_pca.values()), axis=0)
|
| 146 |
+
else:
|
| 147 |
+
src = best
|
| 148 |
+
if src is not None:
|
| 149 |
+
x_pca = cls_to_mean_pca[src]
|
| 150 |
+
used_class_map[cls_name] = src
|
| 151 |
+
x_t = torch.from_numpy(x_pca).float().to(DEVICE)
|
| 152 |
+
ig = integrated_gradient_pc(model, protos, ci, x_t, n_steps=IG_STEPS)
|
| 153 |
+
pc_att[ci] = ig
|
| 154 |
+
print(f"[IG] class {ci+1}/{len(classes)}: {cls_name:<25s} src={src} |ig|_1={np.abs(ig).sum():.3f} |ig|_inf={np.abs(ig).max():.3f}", flush=True)
|
| 155 |
+
|
| 156 |
+
df_pc = pd.DataFrame(pc_att, index=classes, columns=[f"PC{i+1}" for i in range(50)])
|
| 157 |
+
df_pc.to_csv(OUT / f"80_{sys}_pc_attribution.csv")
|
| 158 |
+
|
| 159 |
+
# (K, 50) @ (50, G) = (K, G)
|
| 160 |
+
gene_att = pc_att @ pca.components_.astype(np.float32)
|
| 161 |
+
np.save(OUT / f"80_{sys}_gene_attribution_full.npy", gene_att)
|
| 162 |
+
|
| 163 |
+
tf_dict = load_tf_list(sys)
|
| 164 |
+
print(f"[TF] loaded {len(tf_dict)} TF program lists: {list(tf_dict.keys())[:5]}...", flush=True)
|
| 165 |
+
|
| 166 |
+
rows = []
|
| 167 |
+
tf_rows = []
|
| 168 |
+
for ci, cls_name in enumerate(classes):
|
| 169 |
+
att = gene_att[ci]
|
| 170 |
+
pos_idx = np.argsort(att)[::-1][:TOP_K_GENES]
|
| 171 |
+
neg_idx = np.argsort(att)[:TOP_K_GENES]
|
| 172 |
+
row = {
|
| 173 |
+
"class": cls_name,
|
| 174 |
+
"src_corpus_class": used_class_map[cls_name],
|
| 175 |
+
"top_pos_genes": ",".join([hvgs[i] for i in pos_idx[:20]]),
|
| 176 |
+
"top_pos_attribution": ",".join([f"{att[i]:+.3f}" for i in pos_idx[:20]]),
|
| 177 |
+
"top_neg_genes": ",".join([hvgs[i] for i in neg_idx[:15]]),
|
| 178 |
+
"top_neg_attribution": ",".join([f"{att[i]:+.3f}" for i in neg_idx[:15]]),
|
| 179 |
+
}
|
| 180 |
+
rows.append(row)
|
| 181 |
+
|
| 182 |
+
pos_top_genes_set = set(hvgs[i] for i in pos_idx[:100])
|
| 183 |
+
for prog_name, prog_genes in tf_dict.items():
|
| 184 |
+
prog_in_hvg = [g for g in prog_genes if g in hvgs]
|
| 185 |
+
if len(prog_in_hvg) == 0: continue
|
| 186 |
+
prog_idx = np.array([hvgs.index(g) for g in prog_in_hvg])
|
| 187 |
+
prog_att = float(att[prog_idx].sum())
|
| 188 |
+
hit_frac = len([g for g in prog_in_hvg if g in pos_top_genes_set]) / len(prog_in_hvg)
|
| 189 |
+
tf_rows.append({
|
| 190 |
+
"class": cls_name,
|
| 191 |
+
"program": prog_name,
|
| 192 |
+
"n_genes_in_hvg": len(prog_in_hvg),
|
| 193 |
+
"sum_attribution": prog_att,
|
| 194 |
+
"hit_frac_top100_pos": hit_frac,
|
| 195 |
+
})
|
| 196 |
+
|
| 197 |
+
pd.DataFrame(rows).to_csv(OUT / f"80_{sys}_gene_attribution.csv", index=False)
|
| 198 |
+
if tf_rows:
|
| 199 |
+
tf_df = pd.DataFrame(tf_rows).sort_values(["class", "sum_attribution"], ascending=[True, False])
|
| 200 |
+
tf_df.to_csv(OUT / f"80_{sys}_tf_enrichment.csv", index=False)
|
| 201 |
+
top_tf = (tf_df.sort_values("sum_attribution", ascending=False)
|
| 202 |
+
.groupby("class").head(3)
|
| 203 |
+
.sort_values(["class", "sum_attribution"], ascending=[True, False]))
|
| 204 |
+
top_tf.to_csv(OUT / f"80_{sys}_top_tf_per_class.csv", index=False)
|
| 205 |
+
print(f"\n[TF top-3 per class]", flush=True)
|
| 206 |
+
print(top_tf.to_string(index=False), flush=True)
|
| 207 |
+
|
| 208 |
+
print(f"\n[wrote] {OUT}/80_{sys}_*", flush=True)
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
for sys in ["pan_skin", "hematopoiesis", "pancreas"]:
|
| 212 |
+
try:
|
| 213 |
+
main_system(sys)
|
| 214 |
+
except Exception as e:
|
| 215 |
+
import traceback; traceback.print_exc()
|
| 216 |
+
print(f"[!!!] {sys} failed: {e}", flush=True)
|
| 217 |
+
continue
|
| 218 |
+
|
| 219 |
+
print("\n===== done =====", flush=True)
|
scripts/analysis/81_counterfactual_knockouts.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""per-class gene knockouts: zero each candidate gene, remeasure prototype cosine."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
import warnings, json, pickle, sys, numpy as np, pandas as pd
|
| 5 |
+
warnings.filterwarnings("ignore")
|
| 6 |
+
import torch
|
| 7 |
+
import anndata as ad
|
| 8 |
+
from pathlib import Path as _P_root
|
| 9 |
+
ROOT = _P_root(__file__).resolve().parents[2]
|
| 10 |
+
ROOT_STR = str(ROOT)
|
| 11 |
+
sys.path.insert(0, ROOT_STR)
|
| 12 |
+
from panda import PANDAEncoder
|
| 13 |
+
|
| 14 |
+
CKPT_ROOT = Path(f"{ROOT_STR}/checkpoints")
|
| 15 |
+
CORP_ROOT = Path(f"{ROOT_STR}/data/corpus")
|
| 16 |
+
OUT = Path(f"{ROOT_STR}/discovery"); OUT.mkdir(exist_ok=True)
|
| 17 |
+
DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 18 |
+
TOP_KO_GENES = 100 # only test top-100 attributed genes for KO
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def compute_ko(sys):
|
| 22 |
+
print(f"\n===== {sys} =====", flush=True)
|
| 23 |
+
stats = np.load(CORP_ROOT / sys / "harmonized/corpus_stats.npz", allow_pickle=True)
|
| 24 |
+
hvgs = [str(g) for g in stats["shared_hvgs"]]
|
| 25 |
+
pca = pickle.load(open(CORP_ROOT / sys / "harmonized/pca_basis.pkl", "rb"))
|
| 26 |
+
mu = np.asarray(stats["mean"], dtype=np.float32)
|
| 27 |
+
sig = np.asarray(stats["std"], dtype=np.float32)
|
| 28 |
+
|
| 29 |
+
ck = torch.load(CKPT_ROOT / sys / "marker" / "panda_final.pt", map_location=DEVICE, weights_only=False)
|
| 30 |
+
classes = ck["classes"]
|
| 31 |
+
model = PANDAEncoder(variant="marker", n_pca=50, n_markers=len(ck.get("marker_genes",[])), n_classes=len(classes), n_sub=3,
|
| 32 |
+
n_datasets=len(ck["datasets"])).to(DEVICE).eval()
|
| 33 |
+
model.load_state_dict(ck["model"])
|
| 34 |
+
protos = torch.from_numpy(ck["prototypes"]).to(DEVICE)
|
| 35 |
+
protos = protos / (protos.norm(dim=1, keepdim=True) + 1e-8)
|
| 36 |
+
|
| 37 |
+
import scipy.sparse as sp
|
| 38 |
+
import scanpy as sc
|
| 39 |
+
corp = ad.read_h5ad(CORP_ROOT / sys / "harmonized/corpus.h5ad")
|
| 40 |
+
label_key = "canonical_label" if "canonical_label" in corp.obs else "cell_type"
|
| 41 |
+
y = corp.obs[label_key].astype(str).values
|
| 42 |
+
hvg2i = {g: i for i, g in enumerate(hvgs)}
|
| 43 |
+
common = [g for g in corp.var_names.astype(str) if g in hvg2i]
|
| 44 |
+
a = corp[:, common].copy()
|
| 45 |
+
sc.pp.normalize_total(a, target_sum=1e4); sc.pp.log1p(a)
|
| 46 |
+
X = a.X.toarray().astype(np.float32) if sp.issparse(a.X) else a.X.astype(np.float32)
|
| 47 |
+
Xf = np.zeros((corp.n_obs, len(hvgs)), dtype=np.float32)
|
| 48 |
+
cols = np.array([hvg2i[g] for g in common])
|
| 49 |
+
Xf[:, cols] = X
|
| 50 |
+
|
| 51 |
+
att = np.load(OUT / f"80_{sys}_gene_attribution_full.npy") # (K, G)
|
| 52 |
+
|
| 53 |
+
rows = []
|
| 54 |
+
for ci, cls_name in enumerate(classes):
|
| 55 |
+
mask = y == cls_name
|
| 56 |
+
if mask.sum() == 0:
|
| 57 |
+
print(f"[!!] {cls_name}: no cells in corpus", flush=True)
|
| 58 |
+
continue
|
| 59 |
+
x_gene_log_mean = Xf[mask].mean(axis=0)
|
| 60 |
+
x_z = np.clip((x_gene_log_mean - mu) / sig, -10, 10)
|
| 61 |
+
x_pca0 = pca.transform(x_z.reshape(1, -1))[0].astype(np.float32)
|
| 62 |
+
|
| 63 |
+
with torch.no_grad():
|
| 64 |
+
xt = torch.from_numpy(x_pca0).unsqueeze(0).to(DEVICE)
|
| 65 |
+
aux = torch.zeros(1, 2, device=DEVICE)
|
| 66 |
+
out = model(xt, aux, lam_dann=0.0)
|
| 67 |
+
z0 = out["z"]
|
| 68 |
+
s0 = float((z0 * protos[ci].unsqueeze(0)).sum())
|
| 69 |
+
|
| 70 |
+
att_c = att[ci]
|
| 71 |
+
cand_idx = np.argsort(-np.abs(att_c))[:TOP_KO_GENES]
|
| 72 |
+
|
| 73 |
+
# zero each gene in log space, reproject; z is clipped so recompute per KO
|
| 74 |
+
deltas = []
|
| 75 |
+
for gi in cand_idx:
|
| 76 |
+
x_gene_ko = x_gene_log_mean.copy()
|
| 77 |
+
x_gene_ko[gi] = 0.0
|
| 78 |
+
x_z_ko = np.clip((x_gene_ko - mu) / sig, -10, 10)
|
| 79 |
+
x_pca_ko = pca.transform(x_z_ko.reshape(1, -1))[0].astype(np.float32)
|
| 80 |
+
with torch.no_grad():
|
| 81 |
+
xtko = torch.from_numpy(x_pca_ko).unsqueeze(0).to(DEVICE)
|
| 82 |
+
zko = model(xtko, aux, lam_dann=0.0)["z"]
|
| 83 |
+
s_ko = float((zko * protos[ci].unsqueeze(0)).sum())
|
| 84 |
+
deltas.append(s0 - s_ko) # positive delta = drop when KO'd
|
| 85 |
+
|
| 86 |
+
deltas = np.array(deltas)
|
| 87 |
+
rank = np.argsort(-deltas)
|
| 88 |
+
top30 = rank[:30]
|
| 89 |
+
rows.append({
|
| 90 |
+
"class": cls_name,
|
| 91 |
+
"baseline_cos": s0,
|
| 92 |
+
"n_cells_class": int(mask.sum()),
|
| 93 |
+
"top_essential_genes": ",".join([hvgs[cand_idx[r]] for r in top30[:20]]),
|
| 94 |
+
"top_essential_deltas": ",".join([f"{deltas[r]:+.4f}" for r in top30[:20]]),
|
| 95 |
+
"top_essential_baseline_expression": ",".join([f"{x_gene_log_mean[cand_idx[r]]:.2f}" for r in top30[:20]]),
|
| 96 |
+
})
|
| 97 |
+
print(f"[{cls_name}] baseline_cos={s0:.4f} top-5 essentials: "
|
| 98 |
+
f"{', '.join([f'{hvgs[cand_idx[r]]}(Δ{deltas[r]:+.3f})' for r in top30[:5]])}",
|
| 99 |
+
flush=True)
|
| 100 |
+
|
| 101 |
+
df = pd.DataFrame(rows)
|
| 102 |
+
df.to_csv(OUT / f"81_{sys}_ko_essentials.csv", index=False)
|
| 103 |
+
print(f"[wrote] {OUT}/81_{sys}_ko_essentials.csv", flush=True)
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
for sys in ["pan_skin", "hematopoiesis", "pancreas"]:
|
| 107 |
+
try:
|
| 108 |
+
compute_ko(sys)
|
| 109 |
+
except Exception as e:
|
| 110 |
+
import traceback; traceback.print_exc()
|
| 111 |
+
print(f"[!] {sys} failed: {e}", flush=True)
|
| 112 |
+
print("\n=== DONE ===", flush=True)
|
scripts/analysis/82_gene_coattribution_modules.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""cluster the top-500 attributed genes by cross-class attribution correlation."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
import warnings, json, pickle, sys, numpy as np, pandas as pd
|
| 5 |
+
warnings.filterwarnings("ignore")
|
| 6 |
+
from scipy.cluster.hierarchy import linkage, fcluster
|
| 7 |
+
from scipy.spatial.distance import squareform
|
| 8 |
+
|
| 9 |
+
CORP_ROOT = Path("/home/bcheng/PRISM/data/corpus")
|
| 10 |
+
OUT = Path("/home/bcheng/PRISM/discovery"); OUT.mkdir(exist_ok=True)
|
| 11 |
+
CKPT_ROOT = Path("/home/bcheng/PRISM/checkpoints")
|
| 12 |
+
|
| 13 |
+
TOP_GENES = 500 # keep top-500 by |att| summed across classes
|
| 14 |
+
N_MODULES = 15
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def main(sys):
|
| 18 |
+
print(f"\n===== {sys} =====", flush=True)
|
| 19 |
+
stats = np.load(CORP_ROOT / sys / "harmonized/corpus_stats.npz", allow_pickle=True)
|
| 20 |
+
hvgs = [str(g) for g in stats["shared_hvgs"]]
|
| 21 |
+
import torch
|
| 22 |
+
ck = torch.load(CKPT_ROOT / sys / "marker" / "panda_final.pt", map_location="cpu", weights_only=False)
|
| 23 |
+
classes = ck["classes"]
|
| 24 |
+
|
| 25 |
+
A = np.load(OUT / f"80_{sys}_gene_attribution_full.npy") # (K, G)
|
| 26 |
+
print(f"[load] A shape {A.shape}, classes={classes}", flush=True)
|
| 27 |
+
|
| 28 |
+
gene_score = np.abs(A).sum(axis=0)
|
| 29 |
+
top_idx = np.argsort(-gene_score)[:TOP_GENES]
|
| 30 |
+
A_top = A[:, top_idx]
|
| 31 |
+
top_genes = [hvgs[i] for i in top_idx]
|
| 32 |
+
|
| 33 |
+
Xn = A_top - A_top.mean(axis=0, keepdims=True)
|
| 34 |
+
Xn = Xn / (Xn.std(axis=0, keepdims=True) + 1e-8)
|
| 35 |
+
C = np.corrcoef(Xn.T)
|
| 36 |
+
print(f"[corr] gene-gene C shape {C.shape}, diag mean={np.diag(C).mean():.3f}", flush=True)
|
| 37 |
+
|
| 38 |
+
D = 1 - C
|
| 39 |
+
np.fill_diagonal(D, 0)
|
| 40 |
+
D = np.clip(D, 0, 2)
|
| 41 |
+
Z = linkage(squareform(D, checks=False), method="average")
|
| 42 |
+
labels = fcluster(Z, t=N_MODULES, criterion="maxclust")
|
| 43 |
+
|
| 44 |
+
rows = []
|
| 45 |
+
for mod in sorted(set(labels)):
|
| 46 |
+
members = np.where(labels == mod)[0]
|
| 47 |
+
if len(members) < 3: continue
|
| 48 |
+
member_genes = [top_genes[i] for i in members]
|
| 49 |
+
mod_att = A_top[:, members].mean(axis=1)
|
| 50 |
+
dom_ci = int(np.argmax(mod_att))
|
| 51 |
+
rows.append({
|
| 52 |
+
"module_id": int(mod),
|
| 53 |
+
"size": int(len(members)),
|
| 54 |
+
"dominant_class": classes[dom_ci],
|
| 55 |
+
"dom_class_mean_att": float(mod_att[dom_ci]),
|
| 56 |
+
"member_genes": ",".join(member_genes[:30]),
|
| 57 |
+
"n_shown": min(30, len(member_genes)),
|
| 58 |
+
})
|
| 59 |
+
|
| 60 |
+
df = pd.DataFrame(rows).sort_values(["dominant_class", "dom_class_mean_att"], ascending=[True, False])
|
| 61 |
+
df.to_csv(OUT / f"82_{sys}_coatt_modules.csv", index=False)
|
| 62 |
+
print(f"[wrote] {len(rows)} modules to {OUT}/82_{sys}_coatt_modules.csv", flush=True)
|
| 63 |
+
print(df.head(15).to_string(index=False)[:2000], flush=True)
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
for sys in ["pan_skin", "hematopoiesis", "pancreas"]:
|
| 67 |
+
try:
|
| 68 |
+
main(sys)
|
| 69 |
+
except Exception as e:
|
| 70 |
+
import traceback; traceback.print_exc()
|
| 71 |
+
print(f"[!] {sys}: {e}", flush=True)
|
| 72 |
+
print("\n=== DONE ===", flush=True)
|
scripts/analysis/83_prototype_training_trajectory.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""prototype cosine drift + participation ratio per curriculum stage."""
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
import json, numpy as np, pandas as pd, torch
|
| 4 |
+
|
| 5 |
+
CKPT = Path("/home/bcheng/PRISM/checkpoints")
|
| 6 |
+
OUT = Path("/home/bcheng/PRISM/discovery"); OUT.mkdir(exist_ok=True)
|
| 7 |
+
SYSTEMS = ["pan_skin", "hematopoiesis", "pancreas"]
|
| 8 |
+
STAGES = ["panda_stage0", "panda_stage1", "panda_stage2", "panda_stage3", "panda_final"]
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def load_prototypes(sys, stage):
|
| 12 |
+
ck = torch.load(CKPT / sys / f"{stage}.pt", map_location="cpu", weights_only=False)
|
| 13 |
+
# older checkpoints keep prototypes inside model state; newer ones at top level
|
| 14 |
+
if "prototypes" in ck:
|
| 15 |
+
P = ck["prototypes"]
|
| 16 |
+
else:
|
| 17 |
+
P = ck["model"]["prototypes"]
|
| 18 |
+
if isinstance(P, torch.Tensor): P = P.numpy()
|
| 19 |
+
P = P / (np.linalg.norm(P, axis=1, keepdims=True) + 1e-8)
|
| 20 |
+
return P, ck["classes"]
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def eff_dim(P):
|
| 24 |
+
G = P @ P.T
|
| 25 |
+
eig = np.clip(np.linalg.eigvalsh(G), 0, None)
|
| 26 |
+
return float(eig.sum()**2 / (eig**2).sum() + 1e-12)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
for sys in SYSTEMS:
|
| 30 |
+
print(f"\n===== {sys} =====", flush=True)
|
| 31 |
+
P_per_stage, classes = {}, None
|
| 32 |
+
for st in STAGES:
|
| 33 |
+
try:
|
| 34 |
+
P, cls = load_prototypes(sys, st)
|
| 35 |
+
P_per_stage[st] = P
|
| 36 |
+
classes = cls
|
| 37 |
+
print(f"[{st}] P shape={P.shape} eff_dim={eff_dim(P):.3f}", flush=True)
|
| 38 |
+
except FileNotFoundError:
|
| 39 |
+
print(f"[{st}] missing")
|
| 40 |
+
continue
|
| 41 |
+
|
| 42 |
+
stages_avail = list(P_per_stage.keys())
|
| 43 |
+
drift_rows = []
|
| 44 |
+
for i, s1 in enumerate(stages_avail[:-1]):
|
| 45 |
+
s2 = stages_avail[i+1]
|
| 46 |
+
P1 = P_per_stage[s1]; P2 = P_per_stage[s2]
|
| 47 |
+
for ci, cn in enumerate(classes):
|
| 48 |
+
cos_shift = float((P1[ci] * P2[ci]).sum())
|
| 49 |
+
drift_rows.append({
|
| 50 |
+
"class": cn,
|
| 51 |
+
"from_stage": s1.replace("panda_", ""),
|
| 52 |
+
"to_stage": s2.replace("panda_", ""),
|
| 53 |
+
"cos_shift": cos_shift,
|
| 54 |
+
"angle_deg": float(np.degrees(np.arccos(np.clip(cos_shift, -1, 1)))),
|
| 55 |
+
})
|
| 56 |
+
pd.DataFrame(drift_rows).to_csv(OUT / f"83_{sys}_prototype_trajectory.csv", index=False)
|
| 57 |
+
|
| 58 |
+
ed_rows = [{"stage": st.replace("panda_", ""),
|
| 59 |
+
"eff_dim": eff_dim(P),
|
| 60 |
+
"K": P.shape[0]}
|
| 61 |
+
for st, P in P_per_stage.items()]
|
| 62 |
+
pd.DataFrame(ed_rows).to_csv(OUT / f"83_{sys}_effdim_by_stage.csv", index=False)
|
| 63 |
+
print(f"[eff_dim by stage] {[(r['stage'], round(r['eff_dim'], 3)) for r in ed_rows]}", flush=True)
|
| 64 |
+
|
| 65 |
+
if "panda_stage0" in P_per_stage and "panda_final" in P_per_stage:
|
| 66 |
+
P0 = P_per_stage["panda_stage0"]
|
| 67 |
+
Pf = P_per_stage["panda_final"]
|
| 68 |
+
per_class_total_cos = (P0 * Pf).sum(axis=1)
|
| 69 |
+
print(f"[stage0 -> final] per-class cos: {[(classes[i], round(float(per_class_total_cos[i]), 3)) for i in range(len(classes))]}", flush=True)
|
| 70 |
+
|
| 71 |
+
print("\n===== DONE =====", flush=True)
|
scripts/analysis/84_adversary_purification.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""probe dataset + depth adversary heads on training data to check trunk invariance."""
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
import json, warnings, pickle, sys, numpy as np, pandas as pd, torch
|
| 4 |
+
warnings.filterwarnings("ignore")
|
| 5 |
+
import anndata as ad, scanpy as sc, scipy.sparse as sp
|
| 6 |
+
from pathlib import Path as _P_root
|
| 7 |
+
ROOT = _P_root(__file__).resolve().parents[2]
|
| 8 |
+
ROOT_STR = str(ROOT)
|
| 9 |
+
sys.path.insert(0, ROOT_STR)
|
| 10 |
+
from panda import PANDAEncoder
|
| 11 |
+
|
| 12 |
+
DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 13 |
+
CKPT = Path(f"{ROOT_STR}/checkpoints")
|
| 14 |
+
CORP = Path(f"{ROOT_STR}/data/corpus")
|
| 15 |
+
OUT = Path(f"{ROOT_STR}/discovery"); OUT.mkdir(exist_ok=True)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def project_corpus(sys, subsample=20000):
|
| 19 |
+
stats = np.load(CORP / sys / "harmonized/corpus_stats.npz", allow_pickle=True)
|
| 20 |
+
hvgs = [str(g) for g in stats["shared_hvgs"]]
|
| 21 |
+
mu = np.asarray(stats["mean"], dtype=np.float32)
|
| 22 |
+
sig = np.asarray(stats["std"], dtype=np.float32)
|
| 23 |
+
pca = pickle.load(open(CORP / sys / "harmonized/pca_basis.pkl", "rb"))
|
| 24 |
+
|
| 25 |
+
ck = torch.load(CKPT / sys / "panda_final.pt", map_location=DEVICE, weights_only=False)
|
| 26 |
+
m = PANDAEncoder(n_pca=50, n_classes=len(ck["classes"]),
|
| 27 |
+
n_datasets=len(ck["datasets"])).to(DEVICE).eval()
|
| 28 |
+
m.load_state_dict(ck["model"])
|
| 29 |
+
|
| 30 |
+
corp = ad.read_h5ad(CORP / sys / "harmonized/corpus.h5ad")
|
| 31 |
+
print(f"[{sys}] corpus {corp.shape}", flush=True)
|
| 32 |
+
|
| 33 |
+
dcol_candidates = ["dataset_id", "dataset", "sample", "batch"]
|
| 34 |
+
dcol = None
|
| 35 |
+
for c in dcol_candidates:
|
| 36 |
+
if c in corp.obs.columns:
|
| 37 |
+
dcol = c; break
|
| 38 |
+
if dcol is None:
|
| 39 |
+
# HSC corpus is Weinreb-only (single dataset)
|
| 40 |
+
corp.obs["dataset_id"] = "single"
|
| 41 |
+
dcol = "dataset_id"
|
| 42 |
+
print(f"[{sys}] dataset col={dcol} n_unique={corp.obs[dcol].nunique()} train datasets in ckpt={len(ck['datasets'])}", flush=True)
|
| 43 |
+
|
| 44 |
+
if corp.n_obs > subsample:
|
| 45 |
+
rng = np.random.default_rng(0)
|
| 46 |
+
idx = rng.choice(corp.n_obs, size=subsample, replace=False)
|
| 47 |
+
corp = corp[idx].copy()
|
| 48 |
+
print(f"[{sys}] subsampled to {corp.n_obs} cells", flush=True)
|
| 49 |
+
|
| 50 |
+
hvg2i = {g: i for i, g in enumerate(hvgs)}
|
| 51 |
+
common = [g for g in corp.var_names.astype(str) if g in hvg2i]
|
| 52 |
+
a_c = corp[:, common].copy()
|
| 53 |
+
sc.pp.normalize_total(a_c, target_sum=1e4); sc.pp.log1p(a_c)
|
| 54 |
+
X = a_c.X.toarray().astype(np.float32) if sp.issparse(a_c.X) else a_c.X.astype(np.float32)
|
| 55 |
+
Xf = np.zeros((corp.n_obs, len(hvgs)), dtype=np.float32)
|
| 56 |
+
cols = np.array([hvg2i[g] for g in common])
|
| 57 |
+
Xf[:, cols] = X
|
| 58 |
+
Xz = np.clip((Xf - mu) / sig, -10, 10)
|
| 59 |
+
Xpca = pca.transform(Xz).astype(np.float32)
|
| 60 |
+
|
| 61 |
+
if "n_counts" in corp.obs.columns:
|
| 62 |
+
n_counts = corp.obs["n_counts"].astype(float).values
|
| 63 |
+
elif "total_counts" in corp.obs.columns:
|
| 64 |
+
n_counts = corp.obs["total_counts"].astype(float).values
|
| 65 |
+
else:
|
| 66 |
+
# X is log-normalised; expm1 first for approximate raw sum
|
| 67 |
+
n_counts = np.expm1(X.sum(axis=1))
|
| 68 |
+
log10c = np.log10(n_counts + 1)
|
| 69 |
+
log10c_z = (log10c - log10c.mean()) / (log10c.std() + 1e-8)
|
| 70 |
+
|
| 71 |
+
dset = corp.obs[dcol].astype(str).values
|
| 72 |
+
train_dsets = ck["datasets"]
|
| 73 |
+
dset_to_idx = {d: i for i, d in enumerate(train_dsets)}
|
| 74 |
+
y_dset = np.array([dset_to_idx.get(d, -1) for d in dset])
|
| 75 |
+
valid = y_dset >= 0
|
| 76 |
+
print(f"[{sys}] valid rows for dataset-eval: {valid.sum()} / {len(y_dset)}", flush=True)
|
| 77 |
+
|
| 78 |
+
dom_preds = np.zeros((corp.n_obs, len(train_dsets)), dtype=np.float32)
|
| 79 |
+
depth_preds = np.zeros(corp.n_obs, dtype=np.float32)
|
| 80 |
+
with torch.no_grad():
|
| 81 |
+
for i in range(0, corp.n_obs, 2048):
|
| 82 |
+
xb = torch.from_numpy(Xpca[i:i+2048]).to(DEVICE)
|
| 83 |
+
aux = torch.zeros(len(xb), 2, device=DEVICE)
|
| 84 |
+
out = m(xb, aux, lam_dann=0.0) # lam=0 disables GRL at inference
|
| 85 |
+
dom_preds[i:i+2048] = out["dom"].cpu().numpy()
|
| 86 |
+
depth_preds[i:i+2048] = out["depth"].cpu().numpy().squeeze()
|
| 87 |
+
|
| 88 |
+
from sklearn.metrics import accuracy_score, top_k_accuracy_score, mean_squared_error, r2_score
|
| 89 |
+
result = {"system": sys, "n_cells": int(corp.n_obs), "n_train_datasets": len(train_dsets)}
|
| 90 |
+
if valid.sum() > 0 and len(train_dsets) > 1:
|
| 91 |
+
dom_pred_argmax = dom_preds[valid].argmax(axis=1)
|
| 92 |
+
acc = float(accuracy_score(y_dset[valid], dom_pred_argmax))
|
| 93 |
+
chance = 1.0 / len(train_dsets)
|
| 94 |
+
result.update({
|
| 95 |
+
"dataset_adv_accuracy": acc,
|
| 96 |
+
"dataset_adv_chance": chance,
|
| 97 |
+
"dataset_adv_above_chance": acc - chance,
|
| 98 |
+
"dataset_adv_random_baseline_test": "None (single-dataset)" if len(train_dsets) == 1 else f"n_datasets={len(train_dsets)}, chance={chance:.3f}",
|
| 99 |
+
})
|
| 100 |
+
|
| 101 |
+
mse_depth = float(mean_squared_error(log10c_z, depth_preds))
|
| 102 |
+
r2_depth = float(r2_score(log10c_z, depth_preds))
|
| 103 |
+
result.update({
|
| 104 |
+
"depth_adv_mse_z": mse_depth,
|
| 105 |
+
"depth_adv_r2_z": r2_depth,
|
| 106 |
+
"depth_target_std_z": float(log10c_z.std()),
|
| 107 |
+
})
|
| 108 |
+
|
| 109 |
+
print(f"[{sys}] dom_adv_acc={result.get('dataset_adv_accuracy', 'NA')} vs chance={result.get('dataset_adv_chance', 'NA')}", flush=True)
|
| 110 |
+
print(f"[{sys}] depth_adv MSE_z={mse_depth:.4f} R²_z={r2_depth:.4f} (R²≤0 ⇒ trunk fully depth-invariant)", flush=True)
|
| 111 |
+
|
| 112 |
+
return result
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
all_results = {}
|
| 116 |
+
for sys in ["pan_skin", "hematopoiesis", "pancreas"]:
|
| 117 |
+
try:
|
| 118 |
+
all_results[sys] = project_corpus(sys)
|
| 119 |
+
except Exception as e:
|
| 120 |
+
import traceback; traceback.print_exc()
|
| 121 |
+
all_results[sys] = {"error": str(e)}
|
| 122 |
+
|
| 123 |
+
json.dump(all_results, open(OUT / "84_adversary_purification.json", "w"), indent=2, default=str)
|
| 124 |
+
print(f"\nwrote {OUT}/84_adversary_purification.json", flush=True)
|
| 125 |
+
print(json.dumps(all_results, indent=2, default=str), flush=True)
|
scripts/analysis/85_hessian_gene_interactions.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""per-class 20x20 hessian of prototype cosine over top-attributed genes."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
import warnings, json, pickle, sys, numpy as np, pandas as pd, torch
|
| 5 |
+
warnings.filterwarnings("ignore")
|
| 6 |
+
import anndata as ad, scanpy as sc, scipy.sparse as sp
|
| 7 |
+
from pathlib import Path as _P_root
|
| 8 |
+
ROOT = _P_root(__file__).resolve().parents[2]
|
| 9 |
+
ROOT_STR = str(ROOT)
|
| 10 |
+
sys.path.insert(0, ROOT_STR)
|
| 11 |
+
from panda import PANDAEncoder
|
| 12 |
+
|
| 13 |
+
DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 14 |
+
CKPT = Path(f"{ROOT_STR}/checkpoints")
|
| 15 |
+
CORP = Path(f"{ROOT_STR}/data/corpus")
|
| 16 |
+
OUT = Path(f"{ROOT_STR}/discovery"); OUT.mkdir(exist_ok=True)
|
| 17 |
+
TOP_K_GENES = 20
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def compute_hessian_for_class(model, protos, class_idx, x_pca_c, pca_V, mu, sig, gene_idx_subset):
|
| 21 |
+
# parametrise perturbation as delta in gene-z space of the D selected genes
|
| 22 |
+
V_sub = torch.from_numpy(pca_V[:, gene_idx_subset]).float().to(DEVICE) # (50, D)
|
| 23 |
+
|
| 24 |
+
def f_of_delta(delta):
|
| 25 |
+
x = x_pca_c + V_sub @ delta
|
| 26 |
+
x = x.clamp(-10, 10).unsqueeze(0)
|
| 27 |
+
aux = torch.zeros(1, 2, device=DEVICE)
|
| 28 |
+
h = model.trunk(x)
|
| 29 |
+
z_raw = model.projection(h)
|
| 30 |
+
z = torch.nn.functional.normalize(z_raw, dim=1)
|
| 31 |
+
return (z * protos[class_idx].unsqueeze(0)).sum()
|
| 32 |
+
|
| 33 |
+
D = len(gene_idx_subset)
|
| 34 |
+
delta0 = torch.zeros(D, device=DEVICE)
|
| 35 |
+
H = torch.autograd.functional.hessian(f_of_delta, delta0)
|
| 36 |
+
return H.detach().cpu().numpy()
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def main(sys):
|
| 40 |
+
print(f"\n===== {sys} =====", flush=True)
|
| 41 |
+
stats = np.load(CORP / sys / "harmonized/corpus_stats.npz", allow_pickle=True)
|
| 42 |
+
hvgs = [str(g) for g in stats["shared_hvgs"]]
|
| 43 |
+
mu = np.asarray(stats["mean"], dtype=np.float32)
|
| 44 |
+
sig = np.asarray(stats["std"], dtype=np.float32)
|
| 45 |
+
pca = pickle.load(open(CORP / sys / "harmonized/pca_basis.pkl", "rb"))
|
| 46 |
+
|
| 47 |
+
ck = torch.load(CKPT / sys / "marker" / "panda_final.pt", map_location=DEVICE, weights_only=False)
|
| 48 |
+
model = PANDAEncoder(variant="marker", n_pca=50, n_markers=len(ck.get("marker_genes",[])), n_classes=len(ck["classes"]), n_sub=3,
|
| 49 |
+
n_datasets=len(ck["datasets"])).to(DEVICE).eval()
|
| 50 |
+
model.load_state_dict(ck["model"])
|
| 51 |
+
classes = ck["classes"]
|
| 52 |
+
protos = torch.from_numpy(ck["prototypes"]).to(DEVICE)
|
| 53 |
+
protos = protos / (protos.norm(dim=1, keepdim=True) + 1e-8)
|
| 54 |
+
|
| 55 |
+
corp = ad.read_h5ad(CORP / sys / "harmonized/corpus.h5ad")
|
| 56 |
+
label_key = "canonical_label" if "canonical_label" in corp.obs else "cell_type"
|
| 57 |
+
y = corp.obs[label_key].astype(str).values
|
| 58 |
+
hvg2i = {g: i for i, g in enumerate(hvgs)}
|
| 59 |
+
common = [g for g in corp.var_names.astype(str) if g in hvg2i]
|
| 60 |
+
a = corp[:, common].copy()
|
| 61 |
+
sc.pp.normalize_total(a, target_sum=1e4); sc.pp.log1p(a)
|
| 62 |
+
X = a.X.toarray().astype(np.float32) if sp.issparse(a.X) else a.X.astype(np.float32)
|
| 63 |
+
Xf = np.zeros((corp.n_obs, len(hvgs)), dtype=np.float32)
|
| 64 |
+
cols = np.array([hvg2i[g] for g in common])
|
| 65 |
+
Xf[:, cols] = X
|
| 66 |
+
|
| 67 |
+
A = np.load(OUT / f"80_{sys}_gene_attribution_full.npy")
|
| 68 |
+
|
| 69 |
+
K = len(classes)
|
| 70 |
+
H_all = np.zeros((K, TOP_K_GENES, TOP_K_GENES), dtype=np.float32)
|
| 71 |
+
top_gene_names_per_class = []
|
| 72 |
+
pair_rows = []
|
| 73 |
+
|
| 74 |
+
for ci, cls_name in enumerate(classes):
|
| 75 |
+
mask = y == cls_name
|
| 76 |
+
if mask.sum() == 0:
|
| 77 |
+
print(f"[!] {cls_name}: no cells in corpus", flush=True)
|
| 78 |
+
continue
|
| 79 |
+
x_gene_log_mean = Xf[mask].mean(axis=0)
|
| 80 |
+
x_z = np.clip((x_gene_log_mean - mu) / sig, -10, 10)
|
| 81 |
+
x_pca_c = torch.from_numpy(pca.transform(x_z.reshape(1, -1))[0]).float().to(DEVICE)
|
| 82 |
+
|
| 83 |
+
att_c = A[ci]
|
| 84 |
+
gene_idx = np.argsort(-np.abs(att_c))[:TOP_K_GENES]
|
| 85 |
+
top_names = [hvgs[i] for i in gene_idx]
|
| 86 |
+
top_gene_names_per_class.append(top_names)
|
| 87 |
+
|
| 88 |
+
H = compute_hessian_for_class(model, protos, ci, x_pca_c,
|
| 89 |
+
pca.components_.astype(np.float32), mu, sig,
|
| 90 |
+
gene_idx.tolist())
|
| 91 |
+
H_all[ci] = H
|
| 92 |
+
|
| 93 |
+
off = H.copy()
|
| 94 |
+
np.fill_diagonal(off, 0)
|
| 95 |
+
rows, cols_ = np.triu_indices(TOP_K_GENES, k=1)
|
| 96 |
+
vals = off[rows, cols_]
|
| 97 |
+
order = np.argsort(-np.abs(vals))[:30]
|
| 98 |
+
for r_i in order:
|
| 99 |
+
gi, gj = int(rows[r_i]), int(cols_[r_i])
|
| 100 |
+
pair_rows.append({
|
| 101 |
+
"class": cls_name,
|
| 102 |
+
"gene_a": top_names[gi],
|
| 103 |
+
"gene_b": top_names[gj],
|
| 104 |
+
"hessian_off_diag": float(vals[r_i]),
|
| 105 |
+
"abs_h": float(abs(vals[r_i])),
|
| 106 |
+
"attribution_a": float(att_c[gene_idx[gi]]),
|
| 107 |
+
"attribution_b": float(att_c[gene_idx[gj]]),
|
| 108 |
+
})
|
| 109 |
+
|
| 110 |
+
print(f"[{cls_name}] Hessian |diag|_max={float(np.abs(np.diag(H)).max()):.4f} "
|
| 111 |
+
f"|offdiag|_max={float(np.abs(off).max()):.4f} "
|
| 112 |
+
f"top-3 pairs: {', '.join([f'{top_names[int(rows[r_i])]}·{top_names[int(cols_[r_i])]}({vals[r_i]:+.4f})' for r_i in order[:3]])}",
|
| 113 |
+
flush=True)
|
| 114 |
+
|
| 115 |
+
np.save(OUT / f"85_{sys}_hessian_top20.npy", H_all)
|
| 116 |
+
with open(OUT / f"85_{sys}_hessian_top20_genes.json", "w") as f:
|
| 117 |
+
json.dump({classes[i]: top_gene_names_per_class[i] for i in range(K)}, f, indent=2)
|
| 118 |
+
pd.DataFrame(pair_rows).to_csv(OUT / f"85_{sys}_hessian_pairs.csv", index=False)
|
| 119 |
+
print(f"[wrote] {OUT}/85_{sys}_hessian_*", flush=True)
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
for sys in ["pan_skin", "hematopoiesis", "pancreas"]:
|
| 123 |
+
try:
|
| 124 |
+
main(sys)
|
| 125 |
+
except Exception as e:
|
| 126 |
+
import traceback; traceback.print_exc()
|
| 127 |
+
print(f"[!] {sys}: {e}", flush=True)
|
| 128 |
+
|
| 129 |
+
print("\n=== DONE ===", flush=True)
|
scripts/analysis/90_dingwall_marker_deep_dive.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""dingwall marker deep-dive: single rank_genes_groups call cross-referenced against canonical panels."""
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
import warnings, json, numpy as np, pandas as pd, anndata as ad, scanpy as sc, scipy.sparse as sp
|
| 4 |
+
warnings.filterwarnings("ignore"); sc.settings.verbosity = 0
|
| 5 |
+
|
| 6 |
+
ROOT = Path("/home/bcheng/PRISM")
|
| 7 |
+
OUT = ROOT / "discovery/pan_skin/marker"
|
| 8 |
+
OUT.mkdir(parents=True, exist_ok=True)
|
| 9 |
+
|
| 10 |
+
PANELS = {
|
| 11 |
+
"eden-dermal-niche": ["S100a4", "Twist2", "Prrx1", "Pdgfra", "Fap", "Fn1"],
|
| 12 |
+
"eccrine-secretory": ["Dcd", "Aqp5", "Muc7", "Cst6", "Krt7"],
|
| 13 |
+
"eccrine-ductal": ["Krt77", "Krt5", "Krt14", "Cldn6", "Grhl3"],
|
| 14 |
+
"basal-multipotent": ["Krt5", "Krt14", "Trp63", "Itgb4", "Sox2"],
|
| 15 |
+
"hair-placode": ["Shh", "Sox9", "Lhx2", "Foxi3", "Wnt10a"],
|
| 16 |
+
"melanocyte": ["Dct", "Mlana", "Tyrp1", "Pmel", "Sox10"],
|
| 17 |
+
"endothelial": ["Pecam1", "Cdh5", "Kdr", "Flt1"],
|
| 18 |
+
"spinous": ["Krt10", "Krt1", "Dsp"],
|
| 19 |
+
"basal-IFE": ["Krt5", "Krt14", "Krt15", "Col17a1"],
|
| 20 |
+
"immune": ["Ptprc", "Cd68", "Cd3d", "Cd19"],
|
| 21 |
+
"fibroblast": ["Col1a1", "Dcn", "Pdgfra"],
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
CKO_GSMS = {"GSM6833482", "GSM6833483"} # CORRECTED: 480/481 are rttaControl (WT), not cKO
|
| 25 |
+
WT_GSMS = {"GSM6833478", "GSM6833479", "GSM6833480", "GSM6833481"} # CORRECTED: 4 Cre-neg controls per GEO metadata
|
| 26 |
+
|
| 27 |
+
print("[load] Dingwall raw + predictions", flush=True)
|
| 28 |
+
raw = ad.read_h5ad(ROOT / "data/raw/GSE220977_combined.h5ad")
|
| 29 |
+
pred_df = pd.read_csv(ROOT / "discovery/pan_skin/marker/dingwall_predictions.csv")
|
| 30 |
+
common = raw.obs_names.intersection(pd.Index(pred_df["cell_id"].astype(str)))
|
| 31 |
+
raw = raw[list(common)].copy()
|
| 32 |
+
pred_map = dict(zip(pred_df["cell_id"].astype(str), pred_df["pred_label"]))
|
| 33 |
+
raw.obs["pred_label"] = pd.Categorical([pred_map.get(c, "unknown") for c in raw.obs_names])
|
| 34 |
+
raw.obs["genotype"] = np.where(raw.obs["sample"].astype(str).isin(list(CKO_GSMS)), "En1-cKO",
|
| 35 |
+
np.where(raw.obs["sample"].astype(str).isin(list(WT_GSMS)), "WT", "other"))
|
| 36 |
+
print(f"[align] {raw.n_obs} cells across {raw.obs['pred_label'].nunique()} classes", flush=True)
|
| 37 |
+
|
| 38 |
+
# subset to classes with >=30 cells for stable Wilcoxon
|
| 39 |
+
counts = raw.obs["pred_label"].value_counts()
|
| 40 |
+
keep_cls = counts[counts >= 30].index.tolist()
|
| 41 |
+
raw = raw[raw.obs["pred_label"].isin(keep_cls)].copy()
|
| 42 |
+
raw.obs["pred_label"] = raw.obs["pred_label"].astype(str).astype("category")
|
| 43 |
+
print(f"[filter] kept {raw.n_obs} cells × {len(keep_cls)} classes", flush=True)
|
| 44 |
+
|
| 45 |
+
sc.pp.normalize_total(raw, target_sum=1e4); sc.pp.log1p(raw)
|
| 46 |
+
|
| 47 |
+
# single-call with groupby is much faster than per-class loop
|
| 48 |
+
print("[wilcoxon] single-call across all predicted classes...", flush=True)
|
| 49 |
+
sc.tl.rank_genes_groups(raw, groupby="pred_label", method="wilcoxon", n_genes=25, use_raw=False)
|
| 50 |
+
print("[wilcoxon] done", flush=True)
|
| 51 |
+
|
| 52 |
+
rows = []
|
| 53 |
+
for cls in raw.uns["rank_genes_groups"]["names"].dtype.names:
|
| 54 |
+
mask = raw.obs["pred_label"] == cls
|
| 55 |
+
if mask.sum() < 30: continue
|
| 56 |
+
genes = list(raw.uns["rank_genes_groups"]["names"][cls][:20])
|
| 57 |
+
pvals = [float(x) for x in raw.uns["rank_genes_groups"]["pvals_adj"][cls][:20]]
|
| 58 |
+
logfc = [float(x) for x in raw.uns["rank_genes_groups"]["logfoldchanges"][cls][:20]]
|
| 59 |
+
|
| 60 |
+
top_str = ",".join([f"{g}(LFC{lf:+.1f})" for g, lf in zip(genes[:10], logfc[:10])])
|
| 61 |
+
panel_hits = {}
|
| 62 |
+
for pname, plist in PANELS.items():
|
| 63 |
+
hits = [g for g in plist if g in genes[:20]]
|
| 64 |
+
panel_hits[pname] = f"{len(hits)}/{len(plist)}: {','.join(hits)}"
|
| 65 |
+
gt = raw.obs["genotype"][mask]
|
| 66 |
+
ncko = int((gt == "En1-cKO").sum()); nwt = int((gt == "WT").sum())
|
| 67 |
+
frac_cko = ncko / max(1, ncko + nwt)
|
| 68 |
+
best_panel = max(panel_hits.items(),
|
| 69 |
+
key=lambda x: int(x[1].split("/")[0]) / (int(x[1].split(":")[0].split("/")[1]) + 1e-6))
|
| 70 |
+
|
| 71 |
+
rows.append({
|
| 72 |
+
"predicted_class": cls,
|
| 73 |
+
"n_cells": int(mask.sum()),
|
| 74 |
+
"top_wilcoxon_markers": top_str,
|
| 75 |
+
"min_p_adj_top5": min(pvals[:5], default=float("nan")),
|
| 76 |
+
"best_canonical_panel_match": best_panel[0],
|
| 77 |
+
"recovery": best_panel[1],
|
| 78 |
+
"n_En1_cKO": ncko,
|
| 79 |
+
"n_WT": nwt,
|
| 80 |
+
"frac_En1_cKO": frac_cko,
|
| 81 |
+
})
|
| 82 |
+
|
| 83 |
+
df = pd.DataFrame(rows).sort_values("n_cells", ascending=False)
|
| 84 |
+
df.to_csv(OUT / "90_dingwall_marker_deep_dive.csv", index=False)
|
| 85 |
+
print(f"[write] {OUT}/90_dingwall_marker_deep_dive.csv ({len(df)} classes)", flush=True)
|
| 86 |
+
print()
|
| 87 |
+
print(df[["predicted_class", "n_cells", "best_canonical_panel_match", "recovery", "frac_En1_cKO"]].to_string(index=False))
|
scripts/analysis/91_veres_marker_deep_dive.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""veres marker deep-dive.
|
| 2 |
+
|
| 3 |
+
per-predicted-class wilcoxon on raw veres counts, cross-referenced with canonical
|
| 4 |
+
adult-beta / SC-alpha / EP panels. output: discovery/pancreas/marker/91_veres_marker_deep_dive.csv
|
| 5 |
+
"""
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
import warnings, numpy as np, pandas as pd, anndata as ad, scanpy as sc, scipy.sparse as sp
|
| 8 |
+
warnings.filterwarnings("ignore"); sc.settings.verbosity = 0
|
| 9 |
+
|
| 10 |
+
ROOT = Path("/home/bcheng/PRISM")
|
| 11 |
+
OUT = ROOT / "discovery/pancreas/marker"
|
| 12 |
+
OUT.mkdir(parents=True, exist_ok=True)
|
| 13 |
+
|
| 14 |
+
# human panels (Veres is human hPSC)
|
| 15 |
+
PANELS = {
|
| 16 |
+
"adult-beta": ["INS", "MAFA", "UCN3", "NKX6-1", "MNX1", "NEUROD1", "PDX1"],
|
| 17 |
+
"adult-alpha": ["GCG", "ARX", "IRX2", "IRX1", "MAFB", "TTR"],
|
| 18 |
+
"alpha (embryonic prototype)": ["GCG", "ARX", "IRX2", "MAFB"],
|
| 19 |
+
"beta (embryonic prototype)": ["INS", "NKX6-1", "MNX1", "NEUROD1", "PDX1"],
|
| 20 |
+
"delta": ["SST", "HHEX", "LEPR"],
|
| 21 |
+
"gamma": ["PPY", "PYY", "SLC38A4"],
|
| 22 |
+
"epsilon": ["GHRL"],
|
| 23 |
+
"endocrine-progenitor-early": ["NEUROG3", "CBFA2T3", "BTBD17"],
|
| 24 |
+
"endocrine-progenitor-Fev": ["FEV", "INSM1"],
|
| 25 |
+
"endocrine-progenitor-primed": ["PAX4", "ARX"],
|
| 26 |
+
"acinar": ["PRSS1", "PRSS2", "CEL", "CTRB1"],
|
| 27 |
+
"ductal": ["KRT19", "SOX9", "MUC1"],
|
| 28 |
+
"endothelial": ["PECAM1", "CDH5", "KDR"],
|
| 29 |
+
"immune": ["PTPRC", "CD68"],
|
| 30 |
+
"mesenchymal": ["COL1A1", "COL3A1", "DCN"],
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
# Load Veres via existing loader logic
|
| 34 |
+
def load_veres():
|
| 35 |
+
SHARON_DIR = ROOT / "data/corpus/pancreas/held_out_unlabeled/sharon_extract"
|
| 36 |
+
parts = []
|
| 37 |
+
for meta_file in sorted(SHARON_DIR.glob("*.cell_metadata.tsv.gz")):
|
| 38 |
+
counts_file = str(meta_file).replace("cell_metadata", "processed_counts")
|
| 39 |
+
if not Path(counts_file).exists(): continue
|
| 40 |
+
meta = pd.read_csv(meta_file, sep="\t", compression="gzip")
|
| 41 |
+
counts = pd.read_csv(counts_file, sep="\t", compression="gzip", index_col=0)
|
| 42 |
+
obs = meta.set_index("library.barcode")
|
| 43 |
+
obs = obs.loc[obs.index.intersection(counts.index)]
|
| 44 |
+
counts_al = counts.loc[obs.index]
|
| 45 |
+
X = sp.csr_matrix(counts_al.values.astype(np.float32))
|
| 46 |
+
a = ad.AnnData(X=X, obs=obs, var=pd.DataFrame(index=counts_al.columns))
|
| 47 |
+
a.var_names_make_unique()
|
| 48 |
+
parts.append(a)
|
| 49 |
+
return ad.concat(parts, join="outer")
|
| 50 |
+
|
| 51 |
+
print("[load] Veres + predictions", flush=True)
|
| 52 |
+
raw = load_veres()
|
| 53 |
+
pred_df = pd.read_csv(ROOT / "discovery/pancreas/marker/veres_predictions.csv")
|
| 54 |
+
# strip the "veres_" prefix from prediction cell_ids so they align with raw.obs_names
|
| 55 |
+
pred_df["cell_id"] = pred_df["cell_id"].astype(str).str.replace(r"^veres_", "", regex=True)
|
| 56 |
+
common = raw.obs_names.intersection(pd.Index(pred_df["cell_id"].astype(str)))
|
| 57 |
+
raw = raw[list(common)].copy()
|
| 58 |
+
pred_map = dict(zip(pred_df["cell_id"].astype(str), pred_df["pred_label"]))
|
| 59 |
+
raw.obs["pred_label"] = pd.Categorical([pred_map.get(c, "unknown") for c in raw.obs_names])
|
| 60 |
+
print(f"[align] {raw.n_obs} cells across {raw.obs['pred_label'].nunique()} classes", flush=True)
|
| 61 |
+
|
| 62 |
+
counts_s = raw.obs["pred_label"].value_counts()
|
| 63 |
+
keep_cls = counts_s[counts_s >= 30].index.tolist()
|
| 64 |
+
raw = raw[raw.obs["pred_label"].isin(keep_cls)].copy()
|
| 65 |
+
raw.obs["pred_label"] = raw.obs["pred_label"].astype(str).astype("category")
|
| 66 |
+
print(f"[filter] {raw.n_obs} cells × {len(keep_cls)} classes", flush=True)
|
| 67 |
+
|
| 68 |
+
sc.pp.normalize_total(raw, target_sum=1e4); sc.pp.log1p(raw)
|
| 69 |
+
print("[wilcoxon] running...", flush=True)
|
| 70 |
+
sc.tl.rank_genes_groups(raw, groupby="pred_label", method="wilcoxon", n_genes=25, use_raw=False)
|
| 71 |
+
|
| 72 |
+
rows = []
|
| 73 |
+
for cls in raw.uns["rank_genes_groups"]["names"].dtype.names:
|
| 74 |
+
mask = raw.obs["pred_label"] == cls
|
| 75 |
+
if mask.sum() < 30: continue
|
| 76 |
+
genes = list(raw.uns["rank_genes_groups"]["names"][cls][:20])
|
| 77 |
+
pvals = [float(x) for x in raw.uns["rank_genes_groups"]["pvals_adj"][cls][:20]]
|
| 78 |
+
logfc = [float(x) for x in raw.uns["rank_genes_groups"]["logfoldchanges"][cls][:20]]
|
| 79 |
+
|
| 80 |
+
top_str = ",".join([f"{g}(LFC{lf:+.1f})" for g, lf in zip(genes[:10], logfc[:10])])
|
| 81 |
+
panel_hits = {}
|
| 82 |
+
for pname, plist in PANELS.items():
|
| 83 |
+
hits = [g for g in plist if g in genes[:20]]
|
| 84 |
+
panel_hits[pname] = f"{len(hits)}/{len(plist)}: {','.join(hits)}"
|
| 85 |
+
best_panel = max(panel_hits.items(),
|
| 86 |
+
key=lambda x: int(x[1].split("/")[0]) / (int(x[1].split(":")[0].split("/")[1]) + 1e-6))
|
| 87 |
+
|
| 88 |
+
# Stage enrichment
|
| 89 |
+
stage_col = raw.obs.get("Stage", raw.obs.get("stage", pd.Series([""]*raw.n_obs, index=raw.obs.index)))
|
| 90 |
+
stage_vals = pd.to_numeric(stage_col[mask], errors="coerce")
|
| 91 |
+
top_stage = int(stage_vals.mode().iloc[0]) if len(stage_vals.dropna()) else -1
|
| 92 |
+
stage6_frac = float((stage_vals == 6).sum() / max(1, mask.sum()))
|
| 93 |
+
|
| 94 |
+
rows.append({
|
| 95 |
+
"predicted_class": cls,
|
| 96 |
+
"n_cells": int(mask.sum()),
|
| 97 |
+
"top_wilcoxon_markers": top_str,
|
| 98 |
+
"min_p_adj_top5": min(pvals[:5], default=float("nan")),
|
| 99 |
+
"best_canonical_panel_match": best_panel[0],
|
| 100 |
+
"recovery": best_panel[1],
|
| 101 |
+
"top_stage": top_stage,
|
| 102 |
+
"stage6_frac": stage6_frac,
|
| 103 |
+
})
|
| 104 |
+
|
| 105 |
+
df = pd.DataFrame(rows).sort_values("n_cells", ascending=False)
|
| 106 |
+
df.to_csv(OUT / "91_veres_marker_deep_dive.csv", index=False)
|
| 107 |
+
print(f"[write] {OUT}/91_veres_marker_deep_dive.csv ({len(df)} classes)", flush=True)
|
| 108 |
+
print()
|
| 109 |
+
print(df[["predicted_class", "n_cells", "best_canonical_panel_match", "recovery",
|
| 110 |
+
"top_stage", "stage6_frac"]].to_string(index=False))
|
scripts/analysis/92_dahlin_marker_deep_dive.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""dahlin marker deep-dive: wilcoxon per predicted class + Kit-W41 vs WT enrichment."""
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
import warnings, numpy as np, pandas as pd, anndata as ad, scanpy as sc, scipy.sparse as sp, torch, pickle
|
| 4 |
+
warnings.filterwarnings("ignore"); sc.settings.verbosity = 0
|
| 5 |
+
import sys
|
| 6 |
+
sys.path.insert(0, "/home/bcheng/PRISM")
|
| 7 |
+
from panda import PANDAEncoder
|
| 8 |
+
|
| 9 |
+
ROOT = Path("/home/bcheng/PRISM")
|
| 10 |
+
DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 11 |
+
OUT = ROOT / "discovery/hematopoiesis/marker"
|
| 12 |
+
OUT.mkdir(parents=True, exist_ok=True)
|
| 13 |
+
|
| 14 |
+
PANELS = {
|
| 15 |
+
"LT-HSC": ["Hlf", "Meis1", "Mecom", "Procr", "Fgd5", "Mllt3", "Kit"],
|
| 16 |
+
"MPP": ["Cd48", "Flt3", "Cd34", "Sell", "Slamf1"],
|
| 17 |
+
"erythroid": ["Klf1", "Car1", "Car2", "Blvrb", "Hba-a1", "Hba-a2", "Kit"],
|
| 18 |
+
"megakaryocyte": ["Itga2b", "Pf4", "Gp1bb", "Gata1"],
|
| 19 |
+
"myeloid": ["Elane", "Mpo", "Prtn3", "Ctsg", "Cebpe", "Wfdc17", "Mmp8", "Ctss"],
|
| 20 |
+
"basophil-mast": ["Cpa3", "Ms4a2", "Gata2", "Mcpt8", "Hdc"],
|
| 21 |
+
"lymphoid": ["Il7r", "Rag1", "Dntt", "Vpreb1"],
|
| 22 |
+
"Kit-signaling": ["Kit", "Kitl", "Sox4"],
|
| 23 |
+
"MYC-targets": ["Myc", "Nolc1", "Nop58"],
|
| 24 |
+
"ISR": ["Atf4", "Ddit3", "Ppp1r15a"],
|
| 25 |
+
"Apoptosis-pro": ["Bax", "Bak1", "Bid"],
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def load_dahlin():
|
| 30 |
+
from pathlib import Path as _P
|
| 31 |
+
D_DIR = _P("/home/bcheng/PRISM/data/corpus/hematopoiesis/held_out_unlabeled/dahlin_extract")
|
| 32 |
+
GT = {"SIGAB1":"WT","SIGAC1":"WT","SIGAD1":"WT","SIGAF1":"WT","SIGAG1":"WT",
|
| 33 |
+
"SIGAH1":"WT","SIGAG8":"Kit_W41","SIGAH8":"Kit_W41"}
|
| 34 |
+
parts = []
|
| 35 |
+
for f in sorted(D_DIR.glob("*.txt.gz")):
|
| 36 |
+
sample = f.name.split("_")[1].split(".")[0]
|
| 37 |
+
df = pd.read_csv(f, sep="\t", compression="gzip", index_col=0)
|
| 38 |
+
X = sp.csr_matrix(df.values.T.astype(np.float32))
|
| 39 |
+
obs = pd.DataFrame(index=[f"{sample}_{bc}" for bc in df.columns.astype(str)])
|
| 40 |
+
obs["sample"] = sample; obs["genotype"] = GT.get(sample, "unknown")
|
| 41 |
+
var = pd.DataFrame(index=df.index.astype(str))
|
| 42 |
+
parts.append(ad.AnnData(X=X, obs=obs, var=var))
|
| 43 |
+
a = ad.concat(parts, join="outer", label="_batch")
|
| 44 |
+
import mygene
|
| 45 |
+
mg = mygene.MyGeneInfo()
|
| 46 |
+
res = mg.querymany(a.var_names.astype(str).tolist(), scopes="ensembl.gene",
|
| 47 |
+
fields="symbol", species="mouse", verbose=False)
|
| 48 |
+
id2sym = {r["query"]: r["symbol"] for r in res if "symbol" in r}
|
| 49 |
+
syms = pd.Series(a.var_names.astype(str)).map(id2sym).values
|
| 50 |
+
keep = pd.notna(syms)
|
| 51 |
+
a = a[:, keep].copy(); a.var_names = syms[keep]; a.var_names_make_unique()
|
| 52 |
+
return a
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def project_dahlin(a):
|
| 56 |
+
ck = torch.load(ROOT / "checkpoints/hematopoiesis/marker/panda_final.pt",
|
| 57 |
+
map_location=DEVICE, weights_only=False)
|
| 58 |
+
classes = ck["classes"]; marker_genes = ck["marker_genes"]
|
| 59 |
+
stats = np.load(ROOT / "data/corpus/hematopoiesis/harmonized/corpus_stats.npz", allow_pickle=True)
|
| 60 |
+
pca = pickle.load(open(ROOT / "data/corpus/hematopoiesis/harmonized/pca_basis.pkl", "rb"))
|
| 61 |
+
hvgs = [str(g) for g in stats["shared_hvgs"]]
|
| 62 |
+
|
| 63 |
+
hvg2i = {g: i for i, g in enumerate(hvgs)}
|
| 64 |
+
common = [g for g in a.var_names.astype(str) if g in hvg2i]
|
| 65 |
+
a_c = a[:, common].copy()
|
| 66 |
+
sc.pp.normalize_total(a_c, target_sum=1e4); sc.pp.log1p(a_c)
|
| 67 |
+
X = a_c.X.toarray().astype(np.float32) if sp.issparse(a_c.X) else a_c.X.astype(np.float32)
|
| 68 |
+
Xf = np.zeros((a.n_obs, len(hvgs)), dtype=np.float32)
|
| 69 |
+
Xf[:, np.array([hvg2i[g] for g in common])] = X
|
| 70 |
+
Xz = np.clip((Xf - stats["mean"].astype(np.float32)) / stats["std"].astype(np.float32), -10, 10)
|
| 71 |
+
Xpca = pca.transform(Xz).astype(np.float32)
|
| 72 |
+
|
| 73 |
+
mvals = np.zeros((a.n_obs, len(marker_genes)), dtype=np.float32)
|
| 74 |
+
for j, g in enumerate(marker_genes):
|
| 75 |
+
if g in a.var_names:
|
| 76 |
+
col = a[:, g].X
|
| 77 |
+
if sp.issparse(col): col = col.toarray()
|
| 78 |
+
mvals[:, j] = col.flatten().astype(np.float32)
|
| 79 |
+
mmu = mvals.mean(axis=0, keepdims=True); msig = mvals.std(axis=0, keepdims=True) + 1e-6
|
| 80 |
+
Xmark = np.clip((mvals - mmu) / msig, -5, 5).astype(np.float32)
|
| 81 |
+
|
| 82 |
+
model = PANDAEncoder(variant="marker", n_pca=50, n_markers=len(marker_genes),
|
| 83 |
+
n_classes=len(classes), n_sub=3, n_datasets=len(ck["datasets"])).to(DEVICE).eval()
|
| 84 |
+
model.load_state_dict(ck["model"])
|
| 85 |
+
|
| 86 |
+
preds = []
|
| 87 |
+
with torch.no_grad():
|
| 88 |
+
for i in range(0, a.n_obs, 4096):
|
| 89 |
+
xb = torch.from_numpy(Xpca[i:i+4096]).to(DEVICE)
|
| 90 |
+
xmb = torch.from_numpy(Xmark[i:i+4096]).to(DEVICE)
|
| 91 |
+
aux = torch.zeros(len(xb), 2, device=DEVICE)
|
| 92 |
+
out = model(xb, aux, x_markers=xmb, lam_dann=0.0)
|
| 93 |
+
mc = model.max_sub_cos(out["z"])
|
| 94 |
+
preds.append(mc.argmax(dim=1).cpu().numpy())
|
| 95 |
+
preds = np.concatenate(preds)
|
| 96 |
+
return np.array([classes[i] for i in preds])
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
print("[load] Dahlin + predict", flush=True)
|
| 100 |
+
raw = load_dahlin()
|
| 101 |
+
raw.obs["pred_label"] = pd.Categorical(project_dahlin(raw))
|
| 102 |
+
print(f"[align] {raw.n_obs} cells across {raw.obs['pred_label'].nunique()} classes", flush=True)
|
| 103 |
+
|
| 104 |
+
counts_s = raw.obs["pred_label"].value_counts()
|
| 105 |
+
keep_cls = counts_s[counts_s >= 50].index.tolist()
|
| 106 |
+
raw = raw[raw.obs["pred_label"].isin(keep_cls)].copy()
|
| 107 |
+
raw.obs["pred_label"] = raw.obs["pred_label"].astype(str).astype("category")
|
| 108 |
+
print(f"[filter] {raw.n_obs} cells × {len(keep_cls)} classes", flush=True)
|
| 109 |
+
|
| 110 |
+
sc.pp.normalize_total(raw, target_sum=1e4); sc.pp.log1p(raw)
|
| 111 |
+
print("[wilcoxon] running...", flush=True)
|
| 112 |
+
sc.tl.rank_genes_groups(raw, groupby="pred_label", method="wilcoxon", n_genes=25, use_raw=False)
|
| 113 |
+
|
| 114 |
+
rows = []
|
| 115 |
+
for cls in raw.uns["rank_genes_groups"]["names"].dtype.names:
|
| 116 |
+
mask = raw.obs["pred_label"] == cls
|
| 117 |
+
if mask.sum() < 50: continue
|
| 118 |
+
genes = list(raw.uns["rank_genes_groups"]["names"][cls][:20])
|
| 119 |
+
pvals = [float(x) for x in raw.uns["rank_genes_groups"]["pvals_adj"][cls][:20]]
|
| 120 |
+
logfc = [float(x) for x in raw.uns["rank_genes_groups"]["logfoldchanges"][cls][:20]]
|
| 121 |
+
|
| 122 |
+
top_str = ",".join([f"{g}(LFC{lf:+.1f})" for g, lf in zip(genes[:10], logfc[:10])])
|
| 123 |
+
panel_hits = {}
|
| 124 |
+
for pname, plist in PANELS.items():
|
| 125 |
+
hits = [g for g in plist if g in genes[:20]]
|
| 126 |
+
panel_hits[pname] = f"{len(hits)}/{len(plist)}: {','.join(hits)}"
|
| 127 |
+
best_panel = max(panel_hits.items(),
|
| 128 |
+
key=lambda x: int(x[1].split("/")[0]) / (int(x[1].split(":")[0].split("/")[1]) + 1e-6))
|
| 129 |
+
|
| 130 |
+
gt = raw.obs["genotype"][mask].astype(str)
|
| 131 |
+
nwt = int((gt == "WT").sum()); nkit = int((gt == "Kit_W41").sum())
|
| 132 |
+
frac_wt = nwt / max(1, nwt + nkit)
|
| 133 |
+
|
| 134 |
+
rows.append({
|
| 135 |
+
"predicted_class": cls,
|
| 136 |
+
"n_cells": int(mask.sum()),
|
| 137 |
+
"top_wilcoxon_markers": top_str,
|
| 138 |
+
"min_p_adj_top5": min(pvals[:5], default=float("nan")),
|
| 139 |
+
"best_canonical_panel_match": best_panel[0],
|
| 140 |
+
"recovery": best_panel[1],
|
| 141 |
+
"n_WT": nwt,
|
| 142 |
+
"n_Kit_W41": nkit,
|
| 143 |
+
"frac_WT": frac_wt,
|
| 144 |
+
})
|
| 145 |
+
|
| 146 |
+
df = pd.DataFrame(rows).sort_values("n_cells", ascending=False)
|
| 147 |
+
df.to_csv(OUT / "92_dahlin_marker_deep_dive.csv", index=False)
|
| 148 |
+
print(f"[write] {OUT}/92_dahlin_marker_deep_dive.csv ({len(df)} classes)", flush=True)
|
| 149 |
+
print()
|
| 150 |
+
print(df[["predicted_class", "n_cells", "best_canonical_panel_match", "recovery", "frac_WT"]].to_string(index=False))
|
scripts/analysis/93_true_zero_shot_baron.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""true zero-shot on baron test-half (943 mouse islet cells held out of corpus) under pca+marker variants."""
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
import warnings, json, sys, pickle, numpy as np, pandas as pd, anndata as ad, scanpy as sc, scipy.sparse as sp, torch
|
| 4 |
+
warnings.filterwarnings("ignore"); sc.settings.verbosity = 0
|
| 5 |
+
sys.path.insert(0, "/home/bcheng/PRISM")
|
| 6 |
+
from panda import PANDAEncoder
|
| 7 |
+
from sklearn.metrics import accuracy_score, f1_score, classification_report
|
| 8 |
+
|
| 9 |
+
ROOT = Path("/home/bcheng/PRISM")
|
| 10 |
+
DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 11 |
+
BARON = ROOT / "data/corpus/pancreas/held_out_labeled/baron_GSE84133_mouse_test.h5ad"
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def infer(a, variant):
|
| 15 |
+
ck = torch.load(ROOT / f"checkpoints/pancreas/{variant}/panda_final.pt",
|
| 16 |
+
map_location=DEVICE, weights_only=False)
|
| 17 |
+
classes = ck["classes"]; marker_genes = ck.get("marker_genes", [])
|
| 18 |
+
stats = np.load(ROOT / "data/corpus/pancreas/harmonized/corpus_stats.npz", allow_pickle=True)
|
| 19 |
+
pca = pickle.load(open(ROOT / "data/corpus/pancreas/harmonized/pca_basis.pkl", "rb"))
|
| 20 |
+
hvgs = [str(g) for g in stats["shared_hvgs"]]
|
| 21 |
+
hvg2i = {g: i for i, g in enumerate(hvgs)}
|
| 22 |
+
common = [g for g in a.var_names.astype(str) if g in hvg2i]
|
| 23 |
+
a_c = a[:, common].copy()
|
| 24 |
+
sc.pp.normalize_total(a_c, target_sum=1e4); sc.pp.log1p(a_c)
|
| 25 |
+
X = a_c.X.toarray().astype(np.float32) if sp.issparse(a_c.X) else a_c.X.astype(np.float32)
|
| 26 |
+
Xf = np.zeros((a.n_obs, len(hvgs)), dtype=np.float32)
|
| 27 |
+
Xf[:, np.array([hvg2i[g] for g in common])] = X
|
| 28 |
+
Xz = np.clip((Xf - stats["mean"].astype(np.float32)) / stats["std"].astype(np.float32), -10, 10)
|
| 29 |
+
Xpca = pca.transform(Xz).astype(np.float32)
|
| 30 |
+
|
| 31 |
+
Xmark = None
|
| 32 |
+
if variant == "marker":
|
| 33 |
+
mvals = np.zeros((a.n_obs, len(marker_genes)), dtype=np.float32)
|
| 34 |
+
for j, g in enumerate(marker_genes):
|
| 35 |
+
if g in a.var_names:
|
| 36 |
+
col = a[:, g].X
|
| 37 |
+
if sp.issparse(col): col = col.toarray()
|
| 38 |
+
mvals[:, j] = col.flatten().astype(np.float32)
|
| 39 |
+
mmu = mvals.mean(axis=0, keepdims=True); msig = mvals.std(axis=0, keepdims=True) + 1e-6
|
| 40 |
+
Xmark = np.clip((mvals - mmu) / msig, -5, 5).astype(np.float32)
|
| 41 |
+
|
| 42 |
+
model = PANDAEncoder(variant=variant, n_pca=50,
|
| 43 |
+
n_markers=len(marker_genes) if variant == "marker" else 0,
|
| 44 |
+
n_classes=len(classes), n_sub=3,
|
| 45 |
+
n_datasets=len(ck["datasets"])).to(DEVICE).eval()
|
| 46 |
+
model.load_state_dict(ck["model"])
|
| 47 |
+
|
| 48 |
+
preds, probs = [], []
|
| 49 |
+
with torch.no_grad():
|
| 50 |
+
for i in range(0, a.n_obs, 4096):
|
| 51 |
+
xb = torch.from_numpy(Xpca[i:i+4096]).to(DEVICE)
|
| 52 |
+
xmb = torch.from_numpy(Xmark[i:i+4096]).to(DEVICE) if Xmark is not None else None
|
| 53 |
+
aux = torch.zeros(len(xb), 2, device=DEVICE)
|
| 54 |
+
out = model(xb, aux, x_markers=xmb, lam_dann=0.0)
|
| 55 |
+
mc = model.max_sub_cos(out["z"])
|
| 56 |
+
preds.append(mc.argmax(dim=1).cpu().numpy())
|
| 57 |
+
probs.append(torch.softmax(mc / 0.07, dim=1).cpu().numpy())
|
| 58 |
+
return np.array([classes[i] for i in np.concatenate(preds)]), np.concatenate(probs), classes
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def main():
|
| 62 |
+
print(f"[baron] loading {BARON}", flush=True)
|
| 63 |
+
a = ad.read_h5ad(BARON)
|
| 64 |
+
y_true = a.obs["canonical_label"].astype(str).values
|
| 65 |
+
print(f"[baron] {a.shape} true labels: {pd.Series(y_true).value_counts().to_dict()}", flush=True)
|
| 66 |
+
|
| 67 |
+
for variant in ("pca", "marker"):
|
| 68 |
+
print(f"\n=== {variant.upper()} ===", flush=True)
|
| 69 |
+
pred, probs, classes = infer(a, variant)
|
| 70 |
+
# eval only on cells whose true label is in our class vocabulary
|
| 71 |
+
mask = np.isin(y_true, classes)
|
| 72 |
+
acc = accuracy_score(y_true[mask], pred[mask])
|
| 73 |
+
f1 = f1_score(y_true[mask], pred[mask], average="macro", zero_division=0)
|
| 74 |
+
rep = classification_report(y_true[mask], pred[mask], zero_division=0, output_dict=True)
|
| 75 |
+
print(f"[eval-{variant}] n={mask.sum()} acc={acc:.4f} macro-f1={f1:.4f}", flush=True)
|
| 76 |
+
out = ROOT / f"discovery/pancreas/{variant}"
|
| 77 |
+
out.mkdir(parents=True, exist_ok=True)
|
| 78 |
+
(out / "93_baron_zero_shot.json").write_text(json.dumps({
|
| 79 |
+
"variant": variant, "n_cells": int(mask.sum()), "n_classes_eval": int(len(set(y_true[mask]))),
|
| 80 |
+
"acc": float(acc), "macro_f1": float(f1), "per_class": rep,
|
| 81 |
+
"predicted_dist": pd.Series(pred).value_counts().to_dict(),
|
| 82 |
+
"true_dist": pd.Series(y_true).value_counts().to_dict(),
|
| 83 |
+
}, indent=2, default=str))
|
| 84 |
+
pd.DataFrame({"cell_id": a.obs_names, "true_label": y_true, "pred_label": pred,
|
| 85 |
+
"max_cos": probs.max(axis=1)}).to_csv(
|
| 86 |
+
out / "93_baron_predictions.csv", index=False)
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
if __name__ == "__main__":
|
| 90 |
+
main()
|
scripts/analysis/94_true_zero_shot_nestorowa.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""true zero-shot on Nestorowa GSE81682 (1920 smart-seq2 FACS-labeled cells, held out of corpus) under pca+marker variants."""
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
import warnings, json, sys, pickle, numpy as np, pandas as pd, anndata as ad, scanpy as sc, scipy.sparse as sp, torch
|
| 4 |
+
warnings.filterwarnings("ignore"); sc.settings.verbosity = 0
|
| 5 |
+
sys.path.insert(0, "/home/bcheng/PRISM")
|
| 6 |
+
from panda import PANDAEncoder
|
| 7 |
+
from sklearn.metrics import accuracy_score, f1_score, classification_report
|
| 8 |
+
|
| 9 |
+
ROOT = Path("/home/bcheng/PRISM")
|
| 10 |
+
DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 11 |
+
NEST = ROOT / "data/raw/nestorowa_combined.h5ad"
|
| 12 |
+
|
| 13 |
+
# panda fine class -> nestorowa coarse FACS gate (LT-HSC vs HSPC)
|
| 14 |
+
COARSE = {
|
| 15 |
+
"LT-HSC": "LT-HSC",
|
| 16 |
+
"MPP": "HSPC",
|
| 17 |
+
"GMP": "HSPC",
|
| 18 |
+
"myeloid": "HSPC",
|
| 19 |
+
"erythroid": "HSPC",
|
| 20 |
+
"megakaryocyte":"HSPC",
|
| 21 |
+
"basophil-mast":"HSPC",
|
| 22 |
+
"lymphoid": "HSPC",
|
| 23 |
+
"unassigned": "HSPC",
|
| 24 |
+
"UNK": "HSPC",
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def infer(a, variant):
|
| 29 |
+
ck = torch.load(ROOT / f"checkpoints/hematopoiesis/{variant}/panda_final.pt",
|
| 30 |
+
map_location=DEVICE, weights_only=False)
|
| 31 |
+
classes = ck["classes"]; marker_genes = ck.get("marker_genes", [])
|
| 32 |
+
stats = np.load(ROOT / "data/corpus/hematopoiesis/harmonized/corpus_stats.npz", allow_pickle=True)
|
| 33 |
+
pca = pickle.load(open(ROOT / "data/corpus/hematopoiesis/harmonized/pca_basis.pkl", "rb"))
|
| 34 |
+
hvgs = [str(g) for g in stats["shared_hvgs"]]
|
| 35 |
+
hvg2i = {g: i for i, g in enumerate(hvgs)}
|
| 36 |
+
common = [g for g in a.var_names.astype(str) if g in hvg2i]
|
| 37 |
+
a_c = a[:, common].copy()
|
| 38 |
+
sc.pp.normalize_total(a_c, target_sum=1e4); sc.pp.log1p(a_c)
|
| 39 |
+
X = a_c.X.toarray().astype(np.float32) if sp.issparse(a_c.X) else a_c.X.astype(np.float32)
|
| 40 |
+
Xf = np.zeros((a.n_obs, len(hvgs)), dtype=np.float32)
|
| 41 |
+
Xf[:, np.array([hvg2i[g] for g in common])] = X
|
| 42 |
+
Xz = np.clip((Xf - stats["mean"].astype(np.float32)) / stats["std"].astype(np.float32), -10, 10)
|
| 43 |
+
Xpca = pca.transform(Xz).astype(np.float32)
|
| 44 |
+
|
| 45 |
+
Xmark = None
|
| 46 |
+
if variant == "marker":
|
| 47 |
+
mvals = np.zeros((a.n_obs, len(marker_genes)), dtype=np.float32)
|
| 48 |
+
for j, g in enumerate(marker_genes):
|
| 49 |
+
if g in a.var_names:
|
| 50 |
+
col = a[:, g].X
|
| 51 |
+
if sp.issparse(col): col = col.toarray()
|
| 52 |
+
mvals[:, j] = col.flatten().astype(np.float32)
|
| 53 |
+
mmu = mvals.mean(axis=0, keepdims=True); msig = mvals.std(axis=0, keepdims=True) + 1e-6
|
| 54 |
+
Xmark = np.clip((mvals - mmu) / msig, -5, 5).astype(np.float32)
|
| 55 |
+
|
| 56 |
+
model = PANDAEncoder(variant=variant, n_pca=50,
|
| 57 |
+
n_markers=len(marker_genes) if variant == "marker" else 0,
|
| 58 |
+
n_classes=len(classes), n_sub=3,
|
| 59 |
+
n_datasets=len(ck["datasets"])).to(DEVICE).eval()
|
| 60 |
+
model.load_state_dict(ck["model"])
|
| 61 |
+
|
| 62 |
+
preds, probs = [], []
|
| 63 |
+
with torch.no_grad():
|
| 64 |
+
for i in range(0, a.n_obs, 4096):
|
| 65 |
+
xb = torch.from_numpy(Xpca[i:i+4096]).to(DEVICE)
|
| 66 |
+
xmb = torch.from_numpy(Xmark[i:i+4096]).to(DEVICE) if Xmark is not None else None
|
| 67 |
+
aux = torch.zeros(len(xb), 2, device=DEVICE)
|
| 68 |
+
out = model(xb, aux, x_markers=xmb, lam_dann=0.0)
|
| 69 |
+
mc = model.max_sub_cos(out["z"])
|
| 70 |
+
preds.append(mc.argmax(dim=1).cpu().numpy())
|
| 71 |
+
probs.append(torch.softmax(mc / 0.07, dim=1).cpu().numpy())
|
| 72 |
+
return np.array([classes[i] for i in np.concatenate(preds)]), np.concatenate(probs), classes
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def main():
|
| 76 |
+
a = ad.read_h5ad(NEST)
|
| 77 |
+
print(f"[nest] {a.shape} facs gates: {a.obs['cell_type'].value_counts().to_dict()}", flush=True)
|
| 78 |
+
|
| 79 |
+
for variant in ("pca", "marker"):
|
| 80 |
+
print(f"\n=== {variant.upper()} ===", flush=True)
|
| 81 |
+
pred, probs, classes = infer(a, variant)
|
| 82 |
+
pred_coarse = np.array([COARSE.get(p, "HSPC") for p in pred])
|
| 83 |
+
y_true = a.obs["cell_type"].astype(str).values
|
| 84 |
+
mask = y_true != "unknown"
|
| 85 |
+
acc = accuracy_score(y_true[mask], pred_coarse[mask])
|
| 86 |
+
f1 = f1_score(y_true[mask], pred_coarse[mask], average="macro", zero_division=0)
|
| 87 |
+
rep = classification_report(y_true[mask], pred_coarse[mask], zero_division=0, output_dict=True)
|
| 88 |
+
print(f"[eval-{variant}] n_labeled={mask.sum()} coarse-acc={acc:.4f} macro-f1={f1:.4f}", flush=True)
|
| 89 |
+
fine_by_gate = pd.crosstab(a.obs["cell_type"].astype(str), pd.Series(pred))
|
| 90 |
+
print(fine_by_gate.to_string(), flush=True)
|
| 91 |
+
|
| 92 |
+
out = ROOT / f"discovery/hematopoiesis/{variant}"
|
| 93 |
+
out.mkdir(parents=True, exist_ok=True)
|
| 94 |
+
(out / "94_nestorowa_zero_shot.json").write_text(json.dumps({
|
| 95 |
+
"variant": variant, "n_cells_total": int(a.n_obs), "n_cells_labeled": int(mask.sum()),
|
| 96 |
+
"coarse_acc": float(acc), "coarse_f1": float(f1),
|
| 97 |
+
"coarse_per_class": rep,
|
| 98 |
+
"fine_by_gate": fine_by_gate.to_dict(),
|
| 99 |
+
"max_cos_p50": float(np.median(probs.max(axis=1))),
|
| 100 |
+
}, indent=2, default=str))
|
| 101 |
+
pd.DataFrame({"cell_id": a.obs_names, "facs_gate": y_true,
|
| 102 |
+
"pred_fine": pred, "pred_coarse": pred_coarse,
|
| 103 |
+
"max_cos": probs.max(axis=1)}).to_csv(out / "94_nestorowa_predictions.csv", index=False)
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
if __name__ == "__main__":
|
| 107 |
+
main()
|
scripts/analysis/95_adult_beta_validation.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""adult-beta canonical panel enrichment on Veres: mean log1p in adult-beta vs beta vs overall. reports vacuous if n_adult_beta=0."""
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
import warnings, json, numpy as np, pandas as pd, anndata as ad, scanpy as sc, scipy.sparse as sp
|
| 4 |
+
warnings.filterwarnings("ignore"); sc.settings.verbosity = 0
|
| 5 |
+
|
| 6 |
+
ROOT = Path("/home/bcheng/PRISM")
|
| 7 |
+
OUT = ROOT / "discovery/pancreas/marker"
|
| 8 |
+
OUT.mkdir(parents=True, exist_ok=True)
|
| 9 |
+
|
| 10 |
+
PANEL = ["MAFA", "UCN3", "IAPP", "INS", "SIX3", "MAFB", "MNX1", "NEUROD1"]
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def load_veres():
|
| 14 |
+
SHARON_DIR = ROOT / "data/corpus/pancreas/held_out_unlabeled/sharon_extract"
|
| 15 |
+
parts = []
|
| 16 |
+
for meta_file in sorted(SHARON_DIR.glob("*.cell_metadata.tsv.gz")):
|
| 17 |
+
counts_file = str(meta_file).replace("cell_metadata", "processed_counts")
|
| 18 |
+
if not Path(counts_file).exists(): continue
|
| 19 |
+
meta = pd.read_csv(meta_file, sep="\t", compression="gzip")
|
| 20 |
+
counts = pd.read_csv(counts_file, sep="\t", compression="gzip", index_col=0)
|
| 21 |
+
obs = meta.set_index("library.barcode")
|
| 22 |
+
obs = obs.loc[obs.index.intersection(counts.index)]
|
| 23 |
+
counts_al = counts.loc[obs.index]
|
| 24 |
+
X = sp.csr_matrix(counts_al.values.astype(np.float32))
|
| 25 |
+
a = ad.AnnData(X=X, obs=obs, var=pd.DataFrame(index=counts_al.columns))
|
| 26 |
+
a.var_names_make_unique()
|
| 27 |
+
parts.append(a)
|
| 28 |
+
return ad.concat(parts, join="outer")
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
print("[load] Veres + predictions", flush=True)
|
| 32 |
+
raw = load_veres()
|
| 33 |
+
pred_df = pd.read_csv(ROOT / "discovery/pancreas/marker/veres_predictions.csv")
|
| 34 |
+
pred_df["cell_id"] = pred_df["cell_id"].astype(str).str.replace(r"^veres_", "", regex=True)
|
| 35 |
+
common = raw.obs_names.intersection(pd.Index(pred_df["cell_id"].astype(str)))
|
| 36 |
+
raw = raw[list(common)].copy()
|
| 37 |
+
pred_map = dict(zip(pred_df["cell_id"].astype(str), pred_df["pred_label"]))
|
| 38 |
+
raw.obs["pred_label"] = pd.Categorical([pred_map.get(c, "unknown") for c in raw.obs_names])
|
| 39 |
+
print(f"[align] {raw.n_obs} cells", flush=True)
|
| 40 |
+
|
| 41 |
+
sc.pp.normalize_total(raw, target_sum=1e4); sc.pp.log1p(raw)
|
| 42 |
+
|
| 43 |
+
n_adult_beta = int((raw.obs["pred_label"] == "adult-beta").sum())
|
| 44 |
+
n_beta = int((raw.obs["pred_label"] == "beta").sum())
|
| 45 |
+
print(f"[counts] adult-beta={n_adult_beta} beta={n_beta} overall={raw.n_obs}", flush=True)
|
| 46 |
+
|
| 47 |
+
def mean_expr(mask, gene):
|
| 48 |
+
if gene not in raw.var_names or mask.sum() == 0:
|
| 49 |
+
return float("nan")
|
| 50 |
+
col = raw[mask, gene].X
|
| 51 |
+
if sp.issparse(col): col = col.toarray()
|
| 52 |
+
return float(col.mean())
|
| 53 |
+
|
| 54 |
+
mask_ab = (raw.obs["pred_label"] == "adult-beta").values
|
| 55 |
+
mask_b = (raw.obs["pred_label"] == "beta").values
|
| 56 |
+
|
| 57 |
+
result = {
|
| 58 |
+
"cluster": "adult-beta",
|
| 59 |
+
"n_adult_beta": n_adult_beta,
|
| 60 |
+
"n_beta": n_beta,
|
| 61 |
+
"n_overall": int(raw.n_obs),
|
| 62 |
+
"vacuous": n_adult_beta == 0,
|
| 63 |
+
"marker": {},
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
for g in PANEL:
|
| 67 |
+
ab = mean_expr(mask_ab, g)
|
| 68 |
+
b = mean_expr(mask_b, g)
|
| 69 |
+
ov = mean_expr(np.ones(raw.n_obs, dtype=bool), g)
|
| 70 |
+
enr = (ab / b) if (b and not np.isnan(b) and b > 0) else float("nan")
|
| 71 |
+
result["marker"][g] = {
|
| 72 |
+
"adult_beta_mean_log1p": None if np.isnan(ab) else round(ab, 4),
|
| 73 |
+
"beta_mean_log1p": None if np.isnan(b) else round(b, 4),
|
| 74 |
+
"overall_mean_log1p": None if np.isnan(ov) else round(ov, 4),
|
| 75 |
+
"enrichment_adult_beta_vs_beta": None if np.isnan(enr) else round(enr, 3),
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
if n_adult_beta == 0:
|
| 79 |
+
result["interpretation"] = (
|
| 80 |
+
"VACUOUS: current PANDA-Marker (Jul-23 checkpoint) predicts 0 adult-beta cells on Veres. "
|
| 81 |
+
"The 'beta' cluster (n={}) captures INS/IAPP/MAFB/ADCYAP1 signal instead; adult-vs-juvenile "
|
| 82 |
+
"distinction is not resolved on this dataset. Enrichment ratios below use 0/beta and are NaN."
|
| 83 |
+
).format(n_beta)
|
| 84 |
+
else:
|
| 85 |
+
result["interpretation"] = (
|
| 86 |
+
"adult-beta cluster (n={}) canonical panel enrichment vs beta cluster (n={})."
|
| 87 |
+
).format(n_adult_beta, n_beta)
|
| 88 |
+
|
| 89 |
+
with open(OUT / "95_adult_beta_validation.json", "w") as f:
|
| 90 |
+
json.dump(result, f, indent=2)
|
| 91 |
+
print(f"[write] {OUT}/95_adult_beta_validation.json", flush=True)
|
| 92 |
+
print(json.dumps(result, indent=2))
|
scripts/analysis/98_eden_posthoc_detection.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""post-hoc EDEN (S100a4+Tnc+Pdgfra+ derm10 per Dingwall 2024) detection on v3 pan-skin predictions; cKO vs WT dermal-fibro proportions."""
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
import warnings, json, sys, pickle, numpy as np, pandas as pd, anndata as ad, scanpy as sc, scipy.sparse as sp, torch, torch.nn.functional as F
|
| 4 |
+
warnings.filterwarnings("ignore"); sc.settings.verbosity = 0
|
| 5 |
+
sys.path.insert(0, "/home/bcheng/PRISM")
|
| 6 |
+
from panda import PANDAEncoder
|
| 7 |
+
|
| 8 |
+
ROOT = Path("/home/bcheng/PRISM")
|
| 9 |
+
DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 10 |
+
|
| 11 |
+
CKO_GSMS = {"GSM6833482", "GSM6833483"} # CORRECTED: 480/481 are rttaControl (WT), not cKO
|
| 12 |
+
WT_GSMS = {"GSM6833478", "GSM6833479", "GSM6833480", "GSM6833481"} # CORRECTED: 4 Cre-neg controls per GEO metadata
|
| 13 |
+
|
| 14 |
+
# Dingwall 2024 EDEN definition: cluster 20 top-2 markers + broad lineage
|
| 15 |
+
EDEN_CANONICAL_MARKERS = ["S100a4", "Tnc", "Pdgfra"]
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def predict(a, variant="marker"):
|
| 19 |
+
ck = torch.load(ROOT / f"checkpoints/pan_skin/{variant}/panda_final.pt",
|
| 20 |
+
map_location=DEVICE, weights_only=False)
|
| 21 |
+
classes = ck["classes"]; marker_genes = ck.get("marker_genes", [])
|
| 22 |
+
stats = np.load(ROOT / "data/corpus/pan_skin/harmonized/corpus_stats.npz", allow_pickle=True)
|
| 23 |
+
pca = pickle.load(open(ROOT / "data/corpus/pan_skin/harmonized/pca_basis.pkl", "rb"))
|
| 24 |
+
hvgs = [str(g) for g in stats["shared_hvgs"]]
|
| 25 |
+
hvg2i = {g: i for i, g in enumerate(hvgs)}
|
| 26 |
+
common = [g for g in a.var_names.astype(str) if g in hvg2i]
|
| 27 |
+
a_c = a[:, common].copy()
|
| 28 |
+
sc.pp.normalize_total(a_c, target_sum=1e4); sc.pp.log1p(a_c)
|
| 29 |
+
X = a_c.X.toarray().astype(np.float32) if sp.issparse(a_c.X) else a_c.X.astype(np.float32)
|
| 30 |
+
Xf = np.zeros((a.n_obs, len(hvgs)), dtype=np.float32)
|
| 31 |
+
Xf[:, np.array([hvg2i[g] for g in common])] = X
|
| 32 |
+
Xz = np.clip((Xf - stats["mean"].astype(np.float32)) / stats["std"].astype(np.float32), -10, 10)
|
| 33 |
+
Xpca = pca.transform(Xz).astype(np.float32)
|
| 34 |
+
Xmark = None
|
| 35 |
+
if variant == "marker":
|
| 36 |
+
mv = np.zeros((a.n_obs, len(marker_genes)), dtype=np.float32)
|
| 37 |
+
for j, g in enumerate(marker_genes):
|
| 38 |
+
if g in a.var_names:
|
| 39 |
+
col = a[:, g].X
|
| 40 |
+
if sp.issparse(col): col = col.toarray()
|
| 41 |
+
mv[:, j] = col.flatten().astype(np.float32)
|
| 42 |
+
mmu = mv.mean(axis=0, keepdims=True); msig = mv.std(axis=0, keepdims=True) + 1e-6
|
| 43 |
+
Xmark = np.clip((mv - mmu) / msig, -5, 5).astype(np.float32)
|
| 44 |
+
|
| 45 |
+
model = PANDAEncoder(variant=variant, n_pca=50,
|
| 46 |
+
n_markers=len(marker_genes) if variant == "marker" else 0,
|
| 47 |
+
n_classes=len(classes), n_sub=3,
|
| 48 |
+
n_datasets=len(ck["datasets"])).to(DEVICE).eval()
|
| 49 |
+
model.load_state_dict(ck["model"])
|
| 50 |
+
preds, probs = [], []
|
| 51 |
+
with torch.no_grad():
|
| 52 |
+
for i in range(0, a.n_obs, 4096):
|
| 53 |
+
xb = torch.from_numpy(Xpca[i:i+4096]).to(DEVICE)
|
| 54 |
+
xmb = torch.from_numpy(Xmark[i:i+4096]).to(DEVICE) if Xmark is not None else None
|
| 55 |
+
aux = torch.zeros(len(xb), 2, device=DEVICE)
|
| 56 |
+
out = model(xb, aux, x_markers=xmb, lam_dann=0.0)
|
| 57 |
+
mc = model.max_sub_cos(out["z"])
|
| 58 |
+
preds.append(mc.argmax(dim=1).cpu().numpy())
|
| 59 |
+
probs.append(F.softmax(mc / 0.07, dim=1).cpu().numpy())
|
| 60 |
+
return np.array([classes[i] for i in np.concatenate(preds)]), np.concatenate(probs)
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def score_eden_module(a):
|
| 64 |
+
sc.pp.normalize_total(a, target_sum=1e4); sc.pp.log1p(a)
|
| 65 |
+
present = [g for g in EDEN_CANONICAL_MARKERS if g in a.var_names]
|
| 66 |
+
if not present:
|
| 67 |
+
return np.zeros(a.n_obs), present
|
| 68 |
+
sc.tl.score_genes(a, gene_list=present, score_name="eden_score", use_raw=False)
|
| 69 |
+
return a.obs["eden_score"].values, present
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def main():
|
| 73 |
+
print("[eden] loading Dingwall raw counts", flush=True)
|
| 74 |
+
raw = ad.read_h5ad(ROOT / "data/raw/GSE220977_combined.h5ad")
|
| 75 |
+
genotype = np.where(raw.obs["sample"].astype(str).isin(list(CKO_GSMS)), "En1-cKO",
|
| 76 |
+
np.where(raw.obs["sample"].astype(str).isin(list(WT_GSMS)), "WT", "other"))
|
| 77 |
+
print(f"[eden] {raw.n_obs} cells, genotype dist: {pd.Series(genotype).value_counts().to_dict()}", flush=True)
|
| 78 |
+
|
| 79 |
+
print("[eden] running v3 PANDA-Marker prediction", flush=True)
|
| 80 |
+
pred, probs = predict(raw, variant="marker")
|
| 81 |
+
print(f"[eden] pred dist: {pd.Series(pred).value_counts().head().to_dict()}", flush=True)
|
| 82 |
+
|
| 83 |
+
dermal_mask = np.isin(pred, ["fibroblast-papillary", "fibroblast-reticular"])
|
| 84 |
+
print(f"[eden] dermal-fibroblast predictions: {dermal_mask.sum()} cells", flush=True)
|
| 85 |
+
|
| 86 |
+
print(f"[eden] scoring EDEN module ({EDEN_CANONICAL_MARKERS})", flush=True)
|
| 87 |
+
eden_score, present = score_eden_module(raw.copy())
|
| 88 |
+
print(f"[eden] markers present in Dingwall counts: {present}", flush=True)
|
| 89 |
+
|
| 90 |
+
dermal_ix = np.where(dermal_mask)[0]
|
| 91 |
+
dermal_scores = eden_score[dermal_ix]
|
| 92 |
+
thr_p95 = np.percentile(dermal_scores, 95)
|
| 93 |
+
thr_p90 = np.percentile(dermal_scores, 90)
|
| 94 |
+
eden_core_p95 = dermal_ix[dermal_scores >= thr_p95]
|
| 95 |
+
eden_core_p90 = dermal_ix[dermal_scores >= thr_p90]
|
| 96 |
+
|
| 97 |
+
print(f"\n[eden] EDEN core (S100a4+Tnc+Pdgfra top-5% among dermal fibroblasts):", flush=True)
|
| 98 |
+
print(f" p95 threshold: {thr_p95:.3f} n={len(eden_core_p95)}", flush=True)
|
| 99 |
+
print(f" p90 threshold: {thr_p90:.3f} n={len(eden_core_p90)}", flush=True)
|
| 100 |
+
|
| 101 |
+
for p, ix, thr in [(95, eden_core_p95, thr_p95), (90, eden_core_p90, thr_p90)]:
|
| 102 |
+
gt = genotype[ix]
|
| 103 |
+
labeled = gt != "other"
|
| 104 |
+
n_wt = int((gt[labeled] == "WT").sum()); n_ko = int((gt[labeled] == "En1-cKO").sum())
|
| 105 |
+
frac_wt = n_wt / max(1, n_wt + n_ko)
|
| 106 |
+
# dingwall paper: control=1.99% dermal, cKO=0.08% dermal → ~25x depletion in cluster 20
|
| 107 |
+
print(f" p{p}: WT={n_wt} cKO={n_ko} WT_frac={frac_wt:.3f} "
|
| 108 |
+
f"(paper says WT>cKO ~25x depletion in cluster 20)", flush=True)
|
| 109 |
+
|
| 110 |
+
out = ROOT / "discovery/pan_skin/marker"
|
| 111 |
+
out.mkdir(parents=True, exist_ok=True)
|
| 112 |
+
df = pd.DataFrame({
|
| 113 |
+
"cell_id": raw.obs_names,
|
| 114 |
+
"sample": raw.obs["sample"].astype(str).values,
|
| 115 |
+
"genotype": genotype,
|
| 116 |
+
"pred_label": pred,
|
| 117 |
+
"max_cos": probs.max(axis=1),
|
| 118 |
+
"eden_score": eden_score,
|
| 119 |
+
"is_dermal_fibro": dermal_mask,
|
| 120 |
+
"is_eden_core_p95": np.isin(np.arange(raw.n_obs), eden_core_p95),
|
| 121 |
+
"is_eden_core_p90": np.isin(np.arange(raw.n_obs), eden_core_p90),
|
| 122 |
+
})
|
| 123 |
+
df.to_csv(out / "98_eden_dingwall_predictions.csv", index=False)
|
| 124 |
+
|
| 125 |
+
summary = {
|
| 126 |
+
"system": "pan_skin",
|
| 127 |
+
"target": "Dingwall_GSE220977",
|
| 128 |
+
"eden_definition": {
|
| 129 |
+
"source": "Dingwall 2024 Dev Cell PMC10872420",
|
| 130 |
+
"markers_used_by_paper": ["S100a4", "Tnc", "Pdgfra"],
|
| 131 |
+
"cluster_id_paper": "cluster 20 / Derm10",
|
| 132 |
+
"paper_reported_size": {"WT_dermal_frac": 0.0199, "cKO_dermal_frac": 0.0008,
|
| 133 |
+
"WT_n_approx": 516, "cKO_n_approx": 21,
|
| 134 |
+
"depletion_ratio": 24.9},
|
| 135 |
+
},
|
| 136 |
+
"our_detection": {
|
| 137 |
+
"method": "S100a4+Tnc+Pdgfra module score on v3 dermal-fibroblast predictions, top-5% threshold",
|
| 138 |
+
"markers_present_in_dingwall_counts": present,
|
| 139 |
+
"n_dermal_fibroblast_cells": int(dermal_mask.sum()),
|
| 140 |
+
"n_eden_core_p95": int(len(eden_core_p95)),
|
| 141 |
+
"n_eden_core_p90": int(len(eden_core_p90)),
|
| 142 |
+
},
|
| 143 |
+
}
|
| 144 |
+
(out / "98_eden_summary.json").write_text(json.dumps(summary, indent=2, default=str))
|
| 145 |
+
print(f"\n[write] {out}/98_eden_*.{{csv,json}}", flush=True)
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
if __name__ == "__main__":
|
| 149 |
+
main()
|
scripts/analysis/99_en1_dual_role_analysis.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""En1 dual-role on Dingwall: test A local activator (En1+ > En1- Sweat_gland in WT); test B spatial repressor (cKO > WT Sweat_gland per class)."""
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
import warnings, json, sys, numpy as np, pandas as pd, anndata as ad, scanpy as sc, scipy.sparse as sp
|
| 4 |
+
from scipy.stats import mannwhitneyu
|
| 5 |
+
warnings.filterwarnings("ignore"); sc.settings.verbosity = 0
|
| 6 |
+
|
| 7 |
+
ROOT = Path("/home/bcheng/PRISM")
|
| 8 |
+
|
| 9 |
+
# Sweat_gland pathway module (from restored 57_pathway_analysis.py)
|
| 10 |
+
SWEAT_GLAND_GENES = ["Foxi3", "Foxa1", "En1", "Krt8", "Krt18", "Krt19",
|
| 11 |
+
"Muc5b", "Aqp5", "Cutl1"]
|
| 12 |
+
# for TEST A we EXCLUDE En1 from the module to avoid tautology
|
| 13 |
+
SWEAT_GLAND_MINUS_EN1 = [g for g in SWEAT_GLAND_GENES if g != "En1"]
|
| 14 |
+
|
| 15 |
+
CKO_GSMS = {"GSM6833482", "GSM6833483"} # CORRECTED: 480/481 are rttaControl (WT), not cKO
|
| 16 |
+
WT_GSMS = {"GSM6833478", "GSM6833479", "GSM6833480", "GSM6833481"} # CORRECTED: 4 Cre-neg controls per GEO metadata
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def main():
|
| 20 |
+
print("[en1] loading Dingwall raw + v3 marker predictions", flush=True)
|
| 21 |
+
raw = ad.read_h5ad(ROOT / "data/raw/GSE220977_combined.h5ad")
|
| 22 |
+
pred = pd.read_csv(ROOT / "discovery/pan_skin/marker/dingwall_predictions.csv")
|
| 23 |
+
pred_map = dict(zip(pred["cell_id"].astype(str), pred["pred_label"]))
|
| 24 |
+
labels = np.array([pred_map.get(c, "unknown") for c in raw.obs_names.astype(str)])
|
| 25 |
+
raw.obs["pred_label"] = pd.Categorical(labels)
|
| 26 |
+
raw.obs["genotype"] = np.where(raw.obs["sample"].astype(str).isin(list(CKO_GSMS)), "En1-cKO",
|
| 27 |
+
np.where(raw.obs["sample"].astype(str).isin(list(WT_GSMS)), "WT", "other"))
|
| 28 |
+
labeled = raw.obs["genotype"].isin(["WT", "En1-cKO"]).values
|
| 29 |
+
raw = raw[labeled].copy()
|
| 30 |
+
print(f"[en1] {raw.n_obs:,} labeled cells (WT + cKO); classes: {raw.obs['pred_label'].nunique()}", flush=True)
|
| 31 |
+
|
| 32 |
+
sc.pp.normalize_total(raw, target_sum=1e4); sc.pp.log1p(raw)
|
| 33 |
+
|
| 34 |
+
# En1 excluded from module to avoid tautology in Test A
|
| 35 |
+
sg_present = [g for g in SWEAT_GLAND_MINUS_EN1 if g in raw.var_names]
|
| 36 |
+
print(f"[en1] Sweat_gland (En1-excluded) genes present: {sg_present}", flush=True)
|
| 37 |
+
sc.tl.score_genes(raw, gene_list=sg_present, score_name="sg_module_minusEn1",
|
| 38 |
+
random_state=0, use_raw=False)
|
| 39 |
+
|
| 40 |
+
if "En1" in raw.var_names:
|
| 41 |
+
en1_col = raw[:, "En1"].X
|
| 42 |
+
en1_exp = en1_col.toarray().flatten() if sp.issparse(en1_col) else en1_col.flatten()
|
| 43 |
+
else:
|
| 44 |
+
print("[en1] WARNING: En1 not detected in var_names", flush=True)
|
| 45 |
+
en1_exp = np.zeros(raw.n_obs)
|
| 46 |
+
raw.obs["En1_expr"] = en1_exp
|
| 47 |
+
raw.obs["En1_detected"] = en1_exp > 0 # any detection after log-norm
|
| 48 |
+
|
| 49 |
+
# =======================================================================
|
| 50 |
+
# TEST A — LOCAL ACTIVATOR (En1+ > En1- within WT)
|
| 51 |
+
# =======================================================================
|
| 52 |
+
print("\n=== TEST A: LOCAL ACTIVATOR (En1+ vs En1- Sweat_gland in WT only) ===",
|
| 53 |
+
flush=True)
|
| 54 |
+
wt_mask = raw.obs["genotype"] == "WT"
|
| 55 |
+
wt = raw[wt_mask].copy()
|
| 56 |
+
print(f"[testA] {wt.n_obs} WT cells; En1+ = {int(wt.obs['En1_detected'].sum())}, "
|
| 57 |
+
f"En1- = {int((~wt.obs['En1_detected']).sum())}", flush=True)
|
| 58 |
+
en1pos_score = wt[wt.obs["En1_detected"]].obs["sg_module_minusEn1"].values
|
| 59 |
+
en1neg_score = wt[~wt.obs["En1_detected"]].obs["sg_module_minusEn1"].values
|
| 60 |
+
if len(en1pos_score) > 5 and len(en1neg_score) > 5:
|
| 61 |
+
U_A, p_A = mannwhitneyu(en1pos_score, en1neg_score, alternative="greater")
|
| 62 |
+
delta_A = float(en1pos_score.mean() - en1neg_score.mean())
|
| 63 |
+
print(f"[testA] En1+ mean = {en1pos_score.mean():.4f}, En1- mean = {en1neg_score.mean():.4f}",
|
| 64 |
+
flush=True)
|
| 65 |
+
print(f"[testA] delta = {delta_A:+.4f}, MannU-greater p = {p_A:.3e}", flush=True)
|
| 66 |
+
testA = {
|
| 67 |
+
"n_en1pos": int(len(en1pos_score)), "n_en1neg": int(len(en1neg_score)),
|
| 68 |
+
"mean_en1pos": float(en1pos_score.mean()), "mean_en1neg": float(en1neg_score.mean()),
|
| 69 |
+
"delta_activation": delta_A, "mannu_p_greater": float(p_A),
|
| 70 |
+
}
|
| 71 |
+
else:
|
| 72 |
+
print(f"[testA] insufficient cells; skipping", flush=True)
|
| 73 |
+
testA = {"skipped": True}
|
| 74 |
+
|
| 75 |
+
# =======================================================================
|
| 76 |
+
# TEST B — SPATIAL REPRESSOR (cKO > WT in non-eccrine classes)
|
| 77 |
+
# =======================================================================
|
| 78 |
+
print("\n=== TEST B: SPATIAL REPRESSOR (cKO > WT Sweat_gland score per class) ===",
|
| 79 |
+
flush=True)
|
| 80 |
+
rows = []
|
| 81 |
+
for cls in sorted(raw.obs["pred_label"].astype(str).unique()):
|
| 82 |
+
sub = raw[raw.obs["pred_label"].astype(str) == cls]
|
| 83 |
+
wt_cells = sub[sub.obs["genotype"] == "WT"]
|
| 84 |
+
cko_cells = sub[sub.obs["genotype"] == "En1-cKO"]
|
| 85 |
+
if wt_cells.n_obs < 10 or cko_cells.n_obs < 10:
|
| 86 |
+
continue
|
| 87 |
+
wt_scores = wt_cells.obs["sg_module_minusEn1"].values
|
| 88 |
+
cko_scores = cko_cells.obs["sg_module_minusEn1"].values
|
| 89 |
+
U, p_greater = mannwhitneyu(cko_scores, wt_scores, alternative="greater")
|
| 90 |
+
U, p_two = mannwhitneyu(cko_scores, wt_scores, alternative="two-sided")
|
| 91 |
+
delta = float(cko_scores.mean() - wt_scores.mean())
|
| 92 |
+
rows.append({
|
| 93 |
+
"predicted_class": cls,
|
| 94 |
+
"n_WT": int(wt_cells.n_obs),
|
| 95 |
+
"n_cKO": int(cko_cells.n_obs),
|
| 96 |
+
"wt_mean_sg_score": float(wt_scores.mean()),
|
| 97 |
+
"cko_mean_sg_score": float(cko_scores.mean()),
|
| 98 |
+
"delta_derepression": delta,
|
| 99 |
+
"mannu_p_greater_cKO_gt_WT": float(p_greater),
|
| 100 |
+
"mannu_p_two_sided": float(p_two),
|
| 101 |
+
"bonferroni_p_adj": float(min(1.0, p_greater * 20)), # crude adjustment
|
| 102 |
+
"interpretation": "derepressed_in_cKO" if delta > 0 and p_greater < 0.05
|
| 103 |
+
else "normal" if delta > 0 else "downregulated_in_cKO",
|
| 104 |
+
})
|
| 105 |
+
df = pd.DataFrame(rows).sort_values("delta_derepression", ascending=False)
|
| 106 |
+
print(df[["predicted_class", "n_WT", "n_cKO", "delta_derepression",
|
| 107 |
+
"mannu_p_greater_cKO_gt_WT", "interpretation"]].to_string(index=False),
|
| 108 |
+
flush=True)
|
| 109 |
+
|
| 110 |
+
out = ROOT / "discovery/pan_skin/marker"
|
| 111 |
+
out.mkdir(parents=True, exist_ok=True)
|
| 112 |
+
df.to_csv(out / "99_en1_dual_role.csv", index=False)
|
| 113 |
+
summary = {
|
| 114 |
+
"model": "Dingwall 2024 En1 dual role model",
|
| 115 |
+
"hypothesis": "En1 is BOTH a local activator (turns Sweat_gland ON in eccrine-competent cells) "
|
| 116 |
+
"AND a spatial repressor (prevents Sweat_gland from firing elsewhere)",
|
| 117 |
+
"test_A_local_activator": testA,
|
| 118 |
+
"test_B_spatial_repressor": {
|
| 119 |
+
"n_classes_tested": len(rows),
|
| 120 |
+
"n_classes_derepressed_p_lt_0.05": int((df["mannu_p_greater_cKO_gt_WT"] < 0.05).sum()),
|
| 121 |
+
"n_classes_derepressed_p_lt_0.001": int((df["mannu_p_greater_cKO_gt_WT"] < 0.001).sum()),
|
| 122 |
+
"top_derepressed": df.head(5)[["predicted_class", "delta_derepression",
|
| 123 |
+
"mannu_p_greater_cKO_gt_WT"]].to_dict("records"),
|
| 124 |
+
},
|
| 125 |
+
"module_definition": {
|
| 126 |
+
"name": "Sweat_gland (En1-excluded)",
|
| 127 |
+
"genes_used": sg_present,
|
| 128 |
+
"note": "En1 removed from the module to avoid tautology in Test A "
|
| 129 |
+
"(En1+ cells trivially score higher on a module containing En1)",
|
| 130 |
+
},
|
| 131 |
+
}
|
| 132 |
+
(out / "99_en1_dual_role_summary.json").write_text(json.dumps(summary, indent=2, default=str))
|
| 133 |
+
print(f"\n[write] {out}/99_en1_dual_role.{{csv,json}}", flush=True)
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
if __name__ == "__main__":
|
| 137 |
+
main()
|
scripts/analysis/README.md
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# scripts/analysis
|
| 2 |
+
|
| 3 |
+
downstream mechanistic + interpretability analyses on trained PANDA checkpoints.
|
| 4 |
+
runs after `scripts/common/train_panda.py` and `scripts/common/zero_shot.py`.
|
| 5 |
+
each script writes CSV/JSON/npy outputs into `discovery/` (or `discovery/{system}/...`)
|
| 6 |
+
and is safe to re-run.
|
| 7 |
+
|
| 8 |
+
| id | script | what it does |
|
| 9 |
+
|---:|---|---|
|
| 10 |
+
| 70 | `70_prototype_geometry.py` | intra + cross-system prototype cosine geometry |
|
| 11 |
+
| 72 | `72_emergent_axes.py` | within-class PCA of the 128-d projections |
|
| 12 |
+
| 73 | `73_novel_populations_dahlin.py` | same abstain-gate flow, on Dahlin |
|
| 13 |
+
| 80 | `80_prototype_gene_attribution.py` | integrated-gradient prototype-to-gene attribution |
|
| 14 |
+
| 81 | `81_counterfactual_knockouts.py` | per-gene KO delta on prototype cosine |
|
| 15 |
+
| 82 | `82_gene_coattribution_modules.py` | gene-gene co-attribution modules |
|
| 16 |
+
| 83 | `83_prototype_training_trajectory.py` | prototype drift + eff-dim across the 4-stage curriculum |
|
| 17 |
+
| 84 | `84_adversary_purification.py` | tests that dataset + depth adversaries are at chance |
|
| 18 |
+
| 85 | `85_hessian_gene_interactions.py` | second-order gene-gene Hessian per prototype |
|
| 19 |
+
| 90-92 | `9{0,1,2}_*_marker_deep_dive.py` | per-class Wilcoxon vs canonical panels, per target |
|
| 20 |
+
| 93-94 | `9{3,4}_true_zero_shot_*.py` | true zero-shot on fully held-out Baron + Nestorowa |
|
scripts/common/README.md
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# scripts/common
|
| 2 |
+
|
| 3 |
+
system-agnostic training + eval + inference. everything here takes a system name
|
| 4 |
+
(`pan_skin`, `hematopoiesis`, `pancreas`) as an argument and expects the corresponding
|
| 5 |
+
`data/corpus/{system}/harmonized/corpus.h5ad`.
|
| 6 |
+
|
| 7 |
+
| script | what it does |
|
| 8 |
+
|---|---|
|
| 9 |
+
| `train_panda.py` | train one PANDA variant (pca / marker) on one system |
|
| 10 |
+
| `cv_holdout.py` | 5-fold held-out CV (GroupKFold by dataset when available) |
|
| 11 |
+
| `zero_shot.py` | run PANDA checkpoints on Dingwall / Dahlin / Veres targets |
|
| 12 |
+
| `nestorowa_zero_shot.py` | labeled zero-shot on Nestorowa GSE81682 (HSC validation) |
|
| 13 |
+
| `extract_prototypes_for_analysis.py` | prototype extraction helper for downstream analysis scripts |
|
| 14 |
+
| `rerun_all_discovery.sh` | driver that re-runs the whole `scripts/analysis/` block end to end |
|
scripts/common/__init__.py
ADDED
|
File without changes
|
scripts/common/cv_holdout.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""held-out CV for panda. GroupKFold by dataset when possible, else StratifiedKFold."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
import argparse, sys, json, pickle, warnings, numpy as np, pandas as pd, torch, torch.nn.functional as F
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
import anndata as ad, scanpy as sc, scipy.sparse as sp, yaml
|
| 6 |
+
from sklearn.model_selection import GroupKFold, StratifiedKFold
|
| 7 |
+
from sklearn.metrics import accuracy_score, f1_score, roc_auc_score, classification_report
|
| 8 |
+
warnings.filterwarnings("ignore")
|
| 9 |
+
|
| 10 |
+
sys.path.insert(0, "/home/bcheng/PRISM")
|
| 11 |
+
from panda import (
|
| 12 |
+
PANDAEncoder, supcon_loss, vicreg_loss, hsic_biased,
|
| 13 |
+
subcenter_angular_infonce, prototype_repulsion,
|
| 14 |
+
)
|
| 15 |
+
from scripts.common.train_panda import prepare_batches, load_corpus, get_marker_gene_list
|
| 16 |
+
|
| 17 |
+
ROOT = Path("/home/bcheng/PRISM")
|
| 18 |
+
DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def train_one_fold(Xpca, Xmark, y, y_dset, log10cz, classes, variant,
|
| 22 |
+
train_idx, epochs=6, batch=256, lr=1e-3, seed=0):
|
| 23 |
+
n_classes = len(classes)
|
| 24 |
+
n_datasets = int(max(y_dset[train_idx].max() + 1, 1))
|
| 25 |
+
n_markers = Xmark.shape[1] if Xmark is not None else 0
|
| 26 |
+
|
| 27 |
+
torch.manual_seed(seed); np.random.seed(seed)
|
| 28 |
+
model = PANDAEncoder(
|
| 29 |
+
variant=variant, n_pca=50, n_markers=n_markers,
|
| 30 |
+
n_classes=n_classes, n_sub=3, n_datasets=n_datasets, dropout=0.2,
|
| 31 |
+
).to(DEVICE)
|
| 32 |
+
opt = torch.optim.AdamW(model.parameters(), lr=lr, weight_decay=1e-4)
|
| 33 |
+
|
| 34 |
+
train_n = len(train_idx)
|
| 35 |
+
rng = np.random.default_rng(seed)
|
| 36 |
+
Xt = Xpca[train_idx]; yt = y[train_idx]; ydt = y_dset[train_idx]; dt = log10cz[train_idx]
|
| 37 |
+
Xmt = Xmark[train_idx] if Xmark is not None else None
|
| 38 |
+
|
| 39 |
+
for epoch in range(epochs):
|
| 40 |
+
stage = 0 if epoch < 1 else 1 if epoch < 3 else 2 if epoch < 6 else 3
|
| 41 |
+
for g in opt.param_groups: g["lr"] = lr * (0.5 if epoch >= epochs - 1 else 1.0)
|
| 42 |
+
perm = rng.permutation(train_n)
|
| 43 |
+
for bstart in range(0, train_n, batch):
|
| 44 |
+
idx = perm[bstart:bstart+batch]
|
| 45 |
+
x = torch.from_numpy(Xt[idx]).to(DEVICE)
|
| 46 |
+
xm = torch.from_numpy(Xmt[idx]).to(DEVICE) if Xmt is not None else None
|
| 47 |
+
yy = torch.from_numpy(yt[idx]).to(DEVICE)
|
| 48 |
+
yd = torch.from_numpy(ydt[idx]).to(DEVICE)
|
| 49 |
+
dd = torch.from_numpy(dt[idx]).float().to(DEVICE).unsqueeze(1)
|
| 50 |
+
aux = torch.zeros(len(idx), 2, device=DEVICE)
|
| 51 |
+
lam = 0.1 if stage >= 2 else 0.0
|
| 52 |
+
out = model(x, aux, x_markers=xm, lam_dann=lam)
|
| 53 |
+
z = out["z"]
|
| 54 |
+
L = supcon_loss(z, yy, 0.1) + 1.0 * vicreg_loss(z) + 0.4 * F.cross_entropy(out["logits"], yy)
|
| 55 |
+
if stage >= 1:
|
| 56 |
+
L = L + 0.6 * subcenter_angular_infonce(z, yy, model.prototypes.detach().clone(),
|
| 57 |
+
margin=0.15, temperature=0.07)
|
| 58 |
+
if stage >= 2:
|
| 59 |
+
L = L + F.cross_entropy(out["dom"], yd) + 0.3 * F.mse_loss(out["depth"], dd) \
|
| 60 |
+
+ 0.05 * hsic_biased(out["repr"], dd)
|
| 61 |
+
if stage >= 3:
|
| 62 |
+
L = L + 0.5 * prototype_repulsion(model.prototypes.detach().clone())
|
| 63 |
+
opt.zero_grad(); L.backward(); opt.step()
|
| 64 |
+
if stage >= 1:
|
| 65 |
+
with torch.no_grad(): model.update_prototypes(z.detach(), yy)
|
| 66 |
+
return model
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def evaluate(model, Xpca, Xmark, y, val_idx, classes):
|
| 70 |
+
model.eval()
|
| 71 |
+
preds, probs = [], []
|
| 72 |
+
Xv = Xpca[val_idx]; Xmv = Xmark[val_idx] if Xmark is not None else None
|
| 73 |
+
with torch.no_grad():
|
| 74 |
+
for i in range(0, len(val_idx), 2048):
|
| 75 |
+
xb = torch.from_numpy(Xv[i:i+2048]).to(DEVICE)
|
| 76 |
+
xmb = torch.from_numpy(Xmv[i:i+2048]).to(DEVICE) if Xmv is not None else None
|
| 77 |
+
aux = torch.zeros(len(xb), 2, device=DEVICE)
|
| 78 |
+
out = model(xb, aux, x_markers=xmb, lam_dann=0.0)
|
| 79 |
+
z = out["z"]
|
| 80 |
+
mc = model.max_sub_cos(z) # (B, K)
|
| 81 |
+
preds.append(mc.argmax(dim=1).cpu().numpy())
|
| 82 |
+
probs.append(F.softmax(mc / 0.07, dim=1).cpu().numpy())
|
| 83 |
+
preds = np.concatenate(preds); probs = np.concatenate(probs)
|
| 84 |
+
yv = y[val_idx]
|
| 85 |
+
acc = accuracy_score(yv, preds)
|
| 86 |
+
f1 = f1_score(yv, preds, average="macro", zero_division=0)
|
| 87 |
+
# macro AUROC — nan if val has <2 classes
|
| 88 |
+
n_classes = len(classes)
|
| 89 |
+
try:
|
| 90 |
+
y_onehot = np.eye(n_classes)[yv]
|
| 91 |
+
auc = roc_auc_score(y_onehot, probs, average="macro", multi_class="ovr")
|
| 92 |
+
except Exception:
|
| 93 |
+
auc = float("nan")
|
| 94 |
+
rep = classification_report(yv, preds, labels=list(range(n_classes)),
|
| 95 |
+
target_names=classes, output_dict=True, zero_division=0)
|
| 96 |
+
return acc, f1, auc, rep
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def cv(system, variant, folds=5, epochs=6, split_mode="auto", seed=0):
|
| 100 |
+
a, hvgs, mu, sig, pca = load_corpus(system)
|
| 101 |
+
marker_genes = get_marker_gene_list(system) if variant == "marker" else []
|
| 102 |
+
Xpca, Xmark, y, classes, y_dset, dset_classes, log10cz = prepare_batches(
|
| 103 |
+
a, hvgs, mu, sig, pca, marker_genes, variant
|
| 104 |
+
)
|
| 105 |
+
print(f"[cv] {system}/{variant} n={a.n_obs} K={len(classes)} datasets={len(dset_classes)}", flush=True)
|
| 106 |
+
|
| 107 |
+
use_group = (split_mode == "group") or (split_mode == "auto" and len(dset_classes) >= folds)
|
| 108 |
+
if use_group:
|
| 109 |
+
splitter = GroupKFold(n_splits=folds)
|
| 110 |
+
splits = list(splitter.split(np.zeros(len(y)), y, y_dset))
|
| 111 |
+
print(f"[cv] GroupKFold by dataset ({len(dset_classes)} groups)", flush=True)
|
| 112 |
+
else:
|
| 113 |
+
splitter = StratifiedKFold(n_splits=folds, shuffle=True, random_state=seed)
|
| 114 |
+
splits = list(splitter.split(np.zeros(len(y)), y))
|
| 115 |
+
print(f"[cv] StratifiedKFold on labels ({split_mode}) seed={seed}", flush=True)
|
| 116 |
+
|
| 117 |
+
per_fold_acc, per_fold_f1, per_fold_auc = [], [], []
|
| 118 |
+
last_report = None
|
| 119 |
+
for fold, (tr, va) in enumerate(splits):
|
| 120 |
+
print(f"[fold {fold+1}/{folds}] train={len(tr)} val={len(va)}", flush=True)
|
| 121 |
+
model = train_one_fold(Xpca, Xmark, y, y_dset, log10cz, classes, variant, tr,
|
| 122 |
+
epochs=epochs, seed=seed * 100 + fold)
|
| 123 |
+
acc, f1, auc, rep = evaluate(model, Xpca, Xmark, y, va, classes)
|
| 124 |
+
per_fold_acc.append(acc); per_fold_f1.append(f1); per_fold_auc.append(auc)
|
| 125 |
+
last_report = rep
|
| 126 |
+
print(f"[fold {fold+1}] acc={acc:.4f} f1={f1:.4f} auc={auc:.4f}", flush=True)
|
| 127 |
+
|
| 128 |
+
result = {
|
| 129 |
+
"system": system, "variant": variant, "folds": folds, "epochs": epochs, "seed": seed,
|
| 130 |
+
"n_cells": int(a.n_obs), "n_classes": len(classes),
|
| 131 |
+
"per_fold_acc": per_fold_acc, "per_fold_f1": per_fold_f1, "per_fold_auc": per_fold_auc,
|
| 132 |
+
"mean_acc": float(np.mean(per_fold_acc)), "std_acc": float(np.std(per_fold_acc)),
|
| 133 |
+
"mean_f1": float(np.mean(per_fold_f1)), "std_f1": float(np.std(per_fold_f1)),
|
| 134 |
+
"mean_auc": float(np.nanmean(per_fold_auc)),
|
| 135 |
+
"std_auc": float(np.nanstd(per_fold_auc)),
|
| 136 |
+
"per_class_report": last_report,
|
| 137 |
+
}
|
| 138 |
+
out_dir = ROOT / f"discovery/{system}/{variant}"
|
| 139 |
+
out_dir.mkdir(parents=True, exist_ok=True)
|
| 140 |
+
suffix = f"_seed{seed}" if seed != 0 else ""
|
| 141 |
+
(out_dir / f"cv_{folds}fold{suffix}.json").write_text(json.dumps(result, indent=2, default=str))
|
| 142 |
+
print(f"\n[cv] mean acc={result['mean_acc']:.4f}±{result['std_acc']:.4f} "
|
| 143 |
+
f"f1={result['mean_f1']:.4f} auc={result['mean_auc']:.4f}", flush=True)
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
if __name__ == "__main__":
|
| 147 |
+
ap = argparse.ArgumentParser()
|
| 148 |
+
ap.add_argument("system", choices=["pan_skin", "hematopoiesis", "pancreas"])
|
| 149 |
+
ap.add_argument("--variant", choices=["pca", "marker"], required=True)
|
| 150 |
+
ap.add_argument("--folds", type=int, default=5)
|
| 151 |
+
ap.add_argument("--epochs", type=int, default=6)
|
| 152 |
+
ap.add_argument("--split", choices=["auto", "group", "stratified"], default="auto")
|
| 153 |
+
ap.add_argument("--seed", type=int, default=0)
|
| 154 |
+
args = ap.parse_args()
|
| 155 |
+
cv(args.system, args.variant, args.folds, args.epochs, args.split, args.seed)
|
scripts/common/extract_prototypes_for_analysis.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""pull prototypes.npy + label_encoding.json out of panda_final.pt for legacy analysis scripts."""
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
import json, numpy as np, torch
|
| 4 |
+
|
| 5 |
+
ROOT = Path("/home/bcheng/PRISM")
|
| 6 |
+
for sys in ["pan_skin", "hematopoiesis", "pancreas"]:
|
| 7 |
+
for variant in ["pca", "marker"]:
|
| 8 |
+
ckpt_dir = ROOT / f"checkpoints/{sys}/{variant}"
|
| 9 |
+
ck = torch.load(ckpt_dir / "panda_final.pt", map_location="cpu", weights_only=False)
|
| 10 |
+
# v2 stores prototypes as (K, K_sub, D); legacy scripts want (K, D)
|
| 11 |
+
protos = ck["prototypes"]
|
| 12 |
+
if protos.ndim == 3:
|
| 13 |
+
protos_mean = protos.mean(axis=1)
|
| 14 |
+
protos_mean = protos_mean / (np.linalg.norm(protos_mean, axis=1, keepdims=True) + 1e-8)
|
| 15 |
+
else:
|
| 16 |
+
protos_mean = protos
|
| 17 |
+
np.save(ckpt_dir / "prototypes.npy", protos_mean.astype(np.float32))
|
| 18 |
+
json.dump({"classes": ck["classes"], "datasets": ck["datasets"]},
|
| 19 |
+
open(ckpt_dir / "label_encoding.json", "w"), indent=2)
|
| 20 |
+
print(f"[{sys}/{variant}] K={len(ck['classes'])} sub_dim={protos.shape}")
|
| 21 |
+
|
| 22 |
+
# canonical symlinks at checkpoints/{system}/ (marker is canonical)
|
| 23 |
+
for sys in ["pan_skin", "hematopoiesis", "pancreas"]:
|
| 24 |
+
src_dir = ROOT / f"checkpoints/{sys}/marker"
|
| 25 |
+
dst_dir = ROOT / f"checkpoints/{sys}"
|
| 26 |
+
for name in ["prototypes.npy", "label_encoding.json", "panda_final.pt"]:
|
| 27 |
+
src = src_dir / name; dst = dst_dir / name
|
| 28 |
+
if dst.exists() or dst.is_symlink(): dst.unlink()
|
| 29 |
+
dst.symlink_to(src.resolve())
|
| 30 |
+
print(f"linked {sys}/ -> marker/")
|
| 31 |
+
print("DONE")
|
scripts/common/generate_missing_holdouts.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""materialise held-out h5ad slices for nestorowa (hsc) and sulic (skin) using anchor split rules."""
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
import warnings, numpy as np, pandas as pd, anndata as ad, scanpy as sc, scipy.sparse as sp
|
| 4 |
+
warnings.filterwarnings("ignore"); sc.settings.verbosity = 0
|
| 5 |
+
|
| 6 |
+
ROOT = Path("/home/bcheng/PRISM")
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def make_nestorowa_test():
|
| 10 |
+
src = ROOT / "data/raw/nestorowa_combined.h5ad"
|
| 11 |
+
print(f"[nestorowa] loading {src}", flush=True)
|
| 12 |
+
a = ad.read_h5ad(src)
|
| 13 |
+
# anchor split from scripts/hematopoiesis/09_retrain_with_nestorowa_anchor.py:
|
| 14 |
+
# 150 LT-HSC + 600 HSPC stratified (seed=0); rest is test.
|
| 15 |
+
rng = np.random.default_rng(0)
|
| 16 |
+
lt = np.where(a.obs["cell_type"].astype(str).values == "LT-HSC")[0]
|
| 17 |
+
hs = np.where(a.obs["cell_type"].astype(str).values == "HSPC")[0]
|
| 18 |
+
lt_anchor = rng.choice(lt, size=min(150, len(lt)), replace=False)
|
| 19 |
+
hs_anchor = rng.choice(hs, size=min(600, len(hs)), replace=False)
|
| 20 |
+
anchor_ix = np.concatenate([lt_anchor, hs_anchor])
|
| 21 |
+
test_ix = np.setdiff1d(np.arange(a.n_obs), anchor_ix)
|
| 22 |
+
a_test = a[test_ix].copy()
|
| 23 |
+
# coerce obs cols to string so h5ad write survives mixed dtypes
|
| 24 |
+
for c in list(a_test.obs.columns):
|
| 25 |
+
try: a_test.obs[c] = a_test.obs[c].astype(str)
|
| 26 |
+
except Exception: del a_test.obs[c]
|
| 27 |
+
out_dir = ROOT / "data/corpus/hematopoiesis/held_out_labeled"
|
| 28 |
+
out_dir.mkdir(parents=True, exist_ok=True)
|
| 29 |
+
out = out_dir / "nestorowa_GSE81682_test.h5ad"
|
| 30 |
+
a_test.write_h5ad(out)
|
| 31 |
+
print(f"[nestorowa] wrote {out} ({a_test.n_obs:,} cells; "
|
| 32 |
+
f"gate dist: {a_test.obs['cell_type'].value_counts().to_dict()})", flush=True)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def make_sulic_test():
|
| 36 |
+
corpus = ROOT / "data/corpus/pan_skin/harmonized/corpus.h5ad"
|
| 37 |
+
print(f"[sulic] loading {corpus} (legacy, has sulic + labels)", flush=True)
|
| 38 |
+
a = ad.read_h5ad(corpus)
|
| 39 |
+
a_sul = a[a.obs["dataset"] == "sulic_GSE212673"].copy()
|
| 40 |
+
print(f"[sulic] {a_sul.n_obs} sulic cells "
|
| 41 |
+
f"({a_sul.obs['canonical_label'].value_counts().to_dict()})", flush=True)
|
| 42 |
+
# anchor split from scripts/pan_skin/92_retrain_with_sulic_anchor.py:
|
| 43 |
+
# 300 HF-placode + 200 basal-IFE stratified (seed=0); rest (4183) is test.
|
| 44 |
+
rng = np.random.default_rng(0)
|
| 45 |
+
hf = np.where(a_sul.obs["canonical_label"].astype(str).values == "HF-placode")[0]
|
| 46 |
+
bi = np.where(a_sul.obs["canonical_label"].astype(str).values == "basal-IFE")[0]
|
| 47 |
+
hf_anchor = rng.choice(hf, size=min(300, len(hf)), replace=False)
|
| 48 |
+
bi_anchor = rng.choice(bi, size=min(200, len(bi)), replace=False)
|
| 49 |
+
anchor_ix = np.concatenate([hf_anchor, bi_anchor])
|
| 50 |
+
test_ix = np.setdiff1d(np.arange(a_sul.n_obs), anchor_ix)
|
| 51 |
+
a_test = a_sul[test_ix].copy()
|
| 52 |
+
for c in list(a_test.obs.columns):
|
| 53 |
+
try: a_test.obs[c] = a_test.obs[c].astype(str)
|
| 54 |
+
except Exception: del a_test.obs[c]
|
| 55 |
+
out_dir = ROOT / "data/corpus/pan_skin/held_out_labeled"
|
| 56 |
+
out_dir.mkdir(parents=True, exist_ok=True)
|
| 57 |
+
out = out_dir / "sulic_GSE212673_test.h5ad"
|
| 58 |
+
a_test.write_h5ad(out)
|
| 59 |
+
print(f"[sulic] wrote {out} ({a_test.n_obs:,} cells; "
|
| 60 |
+
f"labels: {a_test.obs['canonical_label'].value_counts().to_dict()})", flush=True)
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
if __name__ == "__main__":
|
| 64 |
+
make_nestorowa_test()
|
| 65 |
+
make_sulic_test()
|
scripts/common/nestorowa_zero_shot.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""labeled zero-shot on nestorowa GSE81682 (hsc validation target)."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
import sys, warnings, json, argparse, pickle, numpy as np, pandas as pd, anndata as ad, scanpy as sc, scipy.sparse as sp, torch
|
| 5 |
+
from sklearn.metrics import accuracy_score, f1_score, classification_report
|
| 6 |
+
warnings.filterwarnings("ignore")
|
| 7 |
+
|
| 8 |
+
sys.path.insert(0, "/home/bcheng/PRISM")
|
| 9 |
+
from panda import PANDAEncoder
|
| 10 |
+
|
| 11 |
+
ROOT = Path("/home/bcheng/PRISM")
|
| 12 |
+
DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def load_nestorowa():
|
| 16 |
+
"""load nestorowa GSE81682 htseq counts, map ENSMUSG -> symbol."""
|
| 17 |
+
counts_path = ROOT / "data/raw/GSE81682_HTSeq_counts.txt.gz"
|
| 18 |
+
if not counts_path.exists():
|
| 19 |
+
raise FileNotFoundError(counts_path)
|
| 20 |
+
df = pd.read_csv(counts_path, sep="\t", index_col=0)
|
| 21 |
+
print(f"[nestorowa] raw counts shape: {df.shape}", flush=True)
|
| 22 |
+
# rows=genes, cols=cells; transpose
|
| 23 |
+
if df.shape[0] > df.shape[1]:
|
| 24 |
+
df = df.T
|
| 25 |
+
a = ad.AnnData(X=sp.csr_matrix(df.values.astype(np.float32)),
|
| 26 |
+
obs=pd.DataFrame(index=df.index.astype(str)),
|
| 27 |
+
var=pd.DataFrame(index=df.columns.astype(str)))
|
| 28 |
+
if any(g.startswith("ENSMUSG") for g in a.var_names[:100]):
|
| 29 |
+
import mygene
|
| 30 |
+
mg = mygene.MyGeneInfo()
|
| 31 |
+
res = mg.querymany(a.var_names.astype(str).tolist(), scopes="ensembl.gene",
|
| 32 |
+
fields="symbol", species="mouse", verbose=False)
|
| 33 |
+
id2sym = {r["query"]: r["symbol"] for r in res if "symbol" in r}
|
| 34 |
+
syms = pd.Series(a.var_names.astype(str)).map(id2sym).values
|
| 35 |
+
keep = pd.notna(syms)
|
| 36 |
+
a = a[:, keep].copy(); a.var_names = syms[keep]; a.var_names_make_unique()
|
| 37 |
+
print(f"[nestorowa] {a.shape} after gene symbol conversion", flush=True)
|
| 38 |
+
return a
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def score_hsc_labels(a):
|
| 42 |
+
"""assign hsc labels by marker scoring, proxy for population_annotation."""
|
| 43 |
+
programs = {
|
| 44 |
+
"LT-HSC": ["Hlf", "Meis1", "Mecom", "Procr", "Fgd5", "Mllt3"],
|
| 45 |
+
"MPP": ["Cd48", "Flt3", "Cd34"],
|
| 46 |
+
"LMPP": ["Flt3", "Irf8", "Satb1"],
|
| 47 |
+
"CMP": ["Cd34", "Mpo", "Gata2"],
|
| 48 |
+
"MEP": ["Gata1", "Klf1", "Itga2b"],
|
| 49 |
+
"GMP": ["Elane", "Mpo", "Prtn3", "Ctsg", "Cebpe"],
|
| 50 |
+
"erythroblast": ["Klf1", "Car1", "Car2", "Blvrb", "Hba-a1"],
|
| 51 |
+
"megakaryocyte":["Itga2b", "Pf4", "Gp1bb"],
|
| 52 |
+
"basophil-mast":["Cpa3", "Ms4a2", "Gata2"],
|
| 53 |
+
"CLP": ["Il7r", "Rag1", "Dntt"],
|
| 54 |
+
}
|
| 55 |
+
sc.pp.normalize_total(a, target_sum=1e4); sc.pp.log1p(a)
|
| 56 |
+
score_cols = []
|
| 57 |
+
for cls, gs in programs.items():
|
| 58 |
+
present = [g for g in gs if g in a.var_names]
|
| 59 |
+
if present:
|
| 60 |
+
sc.tl.score_genes(a, gene_list=present, score_name=f"s_{cls}", use_raw=False)
|
| 61 |
+
else:
|
| 62 |
+
a.obs[f"s_{cls}"] = 0.0
|
| 63 |
+
score_cols.append(f"s_{cls}")
|
| 64 |
+
scores = a.obs[score_cols].values
|
| 65 |
+
argmax = np.argmax(scores, axis=1)
|
| 66 |
+
labels = [c.replace("s_", "") for c in score_cols]
|
| 67 |
+
a.obs["approx_label"] = np.array(labels)[argmax]
|
| 68 |
+
a.obs["approx_conf"] = scores.max(axis=1)
|
| 69 |
+
return a
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def infer(a, variant):
|
| 73 |
+
ckpt = torch.load(ROOT / f"checkpoints/hematopoiesis/{variant}/panda_final.pt",
|
| 74 |
+
map_location=DEVICE, weights_only=False)
|
| 75 |
+
classes = ckpt["classes"]
|
| 76 |
+
marker_genes = ckpt.get("marker_genes", [])
|
| 77 |
+
|
| 78 |
+
stats = np.load(ROOT / "data/corpus/hematopoiesis/harmonized/corpus_stats.npz", allow_pickle=True)
|
| 79 |
+
pca = pickle.load(open(ROOT / "data/corpus/hematopoiesis/harmonized/pca_basis.pkl", "rb"))
|
| 80 |
+
hvgs = [str(g) for g in stats["shared_hvgs"]]
|
| 81 |
+
|
| 82 |
+
hvg2i = {g: i for i, g in enumerate(hvgs)}
|
| 83 |
+
common = [g for g in a.var_names.astype(str) if g in hvg2i]
|
| 84 |
+
a_c = a[:, common].copy()
|
| 85 |
+
# score_hsc_labels already log-normed; re-check in case it was skipped
|
| 86 |
+
if a_c.X.max() > 20:
|
| 87 |
+
sc.pp.normalize_total(a_c, target_sum=1e4); sc.pp.log1p(a_c)
|
| 88 |
+
X = a_c.X.toarray().astype(np.float32) if sp.issparse(a_c.X) else a_c.X.astype(np.float32)
|
| 89 |
+
Xf = np.zeros((a.n_obs, len(hvgs)), dtype=np.float32)
|
| 90 |
+
Xf[:, np.array([hvg2i[g] for g in common])] = X
|
| 91 |
+
Xz = np.clip((Xf - stats["mean"].astype(np.float32)) / stats["std"].astype(np.float32), -10, 10)
|
| 92 |
+
Xpca = pca.transform(Xz).astype(np.float32)
|
| 93 |
+
|
| 94 |
+
Xmark = None
|
| 95 |
+
if variant == "marker":
|
| 96 |
+
mvals = np.zeros((a.n_obs, len(marker_genes)), dtype=np.float32)
|
| 97 |
+
for j, g in enumerate(marker_genes):
|
| 98 |
+
if g in a.var_names:
|
| 99 |
+
col = a[:, g].X
|
| 100 |
+
if sp.issparse(col): col = col.toarray()
|
| 101 |
+
mvals[:, j] = col.flatten().astype(np.float32)
|
| 102 |
+
mmu = mvals.mean(axis=0, keepdims=True); msig = mvals.std(axis=0, keepdims=True) + 1e-6
|
| 103 |
+
Xmark = np.clip((mvals - mmu) / msig, -5, 5).astype(np.float32)
|
| 104 |
+
|
| 105 |
+
model = PANDAEncoder(variant=variant, n_pca=50,
|
| 106 |
+
n_markers=len(marker_genes) if variant == "marker" else 0,
|
| 107 |
+
n_classes=len(classes), n_sub=3, n_datasets=len(ckpt["datasets"])).to(DEVICE).eval()
|
| 108 |
+
model.load_state_dict(ckpt["model"])
|
| 109 |
+
preds, max_cos_list = [], []
|
| 110 |
+
with torch.no_grad():
|
| 111 |
+
for i in range(0, a.n_obs, 4096):
|
| 112 |
+
xb = torch.from_numpy(Xpca[i:i+4096]).to(DEVICE)
|
| 113 |
+
xmb = torch.from_numpy(Xmark[i:i+4096]).to(DEVICE) if Xmark is not None else None
|
| 114 |
+
aux = torch.zeros(len(xb), 2, device=DEVICE)
|
| 115 |
+
out = model(xb, aux, x_markers=xmb, lam_dann=0.0)
|
| 116 |
+
mc = model.max_sub_cos(out["z"])
|
| 117 |
+
preds.append(mc.argmax(dim=1).cpu().numpy())
|
| 118 |
+
max_cos_list.append(mc.max(dim=1).values.cpu().numpy())
|
| 119 |
+
return np.array([classes[i] for i in np.concatenate(preds)]), np.concatenate(max_cos_list), classes
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def main():
|
| 123 |
+
ap = argparse.ArgumentParser()
|
| 124 |
+
ap.add_argument("--variant", choices=["pca", "marker"], required=True)
|
| 125 |
+
args = ap.parse_args()
|
| 126 |
+
print(f"=== Nestorowa HSC zero-shot ({args.variant}) ===", flush=True)
|
| 127 |
+
a = load_nestorowa()
|
| 128 |
+
a = score_hsc_labels(a)
|
| 129 |
+
pred, max_cos, classes = infer(a, args.variant)
|
| 130 |
+
a.obs["pred_label"] = pred
|
| 131 |
+
a.obs["max_cos"] = max_cos
|
| 132 |
+
|
| 133 |
+
y_true = a.obs["approx_label"].values
|
| 134 |
+
y_pred = pred
|
| 135 |
+
# only eval on cells with approx-label confidence > 0.05
|
| 136 |
+
conf_mask = a.obs["approx_conf"] > 0.05
|
| 137 |
+
print(f"[eval] eval on {conf_mask.sum()}/{a.n_obs} cells with approx-label conf>0.05", flush=True)
|
| 138 |
+
if conf_mask.sum() > 20:
|
| 139 |
+
common_lbl = sorted(set(y_true[conf_mask]) & set(y_pred[conf_mask]))
|
| 140 |
+
mask2 = conf_mask & np.isin(y_true, common_lbl) & np.isin(y_pred, common_lbl)
|
| 141 |
+
acc = accuracy_score(y_true[mask2], y_pred[mask2])
|
| 142 |
+
f1 = f1_score(y_true[mask2], y_pred[mask2], average="macro", zero_division=0)
|
| 143 |
+
rep = classification_report(y_true[mask2], y_pred[mask2], zero_division=0, output_dict=True)
|
| 144 |
+
else:
|
| 145 |
+
acc = f1 = float("nan"); rep = {}
|
| 146 |
+
result = {
|
| 147 |
+
"variant": args.variant, "n_cells": int(a.n_obs), "n_classes": len(classes),
|
| 148 |
+
"predicted_dist": pd.Series(pred).value_counts().to_dict(),
|
| 149 |
+
"approx_label_dist": pd.Series(y_true).value_counts().to_dict(),
|
| 150 |
+
"eval_acc_vs_approx": float(acc), "eval_f1_vs_approx": float(f1),
|
| 151 |
+
"max_cos_median": float(np.median(max_cos)),
|
| 152 |
+
"n_low_conf_abstain": int((max_cos < 0.5).sum()),
|
| 153 |
+
"per_class_report": rep,
|
| 154 |
+
}
|
| 155 |
+
out_dir = ROOT / f"discovery/hematopoiesis/{args.variant}"
|
| 156 |
+
out_dir.mkdir(parents=True, exist_ok=True)
|
| 157 |
+
(out_dir / "nestorowa_summary.json").write_text(json.dumps(result, indent=2, default=str))
|
| 158 |
+
pd.DataFrame({
|
| 159 |
+
"cell_id": a.obs_names,
|
| 160 |
+
"pred_label": pred, "approx_label": y_true, "approx_conf": a.obs["approx_conf"].values,
|
| 161 |
+
"max_cos": max_cos,
|
| 162 |
+
}).to_csv(out_dir / "nestorowa_predictions.csv", index=False)
|
| 163 |
+
print(f"[write] {out_dir}/nestorowa_*", flush=True)
|
| 164 |
+
print(f"acc_vs_approx={acc:.4f} f1={f1:.4f}", flush=True)
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
if __name__ == "__main__":
|
| 168 |
+
main()
|
scripts/common/phase_e_driver.sh
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/bash
|
| 2 |
+
# Phase E: after all 6 v3 retrains complete, run the full downstream chain.
|
| 3 |
+
set -e
|
| 4 |
+
|
| 5 |
+
cd /home/bcheng/PRISM
|
| 6 |
+
export LD_LIBRARY_PATH=$(python -c "import site,os; print(os.path.join(site.getsitepackages()[0],'nvidia','cusparselt','lib'))"):/home/bcheng/.conda/pkgs/libstdcxx-15.2.0-h39759b7_7/lib/:${LD_LIBRARY_PATH:-}
|
| 7 |
+
|
| 8 |
+
echo "=== phase E step 1: zero-shot predictions on Dingwall/Dahlin/Veres with v3 models ==="
|
| 9 |
+
# skin -> Dingwall
|
| 10 |
+
python scripts/pan_skin/30_zero_shot_aldrich.py --system_v v3 2>&1 | tail -5 || echo "[warn] aldrich script may need path fix"
|
| 11 |
+
|
| 12 |
+
echo "=== phase E step 2: post-hoc EDEN detection using Dingwall paper markers ==="
|
| 13 |
+
python scripts/analysis/98_eden_posthoc_detection.py 2>&1 | tail -10
|
| 14 |
+
|
| 15 |
+
echo "=== phase E step 2b: En1 spatial-repressor + local-activator dual-role analysis ==="
|
| 16 |
+
python scripts/analysis/99_en1_dual_role_analysis.py 2>&1 | tail -20
|
| 17 |
+
|
| 18 |
+
echo "=== phase E step 3: expanded pathway analysis (91 modules, 3 systems) ==="
|
| 19 |
+
for sys in pan_skin hematopoiesis pancreas; do
|
| 20 |
+
echo "--- $sys ---"
|
| 21 |
+
python scripts/analysis/57_pathway_analysis.py --system $sys 2>&1 | tail -5
|
| 22 |
+
done
|
| 23 |
+
|
| 24 |
+
echo "=== phase E step 4: regenerate UMAPs with distinct-hue palette + v3 predictions ==="
|
| 25 |
+
python scripts/figures/build_pca_vs_marker_umaps.py 2>&1 | tail -20
|
| 26 |
+
|
| 27 |
+
echo "=== phase E step 5: regenerate supplement PDF ==="
|
| 28 |
+
python scripts/figures/build_figure_supplement.py 2>&1 | tail -5
|
| 29 |
+
|
| 30 |
+
echo "=== phase E step 6: rebuild paper PDF ==="
|
| 31 |
+
TEXMFHOME=/home/bcheng/.texlive/texmf-dist PATH=/home/bcheng/.local/texlive/2023/bin/x86_64-linux:$PATH pdflatex -interaction=nonstopmode PAPER.tex > /tmp/latex.log 2>&1
|
| 32 |
+
TEXMFHOME=/home/bcheng/.texlive/texmf-dist PATH=/home/bcheng/.local/texlive/2023/bin/x86_64-linux:$PATH pdflatex -interaction=nonstopmode PAPER.tex > /tmp/latex.log 2>&1
|
| 33 |
+
rm -f PAPER.aux PAPER.log PAPER.out
|
| 34 |
+
|
| 35 |
+
echo "=== phase E DONE ==="
|
scripts/common/rerun_all_discovery.sh
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
# Phase C: rerun all mechanistic discovery on new checkpoints.
|
| 3 |
+
# Runs analysis scripts 70-85 for each system with both variants where applicable.
|
| 4 |
+
set -euo pipefail
|
| 5 |
+
cd /home/bcheng/PRISM
|
| 6 |
+
|
| 7 |
+
LDLIBS=$(python -c "import site,os; print(os.path.join(site.getsitepackages()[0],'nvidia','cusparselt','lib'))" 2>/dev/null || echo "")
|
| 8 |
+
|
| 9 |
+
echo "=== Phase C: mechanistic discovery reruns ==="
|
| 10 |
+
|
| 11 |
+
# All rely on the panda import + checkpoints/ + data/corpus/*/harmonized/
|
| 12 |
+
run() {
|
| 13 |
+
local name="$1"; shift
|
| 14 |
+
LD_LIBRARY_PATH="$LDLIBS" nohup python -u "$@" > "logs/${name}.log" 2>&1 &
|
| 15 |
+
disown
|
| 16 |
+
echo "launched $name"
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
# 70 — prototype geometry (fast, no GPU)
|
| 20 |
+
run rerun_70_proto python scripts/analysis/70_prototype_geometry.py
|
| 21 |
+
|
| 22 |
+
# 80 — prototype-gene attribution (both systems, both variants)
|
| 23 |
+
CUDA_VISIBLE_DEVICES=2 run rerun_80_attr_marker python scripts/analysis/80_prototype_gene_attribution.py
|
| 24 |
+
|
| 25 |
+
# 82 — co-attribution modules
|
| 26 |
+
run rerun_82_coatt python scripts/analysis/82_gene_coattribution_modules.py
|
| 27 |
+
|
| 28 |
+
# 83 — training trajectory
|
| 29 |
+
run rerun_83_traj python scripts/analysis/83_prototype_training_trajectory.py
|
| 30 |
+
|
| 31 |
+
echo "kick off complete — check logs/rerun_*.log"
|
scripts/common/run_all_retrains.sh
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/bash
|
| 2 |
+
# retrain all 6 panda models (3 systems x 2 variants), sequentially to fit GPU
|
| 3 |
+
set -e
|
| 4 |
+
|
| 5 |
+
cd /home/bcheng/PRISM
|
| 6 |
+
export LD_LIBRARY_PATH=$(python -c "import site,os; print(os.path.join(site.getsitepackages()[0],'nvidia','cusparselt','lib'))"):/home/bcheng/.conda/pkgs/libstdcxx-15.2.0-h39759b7_7/lib/:${LD_LIBRARY_PATH:-}
|
| 7 |
+
|
| 8 |
+
echo "=== SKIN PCA ==="
|
| 9 |
+
python -m scripts.common.train_panda pan_skin --variant pca --epochs 8
|
| 10 |
+
|
| 11 |
+
echo "=== SKIN MARKER ==="
|
| 12 |
+
python -m scripts.common.train_panda pan_skin --variant marker --epochs 8
|
| 13 |
+
|
| 14 |
+
echo "=== HSC PCA ==="
|
| 15 |
+
python -m scripts.common.train_panda hematopoiesis --variant pca --epochs 8
|
| 16 |
+
|
| 17 |
+
echo "=== HSC MARKER ==="
|
| 18 |
+
python -m scripts.common.train_panda hematopoiesis --variant marker --epochs 8
|
| 19 |
+
|
| 20 |
+
echo "=== PANCREAS PCA ==="
|
| 21 |
+
python -m scripts.common.train_panda pancreas --variant pca --epochs 8
|
| 22 |
+
|
| 23 |
+
echo "=== PANCREAS MARKER ==="
|
| 24 |
+
python -m scripts.common.train_panda pancreas --variant marker --epochs 8
|
| 25 |
+
|
| 26 |
+
echo "=== ALL DONE ==="
|
scripts/common/run_all_zero_shot.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""zero-shot inference over every held-out target x (pca, marker) checkpoint."""
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
import warnings, json, sys, pickle, argparse, numpy as np, pandas as pd, anndata as ad, scanpy as sc, scipy.sparse as sp, torch, torch.nn.functional as F
|
| 4 |
+
warnings.filterwarnings("ignore"); sc.settings.verbosity = 0
|
| 5 |
+
sys.path.insert(0, "/home/bcheng/PRISM")
|
| 6 |
+
from panda import PANDAEncoder
|
| 7 |
+
from sklearn.metrics import accuracy_score, f1_score, classification_report
|
| 8 |
+
|
| 9 |
+
ROOT = Path("/home/bcheng/PRISM")
|
| 10 |
+
DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def infer(a, system, variant):
|
| 14 |
+
ck = torch.load(ROOT / f"checkpoints/{system}/{variant}/panda_final.pt",
|
| 15 |
+
map_location=DEVICE, weights_only=False)
|
| 16 |
+
classes = ck["classes"]; marker_genes = ck.get("marker_genes", [])
|
| 17 |
+
stats = np.load(ROOT / f"data/corpus/{system}/harmonized/corpus_stats.npz", allow_pickle=True)
|
| 18 |
+
pca = pickle.load(open(ROOT / f"data/corpus/{system}/harmonized/pca_basis.pkl", "rb"))
|
| 19 |
+
hvgs = [str(g) for g in stats["shared_hvgs"]]
|
| 20 |
+
hvg2i = {g: i for i, g in enumerate(hvgs)}
|
| 21 |
+
common = [g for g in a.var_names.astype(str) if g in hvg2i]
|
| 22 |
+
a_c = a[:, common].copy()
|
| 23 |
+
sc.pp.normalize_total(a_c, target_sum=1e4); sc.pp.log1p(a_c)
|
| 24 |
+
X = a_c.X.toarray().astype(np.float32) if sp.issparse(a_c.X) else a_c.X.astype(np.float32)
|
| 25 |
+
Xf = np.zeros((a.n_obs, len(hvgs)), dtype=np.float32)
|
| 26 |
+
Xf[:, np.array([hvg2i[g] for g in common])] = X
|
| 27 |
+
Xz = np.clip((Xf - stats["mean"].astype(np.float32)) / stats["std"].astype(np.float32), -10, 10)
|
| 28 |
+
Xpca = pca.transform(Xz).astype(np.float32)
|
| 29 |
+
Xmark = None
|
| 30 |
+
if variant == "marker":
|
| 31 |
+
mv = np.zeros((a.n_obs, len(marker_genes)), dtype=np.float32)
|
| 32 |
+
for j, g in enumerate(marker_genes):
|
| 33 |
+
if g in a.var_names:
|
| 34 |
+
col = a[:, g].X
|
| 35 |
+
if sp.issparse(col): col = col.toarray()
|
| 36 |
+
mv[:, j] = col.flatten().astype(np.float32)
|
| 37 |
+
mmu = mv.mean(axis=0, keepdims=True); msig = mv.std(axis=0, keepdims=True) + 1e-6
|
| 38 |
+
Xmark = np.clip((mv - mmu) / msig, -5, 5).astype(np.float32)
|
| 39 |
+
model = PANDAEncoder(variant=variant, n_pca=50,
|
| 40 |
+
n_markers=len(marker_genes) if variant == "marker" else 0,
|
| 41 |
+
n_classes=len(classes), n_sub=3,
|
| 42 |
+
n_datasets=len(ck["datasets"])).to(DEVICE).eval()
|
| 43 |
+
model.load_state_dict(ck["model"])
|
| 44 |
+
preds, probs = [], []
|
| 45 |
+
with torch.no_grad():
|
| 46 |
+
for i in range(0, a.n_obs, 4096):
|
| 47 |
+
xb = torch.from_numpy(Xpca[i:i+4096]).to(DEVICE)
|
| 48 |
+
xmb = torch.from_numpy(Xmark[i:i+4096]).to(DEVICE) if Xmark is not None else None
|
| 49 |
+
aux = torch.zeros(len(xb), 2, device=DEVICE)
|
| 50 |
+
out = model(xb, aux, x_markers=xmb, lam_dann=0.0)
|
| 51 |
+
mc = model.max_sub_cos(out["z"])
|
| 52 |
+
preds.append(mc.argmax(dim=1).cpu().numpy())
|
| 53 |
+
probs.append(F.softmax(mc / 0.07, dim=1).cpu().numpy())
|
| 54 |
+
return np.array([classes[i] for i in np.concatenate(preds)]), np.concatenate(probs), classes
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
TARGETS = {
|
| 58 |
+
"pan_skin": [
|
| 59 |
+
("dingwall", ROOT / "data/raw/GSE220977_combined.h5ad", None),
|
| 60 |
+
("sulic", ROOT / "data/corpus/pan_skin/held_out_labeled/sulic_GSE212673_test.h5ad", "canonical_label"),
|
| 61 |
+
("belote", ROOT / "data/corpus/pan_skin/held_out_labeled/belote_GSE151091_test.h5ad", "canonical_label"),
|
| 62 |
+
],
|
| 63 |
+
"hematopoiesis": [
|
| 64 |
+
("nestorowa", ROOT / "data/corpus/hematopoiesis/held_out_labeled/nestorowa_GSE81682_test.h5ad", "cell_type"),
|
| 65 |
+
("dahlin", None, None), # loaded per-file via loader (61k cells across 8 samples)
|
| 66 |
+
],
|
| 67 |
+
"pancreas": [
|
| 68 |
+
("baron", ROOT / "data/corpus/pancreas/held_out_labeled/baron_GSE84133_mouse_test.h5ad", "canonical_label"),
|
| 69 |
+
("veres", ROOT / "data/corpus/pancreas/held_out_labeled/veres_GSE114412_test.h5ad", "canonical_label"),
|
| 70 |
+
],
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def load_dahlin():
|
| 75 |
+
"""dahlin 61k held-out unlabeled hsc target, 8 sample files."""
|
| 76 |
+
D = ROOT / "data/corpus/hematopoiesis/held_out_unlabeled/dahlin_extract"
|
| 77 |
+
GT = {"SIGAB1":"WT","SIGAC1":"WT","SIGAD1":"WT","SIGAF1":"WT","SIGAG1":"WT",
|
| 78 |
+
"SIGAH1":"WT","SIGAG8":"Kit_W41","SIGAH8":"Kit_W41"}
|
| 79 |
+
parts = []
|
| 80 |
+
for f in sorted(D.glob("*.txt.gz")):
|
| 81 |
+
sample = f.name.split("_")[1].split(".")[0]
|
| 82 |
+
df = pd.read_csv(f, sep="\t", compression="gzip", index_col=0)
|
| 83 |
+
X = sp.csr_matrix(df.values.T.astype(np.float32))
|
| 84 |
+
obs = pd.DataFrame(index=[f"{sample}_{bc}" for bc in df.columns.astype(str)])
|
| 85 |
+
obs["sample"] = sample; obs["genotype"] = GT.get(sample, "unknown")
|
| 86 |
+
var = pd.DataFrame(index=df.index.astype(str))
|
| 87 |
+
parts.append(ad.AnnData(X=X, obs=obs, var=var))
|
| 88 |
+
a = ad.concat(parts, join="outer")
|
| 89 |
+
import mygene
|
| 90 |
+
mg = mygene.MyGeneInfo()
|
| 91 |
+
res = mg.querymany(a.var_names.astype(str).tolist(), scopes="ensembl.gene",
|
| 92 |
+
fields="symbol", species="mouse", verbose=False)
|
| 93 |
+
id2sym = {r["query"]: r["symbol"] for r in res if "symbol" in r}
|
| 94 |
+
syms = pd.Series(a.var_names.astype(str)).map(id2sym).values
|
| 95 |
+
keep = pd.notna(syms)
|
| 96 |
+
a = a[:, keep].copy(); a.var_names = syms[keep]; a.var_names_make_unique()
|
| 97 |
+
return a
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def process(system, variant):
|
| 101 |
+
print(f"\n===== {system} / {variant} =====", flush=True)
|
| 102 |
+
for tgt_name, tgt_path, tgt_label in TARGETS[system]:
|
| 103 |
+
print(f"\n[{tgt_name}] loading", flush=True)
|
| 104 |
+
if tgt_name == "dahlin":
|
| 105 |
+
a = load_dahlin()
|
| 106 |
+
else:
|
| 107 |
+
a = ad.read_h5ad(tgt_path)
|
| 108 |
+
print(f"[{tgt_name}] {a.shape}", flush=True)
|
| 109 |
+
pred, probs, classes = infer(a, system, variant)
|
| 110 |
+
out_dir = ROOT / f"discovery/{system}/{variant}"
|
| 111 |
+
out_dir.mkdir(parents=True, exist_ok=True)
|
| 112 |
+
pd.DataFrame({
|
| 113 |
+
"cell_id": a.obs_names,
|
| 114 |
+
"pred_label": pred,
|
| 115 |
+
"max_cos": probs.max(axis=1),
|
| 116 |
+
}).to_csv(out_dir / f"{tgt_name}_predictions.csv", index=False)
|
| 117 |
+
summary = {
|
| 118 |
+
"system": system, "variant": variant, "target": tgt_name,
|
| 119 |
+
"n_cells": int(a.n_obs), "n_classes_model": len(classes),
|
| 120 |
+
"predicted_class_dist": pd.Series(pred).value_counts().head(30).to_dict(),
|
| 121 |
+
"max_cos_p50": float(np.median(probs.max(axis=1))),
|
| 122 |
+
"max_cos_p05": float(np.quantile(probs.max(axis=1), 0.05)),
|
| 123 |
+
}
|
| 124 |
+
if tgt_label and tgt_label in a.obs.columns:
|
| 125 |
+
y_true = a.obs[tgt_label].astype(str).values
|
| 126 |
+
mask = np.isin(y_true, classes)
|
| 127 |
+
if mask.sum() > 0:
|
| 128 |
+
acc = accuracy_score(y_true[mask], pred[mask])
|
| 129 |
+
f1 = f1_score(y_true[mask], pred[mask], average="macro", zero_division=0)
|
| 130 |
+
rep = classification_report(y_true[mask], pred[mask],
|
| 131 |
+
zero_division=0, output_dict=True)
|
| 132 |
+
summary["labeled_eval"] = {
|
| 133 |
+
"n_eval": int(mask.sum()), "acc": float(acc),
|
| 134 |
+
"macro_f1": float(f1), "per_class_report": rep,
|
| 135 |
+
}
|
| 136 |
+
print(f"[{tgt_name}] acc={acc:.4f} F1={f1:.4f} on {mask.sum()} labeled cells", flush=True)
|
| 137 |
+
(out_dir / f"{tgt_name}_summary.json").write_text(json.dumps(summary, indent=2, default=str))
|
| 138 |
+
print(f"[{tgt_name}] wrote {out_dir}/{tgt_name}_predictions.csv + summary.json", flush=True)
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def main():
|
| 142 |
+
ap = argparse.ArgumentParser()
|
| 143 |
+
ap.add_argument("--systems", nargs="*", default=["pan_skin", "hematopoiesis", "pancreas"])
|
| 144 |
+
ap.add_argument("--variants", nargs="*", default=["pca", "marker"])
|
| 145 |
+
args = ap.parse_args()
|
| 146 |
+
for sys_ in args.systems:
|
| 147 |
+
for var in args.variants:
|
| 148 |
+
process(sys_, var)
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
if __name__ == "__main__":
|
| 152 |
+
main()
|