bryan7264 commited on
Commit
6d63707
·
verified ·
1 Parent(s): 0c18307

sync scripts (token removed)

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. scripts/__init__.py +0 -0
  2. scripts/__pycache__/__init__.cpython-310.pyc +0 -0
  3. scripts/analysis/100_primary_eden_discovery.py +205 -0
  4. scripts/analysis/101_primary_eden_derm_scoring.py +159 -0
  5. scripts/analysis/102_train_on_dingwall_variantA.py +317 -0
  6. scripts/analysis/103_replicate_dingwall_seurat_pipeline.py +261 -0
  7. scripts/analysis/104_train_on_dingwall_derm_labels.py +261 -0
  8. scripts/analysis/105_primary_eden_full_dermal.py +134 -0
  9. scripts/analysis/106_melanoblast_neural_crest.py +138 -0
  10. scripts/analysis/107_dingwall_class_deg_count.py +116 -0
  11. scripts/analysis/108_dahlin_lineage_metabolism.py +158 -0
  12. scripts/analysis/109_veres_mature_beta.py +113 -0
  13. scripts/analysis/110_veres_polyhormonal_alpha.py +125 -0
  14. scripts/analysis/44_en1_cko_contrast.py +117 -0
  15. scripts/analysis/45_marker_refinement.py +70 -0
  16. scripts/analysis/49_melanocyte_deep_dive.py +125 -0
  17. scripts/analysis/57_multiclass_pathway_analysis.py +123 -0
  18. scripts/analysis/57_pathway_analysis.py +529 -0
  19. scripts/analysis/62_time_course_analysis.py +112 -0
  20. scripts/analysis/63_nestorowa_zero_shot.py +142 -0
  21. scripts/analysis/66_dahlin_kit_mutant.py +142 -0
  22. scripts/analysis/67_dahlin_within_class.py +129 -0
  23. scripts/analysis/70_prototype_geometry.py +107 -0
  24. scripts/analysis/72_emergent_axes.py +74 -0
  25. scripts/analysis/73_novel_populations_dahlin.py +123 -0
  26. scripts/analysis/80_prototype_gene_attribution.py +219 -0
  27. scripts/analysis/81_counterfactual_knockouts.py +112 -0
  28. scripts/analysis/82_gene_coattribution_modules.py +72 -0
  29. scripts/analysis/83_prototype_training_trajectory.py +71 -0
  30. scripts/analysis/84_adversary_purification.py +125 -0
  31. scripts/analysis/85_hessian_gene_interactions.py +129 -0
  32. scripts/analysis/90_dingwall_marker_deep_dive.py +87 -0
  33. scripts/analysis/91_veres_marker_deep_dive.py +110 -0
  34. scripts/analysis/92_dahlin_marker_deep_dive.py +150 -0
  35. scripts/analysis/93_true_zero_shot_baron.py +90 -0
  36. scripts/analysis/94_true_zero_shot_nestorowa.py +107 -0
  37. scripts/analysis/95_adult_beta_validation.py +92 -0
  38. scripts/analysis/98_eden_posthoc_detection.py +149 -0
  39. scripts/analysis/99_en1_dual_role_analysis.py +137 -0
  40. scripts/analysis/README.md +20 -0
  41. scripts/common/README.md +14 -0
  42. scripts/common/__init__.py +0 -0
  43. scripts/common/cv_holdout.py +155 -0
  44. scripts/common/extract_prototypes_for_analysis.py +31 -0
  45. scripts/common/generate_missing_holdouts.py +65 -0
  46. scripts/common/nestorowa_zero_shot.py +168 -0
  47. scripts/common/phase_e_driver.sh +35 -0
  48. scripts/common/rerun_all_discovery.sh +31 -0
  49. scripts/common/run_all_retrains.sh +26 -0
  50. 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()