bryan7264 commited on
Commit
141bacd
·
verified ·
1 Parent(s): 6d63707

Correction pass: gate-matched Dahlin, retracted unsupported claims, complete HF-placode DEG set, restyled figures

Browse files

Mirrors github.com/bryanc5864/PRISM @ 879c970.
- Dahlin genotype contrast restricted to the matched LK FACS gate (a within-WT
gate control produced larger shifts than the mutation); recovers Dahlin's
mast/basophil loss that the pooled analysis missed.
- Withdrawn: Veres MAFA/UCN3/IAPP beta-class enrichment (measured on primary-islet
controls), polyhormonal novelty claim, Dahlin 'novel' pathway phenotypes,
Dingwall ectopic sweat-gland claim. epsilon class disclosed as Veres's SC-EC.
- Dingwall citation corrected (title and DOI were both wrong).
- Complete 12-gene HF-placode DEG set recovered; artefacts no longer truncate.
- data/raw/GSE220977_combined.h5ad added so raw-dependent scripts run from a clone.
- Figures restyled and regenerated; legacy duplicate UMAPs removed.

This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +2 -0
  2. PAPER.pdf +2 -2
  3. PAPER.tex +0 -0
  4. README.md +622 -67
  5. REVIEW_REPORT.md +235 -0
  6. data/raw/GSE220977_combined.h5ad +3 -0
  7. discovery/hematopoiesis/marker/66_dahlin_enrichment.csv +14 -0
  8. discovery/hematopoiesis/marker/66_dahlin_enrichment_pooled_CONFOUNDED.csv +14 -0
  9. discovery/hematopoiesis/marker/66_dahlin_gate_negative_control.csv +14 -0
  10. discovery/pan_skin/marker/107_dingwall_class_deg_count.csv +13 -13
  11. discovery/pan_skin/marker/107_dingwall_class_deg_count.json +58 -58
  12. figures/PANDA_all_figures.pdf +2 -2
  13. figures/PANDA_supplement.pdf +2 -2
  14. figures/biology/biology_01_dingwall_umap.pdf +2 -2
  15. figures/biology/biology_02_primary_eden.pdf +2 -2
  16. figures/biology/biology_03_melanoblast_mitf.pdf +2 -2
  17. figures/biology/biology_04_dahlin_metabolism.pdf +0 -0
  18. figures/biology/biology_05_dahlin_composition.pdf +0 -0
  19. figures/biology/biology_06_veres_beta_quadrant.pdf +2 -2
  20. figures/biology/biology_07_veres_polyhormonal.pdf +2 -2
  21. figures/biology/biology_08_prototype_geometry.pdf +0 -0
  22. figures/fig1_perclass_f1.pdf +0 -0
  23. figures/fig3_dahlin_heatmap.pdf +0 -0
  24. figures/fig4_veres_stage_stack.pdf +0 -0
  25. figures/supplement/01_cv_summary.pdf +0 -0
  26. figures/supplement/02_per_class_f1.pdf +0 -0
  27. figures/supplement/03_prototype_cosine.pdf +0 -0
  28. figures/supplement/05_adversary_purification.pdf +0 -0
  29. figures/supplement/06_cross_system_prototypes.pdf +0 -0
  30. figures/supplement/20_placode_wnt_module.pdf +0 -0
  31. figures/supplement/23_anchor_delta_recall.pdf +0 -0
  32. figures/supplement/26_pca_vs_marker_umaps_veres_by_stage.pdf +2 -2
  33. figures/supplement/26b_pca_vs_marker_umaps_veres_by_class.pdf +0 -0
  34. panda/__init__.py +16 -16
  35. panda/markers.yaml +135 -132
  36. panda/model.py +229 -229
  37. pyproject.toml +84 -0
  38. scripts/analysis/100_primary_eden_discovery.py +208 -205
  39. scripts/analysis/101_primary_eden_derm_scoring.py +162 -159
  40. scripts/analysis/102_train_on_dingwall_variantA.py +320 -317
  41. scripts/analysis/103_replicate_dingwall_seurat_pipeline.py +264 -261
  42. scripts/analysis/104_train_on_dingwall_derm_labels.py +264 -261
  43. scripts/analysis/105_primary_eden_full_dermal.py +137 -134
  44. scripts/analysis/106_melanoblast_neural_crest.py +141 -138
  45. scripts/analysis/107_dingwall_class_deg_count.py +122 -116
  46. scripts/analysis/108_dahlin_lineage_metabolism.py +161 -158
  47. scripts/analysis/109_veres_mature_beta.py +116 -113
  48. scripts/analysis/110_veres_polyhormonal_alpha.py +128 -125
  49. scripts/analysis/44_en1_cko_contrast.py +120 -117
  50. scripts/analysis/45_marker_refinement.py +73 -70
.gitattributes CHANGED
@@ -86,3 +86,5 @@ data/processed/dingwall_replica/dingwall_replica.h5ad filter=lfs diff=lfs merge=
86
  data/processed/sulic/adata_sulic.h5ad filter=lfs diff=lfs merge=lfs -text
87
  data/processed/sulic/adata_sulic_clustered.h5ad filter=lfs diff=lfs merge=lfs -text
88
  data/processed/weinreb_larry/adata_weinreb.h5ad filter=lfs diff=lfs merge=lfs -text
 
 
 
86
  data/processed/sulic/adata_sulic.h5ad filter=lfs diff=lfs merge=lfs -text
87
  data/processed/sulic/adata_sulic_clustered.h5ad filter=lfs diff=lfs merge=lfs -text
88
  data/processed/weinreb_larry/adata_weinreb.h5ad filter=lfs diff=lfs merge=lfs -text
89
+ figures/supplement/26b_pca_vs_marker_umaps_veres_by_class.pdf filter=lfs diff=lfs merge=lfs -text
90
+ data/raw/GSE220977_combined.h5ad filter=lfs diff=lfs merge=lfs -text
PAPER.pdf CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:00301cc9e983b32b845d591f0c6d87c1eeb8dca9ba4170ebfcacfedae7493181
3
- size 3977089
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:40dba671f0811830dcacdac7d667c9bc2a53485038c70902f54d4af1baebccaf
3
+ size 4096150
PAPER.tex CHANGED
The diff for this file is too large to render. See raw diff
 
README.md CHANGED
@@ -1,67 +1,622 @@
1
- ---
2
- license: mit
3
- tags:
4
- - single-cell
5
- - scRNA-seq
6
- - cell-type-classification
7
- - contrastive-learning
8
- - domain-adaptation
9
- library_name: pytorch
10
- pipeline_tag: feature-extraction
11
- ---
12
-
13
- # PANDA Pan-tissue Adversarial Normalized Domain-invariant Anchored MLP
14
-
15
- Prototype-anchored MLP classifier for scRNA-seq cell identity across skin, hematopoietic, and pancreatic tissues.
16
- Trained under a composite of SupCon + VICReg + prototype-InfoNCE + GRL dataset+depth adversary + HSIC decorrelation + prototype-repulsion.
17
-
18
- Two variants: **PANDA-PCA** and **PANDA-Marker** (adds a marker gene channel).
19
-
20
- Code + paper: https://github.com/bryanc5864/PRISM
21
-
22
- ## Contents
23
-
24
- | Path | Description |
25
- |---|---|
26
- | `checkpoints/{system}/{pca,marker}/panda_final.pt` | Final trained weights per system × variant (6 core models) |
27
- | `checkpoints/pan_skin_dingwall_derm/panda_final.pt` | Line C: PANDA-Marker trained on Dingwall Derm labels |
28
- | `data/corpus/{system}/harmonized/` | Training corpora (h5ad + stats + PCA basis) |
29
- | `data/external_labels/` | Paper-supplement label files per source study |
30
- | `data/processed/dingwall_replica/` | Independent scanpy reproduction of Dingwall Seurat pipeline |
31
- | `discovery/` | Discovery-analysis outputs backing every paper claim |
32
- | `figures/` | Main + supplement + biology figures + merged PDFs |
33
- | `panda/`, `scripts/` | Model + analysis code (also on GitHub) |
34
- | `PAPER.tex`, `PAPER.pdf` | Manuscript |
35
- | `README.md` | Full end-to-end reproduction recipe |
36
-
37
- ## Quick fetch
38
-
39
- ```bash
40
- # essentials only (~30 GB)
41
- huggingface-cli download bryan7264/PANDA \
42
- --local-dir . \
43
- --include "checkpoints/**" "data/corpus/**" "data/external_labels/**" "discovery/**"
44
-
45
- # individual system
46
- huggingface-cli download bryan7264/PANDA \
47
- --local-dir . \
48
- --include "data/corpus/pan_skin/**" "checkpoints/pan_skin/**"
49
- ```
50
-
51
- ## Usage
52
-
53
- ```python
54
- import torch
55
- from panda.model import PANDAEncoder
56
-
57
- ck = torch.load("checkpoints/pan_skin/marker/panda_final.pt", map_location="cpu",
58
- weights_only=False)
59
- model = PANDAEncoder(variant="marker", n_pca=50,
60
- n_markers=len(ck["marker_genes"]),
61
- n_classes=len(ck["classes"]), n_sub=3,
62
- n_datasets=len(ck["datasets"]))
63
- model.load_state_dict(ck["model"])
64
- model.eval()
65
- ```
66
-
67
- See PAPER.pdf for full experimental setup and README.md for the reproduction recipe.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # PANDA — reproducibility recipe
2
+
3
+ **PANDA** (Pan-tissue Adversarial Normalized Domain-invariant Anchored MLP) is a
4
+ compact prototype-anchored MLP classifier for scRNA-seq cell identity across skin,
5
+ hematopoietic, and pancreatic tissues, trained under a composite loss (supervised-
6
+ contrastive + VICReg + sub-center angular prototype-InfoNCE + gradient-reversal
7
+ dataset/depth adversary + HSIC depth-decorrelation). Two
8
+ input variants ship out of the box: **PANDA-PCA** (`PCA(50) -> trunk`) and
9
+ **PANDA-Marker** (`[PCA(50) || marker_expr] -> trunk`); the marker channel's gain
10
+ is system-dependent, largest on pancreas (+4.2% CV accuracy).
11
+
12
+ This README is a complete recipe to reproduce every result in `PAPER.tex` from a
13
+ clean clone. All commands are copy-pasteable and run from the repo root; scripts
14
+ resolve the repo root automatically (see `PANDA_ROOT` below).
15
+
16
+ Repository layout:
17
+
18
+ ```
19
+ panda/ model + losses + panda/markers.yaml
20
+ scripts/pan_skin/ skin pipeline: download -> corpus -> train -> CV -> zero-shot
21
+ scripts/pancreas/ pancreas pipeline (same shape)
22
+ scripts/hematopoiesis/ HSC pipeline (same shape)
23
+ scripts/common/ system-agnostic train / CV / zero-shot drivers
24
+ scripts/analysis/ downstream discovery + interpretability
25
+ scripts/figures/ paper + supplement figure builders
26
+ data/corpus/{sys}/ downloaded + harmonized data
27
+ data/raw/ per-dataset raw counts (not in git)
28
+ checkpoints/{sys}/{variant}/panda_final.pt
29
+ discovery/{sys}/{variant}/*.json,*.csv all quantitative artefacts
30
+ figures/ fig1_..fig6, PANDA_supplement.pdf, biology/*.pdf
31
+ ```
32
+
33
+ ---
34
+
35
+ ## 1. Requirements
36
+
37
+ - **Python**: 3.9+ (project is tested on 3.10).
38
+ - **CUDA**: PyTorch 2.6.0 wheels — CUDA 12.1/12.4 runtime works.
39
+ - **GPU**: any single CUDA GPU works — the trainer is single-device (batch 256,
40
+ a compact MLP; a consumer GPU trains a checkpoint in well under an hour).
41
+ - **Disk**: ~400 GB (raw GEO tars + harmonized corpora + checkpoints).
42
+ - **RAM**: ~64 GB (the pancreas HVG builder peaks near ~40 GB).
43
+
44
+ Pinned runtime dependencies (from `pyproject.toml` / `requirements.txt`):
45
+
46
+ ```
47
+ torch==2.6.0 scanpy==1.11.5 anndata>=0.11.4
48
+ harmonypy==0.2.0 numpy>=1.24,<3.0 scipy>=1.10
49
+ scikit-learn>=1.2 pandas>=1.5 matplotlib>=3.7
50
+ seaborn>=0.12 umap-learn>=0.5 pyyaml>=6.0
51
+ tqdm>=4.65 leidenalg>=0.10 pynndescent>=0.5
52
+ scikit-misc>=0.5
53
+ ```
54
+
55
+ Optional extras: `viz` (plotly), `dev` (pytest, ruff, mypy).
56
+
57
+ Install:
58
+
59
+ ```bash
60
+ git clone <this-repo> panda && cd panda
61
+ pip install -e .
62
+ # or, editable dev install:
63
+ pip install -e ".[dev]"
64
+ ```
65
+
66
+ ### Repository root resolution (`PANDA_ROOT`)
67
+
68
+ Every script resolves the repository root as
69
+ `os.environ.get("PANDA_ROOT", <two directories above the script>)`, so a clone at
70
+ any path works out of the box. Set `PANDA_ROOT` explicitly only if you run scripts
71
+ from a copied/vendored location or want data/checkpoints rooted elsewhere:
72
+
73
+ ```bash
74
+ export PANDA_ROOT=/path/to/clone # optional
75
+ ```
76
+
77
+ Data and checkpoints are expected at `$PANDA_ROOT/data` and `$PANDA_ROOT/checkpoints`
78
+ (symlink/junction these to the Hugging Face mirror download if you keep it separate).
79
+
80
+ ### 1.1 LD_LIBRARY_PATH prefix (required for every PyTorch invocation)
81
+
82
+ PyTorch 2.6 sparse ops load `libcusparseLt.so.0` which sits under the pip-installed
83
+ `nvidia-cusparselt-cu12` package, and `scanpy` needs a modern `libstdc++`. Both
84
+ paths must be exported **at the shell level, before Python starts**:
85
+
86
+ ```bash
87
+ export LD_LIBRARY_PATH="$(python -c "import site,os; print(os.path.join(site.getsitepackages()[0],'nvidia','cusparselt','lib'))"):$LD_LIBRARY_PATH"
88
+ ```
89
+
90
+ If you also have a conda env that ships a newer `libstdc++`, prepend it:
91
+
92
+ ```bash
93
+ # example — path is machine-specific; drop it if your system libstdc++ is >= 3.4.30
94
+ export LD_LIBRARY_PATH="/home/bcheng/.conda/pkgs/libstdcxx-15.2.0-h39759b7_7/lib:$LD_LIBRARY_PATH"
95
+ ```
96
+
97
+ Every `bash scripts/*/run_all.sh` driver applies the same export automatically.
98
+
99
+ ---
100
+
101
+ ## 2. Data acquisition
102
+
103
+ Every URL below is a public GEO/ArrayExpress FTP link. Raw data is **not**
104
+ committed — it must be re-downloaded before anything else runs. Corpus builders
105
+ expect files at `data/corpus/{system}/tier_{a,b,c,v2}/`.
106
+
107
+ ### Pan-skin (6 studies, 45,387 cells)
108
+
109
+ | Study | GEO | Role |
110
+ |---|---|---|
111
+ | Sulic 2023 (E14.5 dorsal) | GSE212673 | anchor + held-out zero-shot |
112
+ | Dingwall 2024 (En1-cKO) | GSE220977 | discovery target (paired with Aldrich GSE214695) |
113
+ | Belote 2021 (human melanocyte) | GSE151091 | melanocyte anchor + held-out zero-shot |
114
+ | Haensel/Annusver 2020 | GSE142471 | adult homeostasis + wound |
115
+ | Joost 2016 | GSE67602 | Smart-seq2 platform anchor |
116
+ | Sennett 2015 (bulk RNA) | GSE70288 | placode/dermal-condensate marker reference |
117
+ | Han MCA 2018 (neonatal skin) | GSE108097 | Microwell-seq low-depth anchor |
118
+ | Merkel 2022 | GSE201447 | touch dome / volar biology |
119
+ | Aldrich 2023 (paired with Dingwall) | GSE214695 | En1-cKO snRNA-seq |
120
+
121
+ ```bash
122
+ bash scripts/pan_skin/01_download_tier_a.sh # Aldrich, Ge/Gupta, Joost, Haensel
123
+ bash scripts/pan_skin/02_download_tier_b.sh # MCA, WIHN, Ge/Fuchs, Merkel
124
+ bash scripts/pan_skin/03_download_tier_c.sh # Sennett, Tie, Wiedemann (bulk + human)
125
+ # Dingwall / Sulic / Belote must be placed in data/raw/ manually — see repo notes
126
+ ```
127
+
128
+ ### Pan-hematopoietic (3 studies used in the paper, 192,833 cells)
129
+
130
+ | Study | GEO | Role |
131
+ |---|---|---|
132
+ | Weinreb LARRY 2020 | GSE140802 | corpus anchor |
133
+ | Baccin whole-BM 2020 | GSE122465 | corpus (stromal + hematopoietic) |
134
+ | Tabula Muris Senis BM 2020 | GSE132042 | corpus (paper-labeled) |
135
+ | Nestorowa 2016 | GSE81682 | held-out zero-shot (Smart-seq2) |
136
+ | Dahlin 2018 | GSE107727 | discovery target (Kit-W41 mutant) |
137
+ | Paul 2015 (auxiliary) | GSE72857 | myeloid branch reference |
138
+ | Tusi 2018 (auxiliary) | GSE89754 | erythroid trajectory |
139
+
140
+ ```bash
141
+ bash scripts/hematopoiesis/01_download.sh # Paul, Nestorowa, Tusi, Dahlin
142
+ bash scripts/hematopoiesis/02_download.sh # Baccin whole-BM, TMS bone marrow
143
+ ```
144
+
145
+ ### Pan-pancreatic (6 studies, 120,611 cells)
146
+
147
+ | Study | GEO | Role |
148
+ |---|---|---|
149
+ | Baron 2016 | GSE84133 | corpus mouse-train half + held-out mouse-test half |
150
+ | Bastidas-Ponce 2019 (E15.5) | GSE132188 | corpus (endocrine progenitor time course) |
151
+ | Byrnes 2018 | GSE101099 | corpus (paper-labeled subset) |
152
+ | Yu 2021 | GSE139627 | corpus (paper-labeled Ngn3 lineage) |
153
+ | Hrovatin MIA 2023 | GSE211796 | corpus (adult islet, paper-labeled) |
154
+ | Veres 2019 | GSE114412 | 57,297 corpus + 12,297 held-out slice |
155
+
156
+ ```bash
157
+ bash scripts/pancreas/01_download.sh # Baron, Muraro, Grun, Byrnes, Veres
158
+ bash scripts/pancreas/09_download.sh # Yu Ngn3 seq-EP, MIA 4-month adult islet
159
+ ```
160
+
161
+ Wall-clock: 2-6 h depending on bandwidth (GSE108097 MCA tar is ~9 GB, GSE140802
162
+ Weinreb is ~14 GB, GSE114412 Veres is ~4 GB).
163
+
164
+ ---
165
+
166
+ ## 3. Corpus build (per system)
167
+
168
+ Each system builds a `data/corpus/{system}/harmonized/corpus.h5ad` plus a shared
169
+ HVG list, per-HVG mean/std, and a fitted PCA basis. Corpus is 100% paper-labeled;
170
+ every cell carries a label from its source paper's supplementary table.
171
+
172
+ ### Pan-skin
173
+
174
+ ```bash
175
+ python scripts/pan_skin/06_build_per_dataset_h5ads.py
176
+ python scripts/pan_skin/07_build_shared_hvgs_and_pca.py
177
+ python scripts/pan_skin/08_assign_labels.py
178
+ python scripts/pan_skin/08b_curated_label_override.py
179
+ python scripts/pan_skin/10_build_corpus.py # canonical corpus.h5ad
180
+ python scripts/pan_skin/93_add_belote_anchor.py # +Belote melanocyte anchor
181
+ ```
182
+
183
+ Wall-clock ~10-20 min (HVG + PCA is the expensive step).
184
+
185
+ ### Pan-hematopoietic
186
+
187
+ ```bash
188
+ python scripts/hematopoiesis/02_build_per_dataset.py
189
+ python scripts/hematopoiesis/03_shared_hvgs_and_pca.py
190
+ python scripts/hematopoiesis/10_build_corpus.py # canonical corpus.h5ad
191
+ python scripts/hematopoiesis/11_filter_paper_only.py # enforce paper-labeled subset
192
+ python scripts/hematopoiesis/09_retrain_with_nestorowa_anchor.py # optional anchor
193
+ ```
194
+
195
+ Wall-clock ~15-30 min.
196
+
197
+ ### Pan-pancreatic
198
+
199
+ ```bash
200
+ python scripts/pancreas/02_build_per_dataset.py
201
+ python scripts/pancreas/03_shared_hvgs_and_pca.py
202
+ python scripts/pancreas/04_assign_labels.py
203
+ python scripts/pancreas/11_build_corpus.py # canonical corpus.h5ad
204
+ python scripts/pancreas/08_add_baron_split.py # 943-cell Baron test-half
205
+ ```
206
+
207
+ Wall-clock ~30-60 min (peak ~40 GB RAM on the union HVG step).
208
+
209
+ Also generate the held-out labeled slices used for zero-shot:
210
+
211
+ ```bash
212
+ python scripts/common/generate_missing_holdouts.py
213
+ ```
214
+
215
+ writes `data/corpus/hematopoiesis/held_out_labeled/nestorowa_GSE81682_test.h5ad`
216
+ and `data/corpus/pan_skin/held_out_labeled/sulic_GSE212673_test.h5ad`.
217
+
218
+ ---
219
+
220
+ ## 4. Training
221
+
222
+ The **canonical trainer** is system-agnostic. It reads
223
+ `data/corpus/{system}/harmonized/corpus.h5ad` and writes
224
+ `checkpoints/{system}/{variant}/panda_final.pt`.
225
+
226
+ ```bash
227
+ # 6 checkpoints total (3 systems x 2 variants). ~30-60 min each on 1x A100.
228
+ python -m scripts.common.train_panda pan_skin --variant pca --epochs 8
229
+ python -m scripts.common.train_panda pan_skin --variant marker --epochs 8
230
+ python -m scripts.common.train_panda hematopoiesis --variant pca --epochs 8
231
+ python -m scripts.common.train_panda hematopoiesis --variant marker --epochs 8
232
+ python -m scripts.common.train_panda pancreas --variant pca --epochs 8
233
+ python -m scripts.common.train_panda pancreas --variant marker --epochs 8
234
+ ```
235
+
236
+ Legacy per-system entry points also exist and are functionally equivalent for
237
+ skin/HSC/pancreas single-variant training:
238
+ `scripts/pan_skin/20_train_panda.py`, `scripts/hematopoiesis/05_train_panda.py`,
239
+ `scripts/pancreas/05_train_panda.py`. Prefer `scripts.common.train_panda`.
240
+
241
+ ---
242
+
243
+ ## 5. Held-out 5-fold cross-validation (Table 1)
244
+
245
+ The paper's Table 1 CV block reads
246
+ `discovery/{system}/{variant}/cv_5fold.json`. Two drivers exist:
247
+
248
+ - **`scripts/common/run_cv.py`** — canonical, 5 epochs per fold, matches
249
+ paper numbers (mean acc / F1 / AUROC + per-class report).
250
+ - `scripts/common/cv_holdout.py` — same architecture but supports GroupKFold
251
+ by dataset and a fuller 6-8 epoch curriculum; slower.
252
+
253
+ Both accept `--systems` and `--variants`:
254
+
255
+ ```bash
256
+ # canonical 5-fold CV for all 3 systems x 2 variants
257
+ python -m scripts.common.run_cv --folds 5 --epochs 5
258
+ ```
259
+
260
+ Per-system CV drivers also exist (`scripts/pan_skin/40_heldout_5fold_cv.py`,
261
+ `scripts/hematopoiesis/07_heldout_5fold_cv.py`,
262
+ `scripts/pancreas/07_heldout_5fold_cv.py`); they are single-variant, single-
263
+ system alternatives.
264
+
265
+ ### Multi-seed rigor
266
+
267
+ Seed replicates use the canonical driver on the canonical corpora; non-zero seeds
268
+ write `cv_5fold_seed{N}.json` next to the seed-0 `cv_5fold.json`:
269
+
270
+ ```bash
271
+ python -m scripts.common.run_cv --folds 5 --epochs 5 # seed 0 (canonical)
272
+ python -m scripts.common.run_cv --folds 5 --epochs 5 --seed 1
273
+ python -m scripts.common.run_cv --folds 5 --epochs 5 --seed 2
274
+ ```
275
+
276
+ > **Correction (2026-08):** older `cv_5fold_seed{1,2}.json` files in the repo history
277
+ > were produced on different corpus builds (different cell counts and label
278
+ > vocabularies) by `cv_holdout.py` with a different curriculum; the "33/35
279
+ > fold-comparisons" claim previously derived from them was invalid and has been
280
+ > withdrawn. The seeds committed now are regenerated with the commands above on
281
+ > the canonical corpora (verified same n/K per configuration). Honest result:
282
+ > Marker beats PCA on **34/45** fold-comparisons — pancreas 15/15 (+0.04 acc
283
+ > every seed), skin 11/15 (gap ≤0.003), hematopoiesis 8/15 (parity).
284
+
285
+ ---
286
+
287
+ ## 6. Held-out labeled zero-shot targets (Section 5)
288
+
289
+ One driver runs every zero-shot target for both variants:
290
+
291
+ ```bash
292
+ python -m scripts.common.run_all_zero_shot \
293
+ --systems pan_skin hematopoiesis pancreas \
294
+ --variants pca marker
295
+ ```
296
+
297
+ writes `discovery/{system}/{variant}/{target}_predictions.csv` and
298
+ `{target}_summary.json`. Individual per-target scripts exist for finer-grained
299
+ control:
300
+
301
+ | Target | Script | Populates |
302
+ |---|---|---|
303
+ | Baron test-half (pancreas, 943 cells) | `scripts/analysis/93_true_zero_shot_baron.py` | Table 1 Baron row + Sec 5.1 |
304
+ | Nestorowa Smart-seq2 (HSC, 66 LT-HSC gated) | `scripts/analysis/94_true_zero_shot_nestorowa.py` | Sec 5.3 |
305
+ | Sulic E14.5 dorsal skin (4,183 cells) | `scripts/common/run_all_zero_shot.py` (target `sulic`) | Sec 5.5 |
306
+ | Belote melanocyte (6,088 cells) | `scripts/common/run_all_zero_shot.py` (target `belote`) | Sec 5.4 |
307
+ | Veres held-out slice (12,297 pancreas) | `scripts/common/run_all_zero_shot.py` (target `veres`) | Sec 5.2 |
308
+ | Dingwall (25,344 skin, discovery) | `scripts/pan_skin/30_zero_shot_aldrich.py` | Sec 6 |
309
+ | Dahlin (61,122 HSC, discovery) | `scripts/common/run_all_zero_shot.py` (target `dahlin`) | Sec 7 |
310
+
311
+ Adult-beta canonical panel validation on Veres:
312
+
313
+ ```bash
314
+ python scripts/analysis/95_adult_beta_validation.py
315
+ # -> discovery/pancreas/marker/95_adult_beta_validation.json
316
+ ```
317
+
318
+ ---
319
+
320
+ ## 7. Discovery analyses
321
+
322
+ Grouped by paper section. All write to `discovery/{system}/{variant}/`.
323
+
324
+ ### 7.1 Section 5 (held-out labeled) marker deep-dives
325
+
326
+ ```bash
327
+ python scripts/analysis/90_dingwall_marker_deep_dive.py # skin -> 90_..._marker_deep_dive.csv
328
+ python scripts/analysis/91_veres_marker_deep_dive.py # pancreas
329
+ python scripts/analysis/92_dahlin_marker_deep_dive.py # HSC
330
+ ```
331
+
332
+ ### 7.2 Section 6 — Dingwall En1-cKO (skin)
333
+
334
+ ```bash
335
+ python scripts/analysis/44_en1_cko_contrast.py # class-level cKO vs WT contrast
336
+ python scripts/analysis/45_marker_refinement.py # per-class marker refinement
337
+ python scripts/analysis/49_melanocyte_deep_dive.py # melanocyte 2x expansion
338
+ python scripts/analysis/57_multiclass_pathway_analysis.py # Dingwall pathway table
339
+ python scripts/analysis/57_pathway_analysis.py # symmetric 25+ module scoring, all systems
340
+ python scripts/analysis/99_en1_dual_role_analysis.py # spatial repressor / local activator
341
+ python scripts/analysis/106_melanoblast_neural_crest.py # Sec 6.5 MITF-axis vs NC reversion
342
+ python scripts/analysis/107_dingwall_class_deg_count.py # Sec 6.7 HF-placode DEG rank
343
+
344
+ # EDEN validation — three complementary lines of evidence (Sec 6.4)
345
+ python scripts/analysis/98_eden_posthoc_detection.py # Line A (null)
346
+ python scripts/analysis/103_replicate_dingwall_seurat_pipeline.py # Line B (Derm10 4.32x)
347
+ python scripts/analysis/104_train_on_dingwall_derm_labels.py # Line C (Derm10 5.20x)
348
+
349
+ # Primary EDEN (Derm2) discovery — Sec 6.4.1
350
+ python scripts/analysis/100_primary_eden_discovery.py # sub-cluster fibro predictions
351
+ python scripts/analysis/101_primary_eden_derm_scoring.py # score vs Data S1C panels
352
+ python scripts/analysis/105_primary_eden_full_dermal.py # on full dermal denominator
353
+
354
+ # Auxiliary: variant-A Dingwall training used for scope comparison
355
+ python scripts/analysis/102_train_on_dingwall_variantA.py
356
+ ```
357
+
358
+ ### 7.3 Section 7 — Dahlin Kit-mutant (hematopoiesis)
359
+
360
+ ```bash
361
+ python scripts/analysis/66_dahlin_kit_mutant.py # class enrichment WT vs Kit-W41
362
+ python scripts/analysis/67_dahlin_within_class.py # within-class Wilcoxon DE
363
+ python scripts/analysis/73_novel_populations_dahlin.py # abstain-gated novel pops
364
+ python scripts/analysis/108_dahlin_lineage_metabolism.py # per-lineage OXPHOS/glycolysis
365
+ ```
366
+
367
+ ### 7.4 Section 7.4 — Veres held-out (pancreas)
368
+
369
+ ```bash
370
+ python scripts/analysis/62_time_course_analysis.py # class fractions across LARRY days (HSC time-course template)
371
+ python scripts/analysis/109_veres_mature_beta.py # Sec 5.2 adult MAFA/UCN3 quadrant
372
+ python scripts/analysis/110_veres_polyhormonal_alpha.py # Sec 5.2 polyhormonal alpha cluster
373
+ ```
374
+
375
+ ### 7.5 Section 8 — cross-system prototype geometry + interpretability
376
+
377
+ ```bash
378
+ python scripts/analysis/70_prototype_geometry.py # intra + cross-system cosine + eff-dim
379
+ python scripts/analysis/72_emergent_axes.py # within-class PCA of 128-d z
380
+ python scripts/analysis/80_prototype_gene_attribution.py # integrated gradients per prototype
381
+ python scripts/analysis/81_counterfactual_knockouts.py # per-gene KO delta on cosine
382
+ python scripts/analysis/82_gene_coattribution_modules.py # gene co-attribution modules
383
+ python scripts/analysis/83_prototype_training_trajectory.py # prototype drift across curriculum
384
+ python scripts/analysis/84_adversary_purification.py # test GRL adversary is at chance
385
+ python scripts/analysis/85_hessian_gene_interactions.py # second-order gene pair Hessian
386
+ python scripts/analysis/63_nestorowa_zero_shot.py # Nestorowa unlabeled discovery
387
+ ```
388
+
389
+ ---
390
+
391
+ ## 8. Figures + supplement
392
+
393
+ ### Main-text figures (`figures/fig{1,2,3,4}_*.pdf`)
394
+
395
+ ```bash
396
+ python scripts/figures/generate_paper_figures.py
397
+ # fig1_perclass_f1.pdf 3-panel per-class held-out F1 bars
398
+ # fig3_dahlin_heatmap.pdf within-class module-score heatmap
399
+ # fig4_veres_stage_stack.pdf Veres per-stage class fractions
400
+ # (there is no fig2 in the current paper; numbering is historical)
401
+ ```
402
+
403
+ Figures 5/6 (En1-cKO + Kit-W41 recap) are built by the biology page pipeline
404
+ below — the standalone `regen_fig5_fig6.py` referenced in older notes is not in
405
+ the current tree; use the biology pipeline instead.
406
+
407
+ ### Supplement (`figures/PANDA_supplement.pdf`)
408
+
409
+ ```bash
410
+ python scripts/figures/build_pca_vs_marker_umaps.py # PCA vs Marker UMAPs per target
411
+ python scripts/figures/build_figure_supplement.py # combined supplement PDF
412
+ ```
413
+
414
+ ### Biology deep-dive supplement pages
415
+
416
+ Cache UMAPs once, then build per-topic pages, then merge into the supplement:
417
+
418
+ ```bash
419
+ python scripts/figures/biology_00_umap_cache.py
420
+ python scripts/figures/biology_01_dingwall_umap.py
421
+ python scripts/figures/biology_02_primary_eden.py
422
+ python scripts/figures/biology_03_melanoblast_mitf.py
423
+ python scripts/figures/biology_04_dahlin_metabolism.py
424
+ python scripts/figures/biology_05_dahlin_composition.py
425
+ python scripts/figures/biology_06_veres_beta_quadrant.py
426
+ python scripts/figures/biology_07_veres_polyhormonal.py
427
+ python scripts/figures/biology_08_prototype_geometry.py
428
+ python scripts/figures/biology_99_merge_supplement.py # appends into PANDA_supplement.pdf
429
+ ```
430
+
431
+ Wall-clock: 20-40 min end-to-end (UMAPs dominate).
432
+
433
+ ---
434
+
435
+ ## 9. PDF build
436
+
437
+ The paper is a self-contained LaTeX document referencing PDFs in `figures/`:
438
+
439
+ ```bash
440
+ cd <repo-root>
441
+ pdflatex -interaction=nonstopmode PAPER.tex # first pass (writes .aux)
442
+ pdflatex -interaction=nonstopmode PAPER.tex # second pass (resolves refs)
443
+ ```
444
+
445
+ `bibtex` is not required — the paper uses an embedded `thebibliography`.
446
+
447
+ ---
448
+
449
+ ## 10. End-to-end make target
450
+
451
+ The provided `Makefile` covers the canonical skin pipeline end-to-end:
452
+
453
+ ```bash
454
+ make install # pip install -e .
455
+ make run # bash scripts/pan_skin/run_all.sh — full skin pipeline
456
+ make test-heldout # scripts/pan_skin/40_heldout_5fold_cv.py
457
+ make clean # remove __pycache__ + *.pyc
458
+ ```
459
+
460
+ For a full three-system reproduction, chain the per-section commands above.
461
+ A minimal "everything" recipe:
462
+
463
+ ```bash
464
+ # 1) data
465
+ bash scripts/pan_skin/01_download_tier_a.sh
466
+ bash scripts/pan_skin/02_download_tier_b.sh
467
+ bash scripts/pan_skin/03_download_tier_c.sh
468
+ bash scripts/hematopoiesis/01_download.sh
469
+ bash scripts/hematopoiesis/02_download.sh
470
+ bash scripts/pancreas/01_download.sh
471
+ bash scripts/pancreas/09_download.sh
472
+
473
+ # 2) corpora
474
+ bash scripts/pan_skin/run_all.sh # includes build + train + skin CV
475
+ python scripts/hematopoiesis/02_build_per_dataset.py
476
+ python scripts/hematopoiesis/03_shared_hvgs_and_pca.py
477
+ python scripts/hematopoiesis/10_build_corpus.py
478
+ python scripts/hematopoiesis/11_filter_paper_only.py
479
+ python scripts/pancreas/02_build_per_dataset.py
480
+ python scripts/pancreas/03_shared_hvgs_and_pca.py
481
+ python scripts/pancreas/04_assign_labels.py
482
+ python scripts/pancreas/11_build_corpus.py
483
+ python scripts/pancreas/08_add_baron_split.py
484
+ python scripts/common/generate_missing_holdouts.py
485
+ python scripts/pan_skin/93_add_belote_anchor.py
486
+
487
+ # 3) all 6 training runs
488
+ for sys in pan_skin hematopoiesis pancreas; do
489
+ for v in pca marker; do
490
+ python -m scripts.common.train_panda $sys --variant $v --epochs 8
491
+ done
492
+ done
493
+
494
+ # 4) CV + zero-shot
495
+ python -m scripts.common.run_cv --folds 5 --epochs 5
496
+ python -m scripts.common.run_all_zero_shot
497
+
498
+ # 5) discovery
499
+ bash scripts/common/rerun_all_discovery.sh # drives scripts/analysis/* end-to-end
500
+ python scripts/analysis/95_adult_beta_validation.py
501
+
502
+ # 6) figures + PDF
503
+ python scripts/figures/generate_paper_figures.py
504
+ python scripts/figures/build_pca_vs_marker_umaps.py
505
+ python scripts/figures/build_figure_supplement.py
506
+ python scripts/figures/biology_00_umap_cache.py
507
+ for i in 01 02 03 04 05 06 07 08; do
508
+ python scripts/figures/biology_${i}_*.py
509
+ done
510
+ python scripts/figures/biology_99_merge_supplement.py
511
+ pdflatex -interaction=nonstopmode PAPER.tex && pdflatex -interaction=nonstopmode PAPER.tex
512
+ ```
513
+
514
+ ---
515
+
516
+ ## 11. Trouble-shooting
517
+
518
+ - **`libcusparseLt.so.0: cannot open shared object file`** — you forgot to
519
+ export `LD_LIBRARY_PATH` **before** Python started. The pip-installed
520
+ `nvidia-cusparselt-cu12` provides the library; PyTorch does not add its
521
+ path to the loader search. See section 1.1.
522
+ - **`GLIBCXX_3.4.30 not found`** — your system `libstdc++` is too old; prepend
523
+ a newer `libstdc++.so.6`'s directory to `LD_LIBRARY_PATH`.
524
+ - **Dingwall GSM -> genotype mapping** (frequent bug source): the correct map is
525
+ `WT = {GSM6833478, GSM6833479, GSM6833480, GSM6833481}`,
526
+ `cKO = {GSM6833482, GSM6833483}`.
527
+ GSM6833480/481 are `rttaControl` (Cre-negative WT), **not** cKO. Getting this
528
+ wrong flips every En1-cKO enrichment sign.
529
+ - **Pancreas HVG builder OOM** — `scripts/pancreas/03_shared_hvgs_and_pca.py`
530
+ peaks near 40 GB RAM on the 6-study union. Run on a node with >= 64 GB.
531
+ - **`data/raw` not in git** — it is git-ignored (14+ GB of GEO tars). Rerun
532
+ section 2 to repopulate.
533
+ - **`stratified split failure` on rare classes** �� small-support classes
534
+ (< 2 members per fold) are merged into the parent `canonical_label`. If a
535
+ fold still errors, check that `corpus.h5ad`'s `canonical_label` column has
536
+ the expected vocabulary; the paper vocab is the union enumerated in
537
+ `PAPER.tex` Sec 3.
538
+ - **`n_conditions` mismatch** — `PANDAEncoder` reads it from
539
+ `len(datasets)` in the checkpoint; regenerate the checkpoint if you have
540
+ added/removed a dataset.
541
+ - **`ContrastiveSampler` in 1-condition data** — auto-disabled when there is
542
+ only one condition; no config change needed.
543
+ - **DataParallel batch-size** — default `bs=256` is calibrated for 4x A100
544
+ 40 GB. Drop to `bs=64` for a single GPU or you will OOM inside the
545
+ sub-center prototype attention.
546
+ - **Embeddings not persisted** — after training writes the checkpoint, the 128-d
547
+ projection is re-computed on demand in every downstream analysis; if you want it
548
+ cached, re-save the AnnData explicitly via `adata.write_h5ad()`.
549
+ - **Sulic is NOT held out of the standard corpus** — all 4,683 Sulic cells
550
+ (including the 4,183-cell `held_out_labeled` slice) are inside
551
+ `data/corpus/pan_skin/harmonized/corpus.h5ad`. Scoring the standard checkpoints on
552
+ the Sulic slice is a train-set evaluation. Use
553
+ `scripts/pan_skin/92_retrain_with_sulic_anchor.py` (500-cell anchor corpus,
554
+ artefacts `97_sulic_anchor_*`) for the honest number, as the paper now does.
555
+ - **Corpus rebuild from raw GEO data is currently not possible from this repo** —
556
+ the per-dataset loader modules (`panda/data/*_loaders.py`) that
557
+ `06_build_per_dataset_h5ads.py` / `02_build_per_dataset.py` import were never
558
+ committed (only stale v2 copies exist under `archive/`). Everything from
559
+ training onward is reproducible from the harmonized corpora on the Hugging Face
560
+ mirror; re-deriving the corpora from GEO requires reconstructing the loaders.
561
+
562
+ ---
563
+
564
+ ## 12. Artefact index (from PAPER.tex Section 11)
565
+
566
+ Every quantitative claim traces to one of:
567
+
568
+ - **Model checkpoints**: `checkpoints/{system}/{variant}/panda_final.pt`
569
+ - **Marker gene lists**: `panda/markers.yaml`
570
+ - **Corpus builders**: `scripts/{pan_skin,hematopoiesis,pancreas}/`
571
+ - **Unified trainer**: `scripts/common/train_panda.py`
572
+ - **Unified 5-fold CV**: `scripts/common/run_cv.py` (or `cv_holdout.py`)
573
+ - **Zero-shot driver**: `scripts/common/run_all_zero_shot.py`
574
+ - **External label supplements**:
575
+ `data/external_labels/{dingwall_supp,haensel,joost2016,mca,mia,byrnes,yu,baccin,melanocyte_anchor}/`
576
+ - **CV outputs**: `discovery/{system}/{variant}/cv_5fold{,_seed1,_seed2}.json`
577
+ - **Zero-shot summaries**:
578
+ - `discovery/pancreas/{pca,marker}/baron_summary.json`
579
+ - `discovery/pancreas/{pca,marker}/veres_summary.json`
580
+ - `discovery/hematopoiesis/{pca,marker}/nestorowa_summary.json`
581
+ - `discovery/pan_skin/{pca,marker}/sulic_summary.json`
582
+ - `discovery/pan_skin/{pca,marker}/belote_summary.json`
583
+ - **Adult-beta panel**: `discovery/pancreas/marker/95_adult_beta_validation.json`
584
+ - **Pathway modules**: `discovery/{system}/marker/57_pathway_class_by_module_{padj,delta}.tsv`
585
+ - **Dingwall EDEN validation**:
586
+ Line A `discovery/pan_skin/marker/98_eden_summary.json`;
587
+ Line B `data/processed/dingwall_replica/dingwall_replica.h5ad`,
588
+ `replica_cluster_20_qc.json`, `replica_marker_matches.csv`;
589
+ Line C `discovery/pan_skin/marker/104_dingwall_derm_summary.json` + prediction CSVs.
590
+ - **Primary EDEN (Derm2)**:
591
+ `discovery/pan_skin/marker/100_primary_eden_discovery.csv`,
592
+ `100_primary_eden_summary.json`,
593
+ `101_derm_identity_summary.json`,
594
+ `101_derm_subcluster_scores.csv`.
595
+ - **Dingwall other**:
596
+ `discovery/pan_skin/marker/57_pathway_analysis.csv`,
597
+ `90_dingwall_marker_deep_dive.csv`.
598
+ - **Dahlin Kit-mutant**:
599
+ `discovery/hematopoiesis/marker/92_dahlin_marker_deep_dive.csv`, `dahlin_summary.json`.
600
+ - **Veres deep-dive**: `discovery/pancreas/marker/91_veres_marker_deep_dive.csv`.
601
+
602
+ Central architecture: `panda/model.py`. Composite loss lives in the same file
603
+ (`supcon_loss`, `vicreg_loss`, `hsic_biased`, `subcenter_angular_infonce`,
604
+ `prototype_repulsion`) and is imported as `from panda import PANDAEncoder, ...`.
605
+
606
+ ## Data mirror
607
+
608
+ Full data (~195 GB corpus + raw + processed + external labels) is mirrored to Hugging Face at [bryan7264/PANDA](https://huggingface.co/bryan7264/PANDA). Fetch with:
609
+
610
+ ```bash
611
+ huggingface-cli download bryan7264/PANDA --local-dir . --include "data/corpus/pan_skin/**"
612
+ ```
613
+
614
+ Priority folders (fetch these first for the minimum-reproducible pipeline):
615
+ - `data/corpus/{pan_skin,hematopoiesis,pancreas}/harmonized/` — training corpora
616
+ - `data/external_labels/` — paper-supplement label files
617
+ - `checkpoints/{pan_skin,hematopoiesis,pancreas}/marker/` — trained weights
618
+
619
+ Bulk (only needed to reproduce corpus builds from scratch):
620
+ - `data/raw/` — GEO downloads (regenerable from `scripts/*/03_download*.sh`)
621
+ - `data/corpus/tier{1,2,3}/` — pretraining tier data
622
+ - `data/processed/` — intermediate build artefacts
REVIEW_REPORT.md ADDED
@@ -0,0 +1,235 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Review Report — PANDA (Pan-tissue Adversarial Normalized Domain-invariant Anchored MLP)
2
+
3
+ **Review Mode**: Post-Results Review
4
+ **Date**: 2026-07-13
5
+ **Reviewer**: Autonomous Review Skill (invoked from wakeup loop)
6
+
7
+ ## Summary
8
+
9
+ **Overall Status**: 🟡 PASS WITH CONCERNS
10
+
11
+ Comprehensive audit of PAPER.tex against all discovery-artifact JSONs / CSVs
12
+ under `pan_skin_discovery/`, `hematopoiesis_discovery/`, `pancreas_discovery/`,
13
+ and `scripts/sulic/`. A single discrepancy was found (pan-skin CV metrics)
14
+ and corrected on this pass. No fabrication, no data leakage, no missing
15
+ traceability. Codebase organisation is clean (legacy preserved in
16
+ `legacy/`; PANDA-visible content grep-clean of forbidden PRISM/TabPFN/CellTab
17
+ terms). Warnings relate to overfull hboxes in LaTeX (cosmetic) and one
18
+ partial JSON that could be pruned.
19
+
20
+ ## Critical Issues (Must Fix)
21
+
22
+ None remaining after this pass.
23
+
24
+ **Fixed on this pass:**
25
+ - **CRITICAL-1 (RESOLVED)**: PAPER.tex reported pan-skin CV
26
+ `accuracy 0.9835 ± 0.0009` and `macro AUROC 0.9995 ± 0.0001`.
27
+ Actual value in `pan_skin_discovery/60_heldout_5fold_cv.json`
28
+ is `0.9851 ± 0.0013` acc, `0.9994 ± 0.0002` AUROC.
29
+ Corrected in the abstract (line 30) and results table (lines 113, 115).
30
+ The absolute delta on accuracy is +0.0016, well within a
31
+ single fold's variance, but publication-grade text must match the JSON.
32
+
33
+ ## Warnings (Should Fix)
34
+
35
+ - **WARN-1**: `pan_skin_discovery/60_heldout_5fold_partial.json`,
36
+ `pancreas_discovery/61_pancreas_heldout_5fold_partial.json`, and
37
+ `hematopoiesis_discovery/61_hematopoiesis_heldout_5fold_partial.json`
38
+ are intermediate-run artefacts that no longer serve a purpose
39
+ now that the full CV JSONs exist. They should be deleted to
40
+ avoid confusion.
41
+ - **WARN-2**: LaTeX overfull hbox warnings at lines 618-626 (URLs in
42
+ Reproducibility section run off the right margin). Cosmetic only —
43
+ the URLs remain fully readable.
44
+ - **WARN-3**: Discovery scripts contain `panda/pan_skin/model.py`
45
+ hardcoded absolute paths in some places; reproducibility on a fresh
46
+ clone at a different root would need path-fixup or a `PANDA_ROOT`
47
+ env var. Documented in `README.md`.
48
+
49
+ ## Observations (Nice to Fix)
50
+
51
+ - Pan-skin CV is the strongest system (0.9851 acc / 0.9994 AUROC / all
52
+ 13 classes at F1 > 0.88). Sulic single-dataset PANDA reproduction
53
+ reaches 0.9996 mean AUROC (5-fold, 5-seed ensemble), effectively
54
+ ceiling the PCA-only regime on that reference task.
55
+ - Pan-pancreatic CV at 0.7914 accuracy is honest — this system has
56
+ the largest developmental-stage gap between training corpus
57
+ (Bastidas E15.5 + Byrnes E12-E17 + Baron adult) and Veres in vitro
58
+ hPSC target. The Baron mouse-test-half at 0.805 acc after adult
59
+ anchor addition (up from 0.367 with embryonic-only training)
60
+ is empirical proof of the corpus-diversity claim.
61
+ - Every mechanistic p-value in the abstract traces to a specific
62
+ CSV row (spot-checked 5 of them below).
63
+
64
+ ## Integrity Verdict
65
+
66
+ | Check | Status |
67
+ |------------------------------------------|--------|
68
+ | Fabrication detected | No |
69
+ | Data leakage detected | No |
70
+ | All results traceable to code | Yes |
71
+ | Statistical claims valid | Yes |
72
+ | Held-out validation is truly held-out | Yes |
73
+ | Metrics in PAPER match JSON/CSV outputs | Yes (after fix) |
74
+
75
+ **Traceability spot-checks (5 random claims from the paper):**
76
+
77
+ 1. Dingwall melanocyte $\sim 2\times$ enrichment, Fisher $p = 4.0 \times 10^{-6}$
78
+ → `pan_skin_discovery/53_en1_cko_class_enrichment.csv` row `melanocyte`
79
+ log2fc=+1.03, p=4.0e-6. ✓
80
+ 2. Dahlin erythroid apoptosis-pro $p = 6.6 \times 10^{-123}$
81
+ → `hematopoiesis_discovery/67_dahlin_module_scores.csv` row
82
+ `class=erythroid, module=Apoptosis_pro` MannU_p ≈ 6.6e-123. ✓
83
+ 3. Sulic mean AUROC $0.9996 \pm 0.0007$
84
+ → `scripts/sulic/sulic_panda_heldout_results.json`
85
+ testA.mean_auroc = 0.999554, std = 0.000681. ✓
86
+ 4. Veres SC-alpha $=78\%$ at Stage 6
87
+ → `pancreas_discovery/64_sharon_class_per_stage.csv` col `6` row
88
+ `alpha` = 0.78. ✓
89
+ 5. Pan-hematopoietic CV acc $0.9403 \pm 0.0024$
90
+ → `hematopoiesis_discovery/61_hematopoiesis_heldout_5fold_cv.json`
91
+ mean_acc = 0.9403, std_acc = 0.0024. ✓
92
+
93
+ **Held-out set independence:**
94
+
95
+ - Sulic testA held-out: cells with `run` matching the fold's held-out
96
+ MAdCAM-1 sample-of-origin bucket, verified in `sulic_panda_heldout.py`
97
+ by set-intersection assertion.
98
+ - Pan-skin 5-fold CV: stratified GroupKFold by `dataset_id`; no cell
99
+ appears in both train and test of the same fold.
100
+ - Dingwall (25,344 cells): This dataset is NEVER in the training corpus.
101
+ Verified by `git log scripts/pan_skin/06_build_per_dataset_h5ads.py`
102
+ — the include-list explicitly excludes `GSE220977`.
103
+ - Dahlin (61,122 cells): Not in Weinreb LARRY corpus.
104
+ - Veres (57,297 human hPSC cells): Not in the mouse pancreatic corpus.
105
+
106
+ **Preprocessing leakage check:**
107
+
108
+ - HVG selection: `07_build_shared_hvgs_and_pca.py` fits union+majority
109
+ HVG list on training data only. Verified.
110
+ - PCA basis: fit on training-only 30-50k cell subsample. Verified.
111
+ - Normalisation stats (mean/std for PCA input): computed on training
112
+ data only. Held-out targets go through the same `normalize_total +
113
+ log1p` transform but use the training-fit centre/scale, not their own.
114
+
115
+ ## Checklist Summary
116
+
117
+ | Category | Items Checked | Passed | Failed | N/A |
118
+ |-----------------------------|---------------|--------|--------|-----|
119
+ | Code Correctness | 17 | 17 | 0 | 0 |
120
+ | Data Leakage | 6 | 6 | 0 | 0 |
121
+ | Experimental Design | 6 | 6 | 0 | 0 |
122
+ | Reproducibility | 5 | 4 | 0 | 1 (single-cmd end-to-end not exposed as one target) |
123
+ | Result Authenticity | 8 | 8 | 0 | 0 |
124
+ | Statistical Validity | 6 | 6 | 0 | 0 |
125
+ | Figure Audit | 6 | 6 | 0 | 0 |
126
+ | Paper Cross-Check | 8 | 7 | 1 (fixed)| 0 |
127
+
128
+ ## Detailed Findings
129
+
130
+ ### Section 1 — Code Correctness
131
+
132
+ - Architecture matches paper description exactly. `panda/pan_skin/model.py`
133
+ is one file, re-used by all three systems.
134
+ - Trunk: `PCA(50) → 512 → 512 → 256` with LayerNorm+GELU+Dropout(0.2). ✓
135
+ - Projection head 128-d L2-normalised for SupCon. ✓
136
+ - $K$ learnable class prototypes with EMA momentum 0.99. ✓
137
+ - Dataset and depth adversaries behind GradReverse. ✓
138
+ - BBSE label-shift correction implemented in inference path. ✓
139
+ - Confidence gate at cos<0.3 for novel-population flagging. ✓
140
+
141
+ ### Section 6 — Result Authenticity
142
+
143
+ Every quantitative claim in the abstract and body traces to at least
144
+ one artefact under the three `*_discovery/` directories. The five
145
+ random spot-checks above all matched. TRAINING logs and the
146
+ `checkpoints/{system}/panda_final.pt` timestamps are consistent
147
+ with the JSON output timestamps (checkpoints predate the discovery
148
+ JSONs by minutes).
149
+
150
+ ### Section 7 — Overfitting & Generalisation
151
+
152
+ - 5-fold CV std deviations are tight (0.0009–0.0032 on acc), suggesting
153
+ the model is not overfitting one fold.
154
+ - Cross-dataset transfer to Dingwall/Dahlin/Veres works despite these
155
+ targets sharing no cells with training.
156
+ - BBSE correction adjusts for the label-shift between training and
157
+ held-out prior distributions and is applied uniformly.
158
+
159
+ ## Recommendations for Continued Polish
160
+
161
+ - Delete the three `*_partial.json` intermediate artefacts.
162
+ - Ensure REVIEW_REPORT.md is regenerated on major experimental changes.
163
+ - Consider adding a `make reproduce` target that runs the full
164
+ end-to-end pipeline from raw data through discovery.
165
+
166
+ ## Addendum — 2026-07-19 true-zero-shot pass
167
+
168
+ The following additional strict-zero-shot experiments were added to §4 of PAPER.tex:
169
+
170
+ - **§4.1 Baron test-half (943 cells)** — pan-pancreas checkpoints run on the held-out
171
+ labeled test half, both variants. Raw acc PCA=0.6734 / Marker=0.4910; after merging
172
+ PANDA's higher-resolution subprototypes into Baron's flat labels (adult-β→β,
173
+ adult-α→α), biology-merged acc PCA=0.8324 / Marker=0.8621 (F1 0.6656 / 0.7672).
174
+ Artefacts: `discovery/pancreas/{pca,marker}/93_baron_{zero_shot,merged_metrics}.json`.
175
+
176
+ - **§4.2 Nestorowa Smart-seq2 (1,068 FACS-labeled cells)** — pan-HSC checkpoints,
177
+ both variants. Coarse LT-HSC vs. HSPC acc = 0.7978 both. Fine distribution shifts
178
+ between PCA (MPP-heavy) and Marker (myeloid-heavy: Ctsg/Mpo LMPP TF panel);
179
+ documented honestly in §4.2. Artefacts:
180
+ `discovery/hematopoiesis/{pca,marker}/94_nestorowa_zero_shot.json`.
181
+
182
+ - **§4.3 Sulic (pan-skin retrained without Sulic)** — pan-skin corpus rebuilt from
183
+ 103,550 → 98,867 cells, HVG list + PCA basis refit on the reduced corpus, both
184
+ variants retrained from scratch, then inference on Sulic labels. Runs at
185
+ `scripts/pan_skin/91_retrain_without_sulic.py`; final numbers write to
186
+ `discovery/pan_skin/{pca,marker}/96_sulic_zero_shot.json` when training completes.
187
+
188
+ - **§4.4 Rigor caveats** integrating findings from an independent audit against
189
+ the three source papers (Dingwall 2024 PMC10872420, Dahlin 2018 PMC5969381,
190
+ Veres 2019 PMC6903417). Confirmed canonical markers and central mechanistic claims;
191
+ softened one population size (EDEN 5,053 vs. Dingwall's cluster-20 ~900),
192
+ post-hoc-validated the "adult-β" naming (MAFA 9.8× / IAPP 2.5× / UCN3 2.1×
193
+ enriched vs. base beta; MNX1 depressed), and reframed the SC-δ reinterpretation
194
+ as a testable hypothesis rather than a claim.
195
+
196
+ ## Sign-off
197
+
198
+ This report closes the post-results review gate. The corrected paper
199
+ metrics ensure every quantitative claim traces cleanly to a JSON/CSV
200
+ under the discovery directories.
201
+
202
+ ## Addendum — 2026-08-19 re-audit (supersedes parts of the above)
203
+
204
+ A full re-audit of the pipeline against the mirrored data found that several
205
+ sign-offs above were incorrect. Corrections applied to the repo and PAPER.tex:
206
+
207
+ 1. **"BBSE label-shift correction implemented in inference path ✓" was wrong.**
208
+ No BBSE exists in the canonical inference path (`scripts/common/`); the one
209
+ legacy implementation (`scripts/pan_skin/30_zero_shot_aldrich.py`) used an
210
+ unconditionally-uniform training prior (a `np.bincount(np.arange(K))` bug)
211
+ and was never valid BBSE. The paper's inference section no longer claims it.
212
+ The cos<0.3 abstain gate likewise exists only in that legacy script.
213
+ 2. **"No data leakage" was wrong for Sulic.** All 4,683 Sulic cells — including
214
+ the 4,183-cell `held_out_labeled` slice — are inside the standard pan-skin
215
+ corpus (verified by barcode intersection). The paper's Sulic §5.5 numbers were
216
+ a training-set evaluation; replaced with the anchor-retrain results
217
+ (`97_sulic_anchor_zero_shot.json`). Nestorowa/Belote/Baron/Veres holdouts were
218
+ re-verified: zero barcode overlap with their training corpora.
219
+ 3. **The multi-seed "33/35" comparison was invalid.** `cv_5fold_seed{1,2}.json`
220
+ were produced on different corpus builds (e.g. skin: 103,550 cells / 24
221
+ classes vs the canonical 45,387 / 13) by a different CV driver. Claim
222
+ withdrawn; seeds regenerated with `run_cv.py --seed N` on canonical corpora.
223
+ 4. **Trainer/inference checkpoint path mismatch fixed.** `train_panda.py` saved
224
+ to `checkpoints/{system}_v3/` while all consumers read `checkpoints/{system}/`.
225
+ 5. **Curriculum description corrected.** The paper described LODO/RankMe-gated
226
+ stages, GRL ramping, mixup, and a P×K×D sampler; the implementation uses
227
+ fixed epoch-gated stages, constant λ_GRL=0.1, and uniform shuffling. The
228
+ prototype-repulsion term contributed zero gradient (detached EMA buffer) and
229
+ was removed from the objective (behaviour-preserving) and from the loss lists.
230
+ 6. **Reproducibility fixes.** All ~90 scripts now resolve the repo root via
231
+ `PANDA_ROOT` (env-overridable, `__file__`-relative default) instead of
232
+ hardcoded `/home/bcheng/PRISM`; Baron zero-shot reproduces bit-exactly from
233
+ the mirrored checkpoints (PCA 0.9426/0.6027, Marker 0.8190/0.5888).
234
+ Known gap: the per-dataset loader modules (`panda/data/*_loaders.py`) were
235
+ never committed, so corpus rebuild from raw GEO data requires reconstruction.
data/raw/GSE220977_combined.h5ad ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:e3b4389d6ab0cbddbd244b0e04df46ab768193b7866ab743c253d6022b06ee74
3
+ size 53962253
discovery/hematopoiesis/marker/66_dahlin_enrichment.csv ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ class,n_Kit_W41,n_WT,pct_Kit_W41,pct_WT,log2_fold,fisher_p
2
+ pro-B,6,21,0.041,0.091,-0.998,0.11212905224791753
3
+ megakaryocyte,1584,3965,10.794,17.164,-0.669,2.3319930527729648e-67
4
+ endothelial,1,4,0.007,0.017,-0.667,0.6548581003415141
5
+ lymphoid,55,139,0.375,0.602,-0.667,0.0024186029526503845
6
+ basophil-mast,245,569,1.67,2.463,-0.558,1.5509467694754263e-07
7
+ MPP,3823,8372,26.051,36.241,-0.476,3.794115798599155e-96
8
+ naive-B,1,3,0.007,0.013,-0.345,1.0
9
+ monocyte,157,242,1.07,1.048,0.033,0.8365429559094492
10
+ macrophage,21,28,0.143,0.121,0.256,0.5609714894758803
11
+ LT-HSC,25,32,0.17,0.139,0.311,0.4968196833343731
12
+ erythroid,7045,8057,48.007,34.877,0.461,1.598221798910364e-141
13
+ T-cell,4,4,0.027,0.017,0.655,0.719202247843629
14
+ myeloid,1708,1665,11.639,7.207,0.691,6.738577350112824e-48
discovery/hematopoiesis/marker/66_dahlin_enrichment_pooled_CONFOUNDED.csv ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ class,n_Kit_W41,n_WT,pct_Kit_W41,pct_WT,log2_fold,fisher_p
2
+ lymphoid,55,639,0.375,1.376,-1.852,1.1197303892308873e-28
3
+ pro-B,6,60,0.041,0.129,-1.461,0.0034713936539364585
4
+ MPP,3823,27931,26.051,60.135,-1.207,0.0
5
+ LT-HSC,25,135,0.17,0.291,-0.725,0.012107699545406006
6
+ megakaryocyte,1584,6596,10.794,14.201,-0.395,4.402462055443566e-27
7
+ endothelial,1,5,0.007,0.011,0.077,1.0
8
+ naive-B,1,4,0.007,0.009,0.34,1.0
9
+ basophil-mast,245,603,1.67,1.298,0.366,0.0010286727846522193
10
+ T-cell,4,10,0.027,0.022,0.525,0.7541626787698259
11
+ monocyte,157,315,1.07,0.678,0.662,5.079726729725271e-06
12
+ macrophage,21,42,0.143,0.09,0.695,0.10294110685379751
13
+ erythroid,7045,8352,48.007,17.982,1.417,0.0
14
+ myeloid,1708,1755,11.639,3.779,1.623,1.619214162006283e-243
discovery/hematopoiesis/marker/66_dahlin_gate_negative_control.csv ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ class,n_WT_LK,n_WT_LSK,pct_WT_LK,pct_WT_LSK,log2_fold,fisher_p
2
+ lymphoid,139,500,0.602,2.142,-1.824,1.101428413327718e-48
3
+ LT-HSC,32,103,0.139,0.441,-1.641,6.895372576865848e-10
4
+ MPP,8372,19559,36.241,83.779,-1.209,0.0
5
+ pro-B,21,39,0.091,0.167,-0.847,0.02736897086821087
6
+ T-cell,4,6,0.017,0.026,-0.47,0.7539139981439633
7
+ megakaryocyte,3965,2631,17.164,11.27,0.607,2.4318445901615237e-74
8
+ macrophage,28,14,0.121,0.06,0.966,0.030673995788373296
9
+ naive-B,3,1,0.013,0.004,1.015,0.37236720905366727
10
+ endothelial,4,1,0.017,0.004,1.337,0.21628326401517584
11
+ monocyte,242,73,1.048,0.313,1.731,5.957647765132e-23
12
+ basophil-mast,569,34,2.463,0.146,4.041,3.792290946441402e-129
13
+ myeloid,1665,90,7.207,0.386,4.21,0.0
14
+ erythroid,8057,295,34.877,1.264,4.782,0.0
discovery/pan_skin/marker/107_dingwall_class_deg_count.csv CHANGED
@@ -1,13 +1,13 @@
1
- class,n_cKO,n_WT,n_DEG,n_up,n_down,top_up,top_down
2
- HF-placode,259,287,12,12,0,2610027F03Rik;Mybpc1;Ccdc3;Kcnh7;Cntn5;Bmpr1b;Tnc;Esr1;Pcdh9;Meis2,
3
- melanoblast,2108,3035,8,7,1,2610027F03Rik;Mybpc1;Bmpr1b;En1;Kcnh7;Ttn;Esr1,Ctnnd2
4
- fibroblast-reticular,5464,8334,4,4,0,2610027F03Rik;Ttn;Mybpc1;Krt5,
5
- spinous,93,249,3,2,1,2610027F03Rik;Slc1a3,Acer3
6
- HF-ORS,118,210,2,2,0,2610027F03Rik;Mybpc1,
7
- basal-IFE,152,183,2,2,0,2610027F03Rik;Meis2,
8
- melanocyte,102,88,2,2,0,2610027F03Rik;Bmpr1b,
9
- endothelial,1501,2107,1,1,0,2610027F03Rik,
10
- immune,436,603,1,1,0,2610027F03Rik,
11
- granular,89,173,1,1,0,2610027F03Rik,
12
- melanocyte-precursor,45,78,1,1,0,2610027F03Rik,
13
- sebaceous,33,53,0,0,0,,
 
1
+ class,n_cKO,n_WT,n_DEG,n_up,n_down,top_up,top_down
2
+ HF-placode,259,287,12,12,0,2610027F03Rik;Mybpc1;Ccdc3;Kcnh7;Cntn5;Bmpr1b;Tnc;Esr1;Pcdh9;Meis2;Grip1;Runx1t1,
3
+ melanoblast,2108,3035,8,7,1,2610027F03Rik;Mybpc1;Bmpr1b;En1;Kcnh7;Ttn;Esr1,Ctnnd2
4
+ fibroblast-reticular,5464,8334,4,4,0,2610027F03Rik;Ttn;Mybpc1;Krt5,
5
+ spinous,93,249,3,2,1,2610027F03Rik;Slc1a3,Acer3
6
+ HF-ORS,118,210,2,2,0,2610027F03Rik;Mybpc1,
7
+ basal-IFE,152,183,2,2,0,2610027F03Rik;Meis2,
8
+ melanocyte,102,88,2,2,0,2610027F03Rik;Bmpr1b,
9
+ endothelial,1501,2107,1,1,0,2610027F03Rik,
10
+ granular,89,173,1,1,0,2610027F03Rik,
11
+ immune,436,603,1,1,0,2610027F03Rik,
12
+ melanocyte-precursor,45,78,1,1,0,2610027F03Rik,
13
+ sebaceous,33,53,0,0,0,,
discovery/pan_skin/marker/107_dingwall_class_deg_count.json CHANGED
@@ -1,59 +1,59 @@
1
- {
2
- "lfc_threshold": 1.0,
3
- "padj_threshold": 0.05,
4
- "n_classes_tested": 12,
5
- "top_class": "HF-placode",
6
- "top_n_DEG": 12,
7
- "top_up": "2610027F03Rik;Mybpc1;Ccdc3;Kcnh7;Cntn5;Bmpr1b;Tnc;Esr1;Pcdh9;Meis2",
8
- "top_down": "",
9
- "ranking": [
10
- {
11
- "class": "HF-placode",
12
- "n_DEG": 12
13
- },
14
- {
15
- "class": "melanoblast",
16
- "n_DEG": 8
17
- },
18
- {
19
- "class": "fibroblast-reticular",
20
- "n_DEG": 4
21
- },
22
- {
23
- "class": "spinous",
24
- "n_DEG": 3
25
- },
26
- {
27
- "class": "HF-ORS",
28
- "n_DEG": 2
29
- },
30
- {
31
- "class": "basal-IFE",
32
- "n_DEG": 2
33
- },
34
- {
35
- "class": "melanocyte",
36
- "n_DEG": 2
37
- },
38
- {
39
- "class": "endothelial",
40
- "n_DEG": 1
41
- },
42
- {
43
- "class": "immune",
44
- "n_DEG": 1
45
- },
46
- {
47
- "class": "granular",
48
- "n_DEG": 1
49
- },
50
- {
51
- "class": "melanocyte-precursor",
52
- "n_DEG": 1
53
- },
54
- {
55
- "class": "sebaceous",
56
- "n_DEG": 0
57
- }
58
- ]
59
  }
 
1
+ {
2
+ "lfc_threshold": 1.0,
3
+ "padj_threshold": 0.05,
4
+ "n_classes_tested": 12,
5
+ "top_class": "HF-placode",
6
+ "top_n_DEG": 12,
7
+ "top_up": "2610027F03Rik;Mybpc1;Ccdc3;Kcnh7;Cntn5;Bmpr1b;Tnc;Esr1;Pcdh9;Meis2;Grip1;Runx1t1",
8
+ "top_down": "",
9
+ "ranking": [
10
+ {
11
+ "class": "HF-placode",
12
+ "n_DEG": 12
13
+ },
14
+ {
15
+ "class": "melanoblast",
16
+ "n_DEG": 8
17
+ },
18
+ {
19
+ "class": "fibroblast-reticular",
20
+ "n_DEG": 4
21
+ },
22
+ {
23
+ "class": "spinous",
24
+ "n_DEG": 3
25
+ },
26
+ {
27
+ "class": "HF-ORS",
28
+ "n_DEG": 2
29
+ },
30
+ {
31
+ "class": "basal-IFE",
32
+ "n_DEG": 2
33
+ },
34
+ {
35
+ "class": "melanocyte",
36
+ "n_DEG": 2
37
+ },
38
+ {
39
+ "class": "endothelial",
40
+ "n_DEG": 1
41
+ },
42
+ {
43
+ "class": "granular",
44
+ "n_DEG": 1
45
+ },
46
+ {
47
+ "class": "immune",
48
+ "n_DEG": 1
49
+ },
50
+ {
51
+ "class": "melanocyte-precursor",
52
+ "n_DEG": 1
53
+ },
54
+ {
55
+ "class": "sebaceous",
56
+ "n_DEG": 0
57
+ }
58
+ ]
59
  }
figures/PANDA_all_figures.pdf CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:4586d33cce1b0499d3ebbb6e52a09fab4770b68303301763ee13225581ce115e
3
- size 14422644
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:a98c7e9621475d48e6dd8be321084581b5f9d9d4d55e2ac1acb0ad1b28363d1c
3
+ size 16048898
figures/PANDA_supplement.pdf CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:5c6a2d3eb575b34d8ea5d24a4d423e16ddf7d32f40b8cc06c374d12ec49940b0
3
- size 13465296
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:01aecd5d926dada793372dafc962878d91908e2c7f2859eedcf51a35175d81e5
3
+ size 12124452
figures/biology/biology_01_dingwall_umap.pdf CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:dad41b3e00db248105504178a4e03ebf951d73c3a23f0d02db32000daf02237c
3
- size 785379
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:f8763029b3fe23db81f089a2d6dc659f8b0f62510561e14ee8897c2200d17000
3
+ size 769877
figures/biology/biology_02_primary_eden.pdf CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:c3963d2891a3048e4f67aefb25d4d1ec60daa0a9dac3aec5295177880523ee7f
3
- size 655555
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:fd5290f3a2f9e68ba811a4a977e55de8cbdc253556829ef92f9f6107c24c34c8
3
+ size 646125
figures/biology/biology_03_melanoblast_mitf.pdf CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:3bc20beca0634ee2cbdd92016ec63bc526ffd24918610c4a83d44b480177d0cb
3
- size 177652
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:f271d84e95f71fd3cdbcf80f30382d6158b7ed2f2568fd499b07f46a7d135af8
3
+ size 187770
figures/biology/biology_04_dahlin_metabolism.pdf CHANGED
Binary files a/figures/biology/biology_04_dahlin_metabolism.pdf and b/figures/biology/biology_04_dahlin_metabolism.pdf differ
 
figures/biology/biology_05_dahlin_composition.pdf CHANGED
Binary files a/figures/biology/biology_05_dahlin_composition.pdf and b/figures/biology/biology_05_dahlin_composition.pdf differ
 
figures/biology/biology_06_veres_beta_quadrant.pdf CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:03cd993aed1a3870ffa3691543034584d64e87b6082603f1c80f990c7f7a4ca9
3
- size 149339
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:dd2bd05927e0ce064591d84955a3cb826d99caba6a3539735e610ad6a71dbe5e
3
+ size 147839
figures/biology/biology_07_veres_polyhormonal.pdf CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:672b853bedee5c765f376717f90e724cc71ee87970e961d3af6472378d7559c1
3
- size 184982
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:1f240751d61b29cb519adc75fafde6cef607f8bc9b0916cde0962c29d9780b44
3
+ size 193704
figures/biology/biology_08_prototype_geometry.pdf CHANGED
Binary files a/figures/biology/biology_08_prototype_geometry.pdf and b/figures/biology/biology_08_prototype_geometry.pdf differ
 
figures/fig1_perclass_f1.pdf ADDED
Binary file (25.6 kB). View file
 
figures/fig3_dahlin_heatmap.pdf CHANGED
Binary files a/figures/fig3_dahlin_heatmap.pdf and b/figures/fig3_dahlin_heatmap.pdf differ
 
figures/fig4_veres_stage_stack.pdf CHANGED
Binary files a/figures/fig4_veres_stage_stack.pdf and b/figures/fig4_veres_stage_stack.pdf differ
 
figures/supplement/01_cv_summary.pdf CHANGED
Binary files a/figures/supplement/01_cv_summary.pdf and b/figures/supplement/01_cv_summary.pdf differ
 
figures/supplement/02_per_class_f1.pdf CHANGED
Binary files a/figures/supplement/02_per_class_f1.pdf and b/figures/supplement/02_per_class_f1.pdf differ
 
figures/supplement/03_prototype_cosine.pdf CHANGED
Binary files a/figures/supplement/03_prototype_cosine.pdf and b/figures/supplement/03_prototype_cosine.pdf differ
 
figures/supplement/05_adversary_purification.pdf CHANGED
Binary files a/figures/supplement/05_adversary_purification.pdf and b/figures/supplement/05_adversary_purification.pdf differ
 
figures/supplement/06_cross_system_prototypes.pdf CHANGED
Binary files a/figures/supplement/06_cross_system_prototypes.pdf and b/figures/supplement/06_cross_system_prototypes.pdf differ
 
figures/supplement/20_placode_wnt_module.pdf CHANGED
Binary files a/figures/supplement/20_placode_wnt_module.pdf and b/figures/supplement/20_placode_wnt_module.pdf differ
 
figures/supplement/23_anchor_delta_recall.pdf CHANGED
Binary files a/figures/supplement/23_anchor_delta_recall.pdf and b/figures/supplement/23_anchor_delta_recall.pdf differ
 
figures/supplement/26_pca_vs_marker_umaps_veres_by_stage.pdf CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:9f2b405614801f1390251b19892b64a8ceaaff83aa788ca911586ec8eafdd43c
3
- size 373429
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:80e1ba2c6442ab8977e4225276222562c96d7d59804978d22573ac4fd6689af7
3
+ size 1287055
figures/supplement/26b_pca_vs_marker_umaps_veres_by_class.pdf CHANGED
Binary files a/figures/supplement/26b_pca_vs_marker_umaps_veres_by_class.pdf and b/figures/supplement/26b_pca_vs_marker_umaps_veres_by_class.pdf differ
 
panda/__init__.py CHANGED
@@ -1,16 +1,16 @@
1
- """panda: prototype-anchored cell-identity classifier. marker variant is default."""
2
- __version__ = "1.0"
3
-
4
- from .model import (
5
- PANDAEncoder,
6
- grad_reverse,
7
- supcon_loss, vicreg_loss, hsic_biased,
8
- subcenter_angular_infonce,
9
- prototype_repulsion,
10
- )
11
-
12
- __all__ = [
13
- "PANDAEncoder",
14
- "grad_reverse", "supcon_loss", "vicreg_loss", "hsic_biased",
15
- "subcenter_angular_infonce", "prototype_repulsion",
16
- ]
 
1
+ """panda: prototype-anchored cell-identity classifier. marker variant is default."""
2
+ __version__ = "1.0"
3
+
4
+ from .model import (
5
+ PANDAEncoder,
6
+ grad_reverse,
7
+ supcon_loss, vicreg_loss, hsic_biased,
8
+ subcenter_angular_infonce,
9
+ prototype_repulsion,
10
+ )
11
+
12
+ __all__ = [
13
+ "PANDAEncoder",
14
+ "grad_reverse", "supcon_loss", "vicreg_loss", "hsic_biased",
15
+ "subcenter_angular_infonce", "prototype_repulsion",
16
+ ]
panda/markers.yaml CHANGED
@@ -1,132 +1,135 @@
1
- # Curated marker-channel input genes per system for PANDA-Marker variant.
2
- #
3
- # These are the SIBLING-DISCRIMINATING genes that PCA(50) tends to discard.
4
- # They are concatenated directly onto PCA(50) as log-normalised expression,
5
- # bypassing the PCA bottleneck for the low-variance signal that separates
6
- # closely-related subtypes.
7
- #
8
- # Set size targets: 20-40 markers per system. Curated for BOTH:
9
- # (a) each class's positive identity marker (recovers within-class)
10
- # (b) sibling-pair contrast markers (e.g. Dkk4 for eccrine vs hair placode)
11
- #
12
- # All symbols are mouse gene symbols (case-fold applied at inference for
13
- # cross-species targets like Veres).
14
-
15
- pan_skin:
16
- # eccrine placode / nascent-gland / hair-placode axis
17
- - Dkk4 # eccrine placode > hair placode (Dingwall Fig 2)
18
- - Lgr6 # placode marker (Epi3)
19
- - Trpv6 # nascent eccrine gland (Epi5/Epi8)
20
- - Edar # both placodes; hair > eccrine
21
- - Shh # hair placode discriminator
22
- - Sox9 # hair placode / HF-primary-germ
23
- - Lhx2 # hair placode
24
- - Foxi3 # placode induction
25
- - Wnt6 # placode Wnt program
26
- - Wnt7b # placode Wnt program
27
- - Lef1 # Wnt signalling
28
- - Bmp7 # placode
29
- # eccrine/appendage identity
30
- - En1 # spatial repressor of eccrine outside placode
31
- - Grhl3 # eccrine-duct master TF
32
- - Cldn6 # eccrine tight junction
33
- - Kremen2 # placode / eccrine
34
- # basal / IFE / spinous / granular
35
- - Krt5 # basal
36
- - Krt14 # basal
37
- - Krt10 # spinous differentiation
38
- - Krt1 # spinous differentiation
39
- - Trp63 # basal master
40
- # melanocyte MITF regulon
41
- - Dct # melanocyte
42
- - Mlana # melanocyte
43
- - Tyrp1 # melanocyte
44
- - Pmel # melanocyte
45
- - Sox10 # neural crest / melanocyte
46
- # endothelial / fibroblast / immune
47
- - Pecam1 # endothelial
48
- - Cdh5 # endothelial
49
- - Col1a1 # fibroblast reticular
50
- - Dcn # fibroblast papillary
51
- - Ptprc # immune (CD45)
52
- # dermal niche (EDEN — Dingwall §5)
53
- - S100a4 # EDEN dermal niche
54
- - Pdgfra # dermal mesenchyme
55
-
56
- hematopoiesis:
57
- # LT-HSC / stem
58
- - Hlf # LT-HSC canonical (Komorowska 2017)
59
- - Meis1 # HSC/MPP
60
- - Mecom # LT-HSC
61
- - Procr # LT-HSC (EPCR)
62
- - Fgd5 # LT-HSC reporter
63
- - Mllt3 # LT-HSC self-renewal
64
- # MPP substates
65
- - Cd48 # MPP1 vs LT
66
- - Flt3 # MPP4/LMPP
67
- - Sell # ST-HSC / MPP1 (CD62L)
68
- - Slamf1 # SLAM CD150
69
- # erythroid
70
- - Klf1 # erythroid master TF
71
- - Gata1 # erythroid/mega
72
- - Car1 # erythroblast
73
- - Car2 # erythroblast
74
- - Blvrb # committed erythroid
75
- - Hba-a1 # hemoglobin
76
- # megakaryocyte
77
- - Itga2b # CD41 megakaryocyte
78
- - Pf4 # megakaryocyte
79
- - Gp1bb # megakaryocyte
80
- # lymphoid
81
- - Dntt # pre-B / lymphoid
82
- - Vpreb1 # pre-B lymphoid
83
- - Vpreb3 # pre-B lymphoid
84
- - Il7r # lymphoid progenitor
85
- # myeloid / mast / basophil
86
- - Elane # GMP / granulocyte
87
- - Mpo # myeloid
88
- - Cpa3 # basophil-mast lineage
89
- - Ms4a2 # mast Fc-epsilon-RI
90
- - Csf1r # macrophage
91
- # macrophage antimicrobial (myeloid combinatorial identity per §10.3)
92
- - Wfdc17
93
- - Mmp8
94
- - Ctss
95
-
96
- pancreas:
97
- # endocrine progenitor (Ngn3 → Fev → hormone axis)
98
- - Neurog3 # Ngn3 endocrine progenitor
99
- - Fev # Fev+ intermediate EP
100
- - Pax4 # alpha/beta bipotential
101
- - Insm1 # endocrine progenitor
102
- - Neurod1 # beta / late endocrine
103
- - Cbfa2t3 # endocrine progenitor
104
- - Btbd17 # endocrine progenitor
105
- # alpha lineage
106
- - Arx # alpha master
107
- - Irx1 # alpha
108
- - Irx2 # alpha (Veres §7 finding)
109
- - Mafb # alpha
110
- - Gcg # glucagon
111
- # beta lineage
112
- - Nkx6-1 # beta master
113
- - Mnx1 # beta
114
- - Ins1 # insulin
115
- - Ins2 # insulin
116
- - Mafa # mature beta
117
- - Pdx1 # pancreatic progenitor + beta
118
- # delta / gamma / epsilon
119
- - Sst # delta
120
- - Hhex # delta
121
- - Ppy # gamma (PP)
122
- - Aqp3 # gamma
123
- - Ghrl # epsilon
124
- # exocrine / stromal
125
- - Prss1 # acinar
126
- - Cel # acinar
127
- - Ptf1a # acinar/pro-endocrine
128
- - Krt19 # ductal
129
- - Sox9 # ductal / progenitor
130
- # embryonic hematopoiesis contamination flag (per §10.3 finding)
131
- - Hbb-bs
132
- - Hba-a1
 
 
 
 
1
+ # Curated marker-channel input genes per system for PANDA-Marker variant.
2
+ #
3
+ # These are the SIBLING-DISCRIMINATING genes that PCA(50) tends to discard.
4
+ # They are concatenated directly onto PCA(50) as z-scored RAW-count expression
5
+ # (clipped to +/-5; see prepare_batches in scripts/common/train_panda.py the
6
+ # values are read from the un-normalized AnnData, then z-scored), bypassing the
7
+ # PCA bottleneck for the low-variance signal that separates closely-related
8
+ # subtypes. Checkpoints trained after 2026-08 store the training-corpus marker
9
+ # mean/std (marker_mu/marker_sig) so inference reuses the training scale.
10
+ #
11
+ # Set size targets: 20-40 markers per system. Curated for BOTH:
12
+ # (a) each class's positive identity marker (recovers within-class)
13
+ # (b) sibling-pair contrast markers (e.g. Dkk4 for eccrine vs hair placode)
14
+ #
15
+ # All symbols are mouse gene symbols (case-fold applied at inference for
16
+ # cross-species targets like Veres).
17
+
18
+ pan_skin:
19
+ # eccrine placode / nascent-gland / hair-placode axis
20
+ - Dkk4 # eccrine placode > hair placode (Dingwall Fig 2)
21
+ - Lgr6 # placode marker (Epi3)
22
+ - Trpv6 # nascent eccrine gland (Epi5/Epi8)
23
+ - Edar # both placodes; hair > eccrine
24
+ - Shh # hair placode discriminator
25
+ - Sox9 # hair placode / HF-primary-germ
26
+ - Lhx2 # hair placode
27
+ - Foxi3 # placode induction
28
+ - Wnt6 # placode Wnt program
29
+ - Wnt7b # placode Wnt program
30
+ - Lef1 # Wnt signalling
31
+ - Bmp7 # placode
32
+ # eccrine/appendage identity
33
+ - En1 # spatial repressor of eccrine outside placode
34
+ - Grhl3 # eccrine-duct master TF
35
+ - Cldn6 # eccrine tight junction
36
+ - Kremen2 # placode / eccrine
37
+ # basal / IFE / spinous / granular
38
+ - Krt5 # basal
39
+ - Krt14 # basal
40
+ - Krt10 # spinous differentiation
41
+ - Krt1 # spinous differentiation
42
+ - Trp63 # basal master
43
+ # melanocyte MITF regulon
44
+ - Dct # melanocyte
45
+ - Mlana # melanocyte
46
+ - Tyrp1 # melanocyte
47
+ - Pmel # melanocyte
48
+ - Sox10 # neural crest / melanocyte
49
+ # endothelial / fibroblast / immune
50
+ - Pecam1 # endothelial
51
+ - Cdh5 # endothelial
52
+ - Col1a1 # fibroblast reticular
53
+ - Dcn # fibroblast papillary
54
+ - Ptprc # immune (CD45)
55
+ # dermal niche (EDEN — Dingwall §5)
56
+ - S100a4 # EDEN dermal niche
57
+ - Pdgfra # dermal mesenchyme
58
+
59
+ hematopoiesis:
60
+ # LT-HSC / stem
61
+ - Hlf # LT-HSC canonical (Komorowska 2017)
62
+ - Meis1 # HSC/MPP
63
+ - Mecom # LT-HSC
64
+ - Procr # LT-HSC (EPCR)
65
+ - Fgd5 # LT-HSC reporter
66
+ - Mllt3 # LT-HSC self-renewal
67
+ # MPP substates
68
+ - Cd48 # MPP1 vs LT
69
+ - Flt3 # MPP4/LMPP
70
+ - Sell # ST-HSC / MPP1 (CD62L)
71
+ - Slamf1 # SLAM CD150
72
+ # erythroid
73
+ - Klf1 # erythroid master TF
74
+ - Gata1 # erythroid/mega
75
+ - Car1 # erythroblast
76
+ - Car2 # erythroblast
77
+ - Blvrb # committed erythroid
78
+ - Hba-a1 # hemoglobin
79
+ # megakaryocyte
80
+ - Itga2b # CD41 megakaryocyte
81
+ - Pf4 # megakaryocyte
82
+ - Gp1bb # megakaryocyte
83
+ # lymphoid
84
+ - Dntt # pre-B / lymphoid
85
+ - Vpreb1 # pre-B lymphoid
86
+ - Vpreb3 # pre-B lymphoid
87
+ - Il7r # lymphoid progenitor
88
+ # myeloid / mast / basophil
89
+ - Elane # GMP / granulocyte
90
+ - Mpo # myeloid
91
+ - Cpa3 # basophil-mast lineage
92
+ - Ms4a2 # mast Fc-epsilon-RI
93
+ - Csf1r # macrophage
94
+ # macrophage antimicrobial (myeloid combinatorial identity per §10.3)
95
+ - Wfdc17
96
+ - Mmp8
97
+ - Ctss
98
+
99
+ pancreas:
100
+ # endocrine progenitor (Ngn3 → Fev → hormone axis)
101
+ - Neurog3 # Ngn3 endocrine progenitor
102
+ - Fev # Fev+ intermediate EP
103
+ - Pax4 # alpha/beta bipotential
104
+ - Insm1 # endocrine progenitor
105
+ - Neurod1 # beta / late endocrine
106
+ - Cbfa2t3 # endocrine progenitor
107
+ - Btbd17 # endocrine progenitor
108
+ # alpha lineage
109
+ - Arx # alpha master
110
+ - Irx1 # alpha
111
+ - Irx2 # alpha (Veres §7 finding)
112
+ - Mafb # alpha
113
+ - Gcg # glucagon
114
+ # beta lineage
115
+ - Nkx6-1 # beta master
116
+ - Mnx1 # beta
117
+ - Ins1 # insulin
118
+ - Ins2 # insulin
119
+ - Mafa # mature beta
120
+ - Pdx1 # pancreatic progenitor + beta
121
+ # delta / gamma / epsilon
122
+ - Sst # delta
123
+ - Hhex # delta
124
+ - Ppy # gamma (PP)
125
+ - Aqp3 # gamma
126
+ - Ghrl # epsilon
127
+ # exocrine / stromal
128
+ - Prss1 # acinar
129
+ - Cel # acinar
130
+ - Ptf1a # acinar/pro-endocrine
131
+ - Krt19 # ductal
132
+ - Sox9 # ductal / progenitor
133
+ # embryonic hematopoiesis contamination flag (per §10.3 finding)
134
+ - Hbb-bs
135
+ - Hba-a1
panda/model.py CHANGED
@@ -1,229 +1,229 @@
1
- """panda encoder with two input variants (pca / marker), sub-center prototypes."""
2
- from __future__ import annotations
3
- import math
4
- from dataclasses import dataclass
5
- from typing import Optional, List
6
-
7
- import torch
8
- import torch.nn as nn
9
- import torch.nn.functional as F
10
- from torch.autograd import Function
11
-
12
-
13
- # gradient reversal layer
14
-
15
- class GradReverse(Function):
16
- @staticmethod
17
- def forward(ctx, x, lam):
18
- ctx.lam = lam
19
- return x.view_as(x)
20
-
21
- @staticmethod
22
- def backward(ctx, g):
23
- return -ctx.lam * g, None
24
-
25
-
26
- def grad_reverse(x, lam):
27
- return GradReverse.apply(x, lam)
28
-
29
-
30
- # encoder with variant + sub-centers
31
-
32
- class PANDAEncoder(nn.Module):
33
- """trunk + projection head + sub-center prototypes.
34
-
35
- args:
36
- variant : "pca" or "marker"
37
- n_pca : 50
38
- n_markers : m >= 0. required > 0 if variant == "marker".
39
- n_classes : K
40
- n_sub : sub-centers per class (default 3)
41
- d_hidden, d_repr, d_proj: trunk sizing
42
- n_datasets : for the dataset adversary head
43
- """
44
-
45
- def __init__(
46
- self,
47
- variant: str = "pca",
48
- n_pca: int = 50,
49
- n_markers: int = 0,
50
- d_hidden: int = 512,
51
- d_repr: int = 256,
52
- d_proj: int = 128,
53
- n_classes: int = 10,
54
- n_sub: int = 3,
55
- n_datasets: int = 1,
56
- dropout: float = 0.2,
57
- ):
58
- super().__init__()
59
- assert variant in ("pca", "marker"), variant
60
- if variant == "marker":
61
- assert n_markers > 0, "PANDA-Marker requires n_markers>0"
62
- self.variant = variant
63
- self.n_pca = n_pca
64
- self.n_markers = n_markers if variant == "marker" else 0
65
- self.n_classes = n_classes
66
- self.n_sub = n_sub
67
- self.n_datasets = n_datasets
68
-
69
- input_dim = n_pca + self.n_markers
70
- self.input_dim = input_dim
71
-
72
- self.trunk = nn.Sequential(
73
- nn.Linear(input_dim, d_hidden), nn.LayerNorm(d_hidden), nn.GELU(), nn.Dropout(dropout),
74
- nn.Linear(d_hidden, d_hidden), nn.LayerNorm(d_hidden), nn.GELU(), nn.Dropout(dropout),
75
- nn.Linear(d_hidden, d_repr), nn.LayerNorm(d_repr), nn.GELU(),
76
- )
77
- self.projection = nn.Sequential(
78
- nn.Linear(d_repr, d_repr), nn.GELU(),
79
- nn.Linear(d_repr, d_proj),
80
- )
81
- self.classifier = nn.Sequential(nn.Linear(d_repr + 2, n_classes))
82
- self.dom_adv = nn.Sequential(nn.Linear(d_repr, 128), nn.ReLU(), nn.Linear(128, n_datasets))
83
- self.depth_adv = nn.Sequential(nn.Linear(d_repr, 64), nn.ReLU(), nn.Linear(64, 1))
84
-
85
- # sub-center prototypes (K, n_sub, d_proj), L2-normalised per sub-center
86
- self.register_buffer(
87
- "prototypes",
88
- F.normalize(torch.randn(n_classes, n_sub, d_proj), dim=-1),
89
- )
90
- # EMA momentum as a buffer so we can overwrite it in place
91
- self.register_buffer("proto_ema", torch.tensor(0.99))
92
-
93
- @torch.no_grad()
94
- def update_prototypes(self, z_norm: torch.Tensor, y: torch.Tensor):
95
- """ema update: assign each in-class cell to nearest sub-center, take the mean."""
96
- ema = float(self.proto_ema.item())
97
- for c in torch.unique(y):
98
- mask = y == c
99
- if not mask.any():
100
- continue
101
- zc = z_norm[mask] # (n_c, d_proj)
102
- protos_c = self.prototypes[c] # (n_sub, d_proj)
103
- sims = zc @ protos_c.T # (n_c, n_sub)
104
- assign = sims.argmax(dim=1) # each cell -> nearest sub-center
105
- for k in range(self.n_sub):
106
- m2 = assign == k
107
- if not m2.any():
108
- continue
109
- new = F.normalize(zc[m2].mean(dim=0), dim=0)
110
- self.prototypes[c, k] = F.normalize(
111
- ema * self.prototypes[c, k] + (1 - ema) * new, dim=0
112
- )
113
-
114
- @torch.no_grad()
115
- def max_sub_cos(self, z_norm: torch.Tensor) -> torch.Tensor:
116
- """(B, K) cos(z, best sub-center) per class."""
117
- B = z_norm.size(0); K, n_sub, D = self.prototypes.shape
118
- sims = torch.einsum("bd,ksd->bks", z_norm, self.prototypes) # (B, K, n_sub)
119
- return sims.max(dim=2).values # (B, K)
120
-
121
- def forward(
122
- self,
123
- x_pca: torch.Tensor,
124
- aux: torch.Tensor,
125
- x_markers: Optional[torch.Tensor] = None,
126
- lam_dann: float = 0.0,
127
- ) -> dict:
128
- if self.variant == "marker":
129
- assert x_markers is not None and x_markers.size(1) == self.n_markers
130
- x = torch.cat([x_pca, x_markers], dim=1)
131
- else:
132
- x = x_pca
133
-
134
- h = self.trunk(x)
135
- z_raw = self.projection(h)
136
- z = F.normalize(z_raw, dim=1)
137
- logits = self.classifier(torch.cat([h, aux], dim=1))
138
- h_rev = grad_reverse(h, lam_dann)
139
- return {
140
- "repr": h,
141
- "z": z,
142
- "logits": logits,
143
- "dom": self.dom_adv(h_rev),
144
- "depth": self.depth_adv(h_rev),
145
- }
146
-
147
-
148
- # losses
149
-
150
- def supcon_loss(z: torch.Tensor, y: torch.Tensor, temperature: float = 0.1) -> torch.Tensor:
151
- if z.size(0) < 2:
152
- return z.new_zeros(())
153
- sim = z @ z.T / temperature
154
- sim_max, _ = sim.max(dim=1, keepdim=True)
155
- sim = sim - sim_max.detach()
156
- logits_mask = torch.ones_like(sim) - torch.eye(z.size(0), device=z.device)
157
- exp_sim = torch.exp(sim) * logits_mask
158
- log_prob = sim - torch.log(exp_sim.sum(dim=1, keepdim=True) + 1e-12)
159
- labels_eq = (y.unsqueeze(0) == y.unsqueeze(1)).float() * logits_mask
160
- denom = labels_eq.sum(dim=1).clamp_min(1.0)
161
- per = -(labels_eq * log_prob).sum(dim=1) / denom
162
- per = per * (labels_eq.sum(dim=1) > 0).float()
163
- counts = torch.bincount(y, minlength=int(y.max().item()) + 1).float().clamp_min(1.0)
164
- w = 1.0 / counts.sqrt()
165
- return (per * w[y]).sum() / w[y].sum().clamp_min(1e-6)
166
-
167
-
168
- def vicreg_loss(z: torch.Tensor, sim_weight: float = 0.0, var_weight: float = 25.0,
169
- cov_weight: float = 1.0) -> torch.Tensor:
170
- zc = z - z.mean(dim=0, keepdim=True)
171
- std = (zc.var(dim=0) + 1e-4).sqrt()
172
- var_loss = F.relu(1.0 - std).mean()
173
- N, D = zc.shape
174
- cov = (zc.T @ zc) / (N - 1)
175
- off = cov - torch.diag(torch.diagonal(cov))
176
- cov_loss = off.pow(2).sum() / D
177
- return var_weight * var_loss + cov_weight * cov_loss
178
-
179
-
180
- def hsic_biased(x: torch.Tensor, y: torch.Tensor,
181
- sigma_x: float = 1.0, sigma_y: float = 1.0) -> torch.Tensor:
182
- Nx = x.size(0)
183
- if Nx < 2:
184
- return x.new_zeros(())
185
- K = torch.exp(-torch.cdist(x, x) ** 2 / (2 * sigma_x ** 2))
186
- L = torch.exp(-torch.cdist(y, y) ** 2 / (2 * sigma_y ** 2))
187
- H = torch.eye(Nx, device=x.device) - torch.ones(Nx, Nx, device=x.device) / Nx
188
- return (K @ H @ L @ H).trace() / (Nx - 1) ** 2
189
-
190
-
191
- def subcenter_angular_infonce(
192
- z: torch.Tensor, # (B, d_proj) L2-normalised
193
- y: torch.Tensor, # (B,)
194
- prototypes: torch.Tensor, # (K, n_sub, d_proj)
195
- margin: float = 0.15, # angular margin in radians
196
- temperature: float = 0.07,
197
- ) -> torch.Tensor:
198
- """arcface-style angular-margin loss over sub-center prototypes."""
199
- B = z.size(0); K, n_sub, D = prototypes.shape
200
- sims = torch.einsum("bd,ksd->bks", z, prototypes) # (B, K, n_sub)
201
- max_over_sub = sims.max(dim=2).values # (B, K)
202
-
203
- # target class cosine, bump by angular margin, put back
204
- target_cos = max_over_sub.gather(1, y.unsqueeze(1)).squeeze(1) # (B,)
205
- target_cos = target_cos.clamp(-1 + 1e-7, 1 - 1e-7)
206
- theta = torch.acos(target_cos)
207
- target_new_cos = torch.cos(theta + margin)
208
-
209
- logits = max_over_sub.clone()
210
- logits.scatter_(1, y.unsqueeze(1), target_new_cos.unsqueeze(1))
211
- logits = logits / temperature
212
- return F.cross_entropy(logits, y)
213
-
214
-
215
- def prototype_repulsion(prototypes: torch.Tensor, weight: float = 1.0) -> torch.Tensor:
216
- """penalise inter-class prototype cosine so eff-dim doesn't collapse."""
217
- K, n_sub, D = prototypes.shape
218
- centroids = F.normalize(prototypes.mean(dim=1), dim=1) # (K, D)
219
- sim = centroids @ centroids.T # (K, K)
220
- off = sim - torch.diag(torch.diagonal(sim))
221
- return weight * off.pow(2).sum() / (K * (K - 1) + 1e-6)
222
-
223
-
224
- def prototype_infonce_legacy(z, y, prototypes, temperature=0.07):
225
- """legacy single-prototype InfoNCE. kept for debugging + old checkpoints."""
226
- if prototypes.dim() == 3:
227
- prototypes = F.normalize(prototypes.mean(dim=1), dim=1) # collapse sub-centers
228
- logits = z @ prototypes.T / temperature
229
- return F.cross_entropy(logits, y)
 
1
+ """panda encoder with two input variants (pca / marker), sub-center prototypes."""
2
+ from __future__ import annotations
3
+ import math
4
+ from dataclasses import dataclass
5
+ from typing import Optional, List
6
+
7
+ import torch
8
+ import torch.nn as nn
9
+ import torch.nn.functional as F
10
+ from torch.autograd import Function
11
+
12
+
13
+ # gradient reversal layer
14
+
15
+ class GradReverse(Function):
16
+ @staticmethod
17
+ def forward(ctx, x, lam):
18
+ ctx.lam = lam
19
+ return x.view_as(x)
20
+
21
+ @staticmethod
22
+ def backward(ctx, g):
23
+ return -ctx.lam * g, None
24
+
25
+
26
+ def grad_reverse(x, lam):
27
+ return GradReverse.apply(x, lam)
28
+
29
+
30
+ # encoder with variant + sub-centers
31
+
32
+ class PANDAEncoder(nn.Module):
33
+ """trunk + projection head + sub-center prototypes.
34
+
35
+ args:
36
+ variant : "pca" or "marker"
37
+ n_pca : 50
38
+ n_markers : m >= 0. required > 0 if variant == "marker".
39
+ n_classes : K
40
+ n_sub : sub-centers per class (default 3)
41
+ d_hidden, d_repr, d_proj: trunk sizing
42
+ n_datasets : for the dataset adversary head
43
+ """
44
+
45
+ def __init__(
46
+ self,
47
+ variant: str = "pca",
48
+ n_pca: int = 50,
49
+ n_markers: int = 0,
50
+ d_hidden: int = 512,
51
+ d_repr: int = 256,
52
+ d_proj: int = 128,
53
+ n_classes: int = 10,
54
+ n_sub: int = 3,
55
+ n_datasets: int = 1,
56
+ dropout: float = 0.2,
57
+ ):
58
+ super().__init__()
59
+ assert variant in ("pca", "marker"), variant
60
+ if variant == "marker":
61
+ assert n_markers > 0, "PANDA-Marker requires n_markers>0"
62
+ self.variant = variant
63
+ self.n_pca = n_pca
64
+ self.n_markers = n_markers if variant == "marker" else 0
65
+ self.n_classes = n_classes
66
+ self.n_sub = n_sub
67
+ self.n_datasets = n_datasets
68
+
69
+ input_dim = n_pca + self.n_markers
70
+ self.input_dim = input_dim
71
+
72
+ self.trunk = nn.Sequential(
73
+ nn.Linear(input_dim, d_hidden), nn.LayerNorm(d_hidden), nn.GELU(), nn.Dropout(dropout),
74
+ nn.Linear(d_hidden, d_hidden), nn.LayerNorm(d_hidden), nn.GELU(), nn.Dropout(dropout),
75
+ nn.Linear(d_hidden, d_repr), nn.LayerNorm(d_repr), nn.GELU(),
76
+ )
77
+ self.projection = nn.Sequential(
78
+ nn.Linear(d_repr, d_repr), nn.GELU(),
79
+ nn.Linear(d_repr, d_proj),
80
+ )
81
+ self.classifier = nn.Sequential(nn.Linear(d_repr + 2, n_classes))
82
+ self.dom_adv = nn.Sequential(nn.Linear(d_repr, 128), nn.ReLU(), nn.Linear(128, n_datasets))
83
+ self.depth_adv = nn.Sequential(nn.Linear(d_repr, 64), nn.ReLU(), nn.Linear(64, 1))
84
+
85
+ # sub-center prototypes (K, n_sub, d_proj), L2-normalised per sub-center
86
+ self.register_buffer(
87
+ "prototypes",
88
+ F.normalize(torch.randn(n_classes, n_sub, d_proj), dim=-1),
89
+ )
90
+ # EMA momentum as a buffer so we can overwrite it in place
91
+ self.register_buffer("proto_ema", torch.tensor(0.99))
92
+
93
+ @torch.no_grad()
94
+ def update_prototypes(self, z_norm: torch.Tensor, y: torch.Tensor):
95
+ """ema update: assign each in-class cell to nearest sub-center, take the mean."""
96
+ ema = float(self.proto_ema.item())
97
+ for c in torch.unique(y):
98
+ mask = y == c
99
+ if not mask.any():
100
+ continue
101
+ zc = z_norm[mask] # (n_c, d_proj)
102
+ protos_c = self.prototypes[c] # (n_sub, d_proj)
103
+ sims = zc @ protos_c.T # (n_c, n_sub)
104
+ assign = sims.argmax(dim=1) # each cell -> nearest sub-center
105
+ for k in range(self.n_sub):
106
+ m2 = assign == k
107
+ if not m2.any():
108
+ continue
109
+ new = F.normalize(zc[m2].mean(dim=0), dim=0)
110
+ self.prototypes[c, k] = F.normalize(
111
+ ema * self.prototypes[c, k] + (1 - ema) * new, dim=0
112
+ )
113
+
114
+ @torch.no_grad()
115
+ def max_sub_cos(self, z_norm: torch.Tensor) -> torch.Tensor:
116
+ """(B, K) cos(z, best sub-center) per class."""
117
+ B = z_norm.size(0); K, n_sub, D = self.prototypes.shape
118
+ sims = torch.einsum("bd,ksd->bks", z_norm, self.prototypes) # (B, K, n_sub)
119
+ return sims.max(dim=2).values # (B, K)
120
+
121
+ def forward(
122
+ self,
123
+ x_pca: torch.Tensor,
124
+ aux: torch.Tensor,
125
+ x_markers: Optional[torch.Tensor] = None,
126
+ lam_dann: float = 0.0,
127
+ ) -> dict:
128
+ if self.variant == "marker":
129
+ assert x_markers is not None and x_markers.size(1) == self.n_markers
130
+ x = torch.cat([x_pca, x_markers], dim=1)
131
+ else:
132
+ x = x_pca
133
+
134
+ h = self.trunk(x)
135
+ z_raw = self.projection(h)
136
+ z = F.normalize(z_raw, dim=1)
137
+ logits = self.classifier(torch.cat([h, aux], dim=1))
138
+ h_rev = grad_reverse(h, lam_dann)
139
+ return {
140
+ "repr": h,
141
+ "z": z,
142
+ "logits": logits,
143
+ "dom": self.dom_adv(h_rev),
144
+ "depth": self.depth_adv(h_rev),
145
+ }
146
+
147
+
148
+ # losses
149
+
150
+ def supcon_loss(z: torch.Tensor, y: torch.Tensor, temperature: float = 0.1) -> torch.Tensor:
151
+ if z.size(0) < 2:
152
+ return z.new_zeros(())
153
+ sim = z @ z.T / temperature
154
+ sim_max, _ = sim.max(dim=1, keepdim=True)
155
+ sim = sim - sim_max.detach()
156
+ logits_mask = torch.ones_like(sim) - torch.eye(z.size(0), device=z.device)
157
+ exp_sim = torch.exp(sim) * logits_mask
158
+ log_prob = sim - torch.log(exp_sim.sum(dim=1, keepdim=True) + 1e-12)
159
+ labels_eq = (y.unsqueeze(0) == y.unsqueeze(1)).float() * logits_mask
160
+ denom = labels_eq.sum(dim=1).clamp_min(1.0)
161
+ per = -(labels_eq * log_prob).sum(dim=1) / denom
162
+ per = per * (labels_eq.sum(dim=1) > 0).float()
163
+ counts = torch.bincount(y, minlength=int(y.max().item()) + 1).float().clamp_min(1.0)
164
+ w = 1.0 / counts.sqrt()
165
+ return (per * w[y]).sum() / w[y].sum().clamp_min(1e-6)
166
+
167
+
168
+ def vicreg_loss(z: torch.Tensor, sim_weight: float = 0.0, var_weight: float = 25.0,
169
+ cov_weight: float = 1.0) -> torch.Tensor:
170
+ zc = z - z.mean(dim=0, keepdim=True)
171
+ std = (zc.var(dim=0) + 1e-4).sqrt()
172
+ var_loss = F.relu(1.0 - std).mean()
173
+ N, D = zc.shape
174
+ cov = (zc.T @ zc) / (N - 1)
175
+ off = cov - torch.diag(torch.diagonal(cov))
176
+ cov_loss = off.pow(2).sum() / D
177
+ return var_weight * var_loss + cov_weight * cov_loss
178
+
179
+
180
+ def hsic_biased(x: torch.Tensor, y: torch.Tensor,
181
+ sigma_x: float = 1.0, sigma_y: float = 1.0) -> torch.Tensor:
182
+ Nx = x.size(0)
183
+ if Nx < 2:
184
+ return x.new_zeros(())
185
+ K = torch.exp(-torch.cdist(x, x) ** 2 / (2 * sigma_x ** 2))
186
+ L = torch.exp(-torch.cdist(y, y) ** 2 / (2 * sigma_y ** 2))
187
+ H = torch.eye(Nx, device=x.device) - torch.ones(Nx, Nx, device=x.device) / Nx
188
+ return (K @ H @ L @ H).trace() / (Nx - 1) ** 2
189
+
190
+
191
+ def subcenter_angular_infonce(
192
+ z: torch.Tensor, # (B, d_proj) L2-normalised
193
+ y: torch.Tensor, # (B,)
194
+ prototypes: torch.Tensor, # (K, n_sub, d_proj)
195
+ margin: float = 0.15, # angular margin in radians
196
+ temperature: float = 0.07,
197
+ ) -> torch.Tensor:
198
+ """arcface-style angular-margin loss over sub-center prototypes."""
199
+ B = z.size(0); K, n_sub, D = prototypes.shape
200
+ sims = torch.einsum("bd,ksd->bks", z, prototypes) # (B, K, n_sub)
201
+ max_over_sub = sims.max(dim=2).values # (B, K)
202
+
203
+ # target class cosine, bump by angular margin, put back
204
+ target_cos = max_over_sub.gather(1, y.unsqueeze(1)).squeeze(1) # (B,)
205
+ target_cos = target_cos.clamp(-1 + 1e-7, 1 - 1e-7)
206
+ theta = torch.acos(target_cos)
207
+ target_new_cos = torch.cos(theta + margin)
208
+
209
+ logits = max_over_sub.clone()
210
+ logits.scatter_(1, y.unsqueeze(1), target_new_cos.unsqueeze(1))
211
+ logits = logits / temperature
212
+ return F.cross_entropy(logits, y)
213
+
214
+
215
+ def prototype_repulsion(prototypes: torch.Tensor, weight: float = 1.0) -> torch.Tensor:
216
+ """penalise inter-class prototype cosine so eff-dim doesn't collapse."""
217
+ K, n_sub, D = prototypes.shape
218
+ centroids = F.normalize(prototypes.mean(dim=1), dim=1) # (K, D)
219
+ sim = centroids @ centroids.T # (K, K)
220
+ off = sim - torch.diag(torch.diagonal(sim))
221
+ return weight * off.pow(2).sum() / (K * (K - 1) + 1e-6)
222
+
223
+
224
+ def prototype_infonce_legacy(z, y, prototypes, temperature=0.07):
225
+ """legacy single-prototype InfoNCE. kept for debugging + old checkpoints."""
226
+ if prototypes.dim() == 3:
227
+ prototypes = F.normalize(prototypes.mean(dim=1), dim=1) # collapse sub-centers
228
+ logits = z @ prototypes.T / temperature
229
+ return F.cross_entropy(logits, y)
pyproject.toml ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [build-system]
2
+ requires = ["setuptools>=68.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "panda-scrnaseq"
7
+ version = "1.0.0"
8
+ description = "PANDA: Pan-tissue Adversarial Normalized Domain-invariant Anchored MLP"
9
+ readme = "README.md"
10
+ license = {text = "MIT"}
11
+ requires-python = ">=3.9"
12
+ authors = [
13
+ {name = "Bryan Cheng"},
14
+ ]
15
+
16
+ # Runtime dependencies. ML/inference versions are pinned (==) for
17
+ # reproducibility; tooling / plotting libs use lower bounds (>=).
18
+ # Pins reflect the versions used to produce the results in README.md.
19
+ dependencies = [
20
+ # --- Deep learning core (PINNED) ---
21
+ "torch==2.6.0",
22
+ # --- Single-cell stack (PINNED for reproducibility) ---
23
+ "scanpy==1.11.5",
24
+ "anndata>=0.11.4",
25
+ "harmonypy==0.2.0",
26
+ # --- Scientific stack ---
27
+ "numpy>=1.24,<3.0",
28
+ "scipy>=1.10",
29
+ "scikit-learn>=1.2",
30
+ "pandas>=1.5",
31
+ # --- Visualization / UMAP ---
32
+ "matplotlib>=3.7",
33
+ "seaborn>=0.12",
34
+ "umap-learn>=0.5",
35
+ # --- I/O and utilities ---
36
+ "pyyaml>=6.0",
37
+ "tqdm>=4.65",
38
+ # --- scanpy plugins (clustering / neighbors / DE) ---
39
+ "leidenalg>=0.10",
40
+ "pynndescent>=0.5",
41
+ "scikit-misc>=0.5",
42
+ ]
43
+ # Removed 2026-08 (never imported anywhere in scripts/ or panda/):
44
+ # transformers, peft, scvi-tools, einops, gdown, GEOparse — PRISM-era leftovers.
45
+
46
+ [project.optional-dependencies]
47
+ # Interactive plotting (optional alternative to matplotlib).
48
+ viz = [
49
+ "plotly>=5.0",
50
+ ]
51
+ # Development tooling. Kept in sync with requirements-dev.txt.
52
+ dev = [
53
+ "pytest>=7.0",
54
+ "pytest-cov>=4.0",
55
+ "pytest-xdist>=3.0",
56
+ "ruff>=0.1.0",
57
+ "mypy>=1.0",
58
+ ]
59
+
60
+ [project.scripts]
61
+ # entry-point removed with legacy cli
62
+
63
+ [tool.setuptools.packages.find]
64
+ include = ["panda*"]
65
+
66
+ [tool.setuptools.package-data]
67
+ panda = ["markers.yaml"]
68
+
69
+ [tool.pytest.ini_options]
70
+ testpaths = ["tests"]
71
+ python_files = ["test_*.py"]
72
+ python_classes = ["Test*"]
73
+ python_functions = ["test_*"]
74
+ addopts = "-v --tb=short"
75
+
76
+ [tool.ruff]
77
+ line-length = 120
78
+ target-version = "py39"
79
+
80
+ [tool.mypy]
81
+ python_version = "3.9"
82
+ warn_return_any = false
83
+ warn_unused_configs = true
84
+ ignore_missing_imports = true
scripts/analysis/100_primary_eden_discovery.py CHANGED
@@ -1,205 +1,208 @@
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()
 
 
 
 
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
+ import os as _os
7
+ from pathlib import Path as _Path
8
+ PANDA_ROOT = _Path(_os.environ.get("PANDA_ROOT", str(_Path(__file__).resolve().parents[2])))
9
+ sys.path.insert(0, str(PANDA_ROOT))
10
+ from panda import PANDAEncoder
11
+
12
+ ROOT = Path(str(PANDA_ROOT))
13
+ DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
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
+ # secondary EDEN definition per Dingwall 2024
19
+ SECONDARY_EDEN_PANEL = ["S100a4", "Tnc", "Pdgfra"]
20
+
21
+ # En1-responsive eccrine program (from restored 57_pathway_analysis.py, En1 removed)
22
+ SWEAT_GLAND_PANEL_ENSMINUSEN1 = ["Foxi3", "Foxa1", "Krt8", "Krt18", "Krt19", "Muc5b", "Aqp5"]
23
+ # Eda pathway
24
+ EDA_PATHWAY_PANEL = ["Eda", "Edar", "Edaradd", "Nfkb1", "Nfkb2", "Rela"]
25
+
26
+
27
+ def load_dingwall_with_v3_predictions():
28
+ print("[eden] loading Dingwall raw + v3 predictions", flush=True)
29
+ raw = ad.read_h5ad(ROOT / "data/raw/GSE220977_combined.h5ad")
30
+ pred = pd.read_csv(ROOT / "discovery/pan_skin/marker/dingwall_predictions.csv")
31
+ pred_map = dict(zip(pred["cell_id"].astype(str), pred["pred_label"]))
32
+ raw.obs["pred_label"] = np.array([pred_map.get(c, "unknown") for c in raw.obs_names.astype(str)])
33
+ raw.obs["genotype"] = np.where(raw.obs["sample"].astype(str).isin(list(CKO_GSMS)), "En1-cKO",
34
+ np.where(raw.obs["sample"].astype(str).isin(list(WT_GSMS)), "WT", "other"))
35
+ labeled = raw.obs["genotype"].isin(["WT", "En1-cKO"]).values
36
+ raw = raw[labeled].copy()
37
+
38
+ dermal_mask = np.isin(raw.obs["pred_label"], ["fibroblast-papillary", "fibroblast-reticular"])
39
+ dermal = raw[dermal_mask].copy()
40
+ print(f"[eden] {dermal.n_obs} dermal-fibroblast cells for sub-clustering", flush=True)
41
+ return dermal
42
+
43
+
44
+ def subcluster_dermal(dermal, resolution=1.5):
45
+ print(f"[eden] preprocessing + PCA (Leiden resolution={resolution})", flush=True)
46
+ sc.pp.normalize_total(dermal, target_sum=1e4); sc.pp.log1p(dermal)
47
+ sc.pp.highly_variable_genes(dermal, n_top_genes=3000, flavor="seurat_v3",
48
+ inplace=True, batch_key=None)
49
+ dermal_hvg = dermal[:, dermal.var["highly_variable"]].copy() if "highly_variable" in dermal.var else dermal
50
+ sc.pp.scale(dermal_hvg, max_value=10)
51
+ sc.tl.pca(dermal_hvg, n_comps=30, random_state=0)
52
+ sc.pp.neighbors(dermal_hvg, n_neighbors=20, use_rep="X_pca")
53
+ sc.tl.leiden(dermal_hvg, resolution=resolution, random_state=0)
54
+ dermal.obs["leiden"] = dermal_hvg.obs["leiden"].astype(str)
55
+ print(f"[eden] {dermal.obs['leiden'].nunique()} sub-clusters found", flush=True)
56
+ return dermal
57
+
58
+
59
+ def score_modules(dermal):
60
+ for name, genes in [("secondary_eden", SECONDARY_EDEN_PANEL),
61
+ ("sweat_gland", SWEAT_GLAND_PANEL_ENSMINUSEN1),
62
+ ("eda_pathway", EDA_PATHWAY_PANEL)]:
63
+ present = [g for g in genes if g in dermal.var_names]
64
+ if not present:
65
+ dermal.obs[f"score_{name}"] = 0.0
66
+ continue
67
+ sc.tl.score_genes(dermal, gene_list=present, score_name=f"score_{name}",
68
+ random_state=0, use_raw=False)
69
+ return dermal
70
+
71
+
72
+ def per_subcluster_analysis(dermal):
73
+ n_wt_tot = int((dermal.obs["genotype"] == "WT").sum())
74
+ n_cko_tot = int((dermal.obs["genotype"] == "En1-cKO").sum())
75
+ baseline_cko_frac = n_cko_tot / (n_wt_tot + n_cko_tot)
76
+ print(f"[eden] baseline: WT={n_wt_tot} cKO={n_cko_tot} (baseline cKO frac = {baseline_cko_frac:.3f})", flush=True)
77
+
78
+ sc.tl.rank_genes_groups(dermal, "leiden", method="wilcoxon", n_genes=30, use_raw=False)
79
+
80
+ rows = []
81
+ for cls in sorted(dermal.obs["leiden"].unique(), key=int):
82
+ sub = dermal[dermal.obs["leiden"] == cls]
83
+ n_wt = int((sub.obs["genotype"] == "WT").sum())
84
+ n_cko = int((sub.obs["genotype"] == "En1-cKO").sum())
85
+ if n_wt + n_cko < 20:
86
+ continue
87
+ cko_frac = n_cko / (n_wt + n_cko)
88
+
89
+ # fisher 2x2: (n_wt_in, n_wt_out) vs (n_cko_in, n_cko_out) — cluster depletion in cKO
90
+ n_wt_elsewhere = n_wt_tot - n_wt
91
+ n_cko_elsewhere = n_cko_tot - n_cko
92
+ odds, p_fisher = fisher_exact([[n_wt, n_wt_elsewhere], [n_cko, n_cko_elsewhere]],
93
+ alternative="two-sided")
94
+ depletion_direction = "cKO-depleted" if cko_frac < baseline_cko_frac else "cKO-enriched"
95
+
96
+ s2eden_mean_wt = float(sub[sub.obs["genotype"] == "WT"].obs["score_secondary_eden"].mean()) if n_wt > 0 else 0.0
97
+ s2eden_mean_cko = float(sub[sub.obs["genotype"] == "En1-cKO"].obs["score_secondary_eden"].mean()) if n_cko > 0 else 0.0
98
+ sg_mean_wt = float(sub[sub.obs["genotype"] == "WT"].obs["score_sweat_gland"].mean()) if n_wt > 0 else 0.0
99
+ sg_mean_cko = float(sub[sub.obs["genotype"] == "En1-cKO"].obs["score_sweat_gland"].mean()) if n_cko > 0 else 0.0
100
+ eda_mean_wt = float(sub[sub.obs["genotype"] == "WT"].obs["score_eda_pathway"].mean()) if n_wt > 0 else 0.0
101
+ eda_mean_cko = float(sub[sub.obs["genotype"] == "En1-cKO"].obs["score_eda_pathway"].mean()) if n_cko > 0 else 0.0
102
+
103
+ genes_list = list(dermal.uns["rank_genes_groups"]["names"][cls][:10])
104
+ lfc_list = list(dermal.uns["rank_genes_groups"]["logfoldchanges"][cls][:10])
105
+ top_markers = ", ".join([f"{g}({lfc:+.1f})" for g, lfc in zip(genes_list, lfc_list)])
106
+
107
+ rows.append({
108
+ "leiden_cluster": cls,
109
+ "n_cells": n_wt + n_cko,
110
+ "n_WT": n_wt, "n_cKO": n_cko,
111
+ "cko_frac": cko_frac,
112
+ "baseline_cko_frac": baseline_cko_frac,
113
+ "depletion_direction": depletion_direction,
114
+ "fisher_p_two_sided": p_fisher,
115
+ "odds_ratio": odds,
116
+ "score_secondary_eden_WT_mean": s2eden_mean_wt,
117
+ "score_secondary_eden_cKO_mean": s2eden_mean_cko,
118
+ "score_sweat_gland_WT_mean": sg_mean_wt,
119
+ "score_sweat_gland_cKO_mean": sg_mean_cko,
120
+ "score_eda_pathway_WT_mean": eda_mean_wt,
121
+ "score_eda_pathway_cKO_mean": eda_mean_cko,
122
+ "top_wilcoxon_markers": top_markers,
123
+ })
124
+ return pd.DataFrame(rows), baseline_cko_frac
125
+
126
+
127
+ def call_primary_and_secondary(df, baseline_cko_frac):
128
+ # secondary EDEN: highest score_secondary_eden_WT_mean AND cKO-depleted (Fisher p<0.05)
129
+ df_wt_ordered = df.sort_values("score_secondary_eden_WT_mean", ascending=False)
130
+ secondary_candidates = df_wt_ordered[
131
+ (df_wt_ordered["depletion_direction"] == "cKO-depleted") &
132
+ (df_wt_ordered["fisher_p_two_sided"] < 0.05)
133
+ ]
134
+ secondary = secondary_candidates.iloc[0]["leiden_cluster"] if len(secondary_candidates) > 0 else None
135
+
136
+ # primary EDEN: cKO-depleted + LOW secondary_eden (S100a4-neg) + HIGH Eda_pathway (En1-responsive)
137
+ df_ranked = df.copy()
138
+ df_ranked["depletion_score"] = -np.log10(df_ranked["fisher_p_two_sided"].clip(lower=1e-300)) * \
139
+ (df_ranked["cko_frac"] < baseline_cko_frac).astype(int)
140
+ primary_score = df_ranked["depletion_score"] * \
141
+ (1.0 / (df_ranked["score_secondary_eden_WT_mean"].abs() + 0.01)) * \
142
+ (df_ranked["score_eda_pathway_WT_mean"] + 0.1)
143
+ df_ranked["primary_eden_composite_score"] = primary_score
144
+ df_ranked = df_ranked.sort_values("primary_eden_composite_score", ascending=False)
145
+ primary_candidates = df_ranked[
146
+ (df_ranked["depletion_direction"] == "cKO-depleted") &
147
+ (df_ranked["fisher_p_two_sided"] < 0.05) &
148
+ (df_ranked["leiden_cluster"] != secondary)
149
+ ].head(3)
150
+ return secondary, primary_candidates, df_ranked
151
+
152
+
153
+ def main():
154
+ dermal = load_dingwall_with_v3_predictions()
155
+ dermal = subcluster_dermal(dermal, resolution=1.5)
156
+ dermal = score_modules(dermal)
157
+ df, baseline_cko = per_subcluster_analysis(dermal)
158
+ secondary, primary_cands, df_ranked = call_primary_and_secondary(df, baseline_cko)
159
+
160
+ out = ROOT / "discovery/pan_skin/marker"
161
+ out.mkdir(parents=True, exist_ok=True)
162
+ df_ranked.to_csv(out / "100_primary_eden_discovery.csv", index=False)
163
+
164
+ summary = {
165
+ "target": "Dingwall_GSE220977",
166
+ "hypothesis": "Primary EDEN precedes Secondary EDEN (S100a4+/Tnc+ cluster 20/Derm10) in dermal lineage",
167
+ "method": "PANDA-v3 predicts dermal-fibroblast compartment; Leiden sub-clustering "
168
+ "(resolution=1.5) resolves substructure; Wilcoxon markers + Fisher-exact "
169
+ "cKO enrichment + module scoring (Secondary_EDEN, Sweat_gland, Eda_pathway) "
170
+ "rank sub-clusters for Primary EDEN candidacy",
171
+ "baseline_cko_frac": float(baseline_cko),
172
+ "n_subclusters": int(len(df)),
173
+ "secondary_eden_call": {
174
+ "leiden_cluster": str(secondary),
175
+ "criteria": "highest S100a4+Tnc+Pdgfra score AND Fisher cKO-depleted p<0.05",
176
+ "row": df[df["leiden_cluster"] == secondary].iloc[0].to_dict() if secondary else None,
177
+ },
178
+ "primary_eden_candidates_top3": primary_cands[[
179
+ "leiden_cluster", "n_cells", "n_WT", "n_cKO", "cko_frac",
180
+ "fisher_p_two_sided", "score_secondary_eden_WT_mean",
181
+ "score_sweat_gland_WT_mean", "score_eda_pathway_WT_mean",
182
+ "top_wilcoxon_markers", "primary_eden_composite_score",
183
+ ]].to_dict("records") if len(primary_cands) > 0 else [],
184
+ }
185
+ (out / "100_primary_eden_summary.json").write_text(json.dumps(summary, indent=2, default=str))
186
+
187
+ print(f"\n[eden] wrote {out}/100_primary_eden_*", flush=True)
188
+ print(f"\n=== SECONDARY EDEN CALL ===", flush=True)
189
+ print(f" leiden cluster: {secondary}", flush=True)
190
+ if secondary:
191
+ row = df[df["leiden_cluster"] == secondary].iloc[0]
192
+ print(f" n={row['n_cells']} (WT {row['n_WT']} / cKO {row['n_cKO']}), "
193
+ f"cko_frac={row['cko_frac']:.3f} vs baseline {baseline_cko:.3f}", flush=True)
194
+ print(f" Fisher p={row['fisher_p_two_sided']:.2e}, "
195
+ f"score_secondary_eden WT={row['score_secondary_eden_WT_mean']:.3f}", flush=True)
196
+
197
+ print(f"\n=== PRIMARY EDEN CANDIDATES (top 3) ===", flush=True)
198
+ for _, row in primary_cands.iterrows():
199
+ print(f" leiden {row['leiden_cluster']} n={row['n_cells']} (WT {row['n_WT']} / cKO {row['n_cKO']}), "
200
+ f"cko_frac={row['cko_frac']:.3f}, Fisher p={row['fisher_p_two_sided']:.2e}", flush=True)
201
+ print(f" S2EDEN_WT={row['score_secondary_eden_WT_mean']:.3f}, "
202
+ f"Sweat_WT={row['score_sweat_gland_WT_mean']:.3f}, "
203
+ f"Eda_WT={row['score_eda_pathway_WT_mean']:.3f}", flush=True)
204
+ print(f" top markers: {row['top_wilcoxon_markers']}", flush=True)
205
+
206
+
207
+ if __name__ == "__main__":
208
+ main()
scripts/analysis/101_primary_eden_derm_scoring.py CHANGED
@@ -1,159 +1,162 @@
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()
 
 
 
 
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
+ import os as _os
8
+ from pathlib import Path as _Path
9
+ PANDA_ROOT = _Path(_os.environ.get("PANDA_ROOT", str(_Path(__file__).resolve().parents[2])))
10
+ ROOT = Path(str(PANDA_ROOT))
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
+ DERM_MARKERS = ROOT / "data/external_labels/dingwall_supp/biorxiv_media-3.xlsx"
14
+ TOP_N = 30 # top N markers per Derm cluster for scoring
15
+
16
+ # EDEN identity map based on Data S1C + Data S2 CellChat
17
+ EDEN_IDENTITY = {
18
+ 10: "Secondary_EDEN",
19
+ 2: "Primary_EDEN_candidate_1",
20
+ 9: "Primary_EDEN_candidate_2",
21
+ 3: "EDEN_signalling",
22
+ 6: "EDEN_signalling",
23
+ }
24
+
25
+
26
+ def load_derm_marker_panels():
27
+ df = pd.read_excel(DERM_MARKERS)
28
+ df = df.sort_values(["cluster", "avg_log2FC"], ascending=[True, False])
29
+ panels = {}
30
+ for cl in sorted(df["cluster"].unique()):
31
+ genes = df[df["cluster"] == cl].head(TOP_N)["gene"].tolist()
32
+ panels[int(cl)] = genes
33
+ return panels
34
+
35
+
36
+ def load_dingwall_v3_dermal():
37
+ raw = ad.read_h5ad(ROOT / "data/raw/GSE220977_combined.h5ad")
38
+ pred = pd.read_csv(ROOT / "discovery/pan_skin/marker/dingwall_predictions.csv")
39
+ pred_map = dict(zip(pred["cell_id"].astype(str), pred["pred_label"]))
40
+ raw.obs["pred_label"] = np.array([pred_map.get(c, "unknown") for c in raw.obs_names.astype(str)])
41
+ raw.obs["genotype"] = np.where(raw.obs["sample"].astype(str).isin(list(CKO_GSMS)), "En1-cKO",
42
+ np.where(raw.obs["sample"].astype(str).isin(list(WT_GSMS)), "WT", "other"))
43
+ labeled = raw.obs["genotype"].isin(["WT", "En1-cKO"]).values
44
+ raw = raw[labeled].copy()
45
+ dermal_mask = np.isin(raw.obs["pred_label"], ["fibroblast-papillary", "fibroblast-reticular"])
46
+ dermal = raw[dermal_mask].copy()
47
+ return dermal
48
+
49
+
50
+ def score_derm_identities(a, panels):
51
+ for cl, genes in panels.items():
52
+ present = [g for g in genes if g in a.var_names]
53
+ if not present:
54
+ a.obs[f"derm{cl}_score"] = 0.0
55
+ continue
56
+ sc.tl.score_genes(a, gene_list=present, score_name=f"derm{cl}_score",
57
+ random_state=0, use_raw=False)
58
+ return a
59
+
60
+
61
+ def main():
62
+ print("[eden] loading Data S1C marker panels", flush=True)
63
+ panels = load_derm_marker_panels()
64
+ print(f"[eden] loaded {len(panels)} Derm panels", flush=True)
65
+
66
+ print("[eden] loading Dingwall + PANDA-v3 dermal predictions", flush=True)
67
+ dermal = load_dingwall_v3_dermal()
68
+ print(f"[eden] {dermal.n_obs} dermal-fibroblast cells", flush=True)
69
+
70
+ print("[eden] normalise + log1p", flush=True)
71
+ sc.pp.normalize_total(dermal, target_sum=1e4); sc.pp.log1p(dermal)
72
+
73
+ print("[eden] scoring cells on all 12 Derm identity panels", flush=True)
74
+ dermal = score_derm_identities(dermal, panels)
75
+
76
+ score_cols = [f"derm{cl}_score" for cl in sorted(panels.keys())]
77
+ score_mat = dermal.obs[score_cols].values
78
+ argmax = np.argmax(score_mat, axis=1)
79
+ derm_ids = [int(score_cols[i].replace("derm", "").replace("_score", "")) for i in argmax]
80
+ dermal.obs["derm_identity"] = derm_ids
81
+ dermal.obs["max_derm_score"] = score_mat.max(axis=1)
82
+
83
+ n_wt_tot = int((dermal.obs["genotype"] == "WT").sum())
84
+ n_cko_tot = int((dermal.obs["genotype"] == "En1-cKO").sum())
85
+ baseline_cko_frac = n_cko_tot / (n_wt_tot + n_cko_tot)
86
+ print(f"[eden] baseline WT={n_wt_tot} cKO={n_cko_tot} (baseline cKO={baseline_cko_frac:.3f})", flush=True)
87
+
88
+ rows = []
89
+ for cl in sorted(panels.keys()):
90
+ sub = dermal[dermal.obs["derm_identity"] == cl]
91
+ n_wt = int((sub.obs["genotype"] == "WT").sum())
92
+ n_cko = int((sub.obs["genotype"] == "En1-cKO").sum())
93
+ if n_wt + n_cko == 0:
94
+ continue
95
+ cko_frac = n_cko / (n_wt + n_cko) if (n_wt + n_cko) > 0 else 0
96
+ n_wt_else = n_wt_tot - n_wt
97
+ n_cko_else = n_cko_tot - n_cko
98
+ try:
99
+ odds, p_fisher = fisher_exact([[n_wt, n_wt_else], [n_cko, n_cko_else]],
100
+ alternative="two-sided")
101
+ except ValueError:
102
+ odds, p_fisher = 1.0, 1.0
103
+ rows.append({
104
+ "derm_id": cl,
105
+ "identity": EDEN_IDENTITY.get(cl, "other"),
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
+ "cko_delta": cko_frac - baseline_cko_frac,
111
+ "fisher_p": float(p_fisher),
112
+ "odds_ratio": float(odds) if not np.isnan(odds) else None,
113
+ "depletion_direction": "cKO-depleted" if cko_frac < baseline_cko_frac
114
+ else "cKO-enriched",
115
+ "top10_markers_dingwall": ", ".join(panels[cl][:10]),
116
+ })
117
+ df = pd.DataFrame(rows).sort_values("cko_delta")
118
+
119
+ out = ROOT / "discovery/pan_skin/marker"
120
+ out.mkdir(parents=True, exist_ok=True)
121
+ df.to_csv(out / "101_derm_subcluster_scores.csv", index=False)
122
+
123
+ secondary_row = df[df["derm_id"] == 10].iloc[0].to_dict() if 10 in df["derm_id"].values else None
124
+ primary_1_row = df[df["derm_id"] == 2].iloc[0].to_dict() if 2 in df["derm_id"].values else None
125
+ primary_2_row = df[df["derm_id"] == 9].iloc[0].to_dict() if 9 in df["derm_id"].values else None
126
+
127
+ summary = {
128
+ "target": "Dingwall_GSE220977",
129
+ "method": "Score PANDA-v3 dermal-fibroblast predictions on Dingwall's own Derm0-11 "
130
+ "marker panels (Data S1C top-30 genes each); argmax identity per cell; "
131
+ "Fisher-exact cKO depletion per Derm identity",
132
+ "baseline_cko_frac": baseline_cko_frac,
133
+ "n_dermal_cells_total": int(dermal.n_obs),
134
+ "secondary_eden_Derm10": secondary_row,
135
+ "primary_eden_Derm2": primary_1_row,
136
+ "primary_eden_Derm9": primary_2_row,
137
+ "all_derm_summary": df.to_dict("records"),
138
+ }
139
+ (out / "101_derm_identity_summary.json").write_text(json.dumps(summary, indent=2, default=str))
140
+
141
+ print(f"\n[eden] wrote {out}/101_derm_*", flush=True)
142
+ print(f"\n=== SECONDARY EDEN (Derm10) ===", flush=True)
143
+ if secondary_row:
144
+ print(f" n={secondary_row['n_cells']} (WT {secondary_row['n_WT']} / cKO {secondary_row['n_cKO']}), "
145
+ f"cko_frac={secondary_row['cko_frac']:.3f} vs baseline {baseline_cko_frac:.3f}", flush=True)
146
+ print(f" {secondary_row['depletion_direction']}, Fisher p={secondary_row['fisher_p']:.2e}", flush=True)
147
+
148
+ print(f"\n=== PRIMARY EDEN candidate 1 (Derm2) ===", flush=True)
149
+ if primary_1_row:
150
+ print(f" n={primary_1_row['n_cells']} (WT {primary_1_row['n_WT']} / cKO {primary_1_row['n_cKO']}), "
151
+ f"cko_frac={primary_1_row['cko_frac']:.3f}", flush=True)
152
+ print(f" {primary_1_row['depletion_direction']}, Fisher p={primary_1_row['fisher_p']:.2e}", flush=True)
153
+
154
+ print(f"\n=== PRIMARY EDEN candidate 2 (Derm9) ===", flush=True)
155
+ if primary_2_row:
156
+ print(f" n={primary_2_row['n_cells']} (WT {primary_2_row['n_WT']} / cKO {primary_2_row['n_cKO']}), "
157
+ f"cko_frac={primary_2_row['cko_frac']:.3f}", flush=True)
158
+ print(f" {primary_2_row['depletion_direction']}, Fisher p={primary_2_row['fisher_p']:.2e}", flush=True)
159
+
160
+
161
+ if __name__ == "__main__":
162
+ main()
scripts/analysis/102_train_on_dingwall_variantA.py CHANGED
@@ -1,317 +1,320 @@
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()
 
 
 
 
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
+ import os as _os
18
+ from pathlib import Path as _Path
19
+ PANDA_ROOT = _Path(_os.environ.get("PANDA_ROOT", str(_Path(__file__).resolve().parents[2])))
20
+ sys.path.insert(0, str(PANDA_ROOT))
21
+ from panda.model import (
22
+ PANDAEncoder, supcon_loss, vicreg_loss, hsic_biased, subcenter_angular_infonce
23
+ )
24
+
25
+ ROOT = Path(str(PANDA_ROOT))
26
+ RAW_H5 = ROOT / "data/raw/GSE220977_combined.h5ad"
27
+ DERM_MARKERS = ROOT / "data/external_labels/dingwall_supp/biorxiv_media-3.xlsx"
28
+ OUT_DIR = ROOT / "discovery/pan_skin/marker"
29
+ CK_DIR = ROOT / "checkpoints/pan_skin_dingwall_variantA"
30
+
31
+ # Dingwall GSM -> genotype (see 101_primary_eden_derm_scoring)
32
+ CKO_GSMS = {"GSM6833482", "GSM6833483"} # CORRECTED: 480/481 are rttaControl (WT), not cKO
33
+ WT_GSMS = {"GSM6833478", "GSM6833479", "GSM6833480", "GSM6833481"} # CORRECTED: 4 Cre-neg controls per GEO metadata
34
+
35
+ TOP_N = 30 # markers per Derm panel for scoring
36
+ SCORE_MIN = 0.10 # min score to accept a pseudo-label
37
+ MARGIN_MIN = 0.05 # min gap best - runner-up
38
+ N_HVG = 2000 # matches paper
39
+ N_PCA = 40 # matches paper
40
+ DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
41
+
42
+ # Training config (mirrors 20_train_panda.py)
43
+ GUARANTEED_PER_CLASS = 6
44
+ NATURAL_SLOTS = 96
45
+ STAGE_EPOCHS = [15, 25, 40, 40]
46
+ BALANCE_MIX = 0.5
47
+
48
+
49
+ # ---------- data prep ----------
50
+
51
+ def load_derm_panels(top_n: int = TOP_N) -> dict[int, list[str]]:
52
+ df = pd.read_excel(DERM_MARKERS)
53
+ df = df.sort_values(["cluster", "avg_log2FC"], ascending=[True, False])
54
+ return {int(c): df[df["cluster"] == c].head(top_n)["gene"].tolist()
55
+ for c in sorted(df["cluster"].unique())}
56
+
57
+
58
+ def load_dingwall_dermal() -> ad.AnnData:
59
+ a = ad.read_h5ad(RAW_H5)
60
+ sample = a.obs["sample"].astype(str)
61
+ a.obs["genotype"] = np.where(sample.isin(list(CKO_GSMS)), "En1-cKO",
62
+ np.where(sample.isin(list(WT_GSMS)), "WT", "other"))
63
+ a = a[a.obs["genotype"].isin(["WT", "En1-cKO"])].copy()
64
+ return a
65
+
66
+
67
+ def preprocess_paper_style(a: ad.AnnData) -> ad.AnnData:
68
+ """lognormalize + hvg(2000) + pca(40) + harmony per-sample, matches dingwall STAR methods."""
69
+ sc.pp.filter_genes(a, min_cells=10)
70
+ sc.pp.normalize_total(a, target_sum=1e4)
71
+ sc.pp.log1p(a)
72
+ sc.pp.highly_variable_genes(a, n_top_genes=N_HVG, flavor="seurat", batch_key="sample")
73
+ sc.pp.scale(a, max_value=10, zero_center=False)
74
+ sc.tl.pca(a, n_comps=N_PCA, use_highly_variable=True, zero_center=False)
75
+ try:
76
+ import harmonypy as hm # noqa
77
+ sc.external.pp.harmony_integrate(a, key="sample", basis="X_pca",
78
+ adjusted_basis="X_pca_harmony", max_iter_harmony=20)
79
+ a.obsm["X_train"] = a.obsm["X_pca_harmony"]
80
+ except Exception as exc:
81
+ print(f"[preprocess] harmony skipped ({exc}); using raw PCA", flush=True)
82
+ a.obsm["X_train"] = a.obsm["X_pca"]
83
+ return a
84
+
85
+
86
+ # ---------- pseudo-labelling ----------
87
+
88
+ def score_and_gate(a: ad.AnnData, panels: dict[int, list[str]],
89
+ score_min: float = SCORE_MIN,
90
+ margin_min: float = MARGIN_MIN) -> ad.AnnData:
91
+ """score cells on 12 derm panels; accept label if best>score_min and margin>margin_min."""
92
+ for cl, genes in panels.items():
93
+ present = [g for g in genes if g in a.var_names]
94
+ if not present:
95
+ a.obs[f"derm{cl}_score"] = 0.0
96
+ else:
97
+ sc.tl.score_genes(a, gene_list=present, score_name=f"derm{cl}_score",
98
+ random_state=0, use_raw=False)
99
+ cols = [f"derm{cl}_score" for cl in sorted(panels)]
100
+ S = a.obs[cols].values
101
+ top1_ix = S.argmax(axis=1)
102
+ top1 = S[np.arange(len(S)), top1_ix]
103
+ S_copy = S.copy(); S_copy[np.arange(len(S)), top1_ix] = -np.inf
104
+ top2 = S_copy.max(axis=1)
105
+ margin = top1 - top2
106
+ accept = (top1 > score_min) & (margin > margin_min)
107
+
108
+ ids = np.array([int(cols[i].replace("derm", "").replace("_score", "")) for i in top1_ix])
109
+ a.obs["derm_pseudo"] = ids
110
+ a.obs["derm_pseudo_top1"] = top1
111
+ a.obs["derm_pseudo_margin"] = margin
112
+ a.obs["derm_pseudo_accept"] = accept
113
+ return a
114
+
115
+
116
+ # ---------- PANDA training (mirrors 20_train_panda.py) ----------
117
+
118
+ class CorpusDataset(Dataset):
119
+ def __init__(self, X, y, d, aux):
120
+ self.X = X.astype(np.float32); self.y = y.astype(np.int64)
121
+ self.d = d.astype(np.int64); self.aux = aux.astype(np.float32)
122
+ def __len__(self): return self.X.shape[0]
123
+ def __getitem__(self, i):
124
+ return (torch.from_numpy(self.X[i]), torch.tensor(self.y[i]),
125
+ torch.tensor(self.d[i]), torch.from_numpy(self.aux[i]))
126
+
127
+
128
+ class HybridSampler:
129
+ def __init__(self, y, n_batches=100, seed=0):
130
+ self.y = np.asarray(y); self.n_batches = n_batches
131
+ self.rng = np.random.default_rng(seed)
132
+ self.classes = np.unique(self.y)
133
+ self.by_cls = {int(c): np.where(self.y == c)[0] for c in self.classes}
134
+ counts = np.bincount(self.y, minlength=int(self.classes.max()) + 1).astype(float)
135
+ self.natural_p = counts / counts.sum()
136
+ def __iter__(self):
137
+ for _ in range(self.n_batches):
138
+ batch = []
139
+ for c in self.classes:
140
+ idx = self.by_cls[int(c)]
141
+ take = min(GUARANTEED_PER_CLASS, len(idx))
142
+ if take > 0:
143
+ batch.extend(self.rng.choice(idx, size=take, replace=(len(idx) < take)).tolist())
144
+ for _ in range(NATURAL_SLOTS):
145
+ c = self.rng.choice(len(self.natural_p), p=self.natural_p)
146
+ idx = self.by_cls.get(int(c), self.by_cls[int(self.classes[0])])
147
+ batch.append(int(self.rng.choice(idx)))
148
+ yield batch
149
+ def __len__(self): return self.n_batches
150
+
151
+
152
+ def train_panda(X_tr, y_tr, d_tr, aux_tr, n_classes, n_datasets, ck_out: Path):
153
+ ck_out.mkdir(parents=True, exist_ok=True)
154
+ counts = np.bincount(y_tr, minlength=n_classes)
155
+ inv_sqrt = 1.0 / np.sqrt(counts + 1); inv_sqrt = inv_sqrt / inv_sqrt.mean()
156
+ class_w = BALANCE_MIX * inv_sqrt + (1 - BALANCE_MIX) * np.ones_like(inv_sqrt)
157
+ class_w = torch.tensor(class_w, dtype=torch.float32, device=DEVICE)
158
+
159
+ ds = CorpusDataset(X_tr, y_tr, d_tr, aux_tr)
160
+ loader = DataLoader(ds, batch_sampler=HybridSampler(y_tr, n_batches=100), num_workers=0)
161
+
162
+ model = PANDAEncoder(variant="pca", n_pca=X_tr.shape[1], n_classes=n_classes,
163
+ n_datasets=n_datasets).to(DEVICE)
164
+ opt = torch.optim.AdamW(model.parameters(), lr=3e-4, weight_decay=1e-4)
165
+
166
+ for stage, n_ep in enumerate(STAGE_EPOCHS):
167
+ print(f"[panda-A] stage {stage} ({n_ep} epochs)", flush=True)
168
+ for e in range(n_ep):
169
+ t0 = time.time(); losses = []
170
+ for X_b, y_b, d_b, aux_b in loader:
171
+ X_b = X_b.to(DEVICE); y_b = y_b.to(DEVICE); d_b = d_b.to(DEVICE); aux_b = aux_b.to(DEVICE)
172
+ lam = 1.0 if stage >= 2 else 0.0
173
+ out = model(X_b, aux_b, lam_dann=lam)
174
+ L_supcon = supcon_loss(out["z"], y_b)
175
+ L_vic = vicreg_loss(out["z"])
176
+ L_ce = F.cross_entropy(out["logits"], y_b, weight=class_w, label_smoothing=0.05)
177
+ total = L_supcon + 1.0 * L_vic + 0.4 * L_ce
178
+ if stage >= 1:
179
+ proto_ref = model.prototypes.detach().clone()
180
+ total = total + 0.6 * subcenter_angular_infonce(out["z"], y_b, proto_ref)
181
+ if stage >= 2:
182
+ total = total + F.cross_entropy(out["dom"], d_b)
183
+ total = total + 0.3 * F.mse_loss(out["depth"].squeeze(1), aux_b[:, 1])
184
+ total = total + 0.05 * hsic_biased(out["repr"], aux_b[:, 1:2])
185
+ opt.zero_grad(); total.backward()
186
+ torch.nn.utils.clip_grad_norm_(model.parameters(), 5.0)
187
+ opt.step()
188
+ if stage >= 1:
189
+ model.update_prototypes(out["z"].detach(), y_b)
190
+ losses.append(float(total.item()))
191
+ if (e + 1) % 5 == 0:
192
+ print(f" ep {e+1}/{n_ep} loss={np.mean(losses):.3f} dt={time.time()-t0:.1f}s", flush=True)
193
+ torch.save({"model": model.state_dict()}, ck_out / f"panda_stage{stage}.pt")
194
+
195
+ torch.save({"model": model.state_dict(),
196
+ "prototypes": model.prototypes.detach().cpu().numpy()},
197
+ ck_out / "panda_final.pt")
198
+ return model
199
+
200
+
201
+ @torch.no_grad()
202
+ def infer(model, X, aux):
203
+ model.eval()
204
+ Xt = torch.from_numpy(X.astype(np.float32)).to(DEVICE)
205
+ at = torch.from_numpy(aux.astype(np.float32)).to(DEVICE)
206
+ B = 4096; preds = []; confs = []
207
+ for i in range(0, len(Xt), B):
208
+ out = model(Xt[i:i+B], at[i:i+B])
209
+ p = F.softmax(out["logits"], dim=1)
210
+ preds.append(p.argmax(dim=1).cpu().numpy())
211
+ confs.append(p.max(dim=1).values.cpu().numpy())
212
+ return np.concatenate(preds), np.concatenate(confs)
213
+
214
+
215
+ # ---------- reporting ----------
216
+
217
+ def report_depletion(labels: np.ndarray, genotype: np.ndarray, n_classes: int) -> pd.DataFrame:
218
+ n_wt = int((genotype == "WT").sum()); n_cko = int((genotype == "En1-cKO").sum())
219
+ base = n_cko / max(n_wt + n_cko, 1)
220
+ rows = []
221
+ for c in range(n_classes):
222
+ m = labels == c
223
+ w = int(((genotype == "WT") & m).sum()); k = int(((genotype == "En1-cKO") & m).sum())
224
+ if w + k == 0: continue
225
+ try:
226
+ odds, p = fisher_exact([[w, n_wt - w], [k, n_cko - k]], alternative="two-sided")
227
+ except ValueError:
228
+ odds, p = 1.0, 1.0
229
+ rows.append({"derm_id": c, "n": w + k, "n_WT": w, "n_cKO": k,
230
+ "cko_frac": k / (w + k), "baseline": base,
231
+ "odds_ratio": float(odds), "fisher_p": float(p)})
232
+ return pd.DataFrame(rows).sort_values("cko_frac")
233
+
234
+
235
+ def main():
236
+ OUT_DIR.mkdir(parents=True, exist_ok=True); CK_DIR.mkdir(parents=True, exist_ok=True)
237
+
238
+ print("[A] load panels + dermal Dingwall", flush=True)
239
+ panels = load_derm_panels()
240
+ a = load_dingwall_dermal()
241
+ # reuse existing panda-v3 fibroblast calls if present, else all cells
242
+ pred_csv = ROOT / "discovery/pan_skin/marker/dingwall_predictions.csv"
243
+ if pred_csv.exists():
244
+ pred = pd.read_csv(pred_csv)
245
+ pred_map = dict(zip(pred["cell_id"].astype(str), pred["pred_label"]))
246
+ a.obs["v3_label"] = [pred_map.get(c, "unknown") for c in a.obs_names.astype(str)]
247
+ a = a[np.isin(a.obs["v3_label"], ["fibroblast-papillary", "fibroblast-reticular"])].copy()
248
+ print(f"[A] restricted to PANDA-v3 fibroblasts: n={a.n_obs}", flush=True)
249
+
250
+ print("[A] paper-style preprocess", flush=True)
251
+ a = preprocess_paper_style(a)
252
+
253
+ print("[A] score + gate pseudo-labels", flush=True)
254
+ a = score_and_gate(a, panels)
255
+ n_acc = int(a.obs["derm_pseudo_accept"].sum())
256
+ print(f"[A] pseudo-label acceptance: {n_acc}/{a.n_obs} ({100*n_acc/a.n_obs:.1f}%)", flush=True)
257
+
258
+ # train/heldout split (gate = train; rest = infer)
259
+ train_mask = a.obs["derm_pseudo_accept"].values.astype(bool)
260
+ X_all = np.asarray(a.obsm["X_train"])
261
+ y_all = a.obs["derm_pseudo"].astype(int).values
262
+ sample_ix = {s: i for i, s in enumerate(sorted(a.obs["sample"].astype(str).unique()))}
263
+ d_all = np.array([sample_ix[s] for s in a.obs["sample"].astype(str)])
264
+ aux_all = np.stack([np.zeros(a.n_obs, dtype=np.float32),
265
+ np.log10(np.asarray(a.X.sum(axis=1)).ravel() + 1)], axis=1)
266
+ aux_all[:, 1] = (aux_all[:, 1] - aux_all[:, 1].mean()) / (aux_all[:, 1].std() + 1e-6)
267
+
268
+ classes = sorted(np.unique(y_all[train_mask]).tolist())
269
+ if len(classes) < 2:
270
+ print("[A] not enough classes accepted; abort", flush=True); return
271
+ cls_ix = {c: i for i, c in enumerate(classes)}
272
+ y_all_ix = np.array([cls_ix.get(int(c), -1) for c in y_all])
273
+ y_tr = y_all_ix[train_mask]
274
+ X_tr = X_all[train_mask]; d_tr = d_all[train_mask]; aux_tr = aux_all[train_mask]
275
+
276
+ print(f"[A] train n={train_mask.sum()} on {len(classes)} classes: {classes}", flush=True)
277
+ model = train_panda(X_tr, y_tr, d_tr, aux_tr, n_classes=len(classes),
278
+ n_datasets=len(sample_ix), ck_out=CK_DIR)
279
+
280
+ # inference on held-out
281
+ infer_mask = ~train_mask
282
+ preds_ix, confs = infer(model, X_all[infer_mask], aux_all[infer_mask])
283
+ preds_derm = np.array([classes[p] for p in preds_ix])
284
+
285
+ # combine: use pseudo-label on train, prediction on inference
286
+ final = np.where(train_mask, y_all,
287
+ np.concatenate([y_all[train_mask].astype(int) * 0 - 1, # placeholder
288
+ preds_derm.astype(int)])[:a.n_obs] if False else 0)
289
+ # simpler: assemble directly
290
+ final = y_all.astype(int).copy()
291
+ final[infer_mask] = preds_derm.astype(int)
292
+
293
+ df = pd.DataFrame({
294
+ "cell_id": a.obs_names.astype(str).values,
295
+ "sample": a.obs["sample"].astype(str).values,
296
+ "genotype": a.obs["genotype"].astype(str).values,
297
+ "pseudo_derm": y_all,
298
+ "pseudo_accept": train_mask,
299
+ "final_derm": final,
300
+ })
301
+ df.to_csv(OUT_DIR / "102_variantA_predictions.csv", index=False)
302
+
303
+ dep = report_depletion(final, a.obs["genotype"].values, n_classes=12)
304
+ dep.to_csv(OUT_DIR / "102_variantA_depletion.csv", index=False)
305
+ summary = {
306
+ "variant": "A_semi_supervised_S3_scoring",
307
+ "score_min": SCORE_MIN, "margin_min": MARGIN_MIN, "top_n": TOP_N,
308
+ "n_total": int(a.n_obs), "n_train_pseudo": int(train_mask.sum()),
309
+ "classes_trained": classes,
310
+ "derm10": dep[dep["derm_id"] == 10].to_dict("records"),
311
+ "derm2": dep[dep["derm_id"] == 2].to_dict("records"),
312
+ "derm9": dep[dep["derm_id"] == 9].to_dict("records"),
313
+ "all": dep.to_dict("records"),
314
+ }
315
+ (OUT_DIR / "102_variantA_summary.json").write_text(json.dumps(summary, indent=2, default=str))
316
+ print(f"[A] done -> {OUT_DIR}/102_variantA_*", flush=True)
317
+
318
+
319
+ if __name__ == "__main__":
320
+ main()
scripts/analysis/103_replicate_dingwall_seurat_pipeline.py CHANGED
@@ -1,261 +1,264 @@
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()
 
 
 
 
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
+ import os as _os
13
+ from pathlib import Path as _Path
14
+ PANDA_ROOT = _Path(_os.environ.get("PANDA_ROOT", str(_Path(__file__).resolve().parents[2])))
15
+ ROOT = Path(str(PANDA_ROOT))
16
+ RAW_H5 = ROOT / "data/raw/GSE220977_combined.h5ad"
17
+ DERM_MARKERS = ROOT / "data/external_labels/dingwall_supp/biorxiv_media-3.xlsx"
18
+ TOP_MARKERS = ROOT / "data/external_labels/dingwall_supp/biorxiv_media-1.xlsx"
19
+ OUT_DIR = ROOT / "data/processed/dingwall_replica"
20
+
21
+ CKO_GSMS = {"GSM6833482", "GSM6833483"} # CORRECTED: 480/481 are rttaControl (WT), not cKO
22
+ WT_GSMS = {"GSM6833478", "GSM6833479", "GSM6833480", "GSM6833481"} # CORRECTED: 4 Cre-neg controls per GEO metadata
23
+
24
+ # Paper values
25
+ DERMAL_TOP_CLUSTERS = {0, 1, 3, 4, 5, 8, 11, 20} # from STAR Methods
26
+ N_HVG = 2000
27
+ N_PCA = 40
28
+ RES = 0.7
29
+ N_LEIDEN_DERM = 12 # target: Derm0..Derm11
30
+ JACCARD_TOP_N = 50 # top-N markers for label mapping
31
+
32
+
33
+ # ---------- QC + preprocessing ----------
34
+
35
+ def qc_filter(a: ad.AnnData) -> ad.AnnData:
36
+ a.var["mt"] = a.var_names.str.upper().str.startswith("MT-") | \
37
+ a.var_names.str.startswith("mt-")
38
+ sc.pp.calculate_qc_metrics(a, qc_vars=["mt"], inplace=True, percent_top=None,
39
+ log1p=False)
40
+ sc.pp.filter_cells(a, min_genes=300)
41
+ a = a[a.obs["n_genes_by_counts"] < 6000].copy()
42
+ a = a[a.obs["pct_counts_mt"] < 5].copy()
43
+ sc.pp.filter_genes(a, min_cells=10)
44
+ return a
45
+
46
+
47
+ def lognorm(a: ad.AnnData) -> ad.AnnData:
48
+ a.layers["counts"] = a.X.copy() if not hasattr(a.X, "toarray") or True else a.X.copy()
49
+ sc.pp.normalize_total(a, target_sum=1e4)
50
+ sc.pp.log1p(a)
51
+ return a
52
+
53
+
54
+ def hvg_pca_harmony(a: ad.AnnData, n_hvg=N_HVG, n_pca=N_PCA, batch_key="sample") -> ad.AnnData:
55
+ sc.pp.highly_variable_genes(a, n_top_genes=n_hvg, flavor="seurat", batch_key=batch_key)
56
+ a_use = a[:, a.var["highly_variable"]].copy()
57
+ sc.pp.scale(a_use, max_value=10, zero_center=False)
58
+ sc.tl.pca(a_use, n_comps=n_pca, use_highly_variable=False, zero_center=False)
59
+ # copy PCA back — a_use has same obs rows as a
60
+ a.obsm["X_pca"] = a_use.obsm["X_pca"].copy()
61
+ rep = "X_pca"
62
+ try:
63
+ # harmonypy directly on the pca matrix to avoid scanpy wrapper obsm-shape bug
64
+ import harmonypy as hm
65
+ pca_mat = a.obsm["X_pca"].copy()
66
+ meta = a.obs[[batch_key]].reset_index(drop=True)
67
+ ho = hm.run_harmony(pca_mat, meta, batch_key, max_iter_harmony=20)
68
+ # ho.Z_corr is (pcs, cells); make it (cells, pcs)
69
+ z = ho.Z_corr
70
+ if z.shape[1] == a.n_obs:
71
+ harm_mat = np.ascontiguousarray(z.T)
72
+ elif z.shape[0] == a.n_obs:
73
+ harm_mat = np.ascontiguousarray(z)
74
+ else:
75
+ raise RuntimeError(f"unknown harmony shape {z.shape}, n_obs={a.n_obs}")
76
+ if harm_mat.shape[0] == a.n_obs and harm_mat.shape[1] == pca_mat.shape[1]:
77
+ a.obsm["X_pca_harmony"] = harm_mat
78
+ rep = "X_pca_harmony"
79
+ print(f"[replica] Harmony ok, X_pca_harmony shape={harm_mat.shape}", flush=True)
80
+ else:
81
+ print(f"[replica] Harmony output shape mismatch ({harm_mat.shape}); using X_pca", flush=True)
82
+ except Exception as exc:
83
+ print(f"[replica] Harmony skipped ({exc}); using X_pca", flush=True)
84
+ a.uns["_replica_rep"] = rep
85
+ return a
86
+
87
+
88
+ def leiden_cluster(a: ad.AnnData, res=RES) -> ad.AnnData:
89
+ rep = a.uns.get("_replica_rep", "X_pca")
90
+ sc.pp.neighbors(a, n_neighbors=20, use_rep=rep, n_pcs=N_PCA)
91
+ sc.tl.leiden(a, resolution=res, key_added="leiden")
92
+ return a
93
+
94
+
95
+ # ---------- 23-cluster stage (map dermal identity) ----------
96
+
97
+ def call_dermal_23(a: ad.AnnData) -> ad.AnnData:
98
+ """first-pass clustering; mark cells whose leiden id maps to DERMAL_TOP_CLUSTERS."""
99
+ print("[23] preprocess", flush=True)
100
+ a = qc_filter(a); a = lognorm(a); a = hvg_pca_harmony(a)
101
+ print("[23] leiden res=0.7", flush=True)
102
+ a = leiden_cluster(a, res=RES)
103
+
104
+ # rank markers per top-level cluster
105
+ sc.tl.rank_genes_groups(a, "leiden", method="wilcoxon", n_genes=100)
106
+ df_tl = pd.read_excel(TOP_MARKERS) # Data S1 all-cluster markers
107
+ tl_panels = {int(c): df_tl[df_tl["cluster"] == c].sort_values("avg_log2FC", ascending=False)
108
+ .head(JACCARD_TOP_N)["gene"].tolist() for c in sorted(df_tl["cluster"].unique())}
109
+ tl_map = map_leiden_to_paper(a, "leiden", tl_panels, top_n=JACCARD_TOP_N)
110
+ a.obs["paper_cluster_23"] = a.obs["leiden"].map(lambda c: tl_map.get(str(c), -1))
111
+ a.obs["is_dermal_paper"] = a.obs["paper_cluster_23"].isin(DERMAL_TOP_CLUSTERS)
112
+ print(f"[23] cells matched to paper dermal set: {int(a.obs['is_dermal_paper'].sum())}",
113
+ flush=True)
114
+ return a
115
+
116
+
117
+ def map_leiden_to_paper(a: ad.AnnData, key: str, paper_panels: dict[int, list[str]],
118
+ top_n: int = JACCARD_TOP_N) -> dict[str, int]:
119
+ """best-matching paper cluster per leiden id via jaccard on top-N markers."""
120
+ ranks = a.uns["rank_genes_groups"]
121
+ names = pd.DataFrame(ranks["names"])
122
+ out = {}
123
+ used = set()
124
+ scores = []
125
+ for lc in names.columns:
126
+ my_top = set(names[lc].dropna().tolist()[:top_n])
127
+ best_pc, best_j = None, -1.0
128
+ for pc, panel in paper_panels.items():
129
+ j = len(my_top & set(panel[:top_n])) / max(len(my_top | set(panel[:top_n])), 1)
130
+ if j > best_j:
131
+ best_pc, best_j = pc, j
132
+ scores.append({"leiden": lc, "best_paper": best_pc, "jaccard": best_j})
133
+ out[lc] = best_pc
134
+ # convert to json-safe strings for h5ad serialization
135
+ a.uns[f"_map_scores_{key}"] = json.dumps(scores, default=str)
136
+ return out
137
+
138
+
139
+ # ---------- dermal subclustering stage (Derm0..Derm11) ----------
140
+
141
+ def subcluster_dermal(a: ad.AnnData) -> ad.AnnData:
142
+ dermal = a[a.obs["is_dermal_paper"]].copy()
143
+ # start again from raw counts on the subset
144
+ if "counts" in dermal.layers:
145
+ dermal.X = dermal.layers["counts"]
146
+ print(f"[derm] subset n={dermal.n_obs}", flush=True)
147
+ dermal = lognorm(dermal)
148
+ dermal = hvg_pca_harmony(dermal)
149
+ # tune res to hit ~12 clusters; res=0.7 is the paper value but scanpy Leiden can
150
+ # differ from Seurat FindClusters, so we sweep if the exact-res doesn't give 12
151
+ dermal = leiden_cluster(dermal, res=RES)
152
+ # paper uses seurat FindClusters at res=0.7; scanpy leiden can differ so sweep to hit 12
153
+ if len(dermal.obs["leiden"].unique()) != N_LEIDEN_DERM:
154
+ for r in [0.5, 0.6, 0.7, 0.8, 0.9, 1.0, 1.1, 1.2]:
155
+ sc.tl.leiden(dermal, resolution=r, key_added=f"leiden_r{r}")
156
+ if len(dermal.obs[f"leiden_r{r}"].unique()) == N_LEIDEN_DERM:
157
+ dermal.obs["leiden"] = dermal.obs[f"leiden_r{r}"]
158
+ dermal.uns["_replica_derm_res"] = r
159
+ break
160
+ print(f"[derm] n_leiden = {len(dermal.obs['leiden'].unique())}", flush=True)
161
+
162
+ # rank markers + map to Derm0..Derm11
163
+ sc.tl.rank_genes_groups(dermal, "leiden", method="wilcoxon", n_genes=100)
164
+ df_s3 = pd.read_excel(DERM_MARKERS)
165
+ derm_panels = {int(c): df_s3[df_s3["cluster"] == c].sort_values("avg_log2FC", ascending=False)
166
+ .head(JACCARD_TOP_N)["gene"].tolist() for c in sorted(df_s3["cluster"].unique())}
167
+ derm_map = map_leiden_to_paper(dermal, "leiden", derm_panels, top_n=JACCARD_TOP_N)
168
+ dermal.obs["derm_label"] = dermal.obs["leiden"].map(lambda c: f"Derm{derm_map.get(str(c), -1)}")
169
+
170
+ # push labels back into full object
171
+ labels = pd.Series("non_dermal", index=a.obs_names)
172
+ labels.loc[dermal.obs_names] = dermal.obs["derm_label"].values
173
+ a.obs["derm_label"] = labels
174
+ a.obs["leiden_derm"] = ""
175
+ a.obs.loc[dermal.obs_names, "leiden_derm"] = dermal.obs["leiden"].astype(str).values
176
+ # h5py-safe: stringify keys AND serialize dicts to json
177
+ a.uns["derm_leiden_to_paper"] = json.dumps({str(k): int(v) if v is not None else -1
178
+ for k, v in derm_map.items()}, default=str)
179
+ a.uns["derm_panels_used"] = json.dumps({str(k): [str(g) for g in v]
180
+ for k, v in derm_panels.items()}, default=str)
181
+ return a, dermal
182
+
183
+
184
+ # ---------- QC of the replica: cluster 20 fractions ----------
185
+
186
+ def qc_cluster_20(a: ad.AnnData) -> dict:
187
+ a.obs["genotype"] = a.obs.get("genotype", pd.Series("unknown", index=a.obs_names))
188
+ if "genotype" not in a.obs or a.obs["genotype"].nunique() < 2:
189
+ s = a.obs["sample"].astype(str)
190
+ a.obs["genotype"] = np.where(s.isin(list(CKO_GSMS)), "En1-cKO",
191
+ np.where(s.isin(list(WT_GSMS)), "WT", "other"))
192
+
193
+ dermal_mask = a.obs["is_dermal_paper"].values
194
+ wt_derm = int(((a.obs["genotype"] == "WT") & dermal_mask).sum())
195
+ ck_derm = int(((a.obs["genotype"] == "En1-cKO") & dermal_mask).sum())
196
+
197
+ # top-level cluster 20 replica
198
+ c20 = a.obs["paper_cluster_23"] == 20
199
+ wt_c20 = int(((a.obs["genotype"] == "WT") & c20).sum())
200
+ ck_c20 = int(((a.obs["genotype"] == "En1-cKO") & c20).sum())
201
+
202
+ # derm10 replica
203
+ d10 = a.obs["derm_label"] == "Derm10"
204
+ wt_d10 = int(((a.obs["genotype"] == "WT") & d10).sum())
205
+ ck_d10 = int(((a.obs["genotype"] == "En1-cKO") & d10).sum())
206
+
207
+ return {
208
+ "expected_paper": {"wt_dermal_total": 17398, "cko_dermal_total": 8461,
209
+ "wt_c20_pct": 1.99, "cko_c20_pct": 0.08,
210
+ "wt_c20_abs": 346, "cko_c20_abs": 7},
211
+ "replica": {
212
+ "wt_dermal_total": wt_derm, "cko_dermal_total": ck_derm,
213
+ "wt_c20": wt_c20, "cko_c20": ck_c20,
214
+ "wt_c20_pct": 100 * wt_c20 / max(wt_derm, 1),
215
+ "cko_c20_pct": 100 * ck_c20 / max(ck_derm, 1),
216
+ "wt_derm10": wt_d10, "cko_derm10": ck_d10,
217
+ "wt_derm10_pct": 100 * wt_d10 / max(wt_derm, 1),
218
+ "cko_derm10_pct": 100 * ck_d10 / max(ck_derm, 1),
219
+ },
220
+ }
221
+
222
+
223
+ def main():
224
+ OUT_DIR.mkdir(parents=True, exist_ok=True)
225
+ print("[replica] load raw", flush=True)
226
+ a = ad.read_h5ad(RAW_H5)
227
+ # inject genotype
228
+ s = a.obs["sample"].astype(str)
229
+ a.obs["genotype"] = np.where(s.isin(list(CKO_GSMS)), "En1-cKO",
230
+ np.where(s.isin(list(WT_GSMS)), "WT", "other"))
231
+ a = a[a.obs["genotype"].isin(["WT", "En1-cKO"])].copy()
232
+ print(f"[replica] n={a.n_obs}", flush=True)
233
+
234
+ print("[replica] 23-cluster stage", flush=True)
235
+ a = call_dermal_23(a)
236
+
237
+ print("[replica] dermal subcluster stage", flush=True)
238
+ a, dermal = subcluster_dermal(a)
239
+
240
+ print("[replica] QC vs paper", flush=True)
241
+ qc = qc_cluster_20(a)
242
+ (OUT_DIR / "replica_cluster_20_qc.json").write_text(json.dumps(qc, indent=2, default=str))
243
+ print(json.dumps(qc, indent=2, default=str), flush=True)
244
+
245
+ # write per-Leiden -> paper mapping (parse json-back)
246
+ derm_map_parsed = json.loads(a.uns["derm_leiden_to_paper"])
247
+ mm = pd.DataFrame([{"leiden_derm": k, "paper_derm": v}
248
+ for k, v in derm_map_parsed.items()])
249
+ mm.to_csv(OUT_DIR / "replica_marker_matches.csv", index=False)
250
+
251
+ # save first stringify any datetime/complex obs cols to survive h5ad serialization
252
+ for col in list(a.obs.columns):
253
+ dt = a.obs[col].dtype
254
+ if pd.api.types.is_datetime64_any_dtype(dt) or dt == object:
255
+ try:
256
+ a.obs[col] = a.obs[col].astype(str)
257
+ except Exception:
258
+ del a.obs[col]
259
+ a.write_h5ad(OUT_DIR / "dingwall_replica.h5ad")
260
+ print(f"[replica] wrote {OUT_DIR}/dingwall_replica.h5ad", flush=True)
261
+
262
+
263
+ if __name__ == "__main__":
264
+ main()
scripts/analysis/104_train_on_dingwall_derm_labels.py CHANGED
@@ -1,261 +1,264 @@
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()
 
 
 
 
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
+ import os as _os
16
+ from pathlib import Path as _Path
17
+ PANDA_ROOT = _Path(_os.environ.get("PANDA_ROOT", str(_Path(__file__).resolve().parents[2])))
18
+ sys.path.insert(0, str(PANDA_ROOT))
19
+ from panda.model import (
20
+ PANDAEncoder, supcon_loss, vicreg_loss, hsic_biased, subcenter_angular_infonce
21
+ )
22
+
23
+ ROOT = Path(str(PANDA_ROOT))
24
+ REPLICA_H5 = ROOT / "data/processed/dingwall_replica/dingwall_replica.h5ad"
25
+ OUT_DIR = ROOT / "discovery/pan_skin/marker"
26
+ CK_DIR = ROOT / "checkpoints/pan_skin_dingwall_derm"
27
+
28
+ TRAIN_FRAC = 0.7
29
+ SEED = 0
30
+ N_PCA = 40
31
+ DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
32
+
33
+ # Training config (mirrors 20_train_panda.py)
34
+ GUARANTEED_PER_CLASS = 6
35
+ NATURAL_SLOTS = 96
36
+ STAGE_EPOCHS = [15, 25, 40, 40]
37
+ BALANCE_MIX = 0.5
38
+
39
+
40
+ # ---------- split ----------
41
+
42
+ def genotype_stratified_split(labels: np.ndarray, genotypes: np.ndarray,
43
+ frac_train: float = TRAIN_FRAC, seed: int = SEED
44
+ ) -> tuple[np.ndarray, np.ndarray]:
45
+ """stratified 70/30 within each (label, genotype) group; preserves cKO/WT ratio per class."""
46
+ rng = np.random.default_rng(seed)
47
+ n = len(labels); train = np.zeros(n, dtype=bool); test = np.zeros(n, dtype=bool)
48
+ for lab in np.unique(labels):
49
+ for g in np.unique(genotypes):
50
+ idx = np.where((labels == lab) & (genotypes == g))[0]
51
+ if len(idx) == 0: continue
52
+ rng.shuffle(idx)
53
+ k = max(1, int(len(idx) * frac_train)) if len(idx) > 1 else len(idx)
54
+ train[idx[:k]] = True
55
+ if len(idx) > 1:
56
+ test[idx[k:]] = True
57
+ return train, test
58
+
59
+
60
+ # ---------- PANDA training (identical to variant A) ----------
61
+
62
+ class CorpusDataset(Dataset):
63
+ def __init__(self, X, y, d, aux):
64
+ self.X = X.astype(np.float32); self.y = y.astype(np.int64)
65
+ self.d = d.astype(np.int64); self.aux = aux.astype(np.float32)
66
+ def __len__(self): return self.X.shape[0]
67
+ def __getitem__(self, i):
68
+ return (torch.from_numpy(self.X[i]), torch.tensor(self.y[i]),
69
+ torch.tensor(self.d[i]), torch.from_numpy(self.aux[i]))
70
+
71
+
72
+ class HybridSampler:
73
+ def __init__(self, y, n_batches=100, seed=0):
74
+ self.y = np.asarray(y); self.n_batches = n_batches
75
+ self.rng = np.random.default_rng(seed)
76
+ self.classes = np.unique(self.y)
77
+ self.by_cls = {int(c): np.where(self.y == c)[0] for c in self.classes}
78
+ counts = np.bincount(self.y, minlength=int(self.classes.max()) + 1).astype(float)
79
+ self.natural_p = counts / counts.sum()
80
+ def __iter__(self):
81
+ for _ in range(self.n_batches):
82
+ batch = []
83
+ for c in self.classes:
84
+ idx = self.by_cls[int(c)]
85
+ take = min(GUARANTEED_PER_CLASS, len(idx))
86
+ if take > 0:
87
+ batch.extend(self.rng.choice(idx, size=take, replace=(len(idx) < take)).tolist())
88
+ for _ in range(NATURAL_SLOTS):
89
+ c = self.rng.choice(len(self.natural_p), p=self.natural_p)
90
+ idx = self.by_cls.get(int(c), self.by_cls[int(self.classes[0])])
91
+ batch.append(int(self.rng.choice(idx)))
92
+ yield batch
93
+ def __len__(self): return self.n_batches
94
+
95
+
96
+ def train_panda(X_tr, y_tr, d_tr, aux_tr, n_classes, n_datasets, ck_out: Path):
97
+ ck_out.mkdir(parents=True, exist_ok=True)
98
+ counts = np.bincount(y_tr, minlength=n_classes)
99
+ inv_sqrt = 1.0 / np.sqrt(counts + 1); inv_sqrt = inv_sqrt / inv_sqrt.mean()
100
+ class_w = BALANCE_MIX * inv_sqrt + (1 - BALANCE_MIX) * np.ones_like(inv_sqrt)
101
+ class_w = torch.tensor(class_w, dtype=torch.float32, device=DEVICE)
102
+
103
+ ds = CorpusDataset(X_tr, y_tr, d_tr, aux_tr)
104
+ loader = DataLoader(ds, batch_sampler=HybridSampler(y_tr, n_batches=100), num_workers=0)
105
+
106
+ model = PANDAEncoder(variant="pca", n_pca=X_tr.shape[1], n_classes=n_classes,
107
+ n_datasets=n_datasets).to(DEVICE)
108
+ opt = torch.optim.AdamW(model.parameters(), lr=3e-4, weight_decay=1e-4)
109
+
110
+ for stage, n_ep in enumerate(STAGE_EPOCHS):
111
+ print(f"[panda-B] stage {stage} ({n_ep} epochs)", flush=True)
112
+ for e in range(n_ep):
113
+ t0 = time.time(); losses = []
114
+ for X_b, y_b, d_b, aux_b in loader:
115
+ X_b = X_b.to(DEVICE); y_b = y_b.to(DEVICE); d_b = d_b.to(DEVICE); aux_b = aux_b.to(DEVICE)
116
+ lam = 1.0 if stage >= 2 else 0.0
117
+ out = model(X_b, aux_b, lam_dann=lam)
118
+ L_supcon = supcon_loss(out["z"], y_b)
119
+ L_vic = vicreg_loss(out["z"])
120
+ L_ce = F.cross_entropy(out["logits"], y_b, weight=class_w, label_smoothing=0.05)
121
+ total = L_supcon + 1.0 * L_vic + 0.4 * L_ce
122
+ if stage >= 1:
123
+ proto_ref = model.prototypes.detach().clone()
124
+ total = total + 0.6 * subcenter_angular_infonce(out["z"], y_b, proto_ref)
125
+ if stage >= 2:
126
+ total = total + F.cross_entropy(out["dom"], d_b)
127
+ total = total + 0.3 * F.mse_loss(out["depth"].squeeze(1), aux_b[:, 1])
128
+ total = total + 0.05 * hsic_biased(out["repr"], aux_b[:, 1:2])
129
+ opt.zero_grad(); total.backward()
130
+ torch.nn.utils.clip_grad_norm_(model.parameters(), 5.0)
131
+ opt.step()
132
+ if stage >= 1:
133
+ model.update_prototypes(out["z"].detach(), y_b)
134
+ losses.append(float(total.item()))
135
+ if (e + 1) % 5 == 0:
136
+ print(f" ep {e+1}/{n_ep} loss={np.mean(losses):.3f} dt={time.time()-t0:.1f}s", flush=True)
137
+ torch.save({"model": model.state_dict()}, ck_out / f"panda_stage{stage}.pt")
138
+ torch.save({"model": model.state_dict(),
139
+ "prototypes": model.prototypes.detach().cpu().numpy()},
140
+ ck_out / "panda_final.pt")
141
+ return model
142
+
143
+
144
+ @torch.no_grad()
145
+ def infer(model, X, aux):
146
+ model.eval()
147
+ Xt = torch.from_numpy(X.astype(np.float32)).to(DEVICE)
148
+ at = torch.from_numpy(aux.astype(np.float32)).to(DEVICE)
149
+ B = 4096; preds = []; confs = []
150
+ for i in range(0, len(Xt), B):
151
+ out = model(Xt[i:i+B], at[i:i+B])
152
+ p = F.softmax(out["logits"], dim=1)
153
+ preds.append(p.argmax(dim=1).cpu().numpy())
154
+ confs.append(p.max(dim=1).values.cpu().numpy())
155
+ return np.concatenate(preds), np.concatenate(confs)
156
+
157
+
158
+ # ---------- reporting ----------
159
+
160
+ def depletion_table(true_or_pred: np.ndarray, genotype: np.ndarray, class_names: list[str]
161
+ ) -> pd.DataFrame:
162
+ n_wt = int((genotype == "WT").sum()); n_cko = int((genotype == "En1-cKO").sum())
163
+ base = n_cko / max(n_wt + n_cko, 1)
164
+ rows = []
165
+ for i, cn in enumerate(class_names):
166
+ m = true_or_pred == i
167
+ w = int(((genotype == "WT") & m).sum()); k = int(((genotype == "En1-cKO") & m).sum())
168
+ if w + k == 0: continue
169
+ try:
170
+ odds, p = fisher_exact([[w, n_wt - w], [k, n_cko - k]], alternative="two-sided")
171
+ except ValueError:
172
+ odds, p = 1.0, 1.0
173
+ rows.append({"derm_label": cn, "n": w + k, "n_WT": w, "n_cKO": k,
174
+ "cko_frac": k / (w + k), "baseline_cko": base,
175
+ "odds_ratio": float(odds), "fisher_p": float(p)})
176
+ return pd.DataFrame(rows).sort_values("cko_frac")
177
+
178
+
179
+ def main():
180
+ OUT_DIR.mkdir(parents=True, exist_ok=True); CK_DIR.mkdir(parents=True, exist_ok=True)
181
+
182
+ print("[B] load replica", flush=True)
183
+ if not REPLICA_H5.exists():
184
+ raise FileNotFoundError(f"Run 103 first — {REPLICA_H5} missing")
185
+ a = ad.read_h5ad(REPLICA_H5)
186
+
187
+ dermal = a[a.obs["derm_label"].astype(str) != "non_dermal"].copy()
188
+ print(f"[B] dermal n={dermal.n_obs}", flush=True)
189
+ labels_str = dermal.obs["derm_label"].astype(str).values
190
+ classes = sorted(set(labels_str))
191
+ cls_ix = {c: i for i, c in enumerate(classes)}
192
+ y_all = np.array([cls_ix[c] for c in labels_str])
193
+ genotype = dermal.obs["genotype"].astype(str).values
194
+
195
+ # get embedding from replica (harmony-corrected PCA)
196
+ rep_key = dermal.uns.get("_replica_rep", "X_pca_harmony")
197
+ if rep_key not in dermal.obsm:
198
+ rep_key = "X_pca_harmony" if "X_pca_harmony" in dermal.obsm else "X_pca"
199
+ X_all = np.asarray(dermal.obsm[rep_key])
200
+ print(f"[B] using {rep_key} (d={X_all.shape[1]})", flush=True)
201
+
202
+ sample_ix = {s: i for i, s in enumerate(sorted(dermal.obs["sample"].astype(str).unique()))}
203
+ d_all = np.array([sample_ix[s] for s in dermal.obs["sample"].astype(str)])
204
+ total_counts = np.asarray(dermal.X.sum(axis=1)).ravel()
205
+ logc = np.log10(total_counts + 1); logc = (logc - logc.mean()) / (logc.std() + 1e-6)
206
+ aux_all = np.stack([np.zeros(dermal.n_obs, dtype=np.float32), logc.astype(np.float32)], axis=1)
207
+
208
+ print("[B] genotype-stratified 70/30 split", flush=True)
209
+ tr, te = genotype_stratified_split(labels_str, genotype, frac_train=TRAIN_FRAC, seed=SEED)
210
+ print(f"[B] train={tr.sum()} test={te.sum()}", flush=True)
211
+
212
+ manifest = pd.DataFrame({
213
+ "cell_id": dermal.obs_names.astype(str).values,
214
+ "derm_label": labels_str,
215
+ "genotype": genotype,
216
+ "split": np.where(tr, "train", np.where(te, "test", "unassigned")),
217
+ })
218
+ manifest.to_csv(OUT_DIR / "104_dingwall_derm_split_manifest.csv", index=False)
219
+
220
+ print("[B] train PANDA", flush=True)
221
+ model = train_panda(X_all[tr], y_all[tr], d_all[tr], aux_all[tr],
222
+ n_classes=len(classes), n_datasets=len(sample_ix), ck_out=CK_DIR)
223
+
224
+ print("[B] infer on held-out", flush=True)
225
+ pred_ix, conf = infer(model, X_all[te], aux_all[te])
226
+ pred = pd.DataFrame({
227
+ "cell_id": dermal.obs_names.astype(str).values[te],
228
+ "derm_true": labels_str[te],
229
+ "derm_pred": [classes[p] for p in pred_ix],
230
+ "confidence": conf,
231
+ "genotype": genotype[te],
232
+ })
233
+ pred.to_csv(OUT_DIR / "104_dingwall_derm_predictions.csv", index=False)
234
+
235
+ # depletion reported for TEST set only, using PANDA predictions
236
+ pred_ix_full = np.array([cls_ix[c] for c in pred["derm_pred"].values])
237
+ dep_pred = depletion_table(pred_ix_full, genotype[te], classes)
238
+ dep_true = depletion_table(y_all[te], genotype[te], classes)
239
+ dep_pred.to_csv(OUT_DIR / "104_dingwall_derm_depletion_pred.csv", index=False)
240
+ dep_true.to_csv(OUT_DIR / "104_dingwall_derm_depletion_true.csv", index=False)
241
+
242
+ d10_true = dep_true[dep_true["derm_label"] == "Derm10"].to_dict("records")
243
+ d10_pred = dep_pred[dep_pred["derm_label"] == "Derm10"].to_dict("records")
244
+ acc = float((pred_ix == y_all[te]).mean())
245
+
246
+ summary = {
247
+ "variant": "B_fully_supervised_replica_labels",
248
+ "n_dermal_total": int(dermal.n_obs),
249
+ "n_train": int(tr.sum()), "n_test": int(te.sum()),
250
+ "classes": classes,
251
+ "test_accuracy": acc,
252
+ "expected_paper_derm10": {"wt_pct": 1.99, "cko_pct": 0.08,
253
+ "or_approx": 24.5, "wt_n_approx": 346, "cko_n_approx": 7},
254
+ "test_derm10_true": d10_true,
255
+ "test_derm10_pred": d10_pred,
256
+ "test_depletion_true": dep_true.to_dict("records"),
257
+ "test_depletion_pred": dep_pred.to_dict("records"),
258
+ }
259
+ (OUT_DIR / "104_dingwall_derm_summary.json").write_text(json.dumps(summary, indent=2, default=str))
260
+ print(f"[B] done -> {OUT_DIR}/104_dingwall_derm_*", flush=True)
261
+
262
+
263
+ if __name__ == "__main__":
264
+ main()
scripts/analysis/105_primary_eden_full_dermal.py CHANGED
@@ -1,134 +1,137 @@
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()
 
 
 
 
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
+ import os as _os
8
+ from pathlib import Path as _Path
9
+ PANDA_ROOT = _Path(_os.environ.get("PANDA_ROOT", str(_Path(__file__).resolve().parents[2])))
10
+ ROOT = Path(str(PANDA_ROOT))
11
+ REPLICA = ROOT / "data/processed/dingwall_replica/dingwall_replica.h5ad"
12
+ DERM_MARKERS = ROOT / "data/external_labels/dingwall_supp/biorxiv_media-3.xlsx"
13
+ TOP_N = 30
14
+
15
+ # EDEN identity map from Dingwall paper + Data S2 CellChat
16
+ EDEN_IDENTITY = {
17
+ 10: "Secondary_EDEN_(Dingwall_cluster_20)",
18
+ 2: "Primary_EDEN_candidate_1_(Derm2_-_immediate_precursor)",
19
+ 9: "Primary_EDEN_candidate_2_(Derm9)",
20
+ 6: "EDEN-signalling_(Derm6)",
21
+ 3: "EDEN-signalling_(Derm3)",
22
+ }
23
+
24
+
25
+ def main():
26
+ print("[eden] loading replica dermal set (Dingwall-defined)", flush=True)
27
+ a = ad.read_h5ad(REPLICA)
28
+ # keep only cells the Seurat replica classified as belonging to Dingwall's dermal clusters
29
+ if "is_dermal_paper" in a.obs.columns:
30
+ dermal = a[a.obs["is_dermal_paper"] == True].copy()
31
+ elif "derm_label" in a.obs.columns:
32
+ dermal = a[a.obs["derm_label"] != "non_dermal"].copy()
33
+ else:
34
+ raise RuntimeError("no dermal indicator in replica")
35
+ print(f"[eden] Seurat replica dermal cells: {dermal.n_obs}", flush=True)
36
+
37
+ # verify our Derm-label distribution matches the replica
38
+ if "derm_label" in dermal.obs.columns:
39
+ print(f"[eden] Derm label distribution (from replica):", flush=True)
40
+ for k, v in dermal.obs["derm_label"].value_counts().sort_index().items():
41
+ print(f" {k}: {v}", flush=True)
42
+
43
+ # load Data S1C panels
44
+ print(f"\n[eden] loading Data S1C marker panels", flush=True)
45
+ df = pd.read_excel(DERM_MARKERS)
46
+ df = df.sort_values(["cluster", "avg_log2FC"], ascending=[True, False])
47
+ panels = {}
48
+ for cl in sorted(df["cluster"].unique()):
49
+ genes = df[df["cluster"] == cl].head(TOP_N)["gene"].astype(str).tolist()
50
+ panels[int(cl)] = genes
51
+
52
+ # replica may or may not have log1p applied; reset from counts layer if present
53
+ if "counts" in dermal.layers:
54
+ dermal.X = dermal.layers["counts"]
55
+ if dermal.X.max() > 30: # raw counts
56
+ sc.pp.normalize_total(dermal, target_sum=1e4); sc.pp.log1p(dermal)
57
+
58
+ # score each cell on all 12 Derm identity panels
59
+ print(f"\n[eden] scoring cells on all 12 Derm panels (top-30 markers each)", flush=True)
60
+ for cl, genes in panels.items():
61
+ present = [g for g in genes if g in dermal.var_names]
62
+ if len(present) < 3:
63
+ dermal.obs[f"derm{cl}_score"] = 0.0
64
+ continue
65
+ sc.tl.score_genes(dermal, gene_list=present, score_name=f"derm{cl}_score",
66
+ random_state=0, use_raw=False)
67
+
68
+ # global baseline
69
+ n_wt = int((dermal.obs["genotype"] == "WT").sum())
70
+ n_cko = int((dermal.obs["genotype"] == "En1-cKO").sum())
71
+ baseline = n_cko / max(n_wt + n_cko, 1)
72
+ print(f"\n[eden] baseline: WT={n_wt} cKO={n_cko} (baseline cKO frac = {baseline:.3f})", flush=True)
73
+
74
+ # use the replica's derm_label directly, not argmax of scores
75
+ rows = []
76
+ print(f"\n[eden] per-Derm Fisher exact on replica-assigned identities:", flush=True)
77
+ for cl in sorted(panels.keys()):
78
+ derm_label = f"Derm{cl}"
79
+ if derm_label not in dermal.obs["derm_label"].values:
80
+ continue
81
+ sub = dermal[dermal.obs["derm_label"] == derm_label]
82
+ n_wt_c = int((sub.obs["genotype"] == "WT").sum())
83
+ n_cko_c = int((sub.obs["genotype"] == "En1-cKO").sum())
84
+ if n_wt_c + n_cko_c == 0:
85
+ continue
86
+ cko_frac = n_cko_c / (n_wt_c + n_cko_c)
87
+ n_wt_else = n_wt - n_wt_c
88
+ n_cko_else = n_cko - n_cko_c
89
+ try:
90
+ odds, p_f = fisher_exact([[n_wt_c, n_wt_else], [n_cko_c, n_cko_else]],
91
+ alternative="two-sided")
92
+ except ValueError:
93
+ odds, p_f = 1.0, 1.0
94
+ rows.append({
95
+ "derm_id": cl,
96
+ "identity": EDEN_IDENTITY.get(cl, "other"),
97
+ "n_cells": n_wt_c + n_cko_c,
98
+ "n_WT": n_wt_c, "n_cKO": n_cko_c,
99
+ "cko_frac": cko_frac,
100
+ "baseline_cko_frac": baseline,
101
+ "cko_delta": cko_frac - baseline,
102
+ "wt_enrichment_odds_ratio": float(1.0/odds) if odds > 0 else None,
103
+ "fisher_p_two_sided": float(p_f),
104
+ "depletion_direction": "cKO-depleted" if cko_frac < baseline else "cKO-enriched",
105
+ "top10_markers_dingwall_S1C": ", ".join(panels[cl][:10]),
106
+ })
107
+
108
+ result_df = pd.DataFrame(rows).sort_values("cko_delta")
109
+ out = ROOT / "discovery/pan_skin/marker"
110
+ out.mkdir(parents=True, exist_ok=True)
111
+ result_df.to_csv(out / "105_primary_eden_full_dermal.csv", index=False)
112
+
113
+ print(f"\n{'Derm':<8}{'Identity':<50}{'n':<7}{'WT':<6}{'cKO':<6}{'cKO_frac':<10}"
114
+ f"{'OR (WT enrich)':<16}{'Fisher p':<12}", flush=True)
115
+ print("-" * 130, flush=True)
116
+ for _, r in result_df.iterrows():
117
+ print(f"Derm{r['derm_id']:<5}{r['identity'][:47]:<50}{r['n_cells']:<7}"
118
+ f"{r['n_WT']:<6}{r['n_cKO']:<6}{r['cko_frac']:<10.3f}"
119
+ f"{r['wt_enrichment_odds_ratio']:<16.2f}{r['fisher_p_two_sided']:<12.2e}", flush=True)
120
+
121
+ # summary json
122
+ summary = {
123
+ "target": "Dingwall_GSE220977",
124
+ "method": "Seurat-replica-identified 14,251 dermal cells (Dingwall clusters {0,1,3,4,5,8,11,20}); "
125
+ "per-Derm identity Fisher-exact cKO depletion using replica-assigned Derm labels "
126
+ "(mapped via Jaccard on top-50 markers to Dingwall Data S1C)",
127
+ "n_dermal_cells_total": int(dermal.n_obs),
128
+ "baseline_cko_frac": float(baseline),
129
+ "n_WT_dermal": n_wt, "n_cKO_dermal": n_cko,
130
+ "per_derm": rows,
131
+ }
132
+ (out / "105_primary_eden_full_dermal.json").write_text(json.dumps(summary, indent=2, default=str))
133
+ print(f"\n[eden] wrote {out}/105_primary_eden_full_dermal.{{csv,json}}", flush=True)
134
+
135
+
136
+ if __name__ == "__main__":
137
+ main()
scripts/analysis/106_melanoblast_neural_crest.py CHANGED
@@ -1,138 +1,141 @@
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()
 
 
 
 
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
+ import os as _os
18
+ from pathlib import Path as _Path
19
+ PANDA_ROOT = _Path(_os.environ.get("PANDA_ROOT", str(_Path(__file__).resolve().parents[2])))
20
+ ROOT = Path(str(PANDA_ROOT))
21
+ OUT = ROOT / "discovery/pan_skin/marker"
22
+ OUT.mkdir(parents=True, exist_ok=True)
23
+
24
+ RAW = ROOT / "data/raw/GSE220977_combined.h5ad"
25
+ PRED = ROOT / "discovery/pan_skin/marker/dingwall_predictions.csv"
26
+ CKO_GSMS = {"GSM6833482", "GSM6833483"}
27
+ WT_GSMS = {"GSM6833478", "GSM6833479", "GSM6833480", "GSM6833481"}
28
+
29
+ MODULES = {
30
+ "Neural_crest": ["Sox10", "Sox9", "Sox2", "Pax3", "Foxd3", "Nes", "Tfap2a"],
31
+ "Melanogenesis_late": ["Tyrp1", "Slc45a2", "Oca2", "Gpnmb", "Pmel", "Silv",
32
+ "Mlph", "Rab27a", "Melana"],
33
+ "MITF_regulon": ["Mitf", "Dct", "Tyr", "Pmel", "Mlana", "Tyrp1",
34
+ "Slc24a5", "Slc45a2", "Sox10", "Pax3", "Kit", "Ednrb"],
35
+ "Sweat_gland": ["Foxi3", "Foxa1", "En1", "Krt8", "Krt18", "Krt19",
36
+ "Muc5b", "Aqp5", "Cutl1"],
37
+ "Eda_ectodysplasin": ["Eda", "Edar", "Edaradd", "Nfkb1", "Nfkb2", "Rela"],
38
+ }
39
+
40
+
41
+ def score(sub, name, genes):
42
+ present = [g for g in genes if g in sub.var_names]
43
+ if not present:
44
+ sub.obs[f"pw_{name}"] = 0.0
45
+ return
46
+ sc.tl.score_genes(sub, gene_list=present, score_name=f"pw_{name}",
47
+ random_state=0, use_raw=False)
48
+
49
+
50
+ def main():
51
+ print("[load] Dingwall + predictions", flush=True)
52
+ a = ad.read_h5ad(RAW)
53
+ pred = pd.read_csv(PRED)
54
+ pred_map = dict(zip(pred["cell_id"].astype(str), pred["pred_label"]))
55
+ a.obs["pred_label"] = pd.Categorical(
56
+ [pred_map.get(c, "unknown") for c in a.obs_names])
57
+ samp = a.obs["sample"].astype(str)
58
+ a.obs["group"] = np.where(samp.isin(list(CKO_GSMS)), "En1-cKO",
59
+ np.where(samp.isin(list(WT_GSMS)), "WT", "other"))
60
+ a = a[a.obs["group"].isin(["En1-cKO", "WT"])].copy()
61
+ sub = a[a.obs["pred_label"].astype(str) == "melanoblast"].copy()
62
+ print(f"[load] {sub.n_obs} melanoblast cells "
63
+ f"(cKO={(sub.obs['group']=='En1-cKO').sum()}, "
64
+ f"WT={(sub.obs['group']=='WT').sum()})", flush=True)
65
+
66
+ sc.pp.normalize_total(sub, target_sum=1e4)
67
+ sc.pp.log1p(sub)
68
+
69
+ for name, genes in MODULES.items():
70
+ score(sub, name, genes)
71
+
72
+ sub.obs["derepression"] = (sub.obs["pw_Sweat_gland"].astype(float)
73
+ + sub.obs["pw_Eda_ectodysplasin"].astype(float))
74
+
75
+ df = sub.obs[[
76
+ "group", "pw_Neural_crest", "pw_Melanogenesis_late", "pw_MITF_regulon",
77
+ "pw_Sweat_gland", "pw_Eda_ectodysplasin", "derepression"
78
+ ]].copy()
79
+ df.reset_index().rename(columns={"index": "cell_id"}).to_csv(
80
+ OUT / "106_melanoblast_nc_scores.csv", index=False)
81
+
82
+ # baseline cKO vs WT per module
83
+ tests = {}
84
+ for m in ["Neural_crest", "Melanogenesis_late", "MITF_regulon",
85
+ "Sweat_gland", "Eda_ectodysplasin"]:
86
+ v1 = df.loc[df["group"] == "En1-cKO", f"pw_{m}"].astype(float).values
87
+ v0 = df.loc[df["group"] == "WT", f"pw_{m}"].astype(float).values
88
+ _, p = mannwhitneyu(v1, v0, alternative="two-sided")
89
+ tests[m] = {
90
+ "delta_cKO_minus_WT": float(v1.mean() - v0.mean()),
91
+ "mean_cKO": float(v1.mean()), "mean_WT": float(v0.mean()),
92
+ "n_cKO": int(len(v1)), "n_WT": int(len(v0)),
93
+ "mannu_p": float(p),
94
+ }
95
+
96
+ # within cKO, split by derepression quartile
97
+ cko = df[df["group"] == "En1-cKO"].copy()
98
+ q1 = cko["derepression"].quantile(0.25)
99
+ q4 = cko["derepression"].quantile(0.75)
100
+ top = cko[cko["derepression"] >= q4]
101
+ bot = cko[cko["derepression"] <= q1]
102
+ print(f"[q] top-derep cKO: n={len(top)}, bot-derep cKO: n={len(bot)}",
103
+ flush=True)
104
+
105
+ within = {}
106
+ for m in ["Neural_crest", "Melanogenesis_late", "MITF_regulon"]:
107
+ vt = top[f"pw_{m}"].astype(float).values
108
+ vb = bot[f"pw_{m}"].astype(float).values
109
+ _, p = mannwhitneyu(vt, vb, alternative="two-sided")
110
+ within[m] = {
111
+ "mean_top_derep": float(vt.mean()),
112
+ "mean_bot_derep": float(vb.mean()),
113
+ "delta_top_minus_bot": float(vt.mean() - vb.mean()),
114
+ "n_top": int(len(vt)), "n_bot": int(len(vb)),
115
+ "mannu_p": float(p),
116
+ }
117
+
118
+ nc_delta = within["Neural_crest"]["delta_top_minus_bot"]
119
+ mel_delta = within["Melanogenesis_late"]["delta_top_minus_bot"]
120
+ verdict = (
121
+ "novel_neural_crest_reversion" if nc_delta > 0.03 and mel_delta > -0.02
122
+ else "basic_melanocyte_disruption" if nc_delta < 0.01 and mel_delta < -0.02
123
+ else "mixed_or_orthogonal"
124
+ )
125
+ summary = {
126
+ "n_melanoblast_cells": int(sub.n_obs),
127
+ "n_cKO_melanoblasts": int((df["group"] == "En1-cKO").sum()),
128
+ "n_WT_melanoblasts": int((df["group"] == "WT").sum()),
129
+ "baseline_cKO_vs_WT": tests,
130
+ "within_cKO_top_vs_bot_derepression_quartile": within,
131
+ "verdict": verdict,
132
+ }
133
+ with open(OUT / "106_melanoblast_nc_summary.json", "w") as f:
134
+ json.dump(summary, f, indent=2)
135
+
136
+ print("[done] verdict:", verdict, flush=True)
137
+ print(json.dumps(summary, indent=2))
138
+
139
+
140
+ if __name__ == "__main__":
141
+ main()
scripts/analysis/107_dingwall_class_deg_count.py CHANGED
@@ -1,116 +1,122 @@
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()
 
 
 
 
 
 
 
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
+ import os as _os
17
+ from pathlib import Path as _Path
18
+ PANDA_ROOT = _Path(_os.environ.get("PANDA_ROOT", str(_Path(__file__).resolve().parents[2])))
19
+ ROOT = Path(str(PANDA_ROOT))
20
+ OUT = ROOT / "discovery/pan_skin/marker"
21
+ OUT.mkdir(parents=True, exist_ok=True)
22
+
23
+ RAW = ROOT / "data/raw/GSE220977_combined.h5ad"
24
+ PRED = ROOT / "discovery/pan_skin/marker/dingwall_predictions.csv"
25
+ CKO_GSMS = {"GSM6833482", "GSM6833483"}
26
+ WT_GSMS = {"GSM6833478", "GSM6833479", "GSM6833480", "GSM6833481"}
27
+ MIN_PER_GROUP = 15
28
+ LFC_THRESH = 1.0
29
+ PADJ_THRESH = 0.05
30
+
31
+
32
+ def main():
33
+ print("[load]", flush=True)
34
+ a = ad.read_h5ad(RAW)
35
+ pred = pd.read_csv(PRED)
36
+ pred_map = dict(zip(pred["cell_id"].astype(str), pred["pred_label"]))
37
+ a.obs["pred_label"] = pd.Categorical(
38
+ [pred_map.get(c, "unknown") for c in a.obs_names])
39
+ samp = a.obs["sample"].astype(str)
40
+ a.obs["group"] = np.where(samp.isin(list(CKO_GSMS)), "En1-cKO",
41
+ np.where(samp.isin(list(WT_GSMS)), "WT", "other"))
42
+ a = a[a.obs["group"].isin(["En1-cKO", "WT"])].copy()
43
+ print(f"[load] {a.n_obs} cells, {a.n_vars} genes", flush=True)
44
+
45
+ sc.pp.normalize_total(a, target_sum=1e4)
46
+ sc.pp.log1p(a)
47
+
48
+ rows = []
49
+ for cls in sorted(a.obs["pred_label"].astype(str).unique()):
50
+ mask = (a.obs["pred_label"].astype(str) == cls)
51
+ n_cko = int((mask & (a.obs["group"] == "En1-cKO")).sum())
52
+ n_wt = int((mask & (a.obs["group"] == "WT")).sum())
53
+ if n_cko < MIN_PER_GROUP or n_wt < MIN_PER_GROUP:
54
+ print(f"[skip] {cls}: n_cKO={n_cko} n_WT={n_wt}", flush=True)
55
+ continue
56
+ sub = a[mask].copy()
57
+ sub.obs["group"] = pd.Categorical(sub.obs["group"].values,
58
+ categories=["En1-cKO", "WT"])
59
+ # reference=WT so positive LFC means up in cKO
60
+ try:
61
+ sc.tl.rank_genes_groups(sub, "group", reference="WT",
62
+ groups=["En1-cKO"], method="wilcoxon",
63
+ n_genes=sub.n_vars, use_raw=False,
64
+ pts=True)
65
+ rgg = sub.uns["rank_genes_groups"]
66
+ df = pd.DataFrame({
67
+ "gene": [x[0] for x in rgg["names"]],
68
+ "lfc": [x[0] for x in rgg["logfoldchanges"]],
69
+ "padj": [x[0] for x in rgg["pvals_adj"]],
70
+ })
71
+ up = int(((df["lfc"] > LFC_THRESH) & (df["padj"] < PADJ_THRESH)).sum())
72
+ down = int(((df["lfc"] < -LFC_THRESH) & (df["padj"] < PADJ_THRESH)).sum())
73
+ total = up + down
74
+ # persist the COMPLETE significant sets. An earlier .head(10) meant
75
+ # n_DEG could exceed the stored gene list (HF-placode: 12 vs 10),
76
+ # so two of the reported DEGs were never written to any artefact.
77
+ top_up = df[(df["lfc"] > LFC_THRESH) & (df["padj"] < PADJ_THRESH)] \
78
+ .sort_values("lfc", ascending=False)["gene"].tolist()
79
+ top_down = df[(df["lfc"] < -LFC_THRESH) & (df["padj"] < PADJ_THRESH)] \
80
+ .sort_values("lfc", ascending=True)["gene"].tolist()
81
+ except Exception as e:
82
+ print(f"[fail] {cls}: {e}", flush=True)
83
+ continue
84
+ rows.append({
85
+ "class": cls,
86
+ "n_cKO": n_cko,
87
+ "n_WT": n_wt,
88
+ "n_DEG": total,
89
+ "n_up": up,
90
+ "n_down": down,
91
+ "top_up": ";".join(top_up),
92
+ "top_down": ";".join(top_down),
93
+ })
94
+ print(f"[ok] {cls}: n_cKO={n_cko} n_WT={n_wt} DEG={total} "
95
+ f"(up={up}, down={down})", flush=True)
96
+
97
+ df_out = pd.DataFrame(rows).sort_values("n_DEG", ascending=False)
98
+ df_out.to_csv(OUT / "107_dingwall_class_deg_count.csv", index=False)
99
+ print("\n[rank] classes ordered by DEG count (|LFC|>1 padj<0.05):")
100
+ print(df_out[["class", "n_cKO", "n_WT", "n_DEG", "n_up", "n_down"]]
101
+ .to_string(index=False))
102
+
103
+ if not df_out.empty:
104
+ winner = df_out.iloc[0]
105
+ summary = {
106
+ "lfc_threshold": LFC_THRESH, "padj_threshold": PADJ_THRESH,
107
+ "n_classes_tested": int(len(df_out)),
108
+ "top_class": str(winner["class"]),
109
+ "top_n_DEG": int(winner["n_DEG"]),
110
+ "top_up": winner["top_up"],
111
+ "top_down": winner["top_down"],
112
+ "ranking": df_out[["class", "n_DEG"]].to_dict("records"),
113
+ }
114
+ else:
115
+ summary = {"error": "no eligible classes"}
116
+ with open(OUT / "107_dingwall_class_deg_count.json", "w") as f:
117
+ json.dump(summary, f, indent=2)
118
+ print("[done]", flush=True)
119
+
120
+
121
+ if __name__ == "__main__":
122
+ main()
scripts/analysis/108_dahlin_lineage_metabolism.py CHANGED
@@ -1,158 +1,161 @@
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()
 
 
 
 
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
+ import os as _os
19
+ from pathlib import Path as _Path
20
+ PANDA_ROOT = _Path(_os.environ.get("PANDA_ROOT", str(_Path(__file__).resolve().parents[2])))
21
+ ROOT = Path(str(PANDA_ROOT))
22
+ OUT = ROOT / "discovery/hematopoiesis/marker"
23
+ OUT.mkdir(parents=True, exist_ok=True)
24
+
25
+ D_DIR = ROOT / "data/corpus/hematopoiesis/held_out_unlabeled/dahlin_extract"
26
+ PRED = ROOT / "discovery/hematopoiesis/marker/dahlin_predictions.csv"
27
+ GT = {"SIGAB1": "WT", "SIGAC1": "WT", "SIGAD1": "WT", "SIGAF1": "WT",
28
+ "SIGAG1": "WT", "SIGAH1": "WT",
29
+ "SIGAG8": "Kit_W41", "SIGAH8": "Kit_W41"}
30
+
31
+ MIN_PER_GROUP = 15
32
+
33
+ MODULES = {
34
+ "OXPHOS_ETC": ["Ndufa1", "Ndufa2", "Ndufb1", "Ndufb2", "Sdha", "Sdhb",
35
+ "Cox4i1", "Cox5a", "Cox6a1", "Atp5a1", "Atp5b", "Uqcrq"],
36
+ "Glycolysis": ["Hk1", "Hk2", "Pfkm", "Pfkl", "Aldoa", "Gapdh", "Pgk1",
37
+ "Pkm", "Ldha", "Eno1", "Tpi1", "Pgam1"],
38
+ "Fatty_acid_oxidation":["Cpt1a", "Acadm", "Acadl", "Acadvl", "Hadha", "Hadhb",
39
+ "Ppara", "Ppargc1a", "Ucp2"],
40
+ "Redox_glutathione": ["Gpx1", "Gpx2", "Gpx3", "Gpx4", "Gsr", "Prdx1", "Prdx2",
41
+ "Prdx3", "Prdx4", "Prdx5", "Prdx6", "Sod1", "Sod2", "Cat"],
42
+ }
43
+
44
+
45
+ def load_dahlin():
46
+ print("[load] Dahlin raw counts", flush=True)
47
+ parts = []
48
+ for f in sorted(D_DIR.glob("*.txt.gz")):
49
+ sample = f.name.split("_")[1].split(".")[0]
50
+ df = pd.read_csv(f, sep="\t", compression="gzip", index_col=0)
51
+ X = sp.csr_matrix(df.values.T.astype(np.float32))
52
+ obs = pd.DataFrame(index=[f"{sample}_{bc}" for bc in df.columns.astype(str)])
53
+ obs["sample"] = sample
54
+ obs["group"] = GT.get(sample, "unknown")
55
+ var = pd.DataFrame(index=df.index.astype(str))
56
+ parts.append(ad.AnnData(X=X, obs=obs, var=var))
57
+ print(f"[load] {sample}: {X.shape}", flush=True)
58
+ a = ad.concat(parts, join="outer", label="_batch")
59
+ print(f"[load] concat: {a.shape}", flush=True)
60
+
61
+ import mygene
62
+ mg = mygene.MyGeneInfo()
63
+ res = mg.querymany(a.var_names.astype(str).tolist(), scopes="ensembl.gene",
64
+ fields="symbol", species="mouse", verbose=False)
65
+ id2sym = {r["query"]: r["symbol"] for r in res if "symbol" in r}
66
+ syms = pd.Series(a.var_names.astype(str)).map(id2sym).values
67
+ keep = pd.notna(syms)
68
+ a = a[:, keep].copy()
69
+ a.var_names = syms[keep]
70
+ a.var_names_make_unique()
71
+ print(f"[map] {a.n_vars} genes with symbols", flush=True)
72
+ a = a[a.obs["group"].isin(["Kit_W41", "WT"])].copy()
73
+ return a
74
+
75
+
76
+ def main():
77
+ a = load_dahlin()
78
+ pred = pd.read_csv(PRED)
79
+ common = a.obs_names.intersection(pd.Index(pred["cell_id"].astype(str)))
80
+ a = a[list(common)].copy()
81
+ pred_map = dict(zip(pred["cell_id"].astype(str), pred["pred_label"]))
82
+ a.obs["pred_label"] = pd.Categorical(
83
+ [pred_map.get(c, "unknown") for c in a.obs_names])
84
+ print(f"[join] {a.n_obs} cells, "
85
+ f"Kit_W41={int((a.obs['group']=='Kit_W41').sum())}, "
86
+ f"WT={int((a.obs['group']=='WT').sum())}", flush=True)
87
+
88
+ sc.pp.normalize_total(a, target_sum=1e4)
89
+ sc.pp.log1p(a)
90
+
91
+ rows = []
92
+ for cls in sorted(a.obs["pred_label"].astype(str).unique()):
93
+ mask = (a.obs["pred_label"].astype(str) == cls).values
94
+ n_k = int((mask & (a.obs["group"].values == "Kit_W41")).sum())
95
+ n_w = int((mask & (a.obs["group"].values == "WT")).sum())
96
+ if n_k < MIN_PER_GROUP or n_w < MIN_PER_GROUP:
97
+ print(f"[skip] {cls}: n_Kit={n_k} n_WT={n_w}", flush=True)
98
+ continue
99
+ sub = a[mask].copy()
100
+ for name, genes in MODULES.items():
101
+ present = [g for g in genes if g in sub.var_names]
102
+ if not present:
103
+ sub.obs[f"pw_{name}"] = 0.0
104
+ continue
105
+ sc.tl.score_genes(sub, gene_list=present,
106
+ score_name=f"pw_{name}", random_state=0,
107
+ use_raw=False)
108
+
109
+ grp = sub.obs["group"].values
110
+ for name in MODULES:
111
+ v_k = sub.obs[f"pw_{name}"].astype(float).values[grp == "Kit_W41"]
112
+ v_w = sub.obs[f"pw_{name}"].astype(float).values[grp == "WT"]
113
+ try:
114
+ _, p = mannwhitneyu(v_k, v_w, alternative="two-sided")
115
+ except Exception:
116
+ p = 1.0
117
+ rows.append({
118
+ "class": cls,
119
+ "module": name,
120
+ "delta": float(v_k.mean() - v_w.mean()),
121
+ "mean_Kit_W41": float(v_k.mean()),
122
+ "mean_WT": float(v_w.mean()),
123
+ "n_Kit_W41": n_k,
124
+ "n_WT": n_w,
125
+ "mannu_p": float(p),
126
+ })
127
+ print(f"[ok] {cls}: n_Kit={n_k} n_WT={n_w}", flush=True)
128
+
129
+ df = pd.DataFrame(rows)
130
+ n_tests = len(df)
131
+ df["mannu_p_adj_bonferroni"] = np.minimum(df["mannu_p"] * n_tests, 1.0)
132
+ df.to_csv(OUT / "108_dahlin_lineage_metabolism.csv", index=False)
133
+
134
+ # per-class composite magnitude
135
+ piv = df.pivot(index="class", columns="module", values="delta").fillna(0.0)
136
+ piv["sum_abs_delta"] = piv.abs().sum(axis=1)
137
+ piv["l2_delta"] = np.sqrt((piv[list(MODULES)] ** 2).sum(axis=1))
138
+ piv_sorted = piv.sort_values("sum_abs_delta", ascending=False)
139
+ piv_sorted.to_csv(OUT / "108_dahlin_lineage_metabolism_ranked.csv")
140
+
141
+ print("\n[ranked] lineages by sum |delta| across 4 metabolic modules:")
142
+ print(piv_sorted.round(4).to_string())
143
+
144
+ top_cls = piv_sorted.index[0]
145
+ top_row = piv_sorted.iloc[0]
146
+ summary = {
147
+ "modules": list(MODULES),
148
+ "top_lineage": str(top_cls),
149
+ "top_sum_abs_delta": float(top_row["sum_abs_delta"]),
150
+ "top_deltas_per_module": {
151
+ m: float(top_row[m]) for m in MODULES
152
+ },
153
+ "ranking": piv_sorted[["sum_abs_delta"]].reset_index().to_dict("records"),
154
+ }
155
+ with open(OUT / "108_dahlin_lineage_metabolism.json", "w") as f:
156
+ json.dump(summary, f, indent=2)
157
+ print("[done] top lineage:", top_cls, flush=True)
158
+
159
+
160
+ if __name__ == "__main__":
161
+ main()
scripts/analysis/109_veres_mature_beta.py CHANGED
@@ -1,113 +1,116 @@
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()
 
 
 
 
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
+ import os as _os
15
+ from pathlib import Path as _Path
16
+ PANDA_ROOT = _Path(_os.environ.get("PANDA_ROOT", str(_Path(__file__).resolve().parents[2])))
17
+ ROOT = Path(str(PANDA_ROOT))
18
+ OUT = ROOT / "discovery/pancreas/marker"
19
+ OUT.mkdir(parents=True, exist_ok=True)
20
+
21
+ VERES = ROOT / "data/corpus/pancreas/held_out_labeled/veres_GSE114412_test.h5ad"
22
+ PRED = ROOT / "discovery/pancreas/marker/veres_predictions.csv"
23
+
24
+
25
+ def z(x):
26
+ x = np.asarray(x, dtype=float)
27
+ s = x.std()
28
+ return (x - x.mean()) / (s if s > 0 else 1.0)
29
+
30
+
31
+ def main():
32
+ print("[load]", flush=True)
33
+ a = ad.read_h5ad(VERES)
34
+ pred = pd.read_csv(PRED)
35
+ pred_map = dict(zip(pred["cell_id"].astype(str), pred["pred_label"]))
36
+ a.obs["pred_label"] = pd.Categorical(
37
+ [pred_map.get(c, "unknown") for c in a.obs_names])
38
+
39
+ print("[filter] pred_label == beta", flush=True)
40
+ sub = a[a.obs["pred_label"].astype(str) == "beta"].copy()
41
+ print(f"[filter] {sub.n_obs} beta-predicted cells", flush=True)
42
+
43
+ # veres X is already log-normalised (range 0..9)
44
+ def col(g):
45
+ if g not in sub.var_names:
46
+ return np.zeros(sub.n_obs)
47
+ j = sub.var_names.get_loc(g)
48
+ x = sub.X[:, j]
49
+ if hasattr(x, "toarray"):
50
+ x = x.toarray()
51
+ return np.asarray(x).ravel()
52
+
53
+ ins1 = col("Ins1"); ins2 = col("Ins2"); iapp = col("Iapp")
54
+ mafa = col("Mafa"); ucn3 = col("Ucn3")
55
+ insulin = ins1 + ins2
56
+ mature = z(mafa) + z(ucn3)
57
+
58
+ # split thresholds are the median within the veres beta-predicted set
59
+ ins_thr = float(np.median(insulin))
60
+ mat_thr = float(np.median(mature))
61
+ ins_hi = insulin > ins_thr
62
+ mat_hi = mature > mat_thr
63
+
64
+ quadrant = np.array(
65
+ ["INS+/MAT+" if (ih and mh) else
66
+ "INS+/MAT-" if (ih and not mh) else
67
+ "INS-/MAT+" if (not ih and mh) else
68
+ "INS-/MAT-"
69
+ for ih, mh in zip(ins_hi, mat_hi)])
70
+ sub.obs["INS_level"] = insulin
71
+ sub.obs["MAT_score"] = mature
72
+ sub.obs["quadrant"] = pd.Categorical(quadrant)
73
+
74
+ df = sub.obs[["quadrant", "INS_level", "MAT_score"]].copy()
75
+ df["Ins1"] = ins1; df["Ins2"] = ins2; df["Iapp"] = iapp
76
+ df["Mafa"] = mafa; df["Ucn3"] = ucn3
77
+ df["paper_label"] = sub.obs["paper_label"].astype(str).values
78
+ df.reset_index().rename(columns={"index": "cell_id"}).to_csv(
79
+ OUT / "109_veres_mature_beta_scores.csv", index=False)
80
+
81
+ counts = df["quadrant"].value_counts().to_dict()
82
+ paper_by_quad = df.groupby(["quadrant", "paper_label"], observed=True) \
83
+ .size().unstack(fill_value=0)
84
+ paper_by_quad.to_csv(OUT / "109_veres_mature_beta_paper_x_quadrant.csv")
85
+
86
+ n_total = int(sub.n_obs)
87
+ n_mature = int((df["quadrant"] == "INS+/MAT+").sum())
88
+ n_scbeta = int((df["quadrant"] == "INS+/MAT-").sum())
89
+
90
+ fraction_mature = n_mature / n_total if n_total else 0.0
91
+
92
+ means = df.groupby("quadrant", observed=True)[
93
+ ["Mafa", "Ucn3", "Ins1", "Ins2", "Iapp"]].mean().round(3).to_dict()
94
+
95
+ summary = {
96
+ "n_beta_predicted": n_total,
97
+ "ins_threshold": ins_thr,
98
+ "mat_threshold": mat_thr,
99
+ "quadrant_counts": counts,
100
+ "n_mature_INS+MAT+": n_mature,
101
+ "n_SCbeta_INS+MAT-": n_scbeta,
102
+ "fraction_mature": round(fraction_mature, 4),
103
+ "mean_expression_per_quadrant": means,
104
+ "paper_label_x_quadrant": {
105
+ q: paper_by_quad.loc[q].to_dict()
106
+ for q in paper_by_quad.index
107
+ } if len(paper_by_quad) else {},
108
+ }
109
+ with open(OUT / "109_veres_mature_beta_summary.json", "w") as f:
110
+ json.dump(summary, f, indent=2)
111
+ print("\n[done]")
112
+ print(json.dumps(summary, indent=2))
113
+
114
+
115
+ if __name__ == "__main__":
116
+ main()
scripts/analysis/110_veres_polyhormonal_alpha.py CHANGED
@@ -1,125 +1,128 @@
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()
 
 
 
 
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
+ import os as _os
17
+ from pathlib import Path as _Path
18
+ PANDA_ROOT = _Path(_os.environ.get("PANDA_ROOT", str(_Path(__file__).resolve().parents[2])))
19
+ ROOT = Path(str(PANDA_ROOT))
20
+ OUT = ROOT / "discovery/pancreas/marker"
21
+ OUT.mkdir(parents=True, exist_ok=True)
22
+
23
+ VERES = ROOT / "data/corpus/pancreas/held_out_labeled/veres_GSE114412_test.h5ad"
24
+ PRED = ROOT / "discovery/pancreas/marker/veres_predictions.csv"
25
+
26
+
27
+ def col(sub, g):
28
+ if g not in sub.var_names:
29
+ return np.zeros(sub.n_obs)
30
+ j = sub.var_names.get_loc(g)
31
+ x = sub.X[:, j]
32
+ if hasattr(x, "toarray"):
33
+ x = x.toarray()
34
+ return np.asarray(x).ravel()
35
+
36
+
37
+ def main():
38
+ print("[load]", flush=True)
39
+ a = ad.read_h5ad(VERES)
40
+ pred = pd.read_csv(PRED)
41
+ pred_map = dict(zip(pred["cell_id"].astype(str), pred["pred_label"]))
42
+ a.obs["pred_label"] = pd.Categorical(
43
+ [pred_map.get(c, "unknown") for c in a.obs_names])
44
+
45
+ sub = a[a.obs["pred_label"].astype(str).isin(
46
+ ["alpha_progenitor", "alpha"])].copy()
47
+ print(f"[filter] {sub.n_obs} alpha-pool cells", flush=True)
48
+
49
+ ins1 = col(sub, "Ins1"); ins2 = col(sub, "Ins2")
50
+ gcg = col(sub, "Gcg"); sst = col(sub, "Sst"); iapp = col(sub, "Iapp")
51
+
52
+ ins_level = ins1 + ins2
53
+ q_ins = np.quantile(ins_level, 0.75)
54
+ q_gcg = np.quantile(gcg, 0.75)
55
+ q_sst = np.quantile(sst, 0.75)
56
+
57
+ n_pos = (
58
+ (ins_level >= q_ins).astype(int)
59
+ + (gcg >= q_gcg).astype(int)
60
+ + (sst >= q_sst).astype(int)
61
+ )
62
+ sub.obs["INS_level"] = ins_level
63
+ sub.obs["GCG_level"] = gcg
64
+ sub.obs["SST_level"] = sst
65
+ sub.obs["n_hormones_positive"] = n_pos
66
+
67
+ print("[cluster] PCA + Leiden", flush=True)
68
+ sc.pp.highly_variable_genes(sub, n_top_genes=2000, flavor="seurat_v3",
69
+ subset=False, batch_key=None)
70
+ sc.pp.pca(sub, n_comps=30)
71
+ sc.pp.neighbors(sub, n_neighbors=15, n_pcs=30)
72
+ sc.tl.leiden(sub, resolution=0.5, random_state=0, key_added="leiden_alpha")
73
+
74
+ df = sub.obs[[
75
+ "pred_label", "paper_label", "leiden_alpha",
76
+ "INS_level", "GCG_level", "SST_level", "n_hormones_positive"
77
+ ]].copy()
78
+ df["Ins1"] = ins1; df["Ins2"] = ins2; df["Gcg"] = gcg
79
+ df["Sst"] = sst; df["Iapp"] = iapp
80
+ df.reset_index().rename(columns={"index": "cell_id"}).to_csv(
81
+ OUT / "110_veres_polyhormonal_alpha_scores.csv", index=False)
82
+
83
+ baseline_polyhormonal = float((df["n_hormones_positive"] >= 2).mean())
84
+ per_clus = df.groupby("leiden_alpha", observed=True).agg(
85
+ n_cells=("n_hormones_positive", "size"),
86
+ frac_polyhormonal=("n_hormones_positive",
87
+ lambda s: float((s >= 2).mean())),
88
+ frac_gcg_hi=("GCG_level",
89
+ lambda s: float((s >= q_gcg).mean())),
90
+ frac_ins_hi=("INS_level",
91
+ lambda s: float((s >= q_ins).mean())),
92
+ frac_sst_hi=("SST_level",
93
+ lambda s: float((s >= q_sst).mean())),
94
+ mean_gcg=("GCG_level", "mean"),
95
+ mean_ins=("INS_level", "mean"),
96
+ mean_sst=("SST_level", "mean"),
97
+ ).sort_values("frac_polyhormonal", ascending=False).reset_index()
98
+ per_clus["enrichment_vs_baseline"] = per_clus["frac_polyhormonal"] \
99
+ / max(baseline_polyhormonal, 1e-6)
100
+ per_clus.to_csv(OUT / "110_veres_polyhormonal_alpha_per_cluster.csv",
101
+ index=False)
102
+
103
+ n_2x_clusters = int((per_clus["enrichment_vs_baseline"] >= 2.0).sum())
104
+ verdict = ("distinct_polyhormonal_subcluster" if n_2x_clusters in (1, 2)
105
+ else "graded_phenotype" if n_2x_clusters == 0
106
+ else "diffuse_enrichment")
107
+
108
+ summary = {
109
+ "n_alpha_pool": int(sub.n_obs),
110
+ "baseline_polyhormonal_frac": round(baseline_polyhormonal, 4),
111
+ "q75_thresholds": {"Ins": float(q_ins),
112
+ "Gcg": float(q_gcg),
113
+ "Sst": float(q_sst)},
114
+ "leiden_resolution": 0.5,
115
+ "n_clusters": int(per_clus["leiden_alpha"].nunique()),
116
+ "n_clusters_enriched_2x": n_2x_clusters,
117
+ "verdict": verdict,
118
+ "per_cluster": per_clus.to_dict("records"),
119
+ }
120
+ with open(OUT / "110_veres_polyhormonal_alpha_summary.json", "w") as f:
121
+ json.dump(summary, f, indent=2)
122
+
123
+ print("\n[done] verdict:", verdict, flush=True)
124
+ print(per_clus.round(3).to_string(index=False))
125
+
126
+
127
+ if __name__ == "__main__":
128
+ main()
scripts/analysis/44_en1_cko_contrast.py CHANGED
@@ -1,117 +1,120 @@
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()
 
 
 
 
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
+ import os as _os
14
+ from pathlib import Path as _Path
15
+ PANDA_ROOT = _Path(_os.environ.get("PANDA_ROOT", str(_Path(__file__).resolve().parents[2])))
16
+ TARGET = Path(str(PANDA_ROOT / "data/processed/skin/adata_processed.h5ad"))
17
+ PROJ = Path(str(PANDA_ROOT / "discovery/pan_skin/marker/50_aldrich_projections.h5ad"))
18
+ NOVEL = Path(str(PANDA_ROOT / "discovery/pan_skin/marker/51_aldrich_novel_annotation.csv"))
19
+ OUT = Path(str(PANDA_ROOT / "discovery/pan_skin/marker"))
20
+
21
+
22
+ def main():
23
+ a = ad.read_h5ad(TARGET)
24
+ p = ad.read_h5ad(PROJ)
25
+ for c in ["pred_label", "pred_conf", "abstain", "pred_bbse_label"]:
26
+ if c in p.obs.columns:
27
+ a.obs[c] = p.obs[c].values
28
+ if NOVEL.exists():
29
+ nov = pd.read_csv(NOVEL, index_col=0)
30
+ a.obs["novel_cluster"] = nov.reindex(a.obs_names)["novel_cluster"].values
31
+
32
+ print(f"[cko] target: {a.shape}, genotype: {a.obs['genotype'].value_counts().to_dict()}",
33
+ flush=True)
34
+
35
+ rows = []
36
+ for cls in a.obs["pred_bbse_label"].unique():
37
+ mask = a.obs["pred_bbse_label"] == cls
38
+ n_cko = int((mask & (a.obs["genotype"] == "En1-cKO")).sum())
39
+ n_wt = int((mask & (a.obs["genotype"] == "WT")).sum())
40
+ total_cko = (a.obs["genotype"] == "En1-cKO").sum()
41
+ total_wt = (a.obs["genotype"] == "WT").sum()
42
+ contingency = np.array([[n_cko, total_cko - n_cko],
43
+ [n_wt, total_wt - n_wt]])
44
+ odds, p_val = stats.fisher_exact(contingency)
45
+ f_cko = (n_cko + 1) / (total_cko + 2)
46
+ f_wt = (n_wt + 1) / (total_wt + 2)
47
+ log2_fc = np.log2(f_cko / f_wt)
48
+ rows.append({
49
+ "class": cls,
50
+ "n_En1cKO": n_cko,
51
+ "n_WT": n_wt,
52
+ "pct_En1cKO": round(100 * n_cko / total_cko, 2),
53
+ "pct_WT": round(100 * n_wt / total_wt, 2),
54
+ "log2_fold_enrich_cKO_vs_WT": round(log2_fc, 3),
55
+ "fisher_pvalue": p_val,
56
+ })
57
+ df = pd.DataFrame(rows).sort_values("log2_fold_enrich_cKO_vs_WT")
58
+ print("\n[cko] class enrichment (cKO vs WT):")
59
+ print(df.to_string(index=False))
60
+
61
+ df.to_csv(OUT / "53_en1_cko_class_enrichment.csv", index=False)
62
+
63
+ de_rows = []
64
+ for cls in sorted(a.obs["pred_bbse_label"].unique()):
65
+ cls_mask = a.obs["pred_bbse_label"] == cls
66
+ if cls_mask.sum() < 50:
67
+ continue
68
+ sub = a[cls_mask].copy()
69
+ vc = sub.obs["genotype"].value_counts()
70
+ if not {"En1-cKO", "WT"}.issubset(vc.index) or vc.min() < 15:
71
+ continue
72
+ try:
73
+ sc.tl.rank_genes_groups(sub, "genotype", method="wilcoxon",
74
+ n_genes=40, use_raw=False)
75
+ for grp in ["En1-cKO", "WT"]:
76
+ if grp not in sub.uns["rank_genes_groups"]["names"].dtype.names:
77
+ continue
78
+ names = list(sub.uns["rank_genes_groups"]["names"][grp][:15])
79
+ lfcs = list(sub.uns["rank_genes_groups"]["logfoldchanges"][grp][:15])
80
+ for g, lf in zip(names, lfcs):
81
+ de_rows.append({
82
+ "class": cls, "up_in": grp, "gene": g, "logfc": round(float(lf), 3),
83
+ })
84
+ except Exception as exc:
85
+ print(f"[cko] DE failed for {cls}: {exc}")
86
+ continue
87
+ de_df = pd.DataFrame(de_rows)
88
+ de_df.to_csv(OUT / "53_en1_cko_wilcoxon_within_class.csv", index=False)
89
+
90
+ md = ["# Aldrich En1-cKO vs WT contrast on PANDA-MLP predictions\n",
91
+ f"Total cells: {a.n_obs:,} ({int((a.obs['genotype']=='En1-cKO').sum())} En1-cKO, "
92
+ f"{int((a.obs['genotype']=='WT').sum())} WT).\n",
93
+ "## Class-level cKO/WT enrichment (BBSE-corrected predictions)\n",
94
+ df.to_markdown(index=False), "",
95
+ "The direction of `log2_fold_enrich_cKO_vs_WT` indicates whether a class is over-represented",
96
+ "in En1-cKO (positive) or WT (negative). Fisher exact p-value tests significance vs the",
97
+ "background genotype ratio (~40% cKO / 60% WT).\n",
98
+ "## Per-class Wilcoxon DE (En1-cKO vs WT within each class)\n",
99
+ "Top genes differentially expressed BETWEEN genotypes WITHIN a predicted class. Genes up in",
100
+ "cKO reveal En1-loss-responsive programs specific to that cell type; genes up in WT are the",
101
+ "opposite.\n",
102
+ ]
103
+ if len(de_df):
104
+ for cls in sorted(de_df["class"].unique()):
105
+ md.append(f"\n### {cls}\n")
106
+ for grp in ["En1-cKO", "WT"]:
107
+ sub = de_df[(de_df["class"] == cls) & (de_df["up_in"] == grp)]
108
+ if not len(sub):
109
+ continue
110
+ md.append(f"**Up in {grp}**: " + ", ".join(sub["gene"].tolist()))
111
+ md += ["", "## Novel population x genotype cross-tab\n"]
112
+ if "novel_cluster" in a.obs.columns:
113
+ xt = pd.crosstab(a.obs["novel_cluster"], a.obs["genotype"])
114
+ md.append(xt.to_markdown())
115
+ (OUT / "53_en1_cko_contrast.md").write_text("\n".join(md))
116
+ print(f"[cko] wrote {OUT}/53_en1_cko_contrast.md")
117
+
118
+
119
+ if __name__ == "__main__":
120
+ main()
scripts/analysis/45_marker_refinement.py CHANGED
@@ -1,70 +1,73 @@
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()
 
 
 
 
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
+ import os as _os
13
+ from pathlib import Path as _Path
14
+ PANDA_ROOT = _Path(_os.environ.get("PANDA_ROOT", str(_Path(__file__).resolve().parents[2])))
15
+ TARGET = Path(str(PANDA_ROOT / "data/processed/skin/adata_processed.h5ad"))
16
+ PROJ = Path(str(PANDA_ROOT / "discovery/pan_skin/marker/50_aldrich_projections.h5ad"))
17
+ TFS = Path(str(PANDA_ROOT / "discovery/pan_skin/marker/known_skin_tfs.yaml"))
18
+ OUT = Path(str(PANDA_ROOT / "discovery/pan_skin/marker"))
19
+
20
+
21
+ def main():
22
+ a = ad.read_h5ad(TARGET)
23
+ p = ad.read_h5ad(PROJ)
24
+ a.obs["pred_label"] = p.obs["pred_label"].values
25
+ a.obs["pred_conf"] = p.obs["pred_conf"].astype(float).values
26
+
27
+ with open(TFS) as f:
28
+ tf_cfg = yaml.safe_load(f)
29
+ canon = tf_cfg["classes"]
30
+
31
+ keep = a[~a.obs["pred_label"].str.startswith("UNK", na=False)].copy()
32
+ sc.tl.rank_genes_groups(keep, "pred_label", method="wilcoxon",
33
+ n_genes=100, use_raw=False)
34
+
35
+ rows = []
36
+ for cls in keep.obs["pred_label"].unique():
37
+ try:
38
+ names = keep.uns["rank_genes_groups"]["names"][cls]
39
+ lfc = keep.uns["rank_genes_groups"]["logfoldchanges"][cls]
40
+ padj = keep.uns["rank_genes_groups"]["pvals_adj"][cls]
41
+ except Exception:
42
+ continue
43
+ top100 = list(names[:100])
44
+ canon_set = set(canon.get(cls, []))
45
+ retained = [g for g in canon_set if g in top100]
46
+ novel = [g for g in top100 if g not in canon_set]
47
+ mask = keep.obs["pred_label"] == cls
48
+ n_cells = int(mask.sum())
49
+ mean_conf = float(keep.obs.loc[mask, "pred_conf"].mean())
50
+ rows.append({
51
+ "class": cls,
52
+ "n_cells_predicted": n_cells,
53
+ "mean_conf": round(mean_conf, 3),
54
+ "n_canonical": len(canon_set),
55
+ "n_canonical_retained_top100": len(retained),
56
+ "retained_canonical": ",".join(retained[:15]),
57
+ "novel_top20": ",".join(novel[:20]),
58
+ })
59
+ df = pd.DataFrame(rows).sort_values("n_cells_predicted", ascending=False)
60
+ df.to_csv(OUT / "52_refined_markers.csv", index=False)
61
+ print(df.to_string(index=False))
62
+
63
+ md = ["# Refined pan-skin markers for Aldrich zero-shot predictions\n"]
64
+ md.append("Marker refinement is Wilcoxon DE of each predicted class against all other")
65
+ md.append("predicted cells on raw Aldrich gene expression. `retained_canonical` are canonical")
66
+ md.append("markers recovered in the top-100; `novel_top20` are DE genes not in the canonical list.\n")
67
+ md.append(df.to_markdown(index=False))
68
+ (OUT / "52_refined_markers.md").write_text("\n".join(md))
69
+ print(f"[refine] wrote {OUT}/52_refined_markers.csv and .md")
70
+
71
+
72
+ if __name__ == "__main__":
73
+ main()