Correction pass: gate-matched Dahlin, retracted unsupported claims, complete HF-placode DEG set, restyled figures
Browse filesMirrors 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.
- .gitattributes +2 -0
- PAPER.pdf +2 -2
- PAPER.tex +0 -0
- README.md +622 -67
- REVIEW_REPORT.md +235 -0
- data/raw/GSE220977_combined.h5ad +3 -0
- discovery/hematopoiesis/marker/66_dahlin_enrichment.csv +14 -0
- discovery/hematopoiesis/marker/66_dahlin_enrichment_pooled_CONFOUNDED.csv +14 -0
- discovery/hematopoiesis/marker/66_dahlin_gate_negative_control.csv +14 -0
- discovery/pan_skin/marker/107_dingwall_class_deg_count.csv +13 -13
- discovery/pan_skin/marker/107_dingwall_class_deg_count.json +58 -58
- figures/PANDA_all_figures.pdf +2 -2
- figures/PANDA_supplement.pdf +2 -2
- figures/biology/biology_01_dingwall_umap.pdf +2 -2
- figures/biology/biology_02_primary_eden.pdf +2 -2
- figures/biology/biology_03_melanoblast_mitf.pdf +2 -2
- figures/biology/biology_04_dahlin_metabolism.pdf +0 -0
- figures/biology/biology_05_dahlin_composition.pdf +0 -0
- figures/biology/biology_06_veres_beta_quadrant.pdf +2 -2
- figures/biology/biology_07_veres_polyhormonal.pdf +2 -2
- figures/biology/biology_08_prototype_geometry.pdf +0 -0
- figures/fig1_perclass_f1.pdf +0 -0
- figures/fig3_dahlin_heatmap.pdf +0 -0
- figures/fig4_veres_stage_stack.pdf +0 -0
- figures/supplement/01_cv_summary.pdf +0 -0
- figures/supplement/02_per_class_f1.pdf +0 -0
- figures/supplement/03_prototype_cosine.pdf +0 -0
- figures/supplement/05_adversary_purification.pdf +0 -0
- figures/supplement/06_cross_system_prototypes.pdf +0 -0
- figures/supplement/20_placode_wnt_module.pdf +0 -0
- figures/supplement/23_anchor_delta_recall.pdf +0 -0
- figures/supplement/26_pca_vs_marker_umaps_veres_by_stage.pdf +2 -2
- figures/supplement/26b_pca_vs_marker_umaps_veres_by_class.pdf +0 -0
- panda/__init__.py +16 -16
- panda/markers.yaml +135 -132
- panda/model.py +229 -229
- pyproject.toml +84 -0
- scripts/analysis/100_primary_eden_discovery.py +208 -205
- scripts/analysis/101_primary_eden_derm_scoring.py +162 -159
- scripts/analysis/102_train_on_dingwall_variantA.py +320 -317
- scripts/analysis/103_replicate_dingwall_seurat_pipeline.py +264 -261
- scripts/analysis/104_train_on_dingwall_derm_labels.py +264 -261
- scripts/analysis/105_primary_eden_full_dermal.py +137 -134
- scripts/analysis/106_melanoblast_neural_crest.py +141 -138
- scripts/analysis/107_dingwall_class_deg_count.py +122 -116
- scripts/analysis/108_dahlin_lineage_metabolism.py +161 -158
- scripts/analysis/109_veres_mature_beta.py +116 -113
- scripts/analysis/110_veres_polyhormonal_alpha.py +128 -125
- scripts/analysis/44_en1_cko_contrast.py +120 -117
- scripts/analysis/45_marker_refinement.py +73 -70
|
@@ -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
|
|
@@ -1,3 +1,3 @@
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:
|
| 3 |
-
size
|
|
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:40dba671f0811830dcacdac7d667c9bc2a53485038c70902f54d4af1baebccaf
|
| 3 |
+
size 4096150
|
|
The diff for this file is too large to render.
See raw diff
|
|
|
|
@@ -1,67 +1,622 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
```
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
@@ -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.
|
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:e3b4389d6ab0cbddbd244b0e04df46ab768193b7866ab743c253d6022b06ee74
|
| 3 |
+
size 53962253
|
|
@@ -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
|
|
@@ -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
|
|
@@ -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
|
|
@@ -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 |
-
|
| 11 |
-
|
| 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,,
|
|
@@ -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": "
|
| 44 |
-
"n_DEG": 1
|
| 45 |
-
},
|
| 46 |
-
{
|
| 47 |
-
"class": "
|
| 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 |
}
|
|
@@ -1,3 +1,3 @@
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:
|
| 3 |
-
size
|
|
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:a98c7e9621475d48e6dd8be321084581b5f9d9d4d55e2ac1acb0ad1b28363d1c
|
| 3 |
+
size 16048898
|
|
@@ -1,3 +1,3 @@
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:
|
| 3 |
-
size
|
|
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:01aecd5d926dada793372dafc962878d91908e2c7f2859eedcf51a35175d81e5
|
| 3 |
+
size 12124452
|
|
@@ -1,3 +1,3 @@
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:
|
| 3 |
-
size
|
|
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:f8763029b3fe23db81f089a2d6dc659f8b0f62510561e14ee8897c2200d17000
|
| 3 |
+
size 769877
|
|
@@ -1,3 +1,3 @@
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:
|
| 3 |
-
size
|
|
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:fd5290f3a2f9e68ba811a4a977e55de8cbdc253556829ef92f9f6107c24c34c8
|
| 3 |
+
size 646125
|
|
@@ -1,3 +1,3 @@
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:
|
| 3 |
-
size
|
|
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:f271d84e95f71fd3cdbcf80f30382d6158b7ed2f2568fd499b07f46a7d135af8
|
| 3 |
+
size 187770
|
|
Binary files a/figures/biology/biology_04_dahlin_metabolism.pdf and b/figures/biology/biology_04_dahlin_metabolism.pdf differ
|
|
|
|
Binary files a/figures/biology/biology_05_dahlin_composition.pdf and b/figures/biology/biology_05_dahlin_composition.pdf differ
|
|
|
|
@@ -1,3 +1,3 @@
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:
|
| 3 |
-
size
|
|
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:dd2bd05927e0ce064591d84955a3cb826d99caba6a3539735e610ad6a71dbe5e
|
| 3 |
+
size 147839
|
|
@@ -1,3 +1,3 @@
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:
|
| 3 |
-
size
|
|
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:1f240751d61b29cb519adc75fafde6cef607f8bc9b0916cde0962c29d9780b44
|
| 3 |
+
size 193704
|
|
Binary files a/figures/biology/biology_08_prototype_geometry.pdf and b/figures/biology/biology_08_prototype_geometry.pdf differ
|
|
|
|
Binary file (25.6 kB). View file
|
|
|
|
Binary files a/figures/fig3_dahlin_heatmap.pdf and b/figures/fig3_dahlin_heatmap.pdf differ
|
|
|
|
Binary files a/figures/fig4_veres_stage_stack.pdf and b/figures/fig4_veres_stage_stack.pdf differ
|
|
|
|
Binary files a/figures/supplement/01_cv_summary.pdf and b/figures/supplement/01_cv_summary.pdf differ
|
|
|
|
Binary files a/figures/supplement/02_per_class_f1.pdf and b/figures/supplement/02_per_class_f1.pdf differ
|
|
|
|
Binary files a/figures/supplement/03_prototype_cosine.pdf and b/figures/supplement/03_prototype_cosine.pdf differ
|
|
|
|
Binary files a/figures/supplement/05_adversary_purification.pdf and b/figures/supplement/05_adversary_purification.pdf differ
|
|
|
|
Binary files a/figures/supplement/06_cross_system_prototypes.pdf and b/figures/supplement/06_cross_system_prototypes.pdf differ
|
|
|
|
Binary files a/figures/supplement/20_placode_wnt_module.pdf and b/figures/supplement/20_placode_wnt_module.pdf differ
|
|
|
|
Binary files a/figures/supplement/23_anchor_delta_recall.pdf and b/figures/supplement/23_anchor_delta_recall.pdf differ
|
|
|
|
@@ -1,3 +1,3 @@
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:
|
| 3 |
-
size
|
|
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:80e1ba2c6442ab8977e4225276222562c96d7d59804978d22573ac4fd6689af7
|
| 3 |
+
size 1287055
|
|
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
|
|
|
|
@@ -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 |
+
]
|
|
@@ -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
|
| 5 |
-
#
|
| 6 |
-
#
|
| 7 |
-
#
|
| 8 |
-
#
|
| 9 |
-
#
|
| 10 |
-
#
|
| 11 |
-
#
|
| 12 |
-
#
|
| 13 |
-
#
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
-
|
| 21 |
-
-
|
| 22 |
-
-
|
| 23 |
-
-
|
| 24 |
-
-
|
| 25 |
-
-
|
| 26 |
-
-
|
| 27 |
-
-
|
| 28 |
-
-
|
| 29 |
-
#
|
| 30 |
-
-
|
| 31 |
-
-
|
| 32 |
-
|
| 33 |
-
-
|
| 34 |
-
#
|
| 35 |
-
-
|
| 36 |
-
-
|
| 37 |
-
|
| 38 |
-
-
|
| 39 |
-
-
|
| 40 |
-
#
|
| 41 |
-
-
|
| 42 |
-
-
|
| 43 |
-
|
| 44 |
-
-
|
| 45 |
-
-
|
| 46 |
-
#
|
| 47 |
-
-
|
| 48 |
-
-
|
| 49 |
-
|
| 50 |
-
-
|
| 51 |
-
-
|
| 52 |
-
#
|
| 53 |
-
-
|
| 54 |
-
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
-
|
| 62 |
-
-
|
| 63 |
-
-
|
| 64 |
-
#
|
| 65 |
-
-
|
| 66 |
-
-
|
| 67 |
-
|
| 68 |
-
-
|
| 69 |
-
#
|
| 70 |
-
-
|
| 71 |
-
-
|
| 72 |
-
|
| 73 |
-
-
|
| 74 |
-
-
|
| 75 |
-
-
|
| 76 |
-
#
|
| 77 |
-
-
|
| 78 |
-
-
|
| 79 |
-
|
| 80 |
-
#
|
| 81 |
-
-
|
| 82 |
-
-
|
| 83 |
-
|
| 84 |
-
-
|
| 85 |
-
#
|
| 86 |
-
-
|
| 87 |
-
-
|
| 88 |
-
|
| 89 |
-
-
|
| 90 |
-
-
|
| 91 |
-
#
|
| 92 |
-
-
|
| 93 |
-
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
-
|
| 102 |
-
-
|
| 103 |
-
-
|
| 104 |
-
-
|
| 105 |
-
#
|
| 106 |
-
-
|
| 107 |
-
-
|
| 108 |
-
|
| 109 |
-
-
|
| 110 |
-
-
|
| 111 |
-
#
|
| 112 |
-
-
|
| 113 |
-
-
|
| 114 |
-
|
| 115 |
-
-
|
| 116 |
-
-
|
| 117 |
-
-
|
| 118 |
-
#
|
| 119 |
-
-
|
| 120 |
-
-
|
| 121 |
-
|
| 122 |
-
-
|
| 123 |
-
-
|
| 124 |
-
#
|
| 125 |
-
-
|
| 126 |
-
-
|
| 127 |
-
|
| 128 |
-
-
|
| 129 |
-
-
|
| 130 |
-
#
|
| 131 |
-
-
|
| 132 |
-
-
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
@@ -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)
|
|
@@ -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
|
|
@@ -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 |
-
|
| 7 |
-
from
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
#
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
#
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
raw
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
raw = raw[
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
sc.pp.
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
sc.
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
"
|
| 109 |
-
"
|
| 110 |
-
"
|
| 111 |
-
"
|
| 112 |
-
"
|
| 113 |
-
"
|
| 114 |
-
"
|
| 115 |
-
"
|
| 116 |
-
"
|
| 117 |
-
"
|
| 118 |
-
"
|
| 119 |
-
"
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
df_ranked["
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
"
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
"
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
f"
|
| 201 |
-
print(f"
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""primary EDEN discovery on Dingwall: PANDA-v3 dermal-fibro subset, Leiden res=1.5, wilcoxon markers + Fisher cKO depletion + module scoring."""
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
import warnings, json, sys, pickle, numpy as np, pandas as pd, anndata as ad, scanpy as sc, scipy.sparse as sp, torch, torch.nn.functional as F
|
| 4 |
+
from scipy.stats import fisher_exact, mannwhitneyu
|
| 5 |
+
warnings.filterwarnings("ignore"); sc.settings.verbosity = 0
|
| 6 |
+
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()
|
|
@@ -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 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
raw
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
raw = raw[
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
print("[eden]
|
| 71 |
-
dermal =
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
dermal.obs[
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
odds, p_fisher =
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
"
|
| 105 |
-
"
|
| 106 |
-
"
|
| 107 |
-
"
|
| 108 |
-
"
|
| 109 |
-
"
|
| 110 |
-
"
|
| 111 |
-
|
| 112 |
-
"
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
"
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
"
|
| 133 |
-
"
|
| 134 |
-
"
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""primary + secondary EDEN on Dingwall via score_genes against dingwall's own Derm0-11 markers (Data S1C top-30); argmax identity + Fisher cKO depletion."""
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
import warnings, json, sys, numpy as np, pandas as pd, anndata as ad, scanpy as sc, scipy.sparse as sp
|
| 4 |
+
from scipy.stats import fisher_exact
|
| 5 |
+
warnings.filterwarnings("ignore"); sc.settings.verbosity = 0
|
| 6 |
+
|
| 7 |
+
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()
|
|
@@ -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 |
-
|
| 18 |
-
from
|
| 19 |
-
|
| 20 |
-
)
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
sc.pp.
|
| 70 |
-
sc.pp.
|
| 71 |
-
sc.
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
a.obsm["X_train"] = a.obsm["
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
a.obs["
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
def
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
self.
|
| 131 |
-
|
| 132 |
-
self.
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
def
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
if stage >=
|
| 179 |
-
|
| 180 |
-
total = total + 0.
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
print("[A]
|
| 251 |
-
a =
|
| 252 |
-
|
| 253 |
-
print(
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
"
|
| 295 |
-
"
|
| 296 |
-
"
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
"
|
| 307 |
-
"
|
| 308 |
-
"
|
| 309 |
-
"
|
| 310 |
-
"
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""variant A — semi-supervised panda on dingwall using data s3 marker panels with score+margin gate."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
import warnings, json, sys, time
|
| 5 |
+
warnings.filterwarnings("ignore")
|
| 6 |
+
|
| 7 |
+
import numpy as np
|
| 8 |
+
import pandas as pd
|
| 9 |
+
import anndata as ad
|
| 10 |
+
import scanpy as sc
|
| 11 |
+
import scipy.sparse as sp
|
| 12 |
+
from scipy.stats import fisher_exact
|
| 13 |
+
import torch
|
| 14 |
+
import torch.nn.functional as F
|
| 15 |
+
from torch.utils.data import Dataset, DataLoader
|
| 16 |
+
|
| 17 |
+
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()
|
|
@@ -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 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
#
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
# -
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
a =
|
| 39 |
-
|
| 40 |
-
sc.pp.
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
sc.
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
dermal =
|
| 149 |
-
#
|
| 150 |
-
if
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
a.
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
"
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
"
|
| 213 |
-
"
|
| 214 |
-
"
|
| 215 |
-
"
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
a
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
print("[replica]
|
| 235 |
-
a
|
| 236 |
-
|
| 237 |
-
print("[replica]
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
print(
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""replicate dingwall's seurat clustering (QC, harmony, PCA, leiden) to derive Derm0..Derm11 labels."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
import warnings, json, sys
|
| 5 |
+
warnings.filterwarnings("ignore")
|
| 6 |
+
|
| 7 |
+
import numpy as np
|
| 8 |
+
import pandas as pd
|
| 9 |
+
import anndata as ad
|
| 10 |
+
import scanpy as sc
|
| 11 |
+
|
| 12 |
+
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()
|
|
@@ -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 |
-
|
| 16 |
-
from
|
| 17 |
-
|
| 18 |
-
)
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
def
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
self.
|
| 75 |
-
|
| 76 |
-
self.
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
def
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
if stage >=
|
| 123 |
-
|
| 124 |
-
total = total + 0.
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
"
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
"
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
"
|
| 228 |
-
"
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
"
|
| 248 |
-
"
|
| 249 |
-
"
|
| 250 |
-
|
| 251 |
-
"
|
| 252 |
-
"
|
| 253 |
-
|
| 254 |
-
"
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""variant B — fully supervised panda on replicated dingwall Derm0..Derm11 labels from script 103."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
import warnings, json, sys, time
|
| 5 |
+
warnings.filterwarnings("ignore")
|
| 6 |
+
|
| 7 |
+
import numpy as np
|
| 8 |
+
import pandas as pd
|
| 9 |
+
import anndata as ad
|
| 10 |
+
import torch
|
| 11 |
+
import torch.nn.functional as F
|
| 12 |
+
from torch.utils.data import Dataset, DataLoader
|
| 13 |
+
from scipy.stats import fisher_exact
|
| 14 |
+
|
| 15 |
+
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()
|
|
@@ -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 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
panels =
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
odds, p_f =
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
"
|
| 96 |
-
"
|
| 97 |
-
"
|
| 98 |
-
"
|
| 99 |
-
"
|
| 100 |
-
"
|
| 101 |
-
"
|
| 102 |
-
"
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
result_df
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
"
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
"
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""primary EDEN discovery on the full dingwall-defined dermal set (not the panda-v3 fibroblast subset)."""
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
import warnings, json, sys, numpy as np, pandas as pd, anndata as ad, scanpy as sc, scipy.sparse as sp
|
| 4 |
+
from scipy.stats import fisher_exact
|
| 5 |
+
warnings.filterwarnings("ignore"); sc.settings.verbosity = 0
|
| 6 |
+
|
| 7 |
+
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()
|
|
@@ -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 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
"
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
a
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
a.obs["
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
"
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
"
|
| 112 |
-
"
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
"
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
"
|
| 127 |
-
"
|
| 128 |
-
"
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""test whether En1-cKO melanoblasts with sweat/eda derepression revert to neural crest vs disrupted melanocyte."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import json
|
| 5 |
+
import warnings
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
import anndata as ad
|
| 9 |
+
import numpy as np
|
| 10 |
+
import pandas as pd
|
| 11 |
+
import scanpy as sc
|
| 12 |
+
from scipy.stats import mannwhitneyu
|
| 13 |
+
|
| 14 |
+
warnings.filterwarnings("ignore")
|
| 15 |
+
sc.settings.verbosity = 0
|
| 16 |
+
|
| 17 |
+
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()
|
|
@@ -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 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
a
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
a.obs["
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
"
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
"
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
"
|
| 86 |
-
"
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
"
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""rank dingwall cell types by En1-cKO vs WT DEG count (|log2FC|>1, padj<0.05)."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import json
|
| 5 |
+
import warnings
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
import anndata as ad
|
| 9 |
+
import numpy as np
|
| 10 |
+
import pandas as pd
|
| 11 |
+
import scanpy as sc
|
| 12 |
+
|
| 13 |
+
warnings.filterwarnings("ignore")
|
| 14 |
+
sc.settings.verbosity = 0
|
| 15 |
+
|
| 16 |
+
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()
|
|
@@ -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 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
a =
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
a =
|
| 78 |
-
|
| 79 |
-
a.
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
"
|
| 119 |
-
"
|
| 120 |
-
"
|
| 121 |
-
"
|
| 122 |
-
"
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
df
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
"
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
"
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""rank dahlin lineages by Kit_W41 vs WT metabolic shift across 4 modules."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import json
|
| 5 |
+
import warnings
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
import anndata as ad
|
| 9 |
+
import numpy as np
|
| 10 |
+
import pandas as pd
|
| 11 |
+
import scanpy as sc
|
| 12 |
+
import scipy.sparse as sp
|
| 13 |
+
from scipy.stats import mannwhitneyu
|
| 14 |
+
|
| 15 |
+
warnings.filterwarnings("ignore")
|
| 16 |
+
sc.settings.verbosity = 0
|
| 17 |
+
|
| 18 |
+
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()
|
|
@@ -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 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
a
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
df
|
| 75 |
-
df
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
"
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
"
|
| 97 |
-
"
|
| 98 |
-
"
|
| 99 |
-
"
|
| 100 |
-
"
|
| 101 |
-
"
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""split veres beta-predicted cells into mature (MAFA/UCN3 hi) vs SC-beta by INS x MAT quadrants."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import json
|
| 5 |
+
import warnings
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
import anndata as ad
|
| 9 |
+
import numpy as np
|
| 10 |
+
import pandas as pd
|
| 11 |
+
|
| 12 |
+
warnings.filterwarnings("ignore")
|
| 13 |
+
|
| 14 |
+
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()
|
|
@@ -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 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
a
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
[
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
sub.obs["
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
sc.pp.
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
"
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
"
|
| 115 |
-
"
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""test if veres alpha-pool polyhormonal (Ins+/Gcg+/Sst+) cells form a distinct sub-cluster vs graded."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import json
|
| 5 |
+
import warnings
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
import anndata as ad
|
| 9 |
+
import numpy as np
|
| 10 |
+
import pandas as pd
|
| 11 |
+
import scanpy as sc
|
| 12 |
+
|
| 13 |
+
warnings.filterwarnings("ignore")
|
| 14 |
+
sc.settings.verbosity = 0
|
| 15 |
+
|
| 16 |
+
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()
|
|
@@ -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 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
"
|
| 50 |
-
"
|
| 51 |
-
"
|
| 52 |
-
"
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
"
|
| 93 |
-
"
|
| 94 |
-
|
| 95 |
-
"
|
| 96 |
-
"
|
| 97 |
-
"
|
| 98 |
-
"
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""en1-cKO vs WT contrast on aldrich predictions."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
import warnings
|
| 5 |
+
warnings.filterwarnings("ignore")
|
| 6 |
+
|
| 7 |
+
import numpy as np
|
| 8 |
+
import pandas as pd
|
| 9 |
+
import anndata as ad
|
| 10 |
+
import scanpy as sc
|
| 11 |
+
from scipy import stats
|
| 12 |
+
|
| 13 |
+
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()
|
|
@@ -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 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
a
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
"
|
| 52 |
-
"
|
| 53 |
-
"
|
| 54 |
-
"
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
md
|
| 64 |
-
md.append(
|
| 65 |
-
(
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""marker refinement on aldrich zero-shot predictions: per-class wilcoxon DE vs canonical list."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
import warnings, yaml
|
| 5 |
+
warnings.filterwarnings("ignore")
|
| 6 |
+
|
| 7 |
+
import numpy as np
|
| 8 |
+
import pandas as pd
|
| 9 |
+
import anndata as ad
|
| 10 |
+
import scanpy as sc
|
| 11 |
+
|
| 12 |
+
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()
|