Upload BioMod with datasets and the 0810 checkpoint
Browse filesPublish the BioMod source tree, model dependencies, complete downstream datasets, and the unchanged 0810 weight/configuration pair. Include the updated model implementation, shared training utilities, CPU utility tests, and English documentation. Exclude download caches, local environments, and OS metadata.
This view is limited to 50 files because it contains too many changes. See raw diff
- .gitattributes +24 -0
- .gitignore +42 -0
- BioMod/.env.example +13 -0
- BioMod/.gitattributes +24 -0
- BioMod/.gitignore +56 -0
- BioMod/README.md +428 -0
- BioMod/cell_annotation/cmd_finetune.md +15 -0
- BioMod/cell_annotation/module_data/load_module.py +33 -0
- BioMod/cell_annotation/module_data/module1_hallmarks.json +0 -0
- BioMod/cell_annotation/module_data/module2_reactome_2022_filtered.json +0 -0
- BioMod/cell_annotation/performer_module_finetune.py +1591 -0
- BioMod/command.txt +31 -0
- BioMod/drug_combination/README.md +130 -0
- BioMod/drug_combination/train_drug_combination_performer_module.py +895 -0
- BioMod/drug_response/cmd.txt +10 -0
- BioMod/drug_response/train_drug_performer_module.py +1065 -0
- BioMod/environment.yml +68 -0
- BioMod/eval_new_data/cmd.txtx +19 -0
- BioMod/eval_new_data/evaluate_new_data.py +375 -0
- BioMod/gene_inverse_norman/README.md +217 -0
- BioMod/gene_inverse_norman/build_norman_gene_inverse_benchmark.py +300 -0
- BioMod/gene_inverse_norman/common.py +660 -0
- BioMod/gene_inverse_norman/evaluate_gene_inverse.py +405 -0
- BioMod/gene_inverse_norman/finetune_gene_inverse.py +837 -0
- BioMod/gene_inverse_norman/protocols/cellfm/genes.txt +20 -0
- BioMod/gene_inverse_norman/protocols/cellfm/split.json +59 -0
- BioMod/gene_inverse_norman/protocols/scgpt/genes.txt +20 -0
- BioMod/gene_inverse_norman/protocols/scgpt/split.json +61 -0
- BioMod/gene_inverse_norman/utils.py +166 -0
- BioMod/gene_perturbation/README.md +265 -0
- BioMod/gene_perturbation/data_utils_local.py +501 -0
- BioMod/gene_perturbation/gears_reduce_local.py +761 -0
- BioMod/gene_perturbation/inference_local.py +1034 -0
- BioMod/gene_perturbation/model_module.py +760 -0
- BioMod/gene_perturbation/pertdata_local.py +524 -0
- BioMod/gene_perturbation/scfm_utils_local.py +777 -0
- BioMod/gene_perturbation/train_module.py +325 -0
- BioMod/gene_perturbation/utils.py +489 -0
- BioMod/interpretability/README.md +412 -0
- BioMod/interpretability/__init__.py +8 -0
- BioMod/interpretability/attention/README.md +108 -0
- BioMod/interpretability/attention/README_gene_attention.md +114 -0
- BioMod/interpretability/attention/__init__.py +0 -0
- BioMod/interpretability/attention/module_crosstalk.py +605 -0
- BioMod/interpretability/attention/module_gene_attention.py +1090 -0
- BioMod/interpretability/module_geometry.py +654 -0
- BioMod/interpretability/module_importance/README.md +311 -0
- BioMod/interpretability/module_importance/RESULTS.md +221 -0
- BioMod/interpretability/module_importance/TODO.md +68 -0
- BioMod/interpretability/module_importance/__init__.py +0 -0
.gitattributes
CHANGED
|
@@ -33,3 +33,27 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
data/downstream/final_data/single_drug_response/data/GDSC/drug_graph_feat/5460769.hkl filter=lfs diff=lfs merge=lfs -text
|
| 37 |
+
data/downstream/final_data/drug_combination_synergy/data_summary/test_data/41467_2019_9799_MOESM3_ESM.xlsx filter=lfs diff=lfs merge=lfs -text
|
| 38 |
+
data/downstream/final_data/norman/perturb_processed.h5ad filter=lfs diff=lfs merge=lfs -text
|
| 39 |
+
data/downstream/final_data/human_pancreas_norm_complexBatch_with_gene_name.h5ad filter=lfs diff=lfs merge=lfs -text
|
| 40 |
+
data/downstream/final_data/single_drug_response/optional_reference/CCLE/genomic_mutation_34673_demap_features.csv filter=lfs diff=lfs merge=lfs -text
|
| 41 |
+
data/downstream/final_data/single_drug_response/data/GDSC/drug_graph_feat/6918289.hkl filter=lfs diff=lfs merge=lfs -text
|
| 42 |
+
data/downstream/final_data/Zheng68K.h5ad filter=lfs diff=lfs merge=lfs -text
|
| 43 |
+
data/downstream/final_data/Kim2020_Lung_noMalignant.h5ad filter=lfs diff=lfs merge=lfs -text
|
| 44 |
+
data/downstream/final_data/norman/go.csv filter=lfs diff=lfs merge=lfs -text
|
| 45 |
+
data/downstream/final_data/Myeloid/query_adata.h5ad filter=lfs diff=lfs merge=lfs -text
|
| 46 |
+
data/downstream/final_data/Kim2020_Lung.h5ad filter=lfs diff=lfs merge=lfs -text
|
| 47 |
+
data/downstream/final_data/BMMC_processed.h5ad filter=lfs diff=lfs merge=lfs -text
|
| 48 |
+
data/downstream/final_data/Myeloid/reference_adata.h5ad filter=lfs diff=lfs merge=lfs -text
|
| 49 |
+
data/downstream/final_data/MS/c_data.h5ad filter=lfs diff=lfs merge=lfs -text
|
| 50 |
+
data/downstream/final_data/MS/filtered_ms_adata.h5ad filter=lfs diff=lfs merge=lfs -text
|
| 51 |
+
data/downstream/final_data/hp14/demo_test.h5ad filter=lfs diff=lfs merge=lfs -text
|
| 52 |
+
data/downstream/final_data/hp14/demo_train_with_MHC_class_II.h5ad filter=lfs diff=lfs merge=lfs -text
|
| 53 |
+
data/downstream/final_data/drug_response_pytorch/drug_response_pytorch/DeepCDR/DeepCDR_ECCB_presentation.pdf filter=lfs diff=lfs merge=lfs -text
|
| 54 |
+
data/downstream/final_data/drug_response_pytorch/drug_response_pytorch/data/CCLE/genomic_mutation_34673_demap_features.csv filter=lfs diff=lfs merge=lfs -text
|
| 55 |
+
data/downstream/final_data/drug_response_pytorch/drug_response_pytorch/data/GDSC/drug_graph_feat/6918289.hkl filter=lfs diff=lfs merge=lfs -text
|
| 56 |
+
data/downstream/final_data/liver.h5ad filter=lfs diff=lfs merge=lfs -text
|
| 57 |
+
data/downstream/final_data/drug_response_pytorch/drug_response_pytorch/data/GDSC/drug_graph_feat/5460769.hkl filter=lfs diff=lfs merge=lfs -text
|
| 58 |
+
data/downstream/final_data/Immune_ALL_human.h5ad filter=lfs diff=lfs merge=lfs -text
|
| 59 |
+
data/downstream/final_data/drug_response_pytorch/drug_combination/data_summary/test_data/41467_2019_9799_MOESM3_ESM.xlsx filter=lfs diff=lfs merge=lfs -text
|
.gitignore
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ===== 大文件:不进 GitHub,单独托管(HF / OSS)=====
|
| 2 |
+
# weight/ 4.1GB 训练权重 checkpoint
|
| 3 |
+
# data/ 17GB 下游公开 benchmark 数据集
|
| 4 |
+
weight/
|
| 5 |
+
data/
|
| 6 |
+
!data/
|
| 7 |
+
!data/downstream/
|
| 8 |
+
!data/downstream/final_data/
|
| 9 |
+
!data/downstream/final_data/norman/
|
| 10 |
+
!data/downstream/final_data/norman/data_pyg/
|
| 11 |
+
!data/downstream/final_data/norman/data_pyg/cell_graphs.pkl
|
| 12 |
+
!data/downstream/final_data/norman/splits/
|
| 13 |
+
!data/downstream/final_data/norman/splits/*.pkl
|
| 14 |
+
|
| 15 |
+
# ===== 二进制 / 打包 / 缓存 =====
|
| 16 |
+
*.zip
|
| 17 |
+
!data/downstream/final_data/drug_response_pytorch/drug_response_pytorch/data/GDSC/drug_graph_feat.zip
|
| 18 |
+
!data/downstream/final_data/single_drug_response/optional_reference/GDSC/drug_graph_feat.zip
|
| 19 |
+
*.tar
|
| 20 |
+
*.tar.gz
|
| 21 |
+
*.pth
|
| 22 |
+
*.pkl
|
| 23 |
+
!data/downstream/final_data/norman/data_pyg/cell_graphs.pkl
|
| 24 |
+
!data/downstream/final_data/norman/splits/*.pkl
|
| 25 |
+
*.ckpt
|
| 26 |
+
__pycache__/
|
| 27 |
+
*.pyc
|
| 28 |
+
.ipynb_checkpoints/
|
| 29 |
+
|
| 30 |
+
# ===== 本地环境与密钥 =====
|
| 31 |
+
.env
|
| 32 |
+
*.local
|
| 33 |
+
|
| 34 |
+
# Dated release checkpoints are intentionally versioned on Hugging Face.
|
| 35 |
+
!weight/
|
| 36 |
+
weight/*
|
| 37 |
+
!weight/0629model/
|
| 38 |
+
!weight/0629model/*.pkl
|
| 39 |
+
!weight/0629model/*.pth
|
| 40 |
+
!weight/0810model/
|
| 41 |
+
!weight/0810model/*.pkl
|
| 42 |
+
!weight/0810model/*.pth
|
BioMod/.env.example
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copy this file to `.env` or export the variables in your shell.
|
| 2 |
+
# Never commit real API keys.
|
| 3 |
+
|
| 4 |
+
# Alibaba DashScope OpenAI-compatible API, used by Qwen embedding/chat utilities.
|
| 5 |
+
DASHSCOPE_API_KEY=
|
| 6 |
+
DASHSCOPE_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
|
| 7 |
+
|
| 8 |
+
# Optional OpenAI-compatible DeepSeek endpoint used by archived text-quality scripts.
|
| 9 |
+
DEEPSEEK_API_KEY=
|
| 10 |
+
DEEPSEEK_BASE_URL=
|
| 11 |
+
|
| 12 |
+
# Optional experiment tracking.
|
| 13 |
+
WANDB_API_KEY=
|
BioMod/.gitattributes
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
* text=auto eol=lf
|
| 2 |
+
|
| 3 |
+
*.py text diff=python
|
| 4 |
+
*.md text
|
| 5 |
+
*.txt text
|
| 6 |
+
*.yml text
|
| 7 |
+
*.yaml text
|
| 8 |
+
*.json text
|
| 9 |
+
*.csv text
|
| 10 |
+
*.sh text eol=lf
|
| 11 |
+
*.ipynb text
|
| 12 |
+
|
| 13 |
+
# Keep future large scientific artifacts out of normal Git history.
|
| 14 |
+
# These patterns are compatible with Git LFS if release artifacts are needed.
|
| 15 |
+
*.h5 filter=lfs diff=lfs merge=lfs -text
|
| 16 |
+
*.h5ad filter=lfs diff=lfs merge=lfs -text
|
| 17 |
+
*.loom filter=lfs diff=lfs merge=lfs -text
|
| 18 |
+
*.npy filter=lfs diff=lfs merge=lfs -text
|
| 19 |
+
*.npz filter=lfs diff=lfs merge=lfs -text
|
| 20 |
+
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 21 |
+
*.pth filter=lfs diff=lfs merge=lfs -text
|
| 22 |
+
*.pkl filter=lfs diff=lfs merge=lfs -text
|
| 23 |
+
*.ckpt filter=lfs diff=lfs merge=lfs -text
|
| 24 |
+
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
BioMod/.gitignore
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
*$py.class
|
| 5 |
+
.pytest_cache/
|
| 6 |
+
.mypy_cache/
|
| 7 |
+
.ruff_cache/
|
| 8 |
+
.coverage
|
| 9 |
+
htmlcov/
|
| 10 |
+
|
| 11 |
+
# Jupyter
|
| 12 |
+
.ipynb_checkpoints/
|
| 13 |
+
|
| 14 |
+
# Local environments and secrets
|
| 15 |
+
.env
|
| 16 |
+
.env.*
|
| 17 |
+
!.env.example
|
| 18 |
+
.venv/
|
| 19 |
+
venv/
|
| 20 |
+
env/
|
| 21 |
+
*.local
|
| 22 |
+
|
| 23 |
+
# Logs and temporary outputs
|
| 24 |
+
*.log
|
| 25 |
+
logs/
|
| 26 |
+
tmp/
|
| 27 |
+
temp/
|
| 28 |
+
scratch/
|
| 29 |
+
nohup.out
|
| 30 |
+
|
| 31 |
+
# Large datasets, checkpoints, and generated arrays
|
| 32 |
+
*.h5
|
| 33 |
+
*.h5ad
|
| 34 |
+
*.loom
|
| 35 |
+
*.zarr/
|
| 36 |
+
*.npy
|
| 37 |
+
*.npz
|
| 38 |
+
*.pt
|
| 39 |
+
*.pth
|
| 40 |
+
*.pkl
|
| 41 |
+
*.ckpt
|
| 42 |
+
*.safetensors
|
| 43 |
+
*.hkl
|
| 44 |
+
*.hickle
|
| 45 |
+
|
| 46 |
+
# Generated task outputs and caches
|
| 47 |
+
gene_inverse_norman/outputs/
|
| 48 |
+
toturials/execution_checks/
|
| 49 |
+
stage2_alignment/archive_text_quality_eval/qwen_cache/
|
| 50 |
+
stage2_alignment/best_scheme_qwen/cached_text_embeddings.pt
|
| 51 |
+
|
| 52 |
+
# OS/editor files
|
| 53 |
+
.DS_Store
|
| 54 |
+
Thumbs.db
|
| 55 |
+
.vscode/
|
| 56 |
+
.idea/
|
BioMod/README.md
ADDED
|
@@ -0,0 +1,428 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# BioMod / PerformerModule
|
| 2 |
+
|
| 3 |
+
Source code and tutorial notebooks for BioMod single-cell pretraining and
|
| 4 |
+
downstream experiments using PerformerModule representations.
|
| 5 |
+
|
| 6 |
+
This source directory contains the pretraining entry point and shared training
|
| 7 |
+
utilities, alongside downstream task runners, tutorial notebooks, environment
|
| 8 |
+
files, and plotting helpers.
|
| 9 |
+
|
| 10 |
+
## Highlights
|
| 11 |
+
|
| 12 |
+
| Area | What is included |
|
| 13 |
+
| --- | --- |
|
| 14 |
+
| Representation | Module-based single-cell encoder utilities and module metadata |
|
| 15 |
+
| Annotation | Supervised cell-type annotation workflows |
|
| 16 |
+
| Integration | Zero-shot batch integration and UMAP/scIB-style reporting |
|
| 17 |
+
| Perturbation | Norman gene-perturbation tutorial and smoke workflow |
|
| 18 |
+
| Drug response | Single-drug response prediction with GDSC/CCLE data |
|
| 19 |
+
| Drug combinations | Drug-combination synergy prediction with independent-set evaluation |
|
| 20 |
+
| Figures | Notebook-level PDF/PNG/SVG plotting cells for paper and GitHub display |
|
| 21 |
+
|
| 22 |
+
## Repository Layout
|
| 23 |
+
|
| 24 |
+
This is the `BioMod/` source directory inside the project root. See the
|
| 25 |
+
[full project layout](../README.md#repository-layout) for sibling
|
| 26 |
+
`model_dependencies/` and `weight/` directories; `data/` and `.cache/` are omitted
|
| 27 |
+
from these repository trees.
|
| 28 |
+
|
| 29 |
+
```text
|
| 30 |
+
BioMod/ # Task code: training, evaluation, interpretability and tutorials
|
| 31 |
+
├── cell_annotation/ # Cell-type annotation and model fine-tuning
|
| 32 |
+
│ ├── module_data/ # Hallmark and Reactome modules with loading utilities
|
| 33 |
+
│ │ ├── load_module.py
|
| 34 |
+
│ │ ├── module1_hallmarks.json
|
| 35 |
+
│ │ └── module2_reactome_2022_filtered.json
|
| 36 |
+
│ ├── cmd_finetune.md
|
| 37 |
+
│ └── performer_module_finetune.py
|
| 38 |
+
├── drug_combination/ # Drug-combination synergy prediction
|
| 39 |
+
│ ├── README.md
|
| 40 |
+
│ └── train_drug_combination_performer_module.py
|
| 41 |
+
├── drug_response/ # Single-drug response prediction and training
|
| 42 |
+
│ ├── cmd.txt
|
| 43 |
+
│ └── train_drug_performer_module.py
|
| 44 |
+
├── eval_new_data/ # Reconstruction error and generalization on new data
|
| 45 |
+
│ ├── cmd.txtx
|
| 46 |
+
│ └── evaluate_new_data.py
|
| 47 |
+
├── gene_inverse_norman/ # Norman reverse perturbation prediction and evaluation
|
| 48 |
+
│ ├── protocols/ # Preset gene sets and train/validation/test splits
|
| 49 |
+
│ │ ├── cellfm/ # Gene list and data splits for the CellFM protocol
|
| 50 |
+
│ │ │ ├── genes.txt
|
| 51 |
+
│ │ │ └── split.json
|
| 52 |
+
│ │ └── scgpt/ # Gene list and data splits for the scGPT protocol
|
| 53 |
+
│ │ ├── genes.txt
|
| 54 |
+
│ │ └── split.json
|
| 55 |
+
│ ├── build_norman_gene_inverse_benchmark.py
|
| 56 |
+
│ ├── common.py
|
| 57 |
+
│ ├── evaluate_gene_inverse.py
|
| 58 |
+
│ ├── finetune_gene_inverse.py
|
| 59 |
+
│ ├── README.md
|
| 60 |
+
│ └── utils.py
|
| 61 |
+
├── gene_perturbation/ # Perturbation response prediction: processing, training and inference
|
| 62 |
+
│ ├── data_utils_local.py
|
| 63 |
+
│ ├── gears_reduce_local.py
|
| 64 |
+
│ ├── inference_local.py
|
| 65 |
+
│ ├── model_module.py
|
| 66 |
+
│ ├── pertdata_local.py
|
| 67 |
+
│ ├── README.md
|
| 68 |
+
│ ├── scfm_utils_local.py
|
| 69 |
+
│ ├── train_module.py
|
| 70 |
+
│ └── utils.py
|
| 71 |
+
├── interpretability/ # Module representations and biological interpretability
|
| 72 |
+
│ ├── attention/ # Module crosstalk and within-module gene attention
|
| 73 |
+
│ │ ├── __init__.py
|
| 74 |
+
│ │ ├── module_crosstalk.py
|
| 75 |
+
│ │ ├── module_gene_attention.py
|
| 76 |
+
│ │ ├── README.md
|
| 77 |
+
│ │ └── README_gene_attention.md
|
| 78 |
+
│ ├── module_importance/ # Module importance, ablation studies and marker-gene validation
|
| 79 |
+
│ │ ├── __init__.py
|
| 80 |
+
│ │ ├── cross_class_similarity.py
|
| 81 |
+
│ │ ├── family_ablation.py
|
| 82 |
+
│ │ ├── family_importance_analysis.py
|
| 83 |
+
│ │ ├── marker_gene_verification.py
|
| 84 |
+
│ │ ├── masking_ablation.py
|
| 85 |
+
│ │ ├── module_importance.py
|
| 86 |
+
│ │ ├── module_importance_publication.py
|
| 87 |
+
│ │ ├── pub_utils.py
|
| 88 |
+
│ │ ├── random_baseline.py
|
| 89 |
+
│ │ ├── README.md
|
| 90 |
+
│ │ ├── RESULTS.md
|
| 91 |
+
│ │ ├── tcell_reactome_analysis.py
|
| 92 |
+
│ │ └── TODO.md
|
| 93 |
+
│ ├── module_text_alignment/ # Semantic alignment of module representations and pathway text
|
| 94 |
+
│ │ ├── __init__.py
|
| 95 |
+
│ │ ├── DESIGN.md
|
| 96 |
+
│ │ ├── fetch_reactome_descriptions.py
|
| 97 |
+
│ │ ├── MANUSCRIPT_SECTION.md
|
| 98 |
+
│ │ ├── module_text_alignment.py
|
| 99 |
+
│ │ ├── module_text_alignment_extended.py
|
| 100 |
+
│ │ ├── module_text_alignment_publication.py
|
| 101 |
+
│ │ └── reactome_descriptions.json
|
| 102 |
+
│ ├── __init__.py
|
| 103 |
+
│ ├── module_geometry.py
|
| 104 |
+
│ ├── README.md
|
| 105 |
+
│ └── utils.py
|
| 106 |
+
├── module_data_all/ # Module definitions, merging and uncovered-gene tools
|
| 107 |
+
│ ├── create_uncovered_modules.py
|
| 108 |
+
│ ├── merge_modules.py
|
| 109 |
+
│ └── merged_modules.json
|
| 110 |
+
├── tests/ # CPU tests for shared training utilities
|
| 111 |
+
│ └── test_training_utils.py
|
| 112 |
+
├── toturials/ # Jupyter tutorials for five downstream tasks
|
| 113 |
+
│ ├── cell_annotation.ipynb
|
| 114 |
+
│ ├── drug_combination.ipynb
|
| 115 |
+
│ ├── drug_response_prediction.ipynb
|
| 116 |
+
│ ├── gene_perturbation.ipynb
|
| 117 |
+
│ └── zero_shot_batch_integration.ipynb
|
| 118 |
+
├── zero_shot_batch/ # Zero-shot batch integration, evaluation and visualization
|
| 119 |
+
│ ├── sequencing_depth/ # Low-depth data generation and zero-shot evaluation
|
| 120 |
+
│ │ ├── cmd.txt
|
| 121 |
+
│ │ ├── make_low_depth_h5ad.py
|
| 122 |
+
│ │ └── run_zero_shot_low_depth.py
|
| 123 |
+
│ ├── performer_module_zero_shot.py
|
| 124 |
+
│ ├── README.md
|
| 125 |
+
│ └── visualize_results.py
|
| 126 |
+
├── .env.example
|
| 127 |
+
├── .gitattributes
|
| 128 |
+
├── .gitignore
|
| 129 |
+
├── command.txt
|
| 130 |
+
├── environment.yml
|
| 131 |
+
├── models_performer_module.py
|
| 132 |
+
├── module_dataset.py
|
| 133 |
+
├── performer_module.py
|
| 134 |
+
├── README.md
|
| 135 |
+
├── requirements.txt
|
| 136 |
+
└── training_utils.py
|
| 137 |
+
```
|
| 138 |
+
|
| 139 |
+
The notebook directory is intentionally named `toturials` to preserve existing
|
| 140 |
+
paths used by the project.
|
| 141 |
+
|
| 142 |
+
## Quick Start
|
| 143 |
+
|
| 144 |
+
Create a fresh environment:
|
| 145 |
+
|
| 146 |
+
```bash
|
| 147 |
+
cd /path/to/BioMod/BioMod
|
| 148 |
+
conda env create -f environment.yml
|
| 149 |
+
conda activate performer_module
|
| 150 |
+
```
|
| 151 |
+
|
| 152 |
+
Or install Python packages into an existing CUDA/PyTorch environment:
|
| 153 |
+
|
| 154 |
+
```bash
|
| 155 |
+
python -m pip install -r requirements.txt
|
| 156 |
+
```
|
| 157 |
+
|
| 158 |
+
For the original server runs, the tutorials assume the `sclong` environment:
|
| 159 |
+
|
| 160 |
+
```bash
|
| 161 |
+
conda activate sclong
|
| 162 |
+
jupyter lab /path/to/BioMod/BioMod/toturials
|
| 163 |
+
```
|
| 164 |
+
|
| 165 |
+
### Training utility checks
|
| 166 |
+
|
| 167 |
+
The pretraining entry point imports `training_utils.py` explicitly. This local
|
| 168 |
+
module supplies random seeding, a sequential distributed validation sampler,
|
| 169 |
+
cosine learning-rate scheduling with warmup and restarts, scalar metric
|
| 170 |
+
reduction, and resumable checkpoint saving. It does not depend on a separate
|
| 171 |
+
package named `utils` or on a downstream task's utility module.
|
| 172 |
+
|
| 173 |
+
From this source directory, run the CPU utility tests and check entry-point
|
| 174 |
+
imports after installing the dependencies:
|
| 175 |
+
|
| 176 |
+
```bash
|
| 177 |
+
python -B -m unittest discover -s tests -p test_training_utils.py -v
|
| 178 |
+
python -B performer_module.py --help
|
| 179 |
+
```
|
| 180 |
+
|
| 181 |
+
The utility tests use synthetic tensors and temporary checkpoints; they do not
|
| 182 |
+
load the supplied pretrained weights. Passing these checks does not validate a
|
| 183 |
+
full training run. The pretraining entry point still requires CUDA and an NCCL
|
| 184 |
+
process group, with training data and paths configured for the target machine.
|
| 185 |
+
|
| 186 |
+
## Shared Assets
|
| 187 |
+
|
| 188 |
+
This local checkout retains only `weight/0810model/`. The original 0629
|
| 189 |
+
checkpoint/config pair is not included. For examples that reference
|
| 190 |
+
`weight/0629model/`, use the matching 0810 `.pth` and `.pkl` files described below,
|
| 191 |
+
and update any server-specific paths for the local environment.
|
| 192 |
+
|
| 193 |
+
Tutorials expect a pretrained BioMod/PerformerModule checkpoint and shared
|
| 194 |
+
gene assets. Set `BIOMODULEFM_ASSET_ROOT` to the directory containing
|
| 195 |
+
`weight/0810model/` and `model_dependencies/` (the outer `BioMod/` project directory).
|
| 196 |
+
|
| 197 |
+
```bash
|
| 198 |
+
export BIOMODULEFM_ASSET_ROOT=/path/to/BioMod
|
| 199 |
+
```
|
| 200 |
+
|
| 201 |
+
Current local asset files:
|
| 202 |
+
|
| 203 |
+
```text
|
| 204 |
+
$BIOMODULEFM_ASSET_ROOT/weight/0810model/ # 0810 checkpoint: .pth weights and .pkl configuration
|
| 205 |
+
├── mae_tost_70M_75mask_ec2.pkl
|
| 206 |
+
└── mae_tost_70M_75mask_ec2_epoch0.pth
|
| 207 |
+
|
| 208 |
+
$BIOMODULEFM_ASSET_ROOT/model_dependencies/ # Shared gene mappings, gene lists, embeddings and model components
|
| 209 |
+
├── module_data_all/ # Shared module definitions and construction tools
|
| 210 |
+
│ ├── create_uncovered_modules.py
|
| 211 |
+
│ ├── merge_modules.py
|
| 212 |
+
│ └── merged_modules.json
|
| 213 |
+
├── performer_pytorch_cont/ # Performer attention and model components
|
| 214 |
+
│ ├── performer_pytorch_cont/ # Nested package containing copies of the model component source files
|
| 215 |
+
│ │ ├── __init__.py
|
| 216 |
+
│ │ ├── ding_models.py
|
| 217 |
+
│ │ ├── performer_pytorch.py
|
| 218 |
+
│ │ ├── performer_pytorch_old.py
|
| 219 |
+
│ │ └── reversible.py
|
| 220 |
+
│ ├── __init__.py
|
| 221 |
+
│ ├── ding_models.py
|
| 222 |
+
│ ├── performer_pytorch.py
|
| 223 |
+
│ ├── performer_pytorch_old.py
|
| 224 |
+
│ └── reversible.py
|
| 225 |
+
├── gene2ens.csv
|
| 226 |
+
├── selected_gene2vec_27k.npy
|
| 227 |
+
└── selected_genes_27k.txt
|
| 228 |
+
```
|
| 229 |
+
|
| 230 |
+
Project-local module definitions are kept in:
|
| 231 |
+
|
| 232 |
+
```text
|
| 233 |
+
BioMod/module_data_all/ # Module definitions, merging and uncovered-gene tools
|
| 234 |
+
├── create_uncovered_modules.py
|
| 235 |
+
├── merge_modules.py
|
| 236 |
+
└── merged_modules.json
|
| 237 |
+
```
|
| 238 |
+
|
| 239 |
+
## Smaller 0810 model
|
| 240 |
+
|
| 241 |
+
The repository also includes a smaller 2-layer encoder checkpoint:
|
| 242 |
+
|
| 243 |
+
```text
|
| 244 |
+
weight/0810model/ # 0810 checkpoint: .pth weights and .pkl configuration
|
| 245 |
+
|-- mae_tost_70M_75mask_ec2_epoch0.pth
|
| 246 |
+
`-- mae_tost_70M_75mask_ec2.pkl
|
| 247 |
+
```
|
| 248 |
+
|
| 249 |
+
It uses a 2-layer encoder with the same 27,874-gene axis and 660 module
|
| 250 |
+
definitions as the primary 32-layer model. Use its checkpoint and PKL together
|
| 251 |
+
by replacing the two `weight/0629model/` paths in the commands below with the
|
| 252 |
+
matching `weight/0810model/` files. The code restores `enc_depth=2` and the
|
| 253 |
+
other architecture settings from the PKL automatically.
|
| 254 |
+
|
| 255 |
+
## Tutorials
|
| 256 |
+
|
| 257 |
+
| Task | Notebook | Main entry point | Default output style |
|
| 258 |
+
| --- | --- | --- | --- |
|
| 259 |
+
| Cell-type annotation | `toturials/cell_annotation.ipynb` | task script under `cell_annotation/` | metrics, classification report, confusion matrix |
|
| 260 |
+
| Zero-shot batch integration | `toturials/zero_shot_batch_integration.ipynb` | `zero_shot_batch/` runner | UMAP PDF/PNG, avgBatch, avgBio |
|
| 261 |
+
| Gene perturbation | `toturials/gene_perturbation.ipynb` | `gene_perturbation/` runner | GEARS-style metrics and smoke outputs |
|
| 262 |
+
| Drug response prediction | `toturials/drug_response_prediction.ipynb` | `drug_response/train_drug_performer_module.py` | scatter, residuals, ROC, per-drug and per-cancer tables |
|
| 263 |
+
| Drug-combination synergy | `toturials/drug_combination.ipynb` | `drug_combination/train_drug_combination_performer_module.py` | label balance, loss, AUROC, PR-AUC, confusion matrix when predictions exist |
|
| 264 |
+
|
| 265 |
+
Each notebook uses absolute paths through configurable roots and keeps full
|
| 266 |
+
training disabled by default when data are not present. Flip the notebook
|
| 267 |
+
`RUN_TRAINING` flag only after checking the path table at the top of the
|
| 268 |
+
notebook.
|
| 269 |
+
|
| 270 |
+
## Data Layout
|
| 271 |
+
|
| 272 |
+
Large datasets are not committed to this code repository. Place them under
|
| 273 |
+
task-specific data directories or pass absolute paths through environment
|
| 274 |
+
variables.
|
| 275 |
+
|
| 276 |
+
### Single-Drug Response
|
| 277 |
+
|
| 278 |
+
Expected clean data root:
|
| 279 |
+
|
| 280 |
+
```text
|
| 281 |
+
single_drug_response/data/
|
| 282 |
+
|-- CCLE/
|
| 283 |
+
| |-- Cell_lines_annotations_20181226.txt
|
| 284 |
+
| |-- GDSC_IC50.csv
|
| 285 |
+
| `-- genomic_expression_561celllines_697genes_demap_features.csv
|
| 286 |
+
`-- GDSC/
|
| 287 |
+
|-- 1.Drug_listMon Jun 24 09_00_55 2019.csv
|
| 288 |
+
`-- drug_graph_feat/
|
| 289 |
+
```
|
| 290 |
+
|
| 291 |
+
### Drug-Combination Synergy
|
| 292 |
+
|
| 293 |
+
Expected clean data root:
|
| 294 |
+
|
| 295 |
+
```text
|
| 296 |
+
drug_combination/data_summary/
|
| 297 |
+
|-- new_labels_0_10.csv
|
| 298 |
+
|-- new_cell_features_954.csv
|
| 299 |
+
|-- smiles_0_10.csv
|
| 300 |
+
`-- test_data/
|
| 301 |
+
|-- independent_input.csv
|
| 302 |
+
`-- independent_cell_features_954.csv
|
| 303 |
+
```
|
| 304 |
+
|
| 305 |
+
The drug-combination tutorial also supports old DeepDDS-style prediction CSV
|
| 306 |
+
files for ROC, PR, and confusion-matrix visualization.
|
| 307 |
+
|
| 308 |
+
## Example Commands
|
| 309 |
+
|
| 310 |
+
Drug response prediction with the primary 0629 model:
|
| 311 |
+
|
| 312 |
+
```bash
|
| 313 |
+
python drug_response/train_drug_performer_module.py \
|
| 314 |
+
--gpu_id 0 \
|
| 315 |
+
--data_path /path/to/single_drug_response/data \
|
| 316 |
+
--checkpoint_path $BIOMODULEFM_ASSET_ROOT/weight/0629model/mae_tost_48M_cross_attn_only_pure_memory_epoch12.pth \
|
| 317 |
+
--hyper_params_path $BIOMODULEFM_ASSET_ROOT/weight/0629model/mae_tost_48M_cross_attn_only_pure_memory.pkl \
|
| 318 |
+
--module_data_dir ./module_data_all \
|
| 319 |
+
--gene_list_path $BIOMODULEFM_ASSET_ROOT/model_dependencies/selected_genes_27k.txt \
|
| 320 |
+
--gene_mapping_path $BIOMODULEFM_ASSET_ROOT/model_dependencies/gene2ens.csv \
|
| 321 |
+
--gene2vec_path $BIOMODULEFM_ASSET_ROOT/model_dependencies/selected_gene2vec_27k.npy \
|
| 322 |
+
--symbol2ens_path $BIOMODULEFM_ASSET_ROOT/model_dependencies/gene2ens.csv \
|
| 323 |
+
--save_dir ./results/drug_response_0629 \
|
| 324 |
+
--epochs 300 \
|
| 325 |
+
--batch_size 32 \
|
| 326 |
+
--pool_method max
|
| 327 |
+
```
|
| 328 |
+
|
| 329 |
+
Drug-combination synergy prediction:
|
| 330 |
+
|
| 331 |
+
```bash
|
| 332 |
+
python drug_combination/train_drug_combination_performer_module.py \
|
| 333 |
+
--checkpoint_path $BIOMODULEFM_ASSET_ROOT/weight/0629model/mae_tost_48M_cross_attn_only_pure_memory_epoch12.pth \
|
| 334 |
+
--hyper_params_path $BIOMODULEFM_ASSET_ROOT/weight/0629model/mae_tost_48M_cross_attn_only_pure_memory.pkl \
|
| 335 |
+
--module_data_dir ./module_data_all \
|
| 336 |
+
--gene_list_path $BIOMODULEFM_ASSET_ROOT/model_dependencies/selected_genes_27k.txt \
|
| 337 |
+
--gene_mapping_path $BIOMODULEFM_ASSET_ROOT/model_dependencies/gene2ens.csv \
|
| 338 |
+
--gene2vec_path $BIOMODULEFM_ASSET_ROOT/model_dependencies/selected_gene2vec_27k.npy \
|
| 339 |
+
--symbol2ens_path $BIOMODULEFM_ASSET_ROOT/model_dependencies/gene2ens.csv \
|
| 340 |
+
--data_dir ./drug_combination/data_summary \
|
| 341 |
+
--output_dir ./results/drug_combination_0629 \
|
| 342 |
+
--epochs 500 \
|
| 343 |
+
--batch_size 256 \
|
| 344 |
+
--lr 1e-4 \
|
| 345 |
+
--n_folds 1 \
|
| 346 |
+
--pool_method max
|
| 347 |
+
```
|
| 348 |
+
|
| 349 |
+
## Display Outputs
|
| 350 |
+
|
| 351 |
+
The notebooks regenerate figure files in task output directories. Typical files
|
| 352 |
+
include:
|
| 353 |
+
|
| 354 |
+
| Task | Generated display files |
|
| 355 |
+
| --- | --- |
|
| 356 |
+
| Zero-shot integration | A4 UMAP PDF/PNG panels, combined cell-type and batch views |
|
| 357 |
+
| Drug response | `drug_response_overall_scatter.pdf`, `drug_response_median_threshold_roc.svg`, per-drug and per-cancer CSV summaries |
|
| 358 |
+
| Drug combination | `drug_combination_metric_curves.pdf`, `drug_combination_roc_curves.pdf`, `drug_combination_label_balance.pdf`, optional PR and confusion-matrix panels |
|
| 359 |
+
| Cell annotation | confusion matrices, classification reports, UMAP/prediction diagnostics |
|
| 360 |
+
| Gene perturbation | smoke-run logs, perturbation metrics, downstream result tables |
|
| 361 |
+
|
| 362 |
+
For GitHub display, use PNG files. For manuscripts, use the PDF/SVG exports.
|
| 363 |
+
The plotting cells use embedded, editable PDF fonts where supported.
|
| 364 |
+
|
| 365 |
+
## Reference Results
|
| 366 |
+
|
| 367 |
+
These values are retained as run-level reference points and should be refreshed
|
| 368 |
+
from final locked runs before manuscript submission.
|
| 369 |
+
|
| 370 |
+
| Task | Dataset / setting | Reported signal |
|
| 371 |
+
| --- | --- | --- |
|
| 372 |
+
| Drug response | GDSC/CCLE, primary 0629 model | Pearson correlation around `0.93` in the existing full run |
|
| 373 |
+
| Drug combination | Independent-set domain generalization, primary 0629 model | AUROC reference around `0.6824` for the previous run |
|
| 374 |
+
| Zero-shot integration | HP, BMMC, Kim Lung, Kim Lung no-malignant | Notebook computes avgBatch and avgBio across repeated runs |
|
| 375 |
+
| Cell annotation | Seven benchmark datasets | Notebook reports accuracy, macro-F1, and confusion matrices |
|
| 376 |
+
|
| 377 |
+
## Reproducibility Conventions
|
| 378 |
+
|
| 379 |
+
- Record the exact command line, checkpoint, hyper-parameter pkl, data version,
|
| 380 |
+
random seed, and output directory for every final run.
|
| 381 |
+
- Keep raw data, cached embeddings, model checkpoints, metric tables, and paper
|
| 382 |
+
figures in separate directories.
|
| 383 |
+
- Prefer shared storage for large `.h5ad`, `.npy`, and checkpoint outputs.
|
| 384 |
+
- Do not commit private data, raw model weights, API keys, or personal absolute
|
| 385 |
+
paths.
|
| 386 |
+
|
| 387 |
+
## Troubleshooting
|
| 388 |
+
|
| 389 |
+
### Missing data
|
| 390 |
+
|
| 391 |
+
Open the relevant notebook and run the path-check cell. It prints all required
|
| 392 |
+
files and shows which entries are missing.
|
| 393 |
+
|
| 394 |
+
### Missing model assets
|
| 395 |
+
|
| 396 |
+
Check `BIOMODULEFM_ASSET_ROOT` and verify that the 0407 checkpoint, pkl,
|
| 397 |
+
gene2vec, gene list, gene mapping, and `performer_pytorch_cont/` directory
|
| 398 |
+
exist.
|
| 399 |
+
|
| 400 |
+
### No space left on device
|
| 401 |
+
|
| 402 |
+
Large `.h5ad`, `.npy`, checkpoint, and figure outputs can fill local disks.
|
| 403 |
+
Move result directories to shared storage and remove partial failed outputs only
|
| 404 |
+
after confirming they are not needed.
|
| 405 |
+
|
| 406 |
+
### Matplotlib font warnings
|
| 407 |
+
|
| 408 |
+
The notebooks request Arial first and fall back to DejaVu Sans when Arial is not
|
| 409 |
+
installed. PDF font embedding is enabled through `pdf.fonttype = 42`.
|
| 410 |
+
|
| 411 |
+
## GitHub Release Checklist
|
| 412 |
+
|
| 413 |
+
- Keep `README.md`, `requirements.txt`, `environment.yml`, `.gitignore`, and
|
| 414 |
+
`.gitattributes` at the repository root.
|
| 415 |
+
- Confirm tutorial notebooks open without personal paths.
|
| 416 |
+
- Keep large data and checkpoints outside Git, then document download or
|
| 417 |
+
placement instructions.
|
| 418 |
+
- Add final figure examples under a small `docs/` or `assets/` directory if the
|
| 419 |
+
repository needs rendered images on the GitHub landing page.
|
| 420 |
+
- Add license, citation, and data-availability text before public release.
|
| 421 |
+
|
| 422 |
+
## Changelog
|
| 423 |
+
|
| 424 |
+
| Date | Change |
|
| 425 |
+
| --- | --- |
|
| 426 |
+
| 2026-06-15 | Added paper-style plotting cells for single-drug and drug-combination tutorials. |
|
| 427 |
+
| 2026-06-15 | Removed `stage2_alignment/` from the active repository scope. |
|
| 428 |
+
| 2026-06-15 | Added root environment files and GitHub-ready repository documentation. |
|
BioMod/cell_annotation/cmd_finetune.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
torchrun --nproc_per_node=4 performer_module_finetune.py \
|
| 2 |
+
--config_pkl ../ckpts_performer_module_multiply_all_module/performer_module_multiply_all_module.pkl \
|
| 3 |
+
--performer_checkpoint ../ckpts_performer_module_multiply_all_module/performer_module_multiply_all_module_epoch6.pth \
|
| 4 |
+
--data_path ../../cell_annotation/Zheng68K.h5ad \
|
| 5 |
+
--label_column celltype \
|
| 6 |
+
--head_type attn_pool \
|
| 7 |
+
--finetune_mode last_n \
|
| 8 |
+
--unfreeze_last_n 1 \
|
| 9 |
+
--cls_decoder_layers 3 \
|
| 10 |
+
--lr_head 1e-4 \
|
| 11 |
+
--lr_backbone 1e-5 \
|
| 12 |
+
--batch_size 256 \
|
| 13 |
+
--epochs 300 \
|
| 14 |
+
--module_data_dir ../module_data_all \
|
| 15 |
+
--output_dir ../../../../share/extracted_feature/ckpts_performer_module_fintune/performer_module_multiply_all_module
|
BioMod/cell_annotation/module_data/load_module.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
def load_json_files(file1_path, file2_path):
|
| 2 |
+
"""
|
| 3 |
+
|
| 4 |
+
Args:
|
| 5 |
+
file1_path: hallmarks file path
|
| 6 |
+
file2_path: reactome file path
|
| 7 |
+
|
| 8 |
+
Returns:
|
| 9 |
+
tuple: (hallmark_dict, filtered_reactome_dict)
|
| 10 |
+
"""
|
| 11 |
+
# Read HALLMARKS
|
| 12 |
+
with open(file1_path, 'r', encoding='utf-8') as f:
|
| 13 |
+
hallmark_dict = json.load(f)
|
| 14 |
+
|
| 15 |
+
# Read reactome data
|
| 16 |
+
with open(file2_path, 'r', encoding='utf-8') as f:
|
| 17 |
+
reactome_full = json.load(f)
|
| 18 |
+
|
| 19 |
+
# Filter reactome data
|
| 20 |
+
filtered_reactome = {
|
| 21 |
+
k: v for k, v in reactome_full.items()
|
| 22 |
+
if 50 <= len(v) <= 500
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
return hallmark_dict, filtered_reactome
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
# hallmark_data, reactome_data = load_json_files(
|
| 29 |
+
# 'module1_hallmarks.json',
|
| 30 |
+
# 'module2_reactome.json'
|
| 31 |
+
# )
|
| 32 |
+
# len(hallmark_data.keys())
|
| 33 |
+
# len(reactome_data.keys())
|
BioMod/cell_annotation/module_data/module1_hallmarks.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
BioMod/cell_annotation/module_data/module2_reactome_2022_filtered.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
BioMod/cell_annotation/performer_module_finetune.py
ADDED
|
@@ -0,0 +1,1591 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python
|
| 2 |
+
# -*- coding: utf-8 -*-
|
| 3 |
+
|
| 4 |
+
"""
|
| 5 |
+
End-to-end cell annotation fine-tuning for Performer Module.
|
| 6 |
+
|
| 7 |
+
This script is intentionally independent from the legacy two-stage pipeline:
|
| 8 |
+
1. No feature extraction to .npy files.
|
| 9 |
+
2. No imports from other scripts in performer_module/cell_annotation.
|
| 10 |
+
3. Reuses the pretrained Performer Module backbone directly.
|
| 11 |
+
|
| 12 |
+
Supported cell embedding heads:
|
| 13 |
+
- module_mean: mean over restored module tokens
|
| 14 |
+
- max: max pooling over restored module tokens
|
| 15 |
+
- gene_mean: module -> gene -> mean over genes
|
| 16 |
+
- attn_pool: learnable attention pooling over restored module tokens
|
| 17 |
+
- cls: use pretrained CLS token if the checkpoint contains one
|
| 18 |
+
|
| 19 |
+
Supported backbone finetune modes:
|
| 20 |
+
- frozen: freeze the full backbone
|
| 21 |
+
- last_n: unfreeze only the last N encoder blocks
|
| 22 |
+
- full: full-parameter finetuning
|
| 23 |
+
"""
|
| 24 |
+
|
| 25 |
+
import argparse
|
| 26 |
+
import contextlib
|
| 27 |
+
import json
|
| 28 |
+
import logging
|
| 29 |
+
import os
|
| 30 |
+
import pickle
|
| 31 |
+
import random
|
| 32 |
+
import sys
|
| 33 |
+
from pathlib import Path
|
| 34 |
+
from typing import TYPE_CHECKING, Dict, List, Optional, Sequence, Tuple
|
| 35 |
+
|
| 36 |
+
import numpy as np
|
| 37 |
+
import pandas as pd
|
| 38 |
+
import torch
|
| 39 |
+
import anndata as ad
|
| 40 |
+
import torch.distributed as dist
|
| 41 |
+
from scipy import sparse
|
| 42 |
+
from sklearn.metrics import accuracy_score, classification_report, confusion_matrix, f1_score
|
| 43 |
+
from sklearn.model_selection import StratifiedShuffleSplit
|
| 44 |
+
from sklearn.preprocessing import LabelEncoder
|
| 45 |
+
from torch.nn.parallel import DistributedDataParallel as DDP
|
| 46 |
+
from torch import nn
|
| 47 |
+
from torch.utils.data import DataLoader, Dataset, Sampler
|
| 48 |
+
from torch.utils.data.distributed import DistributedSampler
|
| 49 |
+
|
| 50 |
+
SCRIPT_DIR = Path(__file__).resolve().parent
|
| 51 |
+
PERFORMER_MODULE_ROOT = SCRIPT_DIR.parent
|
| 52 |
+
SCLONG_ROOT = PERFORMER_MODULE_ROOT.parent
|
| 53 |
+
|
| 54 |
+
if str(PERFORMER_MODULE_ROOT) not in sys.path:
|
| 55 |
+
sys.path.insert(0, str(PERFORMER_MODULE_ROOT))
|
| 56 |
+
if str(SCLONG_ROOT) not in sys.path:
|
| 57 |
+
sys.path.insert(0, str(SCLONG_ROOT))
|
| 58 |
+
|
| 59 |
+
from models_performer_module import (
|
| 60 |
+
migrate_legacy_module_config,
|
| 61 |
+
migrate_legacy_module_state_dict,
|
| 62 |
+
)
|
| 63 |
+
|
| 64 |
+
if TYPE_CHECKING:
|
| 65 |
+
from models_performer_module import PerformerEncoderOnly_Module
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
|
| 69 |
+
logger = logging.getLogger(__name__)
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def is_dist_initialized() -> bool:
|
| 73 |
+
return dist.is_available() and dist.is_initialized()
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def get_rank() -> int:
|
| 77 |
+
return dist.get_rank() if is_dist_initialized() else 0
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def get_world_size() -> int:
|
| 81 |
+
return dist.get_world_size() if is_dist_initialized() else 1
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def is_main_process() -> bool:
|
| 85 |
+
return get_rank() == 0
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def unwrap_model(model: nn.Module) -> nn.Module:
|
| 89 |
+
return model.module if hasattr(model, "module") else model
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def init_distributed_mode(device_arg: str) -> Tuple[torch.device, bool, int, int, int]:
|
| 93 |
+
use_ddp = int(os.environ.get("WORLD_SIZE", "1")) > 1
|
| 94 |
+
if not use_ddp:
|
| 95 |
+
if device_arg == "cuda" and not torch.cuda.is_available():
|
| 96 |
+
logger.warning("CUDA requested but not available. Falling back to CPU.")
|
| 97 |
+
return torch.device("cpu"), False, 0, 1, 0
|
| 98 |
+
return torch.device(device_arg), False, 0, 1, 0
|
| 99 |
+
|
| 100 |
+
local_rank = int(os.environ["LOCAL_RANK"])
|
| 101 |
+
rank = int(os.environ["RANK"])
|
| 102 |
+
world_size = int(os.environ["WORLD_SIZE"])
|
| 103 |
+
|
| 104 |
+
if device_arg != "cuda":
|
| 105 |
+
backend = "gloo"
|
| 106 |
+
device = torch.device(device_arg)
|
| 107 |
+
else:
|
| 108 |
+
if not torch.cuda.is_available():
|
| 109 |
+
raise RuntimeError("DDP with device='cuda' requires CUDA to be available")
|
| 110 |
+
backend = "nccl"
|
| 111 |
+
torch.cuda.set_device(local_rank)
|
| 112 |
+
device = torch.device("cuda", local_rank)
|
| 113 |
+
|
| 114 |
+
dist.init_process_group(backend=backend)
|
| 115 |
+
return device, True, rank, world_size, local_rank
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def all_gather_numpy(array: np.ndarray) -> np.ndarray:
|
| 119 |
+
if not is_dist_initialized():
|
| 120 |
+
return array
|
| 121 |
+
gathered = [None for _ in range(get_world_size())]
|
| 122 |
+
dist.all_gather_object(gathered, array)
|
| 123 |
+
non_empty = [arr for arr in gathered if arr is not None and len(arr) > 0]
|
| 124 |
+
if not non_empty:
|
| 125 |
+
return np.empty((0,), dtype=array.dtype)
|
| 126 |
+
return np.concatenate(non_empty, axis=0)
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def reduce_sum(value: float, device: torch.device) -> float:
|
| 130 |
+
if not is_dist_initialized():
|
| 131 |
+
return float(value)
|
| 132 |
+
tensor = torch.tensor(float(value), device=device, dtype=torch.float64)
|
| 133 |
+
dist.all_reduce(tensor, op=dist.ReduceOp.SUM)
|
| 134 |
+
return float(tensor.item())
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def resolve_path(path_str: str) -> Path:
|
| 138 |
+
path = Path(path_str)
|
| 139 |
+
if path.is_absolute():
|
| 140 |
+
return path
|
| 141 |
+
return (SCRIPT_DIR / path).resolve()
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def seed_all(seed: int) -> None:
|
| 145 |
+
random.seed(seed)
|
| 146 |
+
np.random.seed(seed)
|
| 147 |
+
torch.manual_seed(seed)
|
| 148 |
+
torch.cuda.manual_seed_all(seed)
|
| 149 |
+
torch.backends.cudnn.deterministic = True
|
| 150 |
+
torch.backends.cudnn.benchmark = False
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
class DataPreprocessor:
|
| 154 |
+
"""Map input genes to the pretrained model gene space."""
|
| 155 |
+
|
| 156 |
+
def __init__(
|
| 157 |
+
self,
|
| 158 |
+
gene2ens_file: str,
|
| 159 |
+
gene2ens_column: str = "Ensembl gene ID",
|
| 160 |
+
model_genes_file: Optional[str] = None,
|
| 161 |
+
):
|
| 162 |
+
self.gene2ens_file = gene2ens_file
|
| 163 |
+
self.gene2ens_column = gene2ens_column
|
| 164 |
+
self.model_genes_file = model_genes_file
|
| 165 |
+
self.gene2ens_dict: Optional[Dict[str, str]] = None
|
| 166 |
+
self.model_genes: Optional[List[str]] = None
|
| 167 |
+
self._index_cache: Dict[str, torch.Tensor] = {}
|
| 168 |
+
|
| 169 |
+
def load_gene_mapping(self) -> None:
|
| 170 |
+
gene2ens = pd.read_csv(self.gene2ens_file, index_col=0)
|
| 171 |
+
if self.gene2ens_column not in gene2ens.columns:
|
| 172 |
+
raise ValueError(f"Column '{self.gene2ens_column}' not found in {self.gene2ens_file}")
|
| 173 |
+
self.gene2ens_dict = gene2ens[self.gene2ens_column].to_dict()
|
| 174 |
+
logger.info("Loaded gene2ens mapping: %d entries", len(self.gene2ens_dict))
|
| 175 |
+
|
| 176 |
+
def load_model_genes(self) -> None:
|
| 177 |
+
if self.model_genes_file is None:
|
| 178 |
+
raise ValueError("model_genes_file must be provided")
|
| 179 |
+
with open(self.model_genes_file, "r", encoding="utf-8") as handle:
|
| 180 |
+
self.model_genes = [line.rstrip("\n") for line in handle if line.strip()]
|
| 181 |
+
logger.info("Loaded %d model genes from %s", len(self.model_genes), self.model_genes_file)
|
| 182 |
+
|
| 183 |
+
def _extract_input_genes(
|
| 184 |
+
self,
|
| 185 |
+
adata: ad.AnnData,
|
| 186 |
+
gene_name_column: Optional[str] = None,
|
| 187 |
+
) -> Tuple[List[str], str]:
|
| 188 |
+
if gene_name_column is not None and gene_name_column.lower() == "index":
|
| 189 |
+
return adata.var.index.astype(str).tolist(), "var.index"
|
| 190 |
+
|
| 191 |
+
if gene_name_column is not None and gene_name_column.lower() != "auto":
|
| 192 |
+
if gene_name_column not in adata.var.columns:
|
| 193 |
+
raise ValueError(f"Gene column '{gene_name_column}' not found in adata.var")
|
| 194 |
+
return adata.var[gene_name_column].astype(str).tolist(), f"var['{gene_name_column}']"
|
| 195 |
+
|
| 196 |
+
if "gene_name" in adata.var.columns:
|
| 197 |
+
return adata.var["gene_name"].astype(str).tolist(), "var['gene_name']"
|
| 198 |
+
return adata.var.index.astype(str).tolist(), "var.index"
|
| 199 |
+
|
| 200 |
+
def _build_reindex_state(
|
| 201 |
+
self,
|
| 202 |
+
adata: ad.AnnData,
|
| 203 |
+
dataset_name: str,
|
| 204 |
+
gene_name_column: Optional[str] = None,
|
| 205 |
+
) -> dict:
|
| 206 |
+
input_genes, gene_source = self._extract_input_genes(adata, gene_name_column=gene_name_column)
|
| 207 |
+
if self.gene2ens_dict is not None:
|
| 208 |
+
input_genes = [self.gene2ens_dict.get(symbol, symbol) for symbol in input_genes]
|
| 209 |
+
logger.info("[%s] Mapped input genes to Ensembl IDs", dataset_name)
|
| 210 |
+
|
| 211 |
+
input_mapping = {gene: i for i, gene in enumerate(input_genes)}
|
| 212 |
+
input_gene_num = len(input_genes)
|
| 213 |
+
|
| 214 |
+
if self.model_genes is None:
|
| 215 |
+
raise RuntimeError("Model genes must be loaded before preprocess_adata")
|
| 216 |
+
|
| 217 |
+
model_index_positions = [input_mapping.get(gene, input_gene_num) for gene in self.model_genes]
|
| 218 |
+
overlap = sum(pos < input_gene_num for pos in model_index_positions)
|
| 219 |
+
logger.info(
|
| 220 |
+
"[%s] Gene source: %s | overlap with model space: %d / %d (model gene space: %d)",
|
| 221 |
+
dataset_name,
|
| 222 |
+
gene_source,
|
| 223 |
+
overlap,
|
| 224 |
+
input_gene_num,
|
| 225 |
+
len(self.model_genes),
|
| 226 |
+
)
|
| 227 |
+
return {
|
| 228 |
+
"cache_key": f"{dataset_name}:{gene_source}:{input_gene_num}",
|
| 229 |
+
"dataset_name": dataset_name,
|
| 230 |
+
"gene_source": gene_source,
|
| 231 |
+
"model_index_positions": model_index_positions,
|
| 232 |
+
"input_gene_num": input_gene_num,
|
| 233 |
+
"overlap": overlap,
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
+
def preprocess_adata(
|
| 237 |
+
self,
|
| 238 |
+
adata_path: str,
|
| 239 |
+
min_genes: int = 10,
|
| 240 |
+
min_cells: int = 10,
|
| 241 |
+
max_cells: Optional[int] = None,
|
| 242 |
+
dataset_name: str = "dataset",
|
| 243 |
+
gene_name_column: Optional[str] = None,
|
| 244 |
+
) -> Tuple[ad.AnnData, dict]:
|
| 245 |
+
logger.info("Loading data from %s", adata_path)
|
| 246 |
+
adata = ad.read_h5ad(adata_path)
|
| 247 |
+
matrix = adata.X
|
| 248 |
+
|
| 249 |
+
if sparse.issparse(matrix):
|
| 250 |
+
cell_nonzero = np.asarray((matrix > 0).sum(axis=1)).ravel()
|
| 251 |
+
gene_nonzero = np.asarray((matrix > 0).sum(axis=0)).ravel()
|
| 252 |
+
else:
|
| 253 |
+
cell_nonzero = np.count_nonzero(np.asarray(matrix), axis=1)
|
| 254 |
+
gene_nonzero = np.count_nonzero(np.asarray(matrix), axis=0)
|
| 255 |
+
|
| 256 |
+
cell_mask = cell_nonzero >= min_genes
|
| 257 |
+
gene_mask = gene_nonzero >= min_cells
|
| 258 |
+
adata = adata[cell_mask, gene_mask].copy()
|
| 259 |
+
|
| 260 |
+
if max_cells is not None and adata.n_obs > max_cells:
|
| 261 |
+
logger.info("Subsampling %d cells from %d", max_cells, adata.n_obs)
|
| 262 |
+
rng = np.random.default_rng(0)
|
| 263 |
+
chosen = np.sort(rng.choice(adata.n_obs, size=max_cells, replace=False))
|
| 264 |
+
adata = adata[chosen].copy()
|
| 265 |
+
|
| 266 |
+
reindex_state = self._build_reindex_state(
|
| 267 |
+
adata,
|
| 268 |
+
dataset_name=dataset_name,
|
| 269 |
+
gene_name_column=gene_name_column,
|
| 270 |
+
)
|
| 271 |
+
return adata, reindex_state
|
| 272 |
+
|
| 273 |
+
def _get_index_tensor(self, reindex_state: dict, device: torch.device) -> torch.Tensor:
|
| 274 |
+
key = f"{str(device)}::{reindex_state['cache_key']}"
|
| 275 |
+
if key not in self._index_cache:
|
| 276 |
+
self._index_cache[key] = torch.as_tensor(
|
| 277 |
+
reindex_state["model_index_positions"],
|
| 278 |
+
dtype=torch.long,
|
| 279 |
+
device=device,
|
| 280 |
+
)
|
| 281 |
+
return self._index_cache[key]
|
| 282 |
+
|
| 283 |
+
def reindex_tensor_to_model_space(
|
| 284 |
+
self,
|
| 285 |
+
tensor: torch.Tensor,
|
| 286 |
+
reindex_state: dict,
|
| 287 |
+
filler: float = 0.0,
|
| 288 |
+
) -> torch.Tensor:
|
| 289 |
+
if tensor.dim() != 2:
|
| 290 |
+
raise ValueError(f"Expected 2D tensor [B, G], got {tuple(tensor.shape)}")
|
| 291 |
+
index_tensor = self._get_index_tensor(reindex_state, tensor.device)
|
| 292 |
+
filler_col = torch.full(
|
| 293 |
+
(tensor.shape[0], 1),
|
| 294 |
+
float(filler),
|
| 295 |
+
dtype=tensor.dtype,
|
| 296 |
+
device=tensor.device,
|
| 297 |
+
)
|
| 298 |
+
expanded_tensor = torch.cat([tensor, filler_col], dim=1)
|
| 299 |
+
return torch.index_select(expanded_tensor, 1, index_tensor)
|
| 300 |
+
|
| 301 |
+
|
| 302 |
+
class ExpressionDataset(Dataset):
|
| 303 |
+
def __init__(self, matrix, labels: np.ndarray, indices: Sequence[int]):
|
| 304 |
+
self.matrix = matrix.tocsr() if sparse.issparse(matrix) and not sparse.isspmatrix_csr(matrix) else matrix
|
| 305 |
+
self.labels = labels.astype(np.int64, copy=False)
|
| 306 |
+
self.indices = np.asarray(indices, dtype=np.int64)
|
| 307 |
+
|
| 308 |
+
def __len__(self) -> int:
|
| 309 |
+
return len(self.indices)
|
| 310 |
+
|
| 311 |
+
def __getitem__(self, idx: int):
|
| 312 |
+
real_idx = int(self.indices[idx])
|
| 313 |
+
row = self.matrix[real_idx]
|
| 314 |
+
if sparse.issparse(row):
|
| 315 |
+
row = row.toarray().ravel()
|
| 316 |
+
else:
|
| 317 |
+
row = np.asarray(row).reshape(-1)
|
| 318 |
+
x = torch.from_numpy(row.astype(np.float32, copy=False))
|
| 319 |
+
y = torch.tensor(self.labels[real_idx], dtype=torch.long)
|
| 320 |
+
return x, y
|
| 321 |
+
|
| 322 |
+
|
| 323 |
+
class DistributedEvalSampler(Sampler[int]):
|
| 324 |
+
"""Shard evaluation data across ranks without padding or duplication."""
|
| 325 |
+
|
| 326 |
+
def __init__(self, dataset_len: int, rank: int, world_size: int):
|
| 327 |
+
self.indices = list(range(rank, dataset_len, world_size))
|
| 328 |
+
|
| 329 |
+
def __iter__(self):
|
| 330 |
+
return iter(self.indices)
|
| 331 |
+
|
| 332 |
+
def __len__(self) -> int:
|
| 333 |
+
return len(self.indices)
|
| 334 |
+
|
| 335 |
+
|
| 336 |
+
class GatedAttentionPool(nn.Module):
|
| 337 |
+
def __init__(self, embed_dim: int, hidden_dim: int):
|
| 338 |
+
super().__init__()
|
| 339 |
+
self.score = nn.Sequential(
|
| 340 |
+
nn.LayerNorm(embed_dim),
|
| 341 |
+
nn.Linear(embed_dim, hidden_dim),
|
| 342 |
+
nn.Tanh(),
|
| 343 |
+
nn.Linear(hidden_dim, 1),
|
| 344 |
+
)
|
| 345 |
+
|
| 346 |
+
def forward(self, tokens: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]:
|
| 347 |
+
scores = self.score(tokens).squeeze(-1)
|
| 348 |
+
weights = torch.softmax(scores, dim=1)
|
| 349 |
+
pooled = torch.sum(tokens * weights.unsqueeze(-1), dim=1)
|
| 350 |
+
return pooled, weights
|
| 351 |
+
|
| 352 |
+
|
| 353 |
+
class ScGPTClsDecoder(nn.Module):
|
| 354 |
+
"""Classifier head aligned with scGPT's ClsDecoder."""
|
| 355 |
+
|
| 356 |
+
def __init__(self, d_model: int, n_cls: int, nlayers: int = 3):
|
| 357 |
+
super().__init__()
|
| 358 |
+
if nlayers < 1:
|
| 359 |
+
raise ValueError(f"nlayers must be >= 1, got {nlayers}")
|
| 360 |
+
self.layers = nn.ModuleList()
|
| 361 |
+
for _ in range(nlayers - 1):
|
| 362 |
+
self.layers.append(nn.Linear(d_model, d_model))
|
| 363 |
+
self.layers.append(nn.ReLU())
|
| 364 |
+
self.layers.append(nn.LayerNorm(d_model))
|
| 365 |
+
self.out_layer = nn.Linear(d_model, n_cls)
|
| 366 |
+
|
| 367 |
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
| 368 |
+
for layer in self.layers:
|
| 369 |
+
x = layer(x)
|
| 370 |
+
return self.out_layer(x)
|
| 371 |
+
|
| 372 |
+
|
| 373 |
+
class ModuleSelector(nn.Module):
|
| 374 |
+
"""Per-module scorer: soft weighting during training, hard threshold during inference."""
|
| 375 |
+
|
| 376 |
+
def __init__(self, embed_dim: int):
|
| 377 |
+
super().__init__()
|
| 378 |
+
self.scorer = nn.Sequential(
|
| 379 |
+
nn.LayerNorm(embed_dim),
|
| 380 |
+
nn.Linear(embed_dim, 1),
|
| 381 |
+
nn.Sigmoid(),
|
| 382 |
+
)
|
| 383 |
+
|
| 384 |
+
def forward(
|
| 385 |
+
self, x: torch.Tensor, tau: float = 0.5, hard: bool = False,
|
| 386 |
+
) -> Tuple[torch.Tensor, torch.Tensor]:
|
| 387 |
+
scores = self.scorer(x) # (B, L, 1)
|
| 388 |
+
if hard:
|
| 389 |
+
x = x * (scores > tau).float()
|
| 390 |
+
else:
|
| 391 |
+
x = x * scores
|
| 392 |
+
return x, scores
|
| 393 |
+
|
| 394 |
+
|
| 395 |
+
class PerformerModuleCellClassifier(nn.Module):
|
| 396 |
+
def __init__(
|
| 397 |
+
self,
|
| 398 |
+
backbone: "PerformerEncoderOnly_Module",
|
| 399 |
+
module_sets: List[List[int]],
|
| 400 |
+
num_classes: int,
|
| 401 |
+
head_type: str,
|
| 402 |
+
pool_hidden_dim: int,
|
| 403 |
+
dropout: float,
|
| 404 |
+
cls_decoder_layers: int,
|
| 405 |
+
use_module_selector: bool = False,
|
| 406 |
+
):
|
| 407 |
+
super().__init__()
|
| 408 |
+
self.backbone = backbone
|
| 409 |
+
self.module_sets = module_sets
|
| 410 |
+
self.num_classes = num_classes
|
| 411 |
+
self.head_type = head_type
|
| 412 |
+
self.base_module_count = len(module_sets)
|
| 413 |
+
self.embed_dim = backbone.base_dim
|
| 414 |
+
self.num_genes = backbone.num_genes
|
| 415 |
+
self.use_cls = bool(backbone.use_cls)
|
| 416 |
+
|
| 417 |
+
if self.head_type == "cls" and not self.use_cls:
|
| 418 |
+
raise ValueError("head_type='cls' requires a checkpoint with cls_token")
|
| 419 |
+
|
| 420 |
+
if self.head_type == "attn_pool":
|
| 421 |
+
hidden_dim = pool_hidden_dim if pool_hidden_dim > 0 else self.embed_dim
|
| 422 |
+
self.pooler = GatedAttentionPool(self.embed_dim, hidden_dim)
|
| 423 |
+
else:
|
| 424 |
+
self.pooler = None
|
| 425 |
+
self.output_dropout = nn.Dropout(dropout) if dropout > 0 else nn.Identity()
|
| 426 |
+
|
| 427 |
+
self.use_module_selector = use_module_selector
|
| 428 |
+
self.selector_tau = 0.5
|
| 429 |
+
if use_module_selector:
|
| 430 |
+
self.module_selector = ModuleSelector(self.embed_dim)
|
| 431 |
+
else:
|
| 432 |
+
self.module_selector = None
|
| 433 |
+
self._selector_scores = None
|
| 434 |
+
|
| 435 |
+
if self.head_type == "gene_mean":
|
| 436 |
+
module_idx, gene_idx, gene_counts = self._build_module_gene_index()
|
| 437 |
+
self.register_buffer("agg_module_idx", module_idx)
|
| 438 |
+
self.register_buffer("agg_gene_idx", gene_idx)
|
| 439 |
+
self.register_buffer("agg_gene_counts", gene_counts)
|
| 440 |
+
else:
|
| 441 |
+
self.agg_module_idx = None
|
| 442 |
+
self.agg_gene_idx = None
|
| 443 |
+
self.agg_gene_counts = None
|
| 444 |
+
|
| 445 |
+
self.classifier = ScGPTClsDecoder(
|
| 446 |
+
d_model=self.embed_dim,
|
| 447 |
+
n_cls=num_classes,
|
| 448 |
+
nlayers=cls_decoder_layers,
|
| 449 |
+
)
|
| 450 |
+
|
| 451 |
+
def load_state_dict(self, state_dict, strict: bool = True, assign: bool = False):
|
| 452 |
+
"""Accept current module names and older serialized backbone names."""
|
| 453 |
+
return super().load_state_dict(
|
| 454 |
+
migrate_legacy_module_state_dict(state_dict),
|
| 455 |
+
strict=strict,
|
| 456 |
+
assign=assign,
|
| 457 |
+
)
|
| 458 |
+
|
| 459 |
+
def _build_module_gene_index(self) -> Tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
|
| 460 |
+
module_indices = []
|
| 461 |
+
gene_indices = []
|
| 462 |
+
gene_counts = torch.zeros(self.num_genes, dtype=torch.long)
|
| 463 |
+
for module_idx, module_genes in enumerate(self.module_sets):
|
| 464 |
+
for gene_idx in module_genes:
|
| 465 |
+
if 0 <= gene_idx < self.num_genes:
|
| 466 |
+
module_indices.append(module_idx)
|
| 467 |
+
gene_indices.append(gene_idx)
|
| 468 |
+
gene_counts[gene_idx] += 1
|
| 469 |
+
return (
|
| 470 |
+
torch.tensor(module_indices, dtype=torch.long),
|
| 471 |
+
torch.tensor(gene_indices, dtype=torch.long),
|
| 472 |
+
gene_counts,
|
| 473 |
+
)
|
| 474 |
+
|
| 475 |
+
def _restore_module_order(self, latent: torch.Tensor, ids_restore: torch.Tensor) -> torch.Tensor:
|
| 476 |
+
if self.use_cls:
|
| 477 |
+
module_tokens = latent[:, 1:, :]
|
| 478 |
+
else:
|
| 479 |
+
module_tokens = latent
|
| 480 |
+
gather_index = ids_restore.unsqueeze(-1).expand(-1, -1, module_tokens.shape[-1])
|
| 481 |
+
return torch.gather(module_tokens, dim=1, index=gather_index)
|
| 482 |
+
|
| 483 |
+
def _module_to_gene(self, module_tokens: torch.Tensor) -> torch.Tensor:
|
| 484 |
+
batch_size, _, dim = module_tokens.shape
|
| 485 |
+
gene_feats = torch.zeros(
|
| 486 |
+
batch_size,
|
| 487 |
+
self.num_genes,
|
| 488 |
+
dim,
|
| 489 |
+
device=module_tokens.device,
|
| 490 |
+
dtype=module_tokens.dtype,
|
| 491 |
+
)
|
| 492 |
+
global_mean = module_tokens.mean(dim=1)
|
| 493 |
+
|
| 494 |
+
base_counts = self.agg_gene_counts.to(device=module_tokens.device, dtype=module_tokens.dtype)
|
| 495 |
+
gene_counts = base_counts.unsqueeze(0).expand(batch_size, -1).clone()
|
| 496 |
+
|
| 497 |
+
static_module_tokens = module_tokens[:, :self.base_module_count, :]
|
| 498 |
+
if self.agg_module_idx.numel() > 0:
|
| 499 |
+
pair_module_feats = static_module_tokens[:, self.agg_module_idx, :]
|
| 500 |
+
gene_idx_expand = self.agg_gene_idx.view(1, -1, 1).expand(batch_size, -1, dim)
|
| 501 |
+
gene_feats.scatter_add_(1, gene_idx_expand, pair_module_feats)
|
| 502 |
+
|
| 503 |
+
zero_mask = gene_counts == 0
|
| 504 |
+
gene_feats = gene_feats / gene_counts.clamp(min=1.0).unsqueeze(-1)
|
| 505 |
+
gene_feats = torch.where(zero_mask.unsqueeze(-1), global_mean.unsqueeze(1), gene_feats)
|
| 506 |
+
return gene_feats
|
| 507 |
+
|
| 508 |
+
def get_selector_sparsity_loss(self) -> torch.Tensor:
|
| 509 |
+
if self._selector_scores is not None:
|
| 510 |
+
return self._selector_scores.mean()
|
| 511 |
+
return torch.tensor(0.0)
|
| 512 |
+
|
| 513 |
+
def forward(self, x_model: torch.Tensor) -> torch.Tensor:
|
| 514 |
+
latent, _, ids_restore, _ = self.backbone.forward_encoder(x_model, mask_ratio=0.0)
|
| 515 |
+
|
| 516 |
+
if self.head_type == "cls":
|
| 517 |
+
cell_emb = latent[:, 0, :]
|
| 518 |
+
else:
|
| 519 |
+
module_tokens = self._restore_module_order(latent, ids_restore)
|
| 520 |
+
if self.module_selector is not None:
|
| 521 |
+
module_tokens, self._selector_scores = self.module_selector(
|
| 522 |
+
module_tokens, tau=self.selector_tau, hard=not self.training,
|
| 523 |
+
)
|
| 524 |
+
if self.head_type == "module_mean":
|
| 525 |
+
cell_emb = module_tokens.mean(dim=1)
|
| 526 |
+
elif self.head_type == "max":
|
| 527 |
+
cell_emb = module_tokens.amax(dim=1)
|
| 528 |
+
elif self.head_type == "attn_pool":
|
| 529 |
+
cell_emb, _ = self.pooler(module_tokens)
|
| 530 |
+
elif self.head_type == "gene_mean":
|
| 531 |
+
gene_tokens = self._module_to_gene(module_tokens)
|
| 532 |
+
cell_emb = gene_tokens.mean(dim=1)
|
| 533 |
+
else:
|
| 534 |
+
raise ValueError(f"Unsupported head_type: {self.head_type}")
|
| 535 |
+
|
| 536 |
+
cell_emb = self.output_dropout(cell_emb)
|
| 537 |
+
return self.classifier(cell_emb)
|
| 538 |
+
|
| 539 |
+
|
| 540 |
+
def get_encoder_blocks(backbone: "PerformerEncoderOnly_Module") -> List[nn.Module]:
|
| 541 |
+
net = backbone.encoder.net
|
| 542 |
+
if hasattr(net, "layers"):
|
| 543 |
+
return list(net.layers)
|
| 544 |
+
if hasattr(net, "blocks"):
|
| 545 |
+
return list(net.blocks)
|
| 546 |
+
raise AttributeError("Cannot find encoder blocks on Performer backbone")
|
| 547 |
+
|
| 548 |
+
|
| 549 |
+
def set_requires_grad(module: nn.Module, flag: bool) -> None:
|
| 550 |
+
for param in module.parameters():
|
| 551 |
+
param.requires_grad = flag
|
| 552 |
+
|
| 553 |
+
|
| 554 |
+
def configure_backbone_trainability(
|
| 555 |
+
backbone: "PerformerEncoderOnly_Module",
|
| 556 |
+
finetune_mode: str,
|
| 557 |
+
unfreeze_last_n: int,
|
| 558 |
+
train_pos_emb: bool,
|
| 559 |
+
train_module_embed: bool,
|
| 560 |
+
) -> Dict[str, int]:
|
| 561 |
+
set_requires_grad(backbone, False)
|
| 562 |
+
|
| 563 |
+
if finetune_mode == "full":
|
| 564 |
+
# "full" here means full finetuning of the encoder pathway used by
|
| 565 |
+
# downstream classification, not the MAE-style reconstruction decoder.
|
| 566 |
+
set_requires_grad(backbone.module_embed, True)
|
| 567 |
+
set_requires_grad(backbone.encoder, True)
|
| 568 |
+
set_requires_grad(backbone.encoder_norm, True)
|
| 569 |
+
backbone.pos_emb.requires_grad = True
|
| 570 |
+
if getattr(backbone, "cls_token", None) is not None:
|
| 571 |
+
backbone.cls_token.requires_grad = True
|
| 572 |
+
elif finetune_mode == "last_n":
|
| 573 |
+
blocks = get_encoder_blocks(backbone)
|
| 574 |
+
if unfreeze_last_n <= 0:
|
| 575 |
+
raise ValueError("unfreeze_last_n must be > 0 when finetune_mode='last_n'")
|
| 576 |
+
for block in blocks[-min(unfreeze_last_n, len(blocks)):]:
|
| 577 |
+
set_requires_grad(block, True)
|
| 578 |
+
set_requires_grad(backbone.encoder_norm, True)
|
| 579 |
+
elif finetune_mode != "frozen":
|
| 580 |
+
raise ValueError(f"Unsupported finetune_mode: {finetune_mode}")
|
| 581 |
+
|
| 582 |
+
if train_pos_emb:
|
| 583 |
+
backbone.pos_emb.requires_grad = True
|
| 584 |
+
if getattr(backbone, "cls_token", None) is not None:
|
| 585 |
+
backbone.cls_token.requires_grad = True
|
| 586 |
+
|
| 587 |
+
if train_module_embed:
|
| 588 |
+
set_requires_grad(backbone.module_embed, True)
|
| 589 |
+
|
| 590 |
+
if getattr(backbone, 'family_emb', None) is not None:
|
| 591 |
+
backbone.family_emb.requires_grad = True
|
| 592 |
+
if getattr(backbone, 'module_gate', None) is not None:
|
| 593 |
+
set_requires_grad(backbone.module_gate, True)
|
| 594 |
+
|
| 595 |
+
total_params = sum(p.numel() for p in backbone.parameters())
|
| 596 |
+
trainable_params = sum(p.numel() for p in backbone.parameters() if p.requires_grad)
|
| 597 |
+
return {
|
| 598 |
+
"total": total_params,
|
| 599 |
+
"trainable": trainable_params,
|
| 600 |
+
}
|
| 601 |
+
|
| 602 |
+
|
| 603 |
+
def build_optimizer(
|
| 604 |
+
model: PerformerModuleCellClassifier,
|
| 605 |
+
lr_head: float,
|
| 606 |
+
lr_backbone: float,
|
| 607 |
+
weight_decay: float,
|
| 608 |
+
) -> torch.optim.Optimizer:
|
| 609 |
+
backbone_params = []
|
| 610 |
+
head_params = []
|
| 611 |
+
new_module_params = []
|
| 612 |
+
for name, param in model.named_parameters():
|
| 613 |
+
if not param.requires_grad:
|
| 614 |
+
continue
|
| 615 |
+
if 'family_emb' in name or 'module_gate' in name or 'module_selector' in name:
|
| 616 |
+
new_module_params.append(param)
|
| 617 |
+
elif name.startswith("backbone."):
|
| 618 |
+
backbone_params.append(param)
|
| 619 |
+
else:
|
| 620 |
+
head_params.append(param)
|
| 621 |
+
|
| 622 |
+
param_groups = []
|
| 623 |
+
if backbone_params:
|
| 624 |
+
param_groups.append({"params": backbone_params, "lr": lr_backbone})
|
| 625 |
+
if head_params:
|
| 626 |
+
param_groups.append({"params": head_params, "lr": lr_head})
|
| 627 |
+
if new_module_params:
|
| 628 |
+
param_groups.append({"params": new_module_params, "lr": lr_head})
|
| 629 |
+
|
| 630 |
+
if not param_groups:
|
| 631 |
+
raise RuntimeError("No trainable parameters found")
|
| 632 |
+
|
| 633 |
+
return torch.optim.AdamW(param_groups, weight_decay=weight_decay, betas=(0.9, 0.999))
|
| 634 |
+
|
| 635 |
+
|
| 636 |
+
def resolve_batch_sizes(args, world_size: int) -> Tuple[int, int, int]:
|
| 637 |
+
if args.global_batch_size is not None:
|
| 638 |
+
denom = world_size * args.grad_accum_steps
|
| 639 |
+
if args.global_batch_size % denom != 0:
|
| 640 |
+
raise ValueError(
|
| 641 |
+
f"global_batch_size={args.global_batch_size} must be divisible by "
|
| 642 |
+
f"world_size({world_size}) * grad_accum_steps({args.grad_accum_steps})"
|
| 643 |
+
)
|
| 644 |
+
train_batch_size = args.global_batch_size // denom
|
| 645 |
+
else:
|
| 646 |
+
train_batch_size = args.batch_size
|
| 647 |
+
|
| 648 |
+
if train_batch_size <= 0:
|
| 649 |
+
raise ValueError(f"Resolved per-device train batch size must be > 0, got {train_batch_size}")
|
| 650 |
+
|
| 651 |
+
if args.global_eval_batch_size is not None:
|
| 652 |
+
if args.global_eval_batch_size % world_size != 0:
|
| 653 |
+
raise ValueError(
|
| 654 |
+
f"global_eval_batch_size={args.global_eval_batch_size} must be divisible by world_size({world_size})"
|
| 655 |
+
)
|
| 656 |
+
eval_batch_size = args.global_eval_batch_size // world_size
|
| 657 |
+
elif args.eval_batch_size is not None:
|
| 658 |
+
eval_batch_size = args.eval_batch_size
|
| 659 |
+
else:
|
| 660 |
+
eval_batch_size = train_batch_size
|
| 661 |
+
|
| 662 |
+
if eval_batch_size <= 0:
|
| 663 |
+
raise ValueError(f"Resolved per-device eval batch size must be > 0, got {eval_batch_size}")
|
| 664 |
+
|
| 665 |
+
effective_global_batch = train_batch_size * world_size * args.grad_accum_steps
|
| 666 |
+
return train_batch_size, eval_batch_size, effective_global_batch
|
| 667 |
+
|
| 668 |
+
|
| 669 |
+
def infer_model_config(args, checkpoint: dict, pkl_config: Optional[dict], module_data: List[List[int]]) -> dict:
|
| 670 |
+
from models_performer_module import sanitize_performer_module_config
|
| 671 |
+
|
| 672 |
+
if pkl_config is not None and isinstance(pkl_config, dict):
|
| 673 |
+
config = sanitize_performer_module_config(
|
| 674 |
+
pkl_config,
|
| 675 |
+
source=str(args.config_pkl),
|
| 676 |
+
log_fn=logger.info,
|
| 677 |
+
)
|
| 678 |
+
logger.info("Using config from PKL: %s", args.config_pkl)
|
| 679 |
+
if "module_sets" in config:
|
| 680 |
+
module_data = config["module_sets"]
|
| 681 |
+
elif isinstance(checkpoint, dict) and "config" in checkpoint:
|
| 682 |
+
config = sanitize_performer_module_config(
|
| 683 |
+
checkpoint["config"],
|
| 684 |
+
source=str(resolve_path(args.performer_checkpoint)),
|
| 685 |
+
log_fn=logger.info,
|
| 686 |
+
)
|
| 687 |
+
logger.info("Using config from checkpoint")
|
| 688 |
+
else:
|
| 689 |
+
raise ValueError(
|
| 690 |
+
"Backbone config was not found in config_pkl or checkpoint['config']. "
|
| 691 |
+
"This script now expects pretrained structural args to be restored automatically."
|
| 692 |
+
)
|
| 693 |
+
|
| 694 |
+
return {
|
| 695 |
+
"module_sets": module_data,
|
| 696 |
+
"num_genes": config["num_genes"],
|
| 697 |
+
"base_dim": config["base_dim"],
|
| 698 |
+
"enc_depth": config["enc_depth"],
|
| 699 |
+
"enc_heads": config["enc_heads"],
|
| 700 |
+
"enc_dim_head": config["enc_dim_head"],
|
| 701 |
+
"gene_embed_dim": config.get("gene_embed_dim", 200),
|
| 702 |
+
"fusion_method": config.get("fusion_method", "multiply"),
|
| 703 |
+
"use_gene2vec": config.get("use_gene2vec", True),
|
| 704 |
+
"loss_mode": config.get("loss_mode", "gene_mean"),
|
| 705 |
+
"recon_target_level": config.get("recon_target_level", "gene"),
|
| 706 |
+
"pretrain_arch": config.get("pretrain_arch", "mae"),
|
| 707 |
+
"mask_style": config.get("mask_style", "drop"),
|
| 708 |
+
"mask_token_mode": config.get("mask_token_mode", "learned"),
|
| 709 |
+
"pos_embed_mode": config.get("pos_embed_mode", "module_id"),
|
| 710 |
+
"decoder_type": config.get("decoder_type", "simple"),
|
| 711 |
+
"decoder_gene_agg": config.get("decoder_gene_agg", False),
|
| 712 |
+
"dec_embed_dim": config.get("dec_embed_dim", config["base_dim"]),
|
| 713 |
+
"dec_depth": config.get("dec_depth", 4),
|
| 714 |
+
"dec_heads": config.get("dec_heads", 8),
|
| 715 |
+
"dec_dim_head": config.get("dec_dim_head", 64),
|
| 716 |
+
"use_cls_token": bool(config.get("use_cls_token", False)),
|
| 717 |
+
"cls_loss_weight": float(config.get("cls_loss_weight", 0.0)),
|
| 718 |
+
"cls_head_hidden_dim": int(config.get("cls_head_hidden_dim", 0)),
|
| 719 |
+
}
|
| 720 |
+
|
| 721 |
+
|
| 722 |
+
def compute_module_family_ids(module_data_dir: str) -> List[int]:
|
| 723 |
+
"""Read merged_modules.json, return family ID (0-3) for each module."""
|
| 724 |
+
merged_path = Path(module_data_dir) / "merged_modules.json"
|
| 725 |
+
with open(merged_path) as f:
|
| 726 |
+
modules = json.load(f)
|
| 727 |
+
PREFIXES = [
|
| 728 |
+
("module1_hallmarks", 0),
|
| 729 |
+
("module2_reactome", 1),
|
| 730 |
+
("module3_hotspot", 2),
|
| 731 |
+
("module3_processed", 3),
|
| 732 |
+
]
|
| 733 |
+
family_ids = []
|
| 734 |
+
for name in modules.keys():
|
| 735 |
+
fid = 0
|
| 736 |
+
for prefix, idx in PREFIXES:
|
| 737 |
+
if name.startswith(prefix):
|
| 738 |
+
fid = idx
|
| 739 |
+
break
|
| 740 |
+
family_ids.append(fid)
|
| 741 |
+
return family_ids
|
| 742 |
+
|
| 743 |
+
|
| 744 |
+
def load_backbone(args, device: torch.device) -> Tuple["PerformerEncoderOnly_Module", List[List[int]], dict]:
|
| 745 |
+
from models_performer_module import PerformerEncoderOnly_Module
|
| 746 |
+
from module_dataset import load_module_data
|
| 747 |
+
|
| 748 |
+
config_pkl = None
|
| 749 |
+
config_path = resolve_path(args.config_pkl)
|
| 750 |
+
if config_path.exists():
|
| 751 |
+
with open(config_path, "rb") as handle:
|
| 752 |
+
config_pkl = migrate_legacy_module_config(pickle.load(handle))
|
| 753 |
+
else:
|
| 754 |
+
logger.warning("Config PKL not found at %s; fallback to checkpoint config", config_path)
|
| 755 |
+
|
| 756 |
+
ckpt_path = resolve_path(args.performer_checkpoint)
|
| 757 |
+
checkpoint = torch.load(ckpt_path, map_location=device)
|
| 758 |
+
|
| 759 |
+
module_data = None
|
| 760 |
+
if isinstance(config_pkl, dict) and "module_sets" in config_pkl:
|
| 761 |
+
module_data = config_pkl["module_sets"]
|
| 762 |
+
logger.info("Using module_sets from PKL (%d modules)", len(module_data))
|
| 763 |
+
elif isinstance(checkpoint, dict) and isinstance(checkpoint.get("config"), dict) and "module_sets" in checkpoint["config"]:
|
| 764 |
+
module_data = checkpoint["config"]["module_sets"]
|
| 765 |
+
logger.info("Using module_sets from checkpoint config (%d modules)", len(module_data))
|
| 766 |
+
else:
|
| 767 |
+
if not args.module_data_dir:
|
| 768 |
+
raise ValueError("module_data_dir is required when module_sets are not stored in the PKL/checkpoint config")
|
| 769 |
+
module_data = load_module_data(
|
| 770 |
+
module_data_dir=str(resolve_path(args.module_data_dir)),
|
| 771 |
+
gene_list_path=str(resolve_path(args.gene_list_path)),
|
| 772 |
+
gene_mapping_path=str(resolve_path(args.gene_mapping_path)),
|
| 773 |
+
verbose=True,
|
| 774 |
+
)
|
| 775 |
+
|
| 776 |
+
if args.shuffle_module_genes:
|
| 777 |
+
logger.info("Shuffling genes across modules with seed=%d", args.shuffle_seed)
|
| 778 |
+
rng = random.Random(args.shuffle_seed)
|
| 779 |
+
module_sizes = [len(module) for module in module_data]
|
| 780 |
+
all_genes = [gene for module in module_data for gene in module]
|
| 781 |
+
rng.shuffle(all_genes)
|
| 782 |
+
shuffled = []
|
| 783 |
+
start = 0
|
| 784 |
+
for size in module_sizes:
|
| 785 |
+
shuffled.append(all_genes[start:start + size])
|
| 786 |
+
start += size
|
| 787 |
+
module_data = shuffled
|
| 788 |
+
|
| 789 |
+
model_config = infer_model_config(args, checkpoint, config_pkl, module_data)
|
| 790 |
+
module_data = model_config["module_sets"]
|
| 791 |
+
|
| 792 |
+
if "model_state_dict" in checkpoint:
|
| 793 |
+
state_dict = checkpoint["model_state_dict"]
|
| 794 |
+
elif "model" in checkpoint:
|
| 795 |
+
state_dict = checkpoint["model"]
|
| 796 |
+
else:
|
| 797 |
+
state_dict = checkpoint
|
| 798 |
+
|
| 799 |
+
has_cls_in_checkpoint = "cls_token" in state_dict
|
| 800 |
+
use_cls_token = has_cls_in_checkpoint or model_config["use_cls_token"]
|
| 801 |
+
|
| 802 |
+
logger.info(
|
| 803 |
+
"Backbone config: base_dim=%d depth=%d heads=%d modules=%d cls=%s",
|
| 804 |
+
model_config["base_dim"],
|
| 805 |
+
model_config["enc_depth"],
|
| 806 |
+
model_config["enc_heads"],
|
| 807 |
+
len(module_data),
|
| 808 |
+
use_cls_token,
|
| 809 |
+
)
|
| 810 |
+
|
| 811 |
+
module_family_ids = None
|
| 812 |
+
if getattr(args, "use_family_emb", False):
|
| 813 |
+
pdd = getattr(args, "module_data_dir", None)
|
| 814 |
+
if pdd:
|
| 815 |
+
module_family_ids = compute_module_family_ids(str(resolve_path(pdd)))
|
| 816 |
+
logger.info("Family embedding enabled: %d module family IDs computed", len(module_family_ids))
|
| 817 |
+
|
| 818 |
+
backbone = PerformerEncoderOnly_Module(
|
| 819 |
+
module_sets=module_data,
|
| 820 |
+
num_genes=model_config["num_genes"],
|
| 821 |
+
max_seq_len=len(module_data),
|
| 822 |
+
base_dim=model_config["base_dim"],
|
| 823 |
+
enc_depth=model_config["enc_depth"],
|
| 824 |
+
enc_heads=model_config["enc_heads"],
|
| 825 |
+
enc_dim_head=model_config["enc_dim_head"],
|
| 826 |
+
mask_ratio=0.75,
|
| 827 |
+
gene2vec_file=str(resolve_path(args.gene2vec_data_path)),
|
| 828 |
+
gene_embed_dim=model_config["gene_embed_dim"],
|
| 829 |
+
fusion_method=model_config["fusion_method"],
|
| 830 |
+
use_cls_token=use_cls_token,
|
| 831 |
+
cls_loss_weight=model_config["cls_loss_weight"] if use_cls_token else 0.0,
|
| 832 |
+
cls_head_hidden_dim=model_config["cls_head_hidden_dim"],
|
| 833 |
+
use_gene2vec=model_config["use_gene2vec"],
|
| 834 |
+
loss_mode=model_config["loss_mode"],
|
| 835 |
+
recon_target_level=model_config["recon_target_level"],
|
| 836 |
+
pretrain_arch=model_config["pretrain_arch"],
|
| 837 |
+
mask_style=model_config["mask_style"],
|
| 838 |
+
mask_token_mode=model_config["mask_token_mode"],
|
| 839 |
+
pos_embed_mode=model_config["pos_embed_mode"],
|
| 840 |
+
decoder_type=model_config["decoder_type"],
|
| 841 |
+
decoder_gene_agg=model_config["decoder_gene_agg"],
|
| 842 |
+
dec_embed_dim=model_config["dec_embed_dim"],
|
| 843 |
+
dec_depth=model_config["dec_depth"],
|
| 844 |
+
dec_heads=model_config["dec_heads"],
|
| 845 |
+
dec_dim_head=model_config["dec_dim_head"],
|
| 846 |
+
use_family_emb=getattr(args, "use_family_emb", False),
|
| 847 |
+
module_family_ids=module_family_ids,
|
| 848 |
+
use_module_gate=getattr(args, "use_module_gate", False),
|
| 849 |
+
)
|
| 850 |
+
|
| 851 |
+
filtered_state_dict = {
|
| 852 |
+
key: value
|
| 853 |
+
for key, value in state_dict.items()
|
| 854 |
+
if "go_conv" not in key and "G_go" not in key.lower()
|
| 855 |
+
}
|
| 856 |
+
missing_keys, unexpected_keys = backbone.load_state_dict(filtered_state_dict, strict=False)
|
| 857 |
+
expected_new = {"family_emb", "module_family_ids", "module_gate."}
|
| 858 |
+
real_missing = [k for k in missing_keys
|
| 859 |
+
if not any(k == e or k.startswith(e) for e in expected_new)]
|
| 860 |
+
if real_missing:
|
| 861 |
+
logger.warning("Unexpected missing keys: %s", real_missing[:10])
|
| 862 |
+
if unexpected_keys:
|
| 863 |
+
logger.warning("Unexpected keys: %s", unexpected_keys[:10])
|
| 864 |
+
|
| 865 |
+
backbone.to(device)
|
| 866 |
+
return backbone, module_data, model_config
|
| 867 |
+
|
| 868 |
+
|
| 869 |
+
def stratified_split(
|
| 870 |
+
labels: np.ndarray,
|
| 871 |
+
seed: int,
|
| 872 |
+
val_ratio: float,
|
| 873 |
+
test_ratio: float,
|
| 874 |
+
) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
|
| 875 |
+
indices = np.arange(len(labels))
|
| 876 |
+
test_split = StratifiedShuffleSplit(n_splits=1, test_size=test_ratio, random_state=seed)
|
| 877 |
+
train_val_idx, test_idx = next(test_split.split(indices, labels))
|
| 878 |
+
|
| 879 |
+
remaining_ratio = 1.0 - test_ratio
|
| 880 |
+
if remaining_ratio <= 0.0:
|
| 881 |
+
raise ValueError("test_ratio must be < 1")
|
| 882 |
+
val_ratio_adjusted = val_ratio / remaining_ratio
|
| 883 |
+
|
| 884 |
+
train_val_labels = labels[train_val_idx]
|
| 885 |
+
val_split = StratifiedShuffleSplit(n_splits=1, test_size=val_ratio_adjusted, random_state=seed)
|
| 886 |
+
train_sub_idx, val_sub_idx = next(val_split.split(train_val_idx, train_val_labels))
|
| 887 |
+
|
| 888 |
+
train_idx = train_val_idx[train_sub_idx]
|
| 889 |
+
val_idx = train_val_idx[val_sub_idx]
|
| 890 |
+
return train_idx, val_idx, test_idx
|
| 891 |
+
|
| 892 |
+
|
| 893 |
+
def stratified_train_val_split(
|
| 894 |
+
labels: np.ndarray,
|
| 895 |
+
seed: int,
|
| 896 |
+
val_ratio: float,
|
| 897 |
+
) -> Tuple[np.ndarray, np.ndarray]:
|
| 898 |
+
if not 0.0 < val_ratio < 1.0:
|
| 899 |
+
raise ValueError(f"val_ratio must be in (0, 1), got {val_ratio}")
|
| 900 |
+
indices = np.arange(len(labels))
|
| 901 |
+
val_split = StratifiedShuffleSplit(n_splits=1, test_size=val_ratio, random_state=seed)
|
| 902 |
+
train_idx, val_idx = next(val_split.split(indices, labels))
|
| 903 |
+
return train_idx, val_idx
|
| 904 |
+
|
| 905 |
+
|
| 906 |
+
@torch.no_grad()
|
| 907 |
+
def evaluate(
|
| 908 |
+
model: PerformerModuleCellClassifier,
|
| 909 |
+
data_loader: DataLoader,
|
| 910 |
+
preprocessor: DataPreprocessor,
|
| 911 |
+
reindex_state: dict,
|
| 912 |
+
criterion: nn.Module,
|
| 913 |
+
device: torch.device,
|
| 914 |
+
) -> dict:
|
| 915 |
+
model.eval()
|
| 916 |
+
loss_sum = 0.0
|
| 917 |
+
sample_count = 0
|
| 918 |
+
preds_all = []
|
| 919 |
+
labels_all = []
|
| 920 |
+
|
| 921 |
+
for x, y in data_loader:
|
| 922 |
+
x = x.to(device)
|
| 923 |
+
y = y.to(device)
|
| 924 |
+
x_model = preprocessor.reindex_tensor_to_model_space(x, reindex_state=reindex_state)
|
| 925 |
+
logits = model(x_model)
|
| 926 |
+
loss = criterion(logits, y)
|
| 927 |
+
loss_sum += loss.item() * y.size(0)
|
| 928 |
+
sample_count += y.size(0)
|
| 929 |
+
preds_all.append(logits.argmax(dim=-1).cpu().numpy())
|
| 930 |
+
labels_all.append(y.cpu().numpy())
|
| 931 |
+
|
| 932 |
+
preds = np.concatenate(preds_all) if preds_all else np.empty((0,), dtype=np.int64)
|
| 933 |
+
labels = np.concatenate(labels_all) if labels_all else np.empty((0,), dtype=np.int64)
|
| 934 |
+
preds = all_gather_numpy(preds)
|
| 935 |
+
labels = all_gather_numpy(labels)
|
| 936 |
+
loss_sum = reduce_sum(loss_sum, device)
|
| 937 |
+
sample_count = int(reduce_sum(sample_count, device))
|
| 938 |
+
return {
|
| 939 |
+
"loss": float(loss_sum / max(sample_count, 1)),
|
| 940 |
+
"acc": float(accuracy_score(labels, preds)),
|
| 941 |
+
"macro_f1": float(f1_score(labels, preds, average="macro")),
|
| 942 |
+
"preds": preds,
|
| 943 |
+
"labels": labels,
|
| 944 |
+
}
|
| 945 |
+
|
| 946 |
+
|
| 947 |
+
def train_one_epoch(
|
| 948 |
+
model: PerformerModuleCellClassifier,
|
| 949 |
+
data_loader: DataLoader,
|
| 950 |
+
preprocessor: DataPreprocessor,
|
| 951 |
+
reindex_state: dict,
|
| 952 |
+
criterion: nn.Module,
|
| 953 |
+
optimizer: torch.optim.Optimizer,
|
| 954 |
+
device: torch.device,
|
| 955 |
+
grad_clip: float,
|
| 956 |
+
grad_accum_steps: int,
|
| 957 |
+
use_ddp: bool,
|
| 958 |
+
selector_sparsity_weight: float = 0.0,
|
| 959 |
+
) -> dict:
|
| 960 |
+
model.train()
|
| 961 |
+
running_loss = 0.0
|
| 962 |
+
sample_count = 0
|
| 963 |
+
preds_all = []
|
| 964 |
+
labels_all = []
|
| 965 |
+
optimizer.zero_grad(set_to_none=True)
|
| 966 |
+
|
| 967 |
+
num_steps = len(data_loader)
|
| 968 |
+
for step, (x, y) in enumerate(data_loader, start=1):
|
| 969 |
+
x = x.to(device)
|
| 970 |
+
y = y.to(device)
|
| 971 |
+
x_model = preprocessor.reindex_tensor_to_model_space(x, reindex_state=reindex_state)
|
| 972 |
+
|
| 973 |
+
sync_context = (
|
| 974 |
+
model.no_sync()
|
| 975 |
+
if use_ddp and grad_accum_steps > 1 and step % grad_accum_steps != 0
|
| 976 |
+
else contextlib.nullcontext()
|
| 977 |
+
)
|
| 978 |
+
with sync_context:
|
| 979 |
+
logits = model(x_model)
|
| 980 |
+
loss = criterion(logits, y)
|
| 981 |
+
if selector_sparsity_weight > 0:
|
| 982 |
+
model_raw = model.module if use_ddp else model
|
| 983 |
+
loss = loss + selector_sparsity_weight * model_raw.get_selector_sparsity_loss()
|
| 984 |
+
(loss / grad_accum_steps).backward()
|
| 985 |
+
|
| 986 |
+
if step % grad_accum_steps == 0 or step == num_steps:
|
| 987 |
+
if grad_clip > 0:
|
| 988 |
+
torch.nn.utils.clip_grad_norm_(model.parameters(), grad_clip)
|
| 989 |
+
optimizer.step()
|
| 990 |
+
optimizer.zero_grad(set_to_none=True)
|
| 991 |
+
|
| 992 |
+
running_loss += loss.item() * y.size(0)
|
| 993 |
+
sample_count += y.size(0)
|
| 994 |
+
preds_all.append(logits.argmax(dim=-1).detach().cpu().numpy())
|
| 995 |
+
labels_all.append(y.detach().cpu().numpy())
|
| 996 |
+
|
| 997 |
+
preds = np.concatenate(preds_all) if preds_all else np.empty((0,), dtype=np.int64)
|
| 998 |
+
labels = np.concatenate(labels_all) if labels_all else np.empty((0,), dtype=np.int64)
|
| 999 |
+
preds = all_gather_numpy(preds)
|
| 1000 |
+
labels = all_gather_numpy(labels)
|
| 1001 |
+
running_loss = reduce_sum(running_loss, device)
|
| 1002 |
+
sample_count = int(reduce_sum(sample_count, device))
|
| 1003 |
+
return {
|
| 1004 |
+
"loss": float(running_loss / max(sample_count, 1)),
|
| 1005 |
+
"acc": float(accuracy_score(labels, preds)),
|
| 1006 |
+
"macro_f1": float(f1_score(labels, preds, average="macro")),
|
| 1007 |
+
}
|
| 1008 |
+
|
| 1009 |
+
|
| 1010 |
+
def save_json(path: Path, payload: dict) -> None:
|
| 1011 |
+
with open(path, "w", encoding="utf-8") as handle:
|
| 1012 |
+
json.dump(payload, handle, indent=2, ensure_ascii=False)
|
| 1013 |
+
|
| 1014 |
+
|
| 1015 |
+
def build_confusion_tables(
|
| 1016 |
+
labels: np.ndarray,
|
| 1017 |
+
preds: np.ndarray,
|
| 1018 |
+
class_names: Sequence[str],
|
| 1019 |
+
) -> Tuple[np.ndarray, np.ndarray, pd.DataFrame, pd.DataFrame]:
|
| 1020 |
+
label_ids = np.arange(len(class_names), dtype=np.int64)
|
| 1021 |
+
conf = confusion_matrix(labels, preds, labels=label_ids)
|
| 1022 |
+
row_sums = conf.sum(axis=1, keepdims=True)
|
| 1023 |
+
conf_norm = np.divide(
|
| 1024 |
+
conf.astype(np.float64),
|
| 1025 |
+
row_sums,
|
| 1026 |
+
out=np.zeros_like(conf, dtype=np.float64),
|
| 1027 |
+
where=row_sums != 0,
|
| 1028 |
+
)
|
| 1029 |
+
conf_df = pd.DataFrame(conf, index=class_names, columns=class_names)
|
| 1030 |
+
conf_norm_df = pd.DataFrame(conf_norm, index=class_names, columns=class_names)
|
| 1031 |
+
return conf, conf_norm, conf_df, conf_norm_df
|
| 1032 |
+
|
| 1033 |
+
|
| 1034 |
+
def apply_dataset_preset(args) -> None:
|
| 1035 |
+
if args.dataset_preset == "none":
|
| 1036 |
+
return
|
| 1037 |
+
|
| 1038 |
+
if args.dataset_preset == "ms":
|
| 1039 |
+
default_single_data_path = "../../cell_annotation/cellxgene80k_balanced_10types.h5ad"
|
| 1040 |
+
default_train_path = "../../cell_annotation/data/c_data.h5ad"
|
| 1041 |
+
default_test_path = "../../cell_annotation/data/filtered_ms_adata.h5ad"
|
| 1042 |
+
|
| 1043 |
+
if args.data_path == default_single_data_path and args.train_data_path is None and args.test_data_path is None:
|
| 1044 |
+
args.train_data_path = default_train_path
|
| 1045 |
+
args.test_data_path = default_test_path
|
| 1046 |
+
|
| 1047 |
+
if args.label_column == "cell_type_anno":
|
| 1048 |
+
args.label_column = "celltype"
|
| 1049 |
+
if args.test_label_column is None:
|
| 1050 |
+
args.test_label_column = "celltype"
|
| 1051 |
+
if args.gene_name_column == "auto":
|
| 1052 |
+
args.gene_name_column = "gene_name"
|
| 1053 |
+
if args.min_genes == 10:
|
| 1054 |
+
args.min_genes = 0
|
| 1055 |
+
if args.min_cells == 10:
|
| 1056 |
+
args.min_cells = 0
|
| 1057 |
+
|
| 1058 |
+
logger.info(
|
| 1059 |
+
"Applied dataset preset 'ms': train_data_path=%s test_data_path=%s label_column=%s "
|
| 1060 |
+
"test_label_column=%s gene_name_column=%s min_genes=%d min_cells=%d",
|
| 1061 |
+
args.train_data_path,
|
| 1062 |
+
args.test_data_path,
|
| 1063 |
+
args.label_column,
|
| 1064 |
+
args.test_label_column,
|
| 1065 |
+
args.gene_name_column,
|
| 1066 |
+
args.min_genes,
|
| 1067 |
+
args.min_cells,
|
| 1068 |
+
)
|
| 1069 |
+
return
|
| 1070 |
+
|
| 1071 |
+
raise ValueError(f"Unsupported dataset_preset: {args.dataset_preset}")
|
| 1072 |
+
|
| 1073 |
+
|
| 1074 |
+
def parse_args():
|
| 1075 |
+
parser = argparse.ArgumentParser(description="End-to-end fine-tuning for Performer Module cell annotation")
|
| 1076 |
+
|
| 1077 |
+
parser.add_argument("--seed", type=int, default=2021)
|
| 1078 |
+
parser.add_argument("--device", type=str, default="cuda")
|
| 1079 |
+
parser.add_argument("--batch_size", type=int, default=32, help="Per-device train batch size.")
|
| 1080 |
+
parser.add_argument("--eval_batch_size", type=int, default=None, help="Per-device eval batch size.")
|
| 1081 |
+
parser.add_argument("--global_batch_size", type=int, default=None, help="Effective global train batch size.")
|
| 1082 |
+
parser.add_argument("--global_eval_batch_size", type=int, default=None, help="Effective global eval batch size.")
|
| 1083 |
+
parser.add_argument("--grad_accum_steps", type=int, default=1)
|
| 1084 |
+
parser.add_argument("--num_workers", type=int, default=4)
|
| 1085 |
+
parser.add_argument("--prefetch_factor", type=int, default=2)
|
| 1086 |
+
parser.add_argument("--epochs", type=int, default=50)
|
| 1087 |
+
parser.add_argument("--patience", type=int, default=10)
|
| 1088 |
+
parser.add_argument("--grad_clip", type=float, default=1.0)
|
| 1089 |
+
|
| 1090 |
+
parser.add_argument("--lr_head", type=float, default=1e-4)
|
| 1091 |
+
parser.add_argument("--lr_backbone", type=float, default=1e-5)
|
| 1092 |
+
parser.add_argument("--weight_decay", type=float, default=0.01)
|
| 1093 |
+
parser.add_argument("--label_smoothing", type=float, default=0.1)
|
| 1094 |
+
parser.add_argument(
|
| 1095 |
+
"--dropout",
|
| 1096 |
+
type=float,
|
| 1097 |
+
default=0.2,
|
| 1098 |
+
help="Shared dropout applied to pooled cell embeddings before classification.",
|
| 1099 |
+
)
|
| 1100 |
+
parser.add_argument("--pool_hidden_dim", type=int, default=0)
|
| 1101 |
+
parser.add_argument("--cls_decoder_layers", type=int, default=3)
|
| 1102 |
+
|
| 1103 |
+
parser.add_argument(
|
| 1104 |
+
"--head_type",
|
| 1105 |
+
type=str,
|
| 1106 |
+
default="attn_pool",
|
| 1107 |
+
choices=["module_mean", "max", "gene_mean", "attn_pool", "cls"],
|
| 1108 |
+
)
|
| 1109 |
+
parser.add_argument(
|
| 1110 |
+
"--finetune_mode",
|
| 1111 |
+
type=str,
|
| 1112 |
+
default="last_n",
|
| 1113 |
+
choices=["frozen", "last_n", "full"],
|
| 1114 |
+
)
|
| 1115 |
+
parser.add_argument("--unfreeze_last_n", type=int, default=1)
|
| 1116 |
+
parser.add_argument("--train_pos_emb", action="store_true")
|
| 1117 |
+
parser.add_argument("--train_module_embed", action="store_true")
|
| 1118 |
+
parser.add_argument("--use_family_emb", action="store_true",
|
| 1119 |
+
help="Add family-type embedding (Hallmarks/Reactome/Hotspot/WGCNA)")
|
| 1120 |
+
parser.add_argument("--use_module_gate", action="store_true",
|
| 1121 |
+
help="Add learnable sparse gate per module")
|
| 1122 |
+
parser.add_argument("--use_module_selector", action="store_true",
|
| 1123 |
+
help="Dynamic module selector with sparsity loss (soft train, hard inference)")
|
| 1124 |
+
parser.add_argument("--selector_sparsity_weight", type=float, default=0.1,
|
| 1125 |
+
help="Lambda for sparsity loss on module selector scores")
|
| 1126 |
+
parser.add_argument("--selector_tau", type=float, default=0.5,
|
| 1127 |
+
help="Score threshold for hard module selection during inference")
|
| 1128 |
+
parser.add_argument("--dataset_preset", type=str, default="none", choices=["none", "ms"])
|
| 1129 |
+
|
| 1130 |
+
parser.add_argument(
|
| 1131 |
+
"--data_path",
|
| 1132 |
+
type=str,
|
| 1133 |
+
default="../../cell_annotation/cellxgene80k_balanced_10types.h5ad",
|
| 1134 |
+
)
|
| 1135 |
+
parser.add_argument("--train_data_path", type=str, default=None)
|
| 1136 |
+
parser.add_argument("--test_data_path", type=str, default=None)
|
| 1137 |
+
parser.add_argument("--label_column", type=str, default="cell_type_anno")
|
| 1138 |
+
parser.add_argument("--test_label_column", type=str, default=None)
|
| 1139 |
+
parser.add_argument(
|
| 1140 |
+
"--gene_name_column",
|
| 1141 |
+
type=str,
|
| 1142 |
+
default="auto",
|
| 1143 |
+
help="Gene-name source in adata.var: 'auto', 'index', or a column name such as 'gene_name'.",
|
| 1144 |
+
)
|
| 1145 |
+
parser.add_argument("--val_ratio", type=float, default=0.1)
|
| 1146 |
+
parser.add_argument("--test_ratio", type=float, default=0.2)
|
| 1147 |
+
parser.add_argument("--min_genes", type=int, default=10)
|
| 1148 |
+
parser.add_argument("--min_cells", type=int, default=10)
|
| 1149 |
+
parser.add_argument("--max_cells", type=int, default=None)
|
| 1150 |
+
|
| 1151 |
+
parser.add_argument("--config_pkl", type=str, default="../ckpts_performer_module_test/performer_module_test.pkl")
|
| 1152 |
+
parser.add_argument(
|
| 1153 |
+
"--performer_checkpoint",
|
| 1154 |
+
type=str,
|
| 1155 |
+
default="../ckpts_performer_module_test/performer_module_test_epoch7.pth",
|
| 1156 |
+
)
|
| 1157 |
+
parser.add_argument("--module_data_dir", type=str, default="../module_data_all")
|
| 1158 |
+
parser.add_argument("--gene_list_path", type=str, default="../../selected_genes_27k.txt")
|
| 1159 |
+
parser.add_argument("--gene_mapping_path", type=str, default="../../gene2ens.csv")
|
| 1160 |
+
parser.add_argument("--gene2vec_data_path", type=str, default="../../selected_gene2vec_27k.npy")
|
| 1161 |
+
parser.add_argument("--gene2ens_file", type=str, default="../../gene2ens.csv")
|
| 1162 |
+
parser.add_argument("--gene2ens_column", type=str, default="Ensembl gene ID")
|
| 1163 |
+
parser.add_argument(
|
| 1164 |
+
"--shuffle_module_genes",
|
| 1165 |
+
action="store_true",
|
| 1166 |
+
help="Apply an extra downstream reshuffle on top of module_sets restored from PKL. "
|
| 1167 |
+
"Do not pass this when reproducing the pretrained backbone as-is.",
|
| 1168 |
+
)
|
| 1169 |
+
parser.add_argument(
|
| 1170 |
+
"--shuffle_seed",
|
| 1171 |
+
type=int,
|
| 1172 |
+
default=2021,
|
| 1173 |
+
help="Random seed for the downstream extra reshuffle when --shuffle_module_genes is enabled.",
|
| 1174 |
+
)
|
| 1175 |
+
|
| 1176 |
+
parser.add_argument("--output_dir", type=str, default="./ckpts_performer_module_finetune")
|
| 1177 |
+
parser.add_argument("--run_name", type=str, default="")
|
| 1178 |
+
parser.add_argument(
|
| 1179 |
+
"--save_test_artifacts",
|
| 1180 |
+
action="store_true",
|
| 1181 |
+
help="Save test classification report, confusion matrices, and metrics summary.",
|
| 1182 |
+
)
|
| 1183 |
+
|
| 1184 |
+
return parser.parse_args()
|
| 1185 |
+
|
| 1186 |
+
|
| 1187 |
+
def main():
|
| 1188 |
+
args = parse_args()
|
| 1189 |
+
apply_dataset_preset(args)
|
| 1190 |
+
device, use_ddp, rank, world_size, local_rank = init_distributed_mode(args.device)
|
| 1191 |
+
if not is_main_process():
|
| 1192 |
+
logger.setLevel(logging.WARNING)
|
| 1193 |
+
seed_all(args.seed + rank)
|
| 1194 |
+
|
| 1195 |
+
run_name = args.run_name or (
|
| 1196 |
+
f"{args.head_type}_{args.finetune_mode}"
|
| 1197 |
+
f"_n{args.unfreeze_last_n}_lrh{args.lr_head:g}_lrb{args.lr_backbone:g}_seed{args.seed}"
|
| 1198 |
+
)
|
| 1199 |
+
output_dir = resolve_path(args.output_dir) / run_name
|
| 1200 |
+
output_dir.mkdir(parents=True, exist_ok=True)
|
| 1201 |
+
|
| 1202 |
+
train_batch_size, eval_batch_size, effective_global_batch = resolve_batch_sizes(args, world_size)
|
| 1203 |
+
logger.info("Run directory: %s", output_dir)
|
| 1204 |
+
logger.info(
|
| 1205 |
+
"Device: %s | DDP=%s rank=%d world_size=%d local_rank=%d",
|
| 1206 |
+
device,
|
| 1207 |
+
use_ddp,
|
| 1208 |
+
rank,
|
| 1209 |
+
world_size,
|
| 1210 |
+
local_rank,
|
| 1211 |
+
)
|
| 1212 |
+
logger.info(
|
| 1213 |
+
"Batch config: per_device_train=%d per_device_eval=%d grad_accum=%d effective_global_train=%d",
|
| 1214 |
+
train_batch_size,
|
| 1215 |
+
eval_batch_size,
|
| 1216 |
+
args.grad_accum_steps,
|
| 1217 |
+
effective_global_batch,
|
| 1218 |
+
)
|
| 1219 |
+
|
| 1220 |
+
preprocessor = DataPreprocessor(
|
| 1221 |
+
gene2ens_file=str(resolve_path(args.gene2ens_file)),
|
| 1222 |
+
gene2ens_column=args.gene2ens_column,
|
| 1223 |
+
model_genes_file=str(resolve_path(args.gene_list_path)),
|
| 1224 |
+
)
|
| 1225 |
+
preprocessor.load_gene_mapping()
|
| 1226 |
+
preprocessor.load_model_genes()
|
| 1227 |
+
paired_data_mode = bool(args.train_data_path or args.test_data_path)
|
| 1228 |
+
if bool(args.train_data_path) != bool(args.test_data_path):
|
| 1229 |
+
raise ValueError("train_data_path and test_data_path must be provided together")
|
| 1230 |
+
|
| 1231 |
+
if paired_data_mode:
|
| 1232 |
+
if args.test_ratio != 0.2:
|
| 1233 |
+
logger.info("External test set provided; ignoring test_ratio=%s", args.test_ratio)
|
| 1234 |
+
|
| 1235 |
+
train_adata, train_reindex_state = preprocessor.preprocess_adata(
|
| 1236 |
+
str(resolve_path(args.train_data_path)),
|
| 1237 |
+
min_genes=args.min_genes,
|
| 1238 |
+
min_cells=args.min_cells,
|
| 1239 |
+
max_cells=args.max_cells,
|
| 1240 |
+
dataset_name="train",
|
| 1241 |
+
gene_name_column=args.gene_name_column,
|
| 1242 |
+
)
|
| 1243 |
+
test_adata, test_reindex_state = preprocessor.preprocess_adata(
|
| 1244 |
+
str(resolve_path(args.test_data_path)),
|
| 1245 |
+
min_genes=args.min_genes,
|
| 1246 |
+
min_cells=args.min_cells,
|
| 1247 |
+
max_cells=None,
|
| 1248 |
+
dataset_name="test",
|
| 1249 |
+
gene_name_column=args.gene_name_column,
|
| 1250 |
+
)
|
| 1251 |
+
|
| 1252 |
+
train_label_column = args.label_column
|
| 1253 |
+
test_label_column = args.test_label_column or args.label_column
|
| 1254 |
+
label_encoder = LabelEncoder()
|
| 1255 |
+
train_labels_raw = train_adata.obs[train_label_column].astype(str).to_numpy()
|
| 1256 |
+
label_encoder.fit(train_labels_raw)
|
| 1257 |
+
labels_train = label_encoder.transform(train_labels_raw)
|
| 1258 |
+
|
| 1259 |
+
test_labels_raw = test_adata.obs[test_label_column].astype(str).to_numpy()
|
| 1260 |
+
unseen_test_labels = sorted(set(test_labels_raw) - set(label_encoder.classes_))
|
| 1261 |
+
if unseen_test_labels:
|
| 1262 |
+
raise ValueError(
|
| 1263 |
+
"External test set contains labels unseen in training data: "
|
| 1264 |
+
+ ", ".join(unseen_test_labels[:10])
|
| 1265 |
+
)
|
| 1266 |
+
labels_test = label_encoder.transform(test_labels_raw)
|
| 1267 |
+
num_classes = len(label_encoder.classes_)
|
| 1268 |
+
|
| 1269 |
+
train_idx, val_idx = stratified_train_val_split(
|
| 1270 |
+
labels=labels_train,
|
| 1271 |
+
seed=args.seed,
|
| 1272 |
+
val_ratio=args.val_ratio,
|
| 1273 |
+
)
|
| 1274 |
+
logger.info(
|
| 1275 |
+
"Using external test split | train=%s test=%s",
|
| 1276 |
+
resolve_path(args.train_data_path),
|
| 1277 |
+
resolve_path(args.test_data_path),
|
| 1278 |
+
)
|
| 1279 |
+
logger.info(
|
| 1280 |
+
"Split sizes: train=%d val=%d external_test=%d classes=%d",
|
| 1281 |
+
len(train_idx),
|
| 1282 |
+
len(val_idx),
|
| 1283 |
+
test_adata.n_obs,
|
| 1284 |
+
num_classes,
|
| 1285 |
+
)
|
| 1286 |
+
|
| 1287 |
+
train_dataset = ExpressionDataset(train_adata.X, labels_train, train_idx)
|
| 1288 |
+
val_dataset = ExpressionDataset(train_adata.X, labels_train, val_idx)
|
| 1289 |
+
test_dataset = ExpressionDataset(test_adata.X, labels_test, np.arange(test_adata.n_obs))
|
| 1290 |
+
val_reindex_state = train_reindex_state
|
| 1291 |
+
else:
|
| 1292 |
+
adata, train_reindex_state = preprocessor.preprocess_adata(
|
| 1293 |
+
str(resolve_path(args.data_path)),
|
| 1294 |
+
min_genes=args.min_genes,
|
| 1295 |
+
min_cells=args.min_cells,
|
| 1296 |
+
max_cells=args.max_cells,
|
| 1297 |
+
dataset_name="data",
|
| 1298 |
+
gene_name_column=args.gene_name_column,
|
| 1299 |
+
)
|
| 1300 |
+
|
| 1301 |
+
label_encoder = LabelEncoder()
|
| 1302 |
+
labels = label_encoder.fit_transform(adata.obs[args.label_column].astype(str).to_numpy())
|
| 1303 |
+
num_classes = len(label_encoder.classes_)
|
| 1304 |
+
train_idx, val_idx, test_idx = stratified_split(
|
| 1305 |
+
labels=labels,
|
| 1306 |
+
seed=args.seed,
|
| 1307 |
+
val_ratio=args.val_ratio,
|
| 1308 |
+
test_ratio=args.test_ratio,
|
| 1309 |
+
)
|
| 1310 |
+
logger.info(
|
| 1311 |
+
"Split sizes: train=%d val=%d test=%d classes=%d",
|
| 1312 |
+
len(train_idx),
|
| 1313 |
+
len(val_idx),
|
| 1314 |
+
len(test_idx),
|
| 1315 |
+
num_classes,
|
| 1316 |
+
)
|
| 1317 |
+
|
| 1318 |
+
matrix = adata.X
|
| 1319 |
+
train_dataset = ExpressionDataset(matrix, labels, train_idx)
|
| 1320 |
+
val_dataset = ExpressionDataset(matrix, labels, val_idx)
|
| 1321 |
+
test_dataset = ExpressionDataset(matrix, labels, test_idx)
|
| 1322 |
+
val_reindex_state = train_reindex_state
|
| 1323 |
+
test_reindex_state = train_reindex_state
|
| 1324 |
+
|
| 1325 |
+
train_sampler = (
|
| 1326 |
+
DistributedSampler(train_dataset, num_replicas=world_size, rank=rank, shuffle=True, drop_last=False)
|
| 1327 |
+
if use_ddp
|
| 1328 |
+
else None
|
| 1329 |
+
)
|
| 1330 |
+
val_sampler = (
|
| 1331 |
+
DistributedEvalSampler(len(val_dataset), rank=rank, world_size=world_size)
|
| 1332 |
+
if use_ddp
|
| 1333 |
+
else None
|
| 1334 |
+
)
|
| 1335 |
+
test_sampler = (
|
| 1336 |
+
DistributedEvalSampler(len(test_dataset), rank=rank, world_size=world_size)
|
| 1337 |
+
if use_ddp
|
| 1338 |
+
else None
|
| 1339 |
+
)
|
| 1340 |
+
|
| 1341 |
+
loader_kwargs = {
|
| 1342 |
+
"num_workers": args.num_workers,
|
| 1343 |
+
"pin_memory": device.type == "cuda",
|
| 1344 |
+
}
|
| 1345 |
+
if args.num_workers > 0:
|
| 1346 |
+
loader_kwargs["persistent_workers"] = True
|
| 1347 |
+
loader_kwargs["prefetch_factor"] = args.prefetch_factor
|
| 1348 |
+
|
| 1349 |
+
train_loader = DataLoader(
|
| 1350 |
+
train_dataset,
|
| 1351 |
+
batch_size=train_batch_size,
|
| 1352 |
+
shuffle=train_sampler is None,
|
| 1353 |
+
sampler=train_sampler,
|
| 1354 |
+
**loader_kwargs,
|
| 1355 |
+
)
|
| 1356 |
+
val_loader = DataLoader(
|
| 1357 |
+
val_dataset,
|
| 1358 |
+
batch_size=eval_batch_size,
|
| 1359 |
+
shuffle=False,
|
| 1360 |
+
sampler=val_sampler,
|
| 1361 |
+
**loader_kwargs,
|
| 1362 |
+
)
|
| 1363 |
+
test_loader = DataLoader(
|
| 1364 |
+
test_dataset,
|
| 1365 |
+
batch_size=eval_batch_size,
|
| 1366 |
+
shuffle=False,
|
| 1367 |
+
sampler=test_sampler,
|
| 1368 |
+
**loader_kwargs,
|
| 1369 |
+
)
|
| 1370 |
+
|
| 1371 |
+
backbone, module_sets, backbone_config = load_backbone(args, device=device)
|
| 1372 |
+
trainability = configure_backbone_trainability(
|
| 1373 |
+
backbone,
|
| 1374 |
+
finetune_mode=args.finetune_mode,
|
| 1375 |
+
unfreeze_last_n=args.unfreeze_last_n,
|
| 1376 |
+
train_pos_emb=args.train_pos_emb,
|
| 1377 |
+
train_module_embed=args.train_module_embed,
|
| 1378 |
+
)
|
| 1379 |
+
|
| 1380 |
+
model = PerformerModuleCellClassifier(
|
| 1381 |
+
backbone=backbone,
|
| 1382 |
+
module_sets=module_sets,
|
| 1383 |
+
num_classes=num_classes,
|
| 1384 |
+
head_type=args.head_type,
|
| 1385 |
+
pool_hidden_dim=args.pool_hidden_dim,
|
| 1386 |
+
dropout=args.dropout,
|
| 1387 |
+
cls_decoder_layers=args.cls_decoder_layers,
|
| 1388 |
+
use_module_selector=getattr(args, "use_module_selector", False),
|
| 1389 |
+
).to(device)
|
| 1390 |
+
model_to_save = model
|
| 1391 |
+
if getattr(args, "use_module_selector", False):
|
| 1392 |
+
model_to_save.selector_tau = getattr(args, "selector_tau", 0.5)
|
| 1393 |
+
|
| 1394 |
+
head_total = sum(p.numel() for name, p in model.named_parameters() if not name.startswith("backbone."))
|
| 1395 |
+
head_trainable = sum(
|
| 1396 |
+
p.numel() for name, p in model.named_parameters() if not name.startswith("backbone.") and p.requires_grad
|
| 1397 |
+
)
|
| 1398 |
+
logger.info(
|
| 1399 |
+
"Backbone params: trainable=%d total=%d | Head params: trainable=%d total=%d",
|
| 1400 |
+
trainability["trainable"],
|
| 1401 |
+
trainability["total"],
|
| 1402 |
+
head_trainable,
|
| 1403 |
+
head_total,
|
| 1404 |
+
)
|
| 1405 |
+
|
| 1406 |
+
optimizer = build_optimizer(
|
| 1407 |
+
model=model,
|
| 1408 |
+
lr_head=args.lr_head,
|
| 1409 |
+
lr_backbone=args.lr_backbone,
|
| 1410 |
+
weight_decay=args.weight_decay,
|
| 1411 |
+
)
|
| 1412 |
+
if use_ddp:
|
| 1413 |
+
model = DDP(model, device_ids=[local_rank] if device.type == "cuda" else None)
|
| 1414 |
+
scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=max(args.epochs, 1))
|
| 1415 |
+
criterion = nn.CrossEntropyLoss(label_smoothing=args.label_smoothing)
|
| 1416 |
+
|
| 1417 |
+
if is_main_process():
|
| 1418 |
+
save_json(
|
| 1419 |
+
output_dir / "run_config.json",
|
| 1420 |
+
{
|
| 1421 |
+
"args": vars(args),
|
| 1422 |
+
"run_name": run_name,
|
| 1423 |
+
"num_classes": num_classes,
|
| 1424 |
+
"label_classes": label_encoder.classes_.tolist(),
|
| 1425 |
+
"backbone_config": backbone_config,
|
| 1426 |
+
"trainable_backbone_params": trainability,
|
| 1427 |
+
"head_type": args.head_type,
|
| 1428 |
+
"data_mode": "paired_train_test" if paired_data_mode else "single_split",
|
| 1429 |
+
"gene_name_column": args.gene_name_column,
|
| 1430 |
+
"world_size": world_size,
|
| 1431 |
+
"effective_global_batch": effective_global_batch,
|
| 1432 |
+
"per_device_batch_size": train_batch_size,
|
| 1433 |
+
"per_device_eval_batch_size": eval_batch_size,
|
| 1434 |
+
},
|
| 1435 |
+
)
|
| 1436 |
+
if use_ddp:
|
| 1437 |
+
dist.barrier()
|
| 1438 |
+
|
| 1439 |
+
best_val_f1 = -1.0
|
| 1440 |
+
best_metrics = None
|
| 1441 |
+
best_epoch = -1
|
| 1442 |
+
patience_counter = 0
|
| 1443 |
+
|
| 1444 |
+
for epoch in range(1, args.epochs + 1):
|
| 1445 |
+
if train_sampler is not None:
|
| 1446 |
+
train_sampler.set_epoch(epoch)
|
| 1447 |
+
train_metrics = train_one_epoch(
|
| 1448 |
+
model=model,
|
| 1449 |
+
data_loader=train_loader,
|
| 1450 |
+
preprocessor=preprocessor,
|
| 1451 |
+
reindex_state=train_reindex_state,
|
| 1452 |
+
criterion=criterion,
|
| 1453 |
+
optimizer=optimizer,
|
| 1454 |
+
device=device,
|
| 1455 |
+
grad_clip=args.grad_clip,
|
| 1456 |
+
grad_accum_steps=args.grad_accum_steps,
|
| 1457 |
+
use_ddp=use_ddp,
|
| 1458 |
+
selector_sparsity_weight=getattr(args, "selector_sparsity_weight", 0.0) if getattr(args, "use_module_selector", False) else 0.0,
|
| 1459 |
+
)
|
| 1460 |
+
val_metrics = evaluate(
|
| 1461 |
+
model=model,
|
| 1462 |
+
data_loader=val_loader,
|
| 1463 |
+
preprocessor=preprocessor,
|
| 1464 |
+
reindex_state=val_reindex_state,
|
| 1465 |
+
criterion=criterion,
|
| 1466 |
+
device=device,
|
| 1467 |
+
)
|
| 1468 |
+
scheduler.step()
|
| 1469 |
+
|
| 1470 |
+
logger.info(
|
| 1471 |
+
"Epoch %d | train loss %.4f acc %.4f f1 %.4f | val loss %.4f acc %.4f f1 %.4f",
|
| 1472 |
+
epoch,
|
| 1473 |
+
train_metrics["loss"],
|
| 1474 |
+
train_metrics["acc"],
|
| 1475 |
+
train_metrics["macro_f1"],
|
| 1476 |
+
val_metrics["loss"],
|
| 1477 |
+
val_metrics["acc"],
|
| 1478 |
+
val_metrics["macro_f1"],
|
| 1479 |
+
)
|
| 1480 |
+
|
| 1481 |
+
if val_metrics["macro_f1"] > best_val_f1:
|
| 1482 |
+
best_val_f1 = val_metrics["macro_f1"]
|
| 1483 |
+
best_epoch = epoch
|
| 1484 |
+
patience_counter = 0
|
| 1485 |
+
|
| 1486 |
+
best_metrics = {
|
| 1487 |
+
"epoch": epoch,
|
| 1488 |
+
"train": train_metrics,
|
| 1489 |
+
"val": {k: v for k, v in val_metrics.items() if k not in {"preds", "labels"}},
|
| 1490 |
+
}
|
| 1491 |
+
if is_main_process():
|
| 1492 |
+
torch.save(
|
| 1493 |
+
{
|
| 1494 |
+
"epoch": epoch,
|
| 1495 |
+
"model_state_dict": unwrap_model(model_to_save).state_dict(),
|
| 1496 |
+
"optimizer_state_dict": optimizer.state_dict(),
|
| 1497 |
+
"scheduler_state_dict": scheduler.state_dict(),
|
| 1498 |
+
"best_val_f1": best_val_f1,
|
| 1499 |
+
"args": vars(args),
|
| 1500 |
+
"label_classes": label_encoder.classes_.tolist(),
|
| 1501 |
+
"backbone_config": backbone_config,
|
| 1502 |
+
"world_size": world_size,
|
| 1503 |
+
"effective_global_batch": effective_global_batch,
|
| 1504 |
+
},
|
| 1505 |
+
output_dir / "best_model.pt",
|
| 1506 |
+
)
|
| 1507 |
+
else:
|
| 1508 |
+
patience_counter += 1
|
| 1509 |
+
if patience_counter >= args.patience:
|
| 1510 |
+
logger.info("Early stopping at epoch %d", epoch)
|
| 1511 |
+
break
|
| 1512 |
+
if use_ddp:
|
| 1513 |
+
dist.barrier()
|
| 1514 |
+
|
| 1515 |
+
if best_epoch < 0:
|
| 1516 |
+
raise RuntimeError("Training finished without a valid checkpoint")
|
| 1517 |
+
|
| 1518 |
+
if use_ddp:
|
| 1519 |
+
dist.barrier()
|
| 1520 |
+
ckpt = torch.load(output_dir / "best_model.pt", map_location=device)
|
| 1521 |
+
unwrap_model(model).load_state_dict(ckpt["model_state_dict"], strict=True)
|
| 1522 |
+
|
| 1523 |
+
test_metrics = evaluate(
|
| 1524 |
+
model=model,
|
| 1525 |
+
data_loader=test_loader,
|
| 1526 |
+
preprocessor=preprocessor,
|
| 1527 |
+
reindex_state=test_reindex_state,
|
| 1528 |
+
criterion=criterion,
|
| 1529 |
+
device=device,
|
| 1530 |
+
)
|
| 1531 |
+
logger.info(
|
| 1532 |
+
"Best epoch %d | test loss %.4f acc %.4f f1 %.4f",
|
| 1533 |
+
best_epoch,
|
| 1534 |
+
test_metrics["loss"],
|
| 1535 |
+
test_metrics["acc"],
|
| 1536 |
+
test_metrics["macro_f1"],
|
| 1537 |
+
)
|
| 1538 |
+
|
| 1539 |
+
report = classification_report(
|
| 1540 |
+
test_metrics["labels"],
|
| 1541 |
+
test_metrics["preds"],
|
| 1542 |
+
target_names=label_encoder.classes_.tolist(),
|
| 1543 |
+
digits=4,
|
| 1544 |
+
)
|
| 1545 |
+
conf, conf_norm, conf_df, conf_norm_df = build_confusion_tables(
|
| 1546 |
+
test_metrics["labels"],
|
| 1547 |
+
test_metrics["preds"],
|
| 1548 |
+
label_encoder.classes_.tolist(),
|
| 1549 |
+
)
|
| 1550 |
+
|
| 1551 |
+
summary = {
|
| 1552 |
+
"best_epoch": best_epoch,
|
| 1553 |
+
"best_val_f1": best_val_f1,
|
| 1554 |
+
"train": best_metrics["train"] if best_metrics is not None else None,
|
| 1555 |
+
"val": best_metrics["val"] if best_metrics is not None else None,
|
| 1556 |
+
"test": {k: v for k, v in test_metrics.items() if k not in {"preds", "labels"}},
|
| 1557 |
+
}
|
| 1558 |
+
if is_main_process():
|
| 1559 |
+
logger.info("Test classification report:\n%s", report)
|
| 1560 |
+
logger.info(
|
| 1561 |
+
"Test confusion matrix (rows=true, cols=pred):\n%s",
|
| 1562 |
+
conf_df.to_string(),
|
| 1563 |
+
)
|
| 1564 |
+
logger.info(
|
| 1565 |
+
"Test confusion matrix normalized by true class:\n%s",
|
| 1566 |
+
conf_norm_df.round(4).to_string(),
|
| 1567 |
+
)
|
| 1568 |
+
if args.save_test_artifacts:
|
| 1569 |
+
with open(output_dir / "test_classification_report.txt", "w", encoding="utf-8") as handle:
|
| 1570 |
+
handle.write(report)
|
| 1571 |
+
with open(output_dir / "test_confusion_matrix.txt", "w", encoding="utf-8") as handle:
|
| 1572 |
+
handle.write(conf_df.to_string())
|
| 1573 |
+
handle.write("\n")
|
| 1574 |
+
with open(output_dir / "test_confusion_matrix_normalized.txt", "w", encoding="utf-8") as handle:
|
| 1575 |
+
handle.write(conf_norm_df.round(4).to_string())
|
| 1576 |
+
handle.write("\n")
|
| 1577 |
+
np.save(output_dir / "test_confusion_matrix.npy", conf)
|
| 1578 |
+
np.save(output_dir / "test_confusion_matrix_normalized.npy", conf_norm)
|
| 1579 |
+
conf_df.to_csv(output_dir / "test_confusion_matrix.csv", encoding="utf-8")
|
| 1580 |
+
conf_norm_df.to_csv(output_dir / "test_confusion_matrix_normalized.csv", encoding="utf-8")
|
| 1581 |
+
save_json(output_dir / "metrics_summary.json", summary)
|
| 1582 |
+
logger.info("Saved test artifacts to %s", output_dir)
|
| 1583 |
+
else:
|
| 1584 |
+
logger.info("Test artifacts were not saved. Use --save_test_artifacts to enable file output.")
|
| 1585 |
+
if use_ddp:
|
| 1586 |
+
dist.barrier()
|
| 1587 |
+
dist.destroy_process_group()
|
| 1588 |
+
|
| 1589 |
+
|
| 1590 |
+
if __name__ == "__main__":
|
| 1591 |
+
main()
|
BioMod/command.txt
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
source activate base
|
| 2 |
+
conda activate sclong
|
| 3 |
+
cd ../data/250010166/scLong/performer_module
|
| 4 |
+
torchrun --nproc_per_node $SENSECORE_ACCELERATE_DEVICE_COUNT --nnodes $SENSECORE_PYTORCH_NNODES --node_rank $SENSECORE_PYTORCH_NODE_RANK --master_addr $MASTER_ADDR --master_port $MASTER_PORT \
|
| 5 |
+
performer_module.py \
|
| 6 |
+
--gene_list_path ../selected_genes_27k.txt \
|
| 7 |
+
--cell_num 4000000 \
|
| 8 |
+
--epoch 15 \
|
| 9 |
+
--batch_size 60 \
|
| 10 |
+
--valid_batch_size 120 \
|
| 11 |
+
--learning_rate 1e-4 \
|
| 12 |
+
--grad_acc 5 \
|
| 13 |
+
--log_every 100 \
|
| 14 |
+
--mask_ratio 0.75 \
|
| 15 |
+
--data_path ../../../share/cell_files \
|
| 16 |
+
--module_data_dir module_data_all/ \
|
| 17 |
+
--gene_mapping_path ../gene2ens.csv \
|
| 18 |
+
--gene2vec_data_path ../selected_gene2vec_27k.npy \
|
| 19 |
+
--ckpt_dir ./performer_module_4M_all_module_hvg_600_singleton/ \
|
| 20 |
+
--model_name performer_module_4M_all_module_hvg_600_singleton \
|
| 21 |
+
--fusion_method multiply \
|
| 22 |
+
-use_wandb \
|
| 23 |
+
--api_key "$WANDB_API_KEY" \
|
| 24 |
+
--loss_mode gene_mean \
|
| 25 |
+
--decoder_type simple \
|
| 26 |
+
--num_workers 12 \
|
| 27 |
+
--prefetch_factor 4 \
|
| 28 |
+
--extra_gene_rank_path hvg_rankings/hvg_seurat_v3_top4096_first4000000cells_gene27874.npz \
|
| 29 |
+
--extra_gene_num 600 \
|
| 30 |
+
--extra_gene_module_strategy singleton \
|
| 31 |
+
--dec_embed_dim 1024
|
BioMod/drug_combination/README.md
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Drug Combination Synergy Prediction (PerformerModule)
|
| 2 |
+
|
| 3 |
+
This task predicts drug-combination synergy using pretrained PerformerModule cell
|
| 4 |
+
embeddings and GCN-based drug molecular graph encoders.
|
| 5 |
+
|
| 6 |
+
It is adapted from [scLong/drug_combination](../../../drug_combination)
|
| 7 |
+
(DeepDDs), replacing the scfm/geneformer cell-line representation with
|
| 8 |
+
PerformerModule.
|
| 9 |
+
|
| 10 |
+
## Model Architecture
|
| 11 |
+
|
| 12 |
+
| Component | Description |
|
| 13 |
+
| --- | --- |
|
| 14 |
+
| Drug branch | Three PyG GCNConv layers (78 -> 78 -> 156 -> 312), `global_max_pool`, and two fully connected layers to 128 dimensions |
|
| 15 |
+
| Cell branch | Pretrained PerformerModule encoder, module mean pooling, and an MLP to 128 dimensions |
|
| 16 |
+
| Fusion layer | `concat(drug1, drug2, cell) = 384 -> MLP -> 2-class prediction` |
|
| 17 |
+
|
| 18 |
+
The drug1 and drug2 branches share weights, matching scLong.
|
| 19 |
+
|
| 20 |
+
## Data
|
| 21 |
+
|
| 22 |
+
Place the data under `data_summary/`:
|
| 23 |
+
|
| 24 |
+
```text
|
| 25 |
+
data_summary/
|
| 26 |
+
|-- new_labels_0_10.csv # Training labels (drug1, drug2, cell, label)
|
| 27 |
+
|-- new_cell_features_954.csv # Training cell features (30 cell lines x 954 genes)
|
| 28 |
+
|-- smiles_0_10.csv # Drug SMILES
|
| 29 |
+
`-- test_data/
|
| 30 |
+
|-- independent_input.csv # Independent test labels (668 samples)
|
| 31 |
+
`-- independent_cell_features_954.csv # Test cell features (1020 cell lines x 954 genes)
|
| 32 |
+
```
|
| 33 |
+
|
| 34 |
+
## Dependencies
|
| 35 |
+
|
| 36 |
+
- PyTorch
|
| 37 |
+
- PyTorch Geometric (`torch_geometric`)
|
| 38 |
+
- RDKit
|
| 39 |
+
- networkx
|
| 40 |
+
- scikit-learn
|
| 41 |
+
- pandas, numpy
|
| 42 |
+
|
| 43 |
+
## Usage
|
| 44 |
+
|
| 45 |
+
```bash
|
| 46 |
+
pip install RDKit -i https://pypi.tuna.tsinghua.edu.cn/simple
|
| 47 |
+
```
|
| 48 |
+
|
| 49 |
+
### Basic Run (Domain Generalization Mode, Default)
|
| 50 |
+
|
| 51 |
+
```bash
|
| 52 |
+
python train_drug_combination_performer_module.py \
|
| 53 |
+
--checkpoint_path /path/to/performer_module_checkpoint.pt \
|
| 54 |
+
--hyper_params_path /path/to/hyper_params.pkl \
|
| 55 |
+
--module_data_dir ../module_data_all \
|
| 56 |
+
--data_dir ./data_summary
|
| 57 |
+
```
|
| 58 |
+
|
| 59 |
+
### Custom Paths
|
| 60 |
+
|
| 61 |
+
```bash
|
| 62 |
+
python train_drug_combination_performer_module.py \
|
| 63 |
+
--checkpoint_path ../mae_tost_48M_cross_attn_only_pure_memory/mae_tost_48M_cross_attn_only_pure_memory_epoch12.pth \
|
| 64 |
+
--hyper_params_path ../mae_tost_48M_cross_attn_only_pure_memory/mae_tost_48M_cross_attn_only_pure_memory.pkl \
|
| 65 |
+
--module_data_dir ../module_data_all \
|
| 66 |
+
--gene_list_path ../../selected_genes_27k.txt\
|
| 67 |
+
--gene_mapping_path ../gene_mapping.pkl \
|
| 68 |
+
--gene2vec_path ../../selected_gene2vec_27k.npy \
|
| 69 |
+
--symbol2ens_path ../../gene2ens.csv \
|
| 70 |
+
--data_dir ./data_summary \
|
| 71 |
+
--output_dir ./results \
|
| 72 |
+
--epochs 500 \
|
| 73 |
+
--batch_size 256 \
|
| 74 |
+
--lr 1e-4 \
|
| 75 |
+
--n_folds 1 \
|
| 76 |
+
--pool_method max
|
| 77 |
+
```
|
| 78 |
+
|
| 79 |
+
Reference note: `1e-4 500 0.6824301861702127`.
|
| 80 |
+
|
| 81 |
+
### Five-Fold Cross-Validation (Disable Domain Generalization)
|
| 82 |
+
|
| 83 |
+
```bash
|
| 84 |
+
python train_drug_combination_performer_module.py \
|
| 85 |
+
--checkpoint_path /path/to/checkpoint.pt \
|
| 86 |
+
--hyper_params_path /path/to/hyper_params.pkl \
|
| 87 |
+
--module_data_dir ../module_data_all \
|
| 88 |
+
--no_domain_gen
|
| 89 |
+
```
|
| 90 |
+
|
| 91 |
+
### Force Reprocessing After Changing the Model or Data
|
| 92 |
+
|
| 93 |
+
```bash
|
| 94 |
+
python train_drug_combination_performer_module.py \
|
| 95 |
+
--checkpoint_path /path/to/checkpoint.pt \
|
| 96 |
+
--hyper_params_path /path/to/hyper_params.pkl \
|
| 97 |
+
--module_data_dir ../module_data_all \
|
| 98 |
+
--force_preprocess
|
| 99 |
+
```
|
| 100 |
+
|
| 101 |
+
## Main Arguments
|
| 102 |
+
|
| 103 |
+
| Argument | Default | Description |
|
| 104 |
+
| --- | --- | --- |
|
| 105 |
+
| `--checkpoint_path` | Required | Path to the pretrained PerformerModule checkpoint |
|
| 106 |
+
| `--hyper_params_path` | Required | Path to the PerformerModule hyper-parameter pkl file |
|
| 107 |
+
| `--module_data_dir` | None | Directory containing module data files |
|
| 108 |
+
| `--data_dir` | `./data_summary` | Data directory |
|
| 109 |
+
| `--batch_size` | 256 | Training batch size |
|
| 110 |
+
| `--lr` | 1e-4 | Learning rate |
|
| 111 |
+
| `--epochs` | 100 | Number of training epochs |
|
| 112 |
+
| `--n_folds` | 5 | Number of folds |
|
| 113 |
+
| `--domain_gen` | True | Domain generalization mode: train on the full training set and evaluate on the independent test set |
|
| 114 |
+
| `--eval_interval` | 5 | Evaluate every N epochs |
|
| 115 |
+
| `--pool_method` | mean | PerformerModule pooling method (`mean`, `max`, or `concat`) |
|
| 116 |
+
| `--cache_emb` | True | Cache cell embeddings to a `.npy` file |
|
| 117 |
+
| `--force_preprocess` | False | Force regeneration of the PyG dataset |
|
| 118 |
+
|
| 119 |
+
## Outputs
|
| 120 |
+
|
| 121 |
+
Results are saved under `--output_dir` (default: `./results/`):
|
| 122 |
+
|
| 123 |
+
- `best_model_fold{i}.pt` - best model weights for each fold
|
| 124 |
+
- `fpr_tpr_fold{i}.pkl` - FPR/TPR curve data for drawing ROC curves
|
| 125 |
+
- `cell_emb_cache_{pool_method}.npy` - cached PerformerModule cell embeddings
|
| 126 |
+
|
| 127 |
+
## Evaluation Metrics
|
| 128 |
+
|
| 129 |
+
Every `--eval_interval` epochs, the script evaluates AUC, PR_AUC, ACC, BACC,
|
| 130 |
+
PREC, TPR, and KAPPA, matching scLong.
|
BioMod/drug_combination/train_drug_combination_performer_module.py
ADDED
|
@@ -0,0 +1,895 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python
|
| 2 |
+
# -*- coding: utf-8 -*-
|
| 3 |
+
"""
|
| 4 |
+
Drug Combination Synergy Prediction with PerformerModule cell embeddings.
|
| 5 |
+
|
| 6 |
+
Aligned with scLong/drug_combination (DeepDDs) architecture:
|
| 7 |
+
- Drug branch: PyG GCNConv + global_max_pool (shared weights for drug1 & drug2)
|
| 8 |
+
- Cell branch: PerformerModule pretrained encoder -> MLP
|
| 9 |
+
- Fusion: concat(drug1, drug2, cell) -> MLP -> 2-class classification
|
| 10 |
+
|
| 11 |
+
Usage:
|
| 12 |
+
python train_drug_combination_performer_module.py \
|
| 13 |
+
--checkpoint_path <ckpt> --hyper_params_path <pkl> \
|
| 14 |
+
--module_data_dir ../module_data_all \
|
| 15 |
+
--data_dir ./data_summary
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
import argparse
|
| 19 |
+
import os
|
| 20 |
+
import pickle
|
| 21 |
+
import random
|
| 22 |
+
import sys
|
| 23 |
+
from pathlib import Path
|
| 24 |
+
from typing import Dict, List, Optional, Tuple
|
| 25 |
+
|
| 26 |
+
import matplotlib
|
| 27 |
+
matplotlib.use('Agg')
|
| 28 |
+
import matplotlib.pyplot as plt
|
| 29 |
+
import networkx as nx
|
| 30 |
+
import numpy as np
|
| 31 |
+
import pandas as pd
|
| 32 |
+
import torch
|
| 33 |
+
import torch.nn as nn
|
| 34 |
+
import torch.nn.functional as F
|
| 35 |
+
from rdkit import Chem
|
| 36 |
+
from sklearn import metrics
|
| 37 |
+
from tqdm import tqdm
|
| 38 |
+
from sklearn.metrics import (
|
| 39 |
+
accuracy_score,
|
| 40 |
+
balanced_accuracy_score,
|
| 41 |
+
cohen_kappa_score,
|
| 42 |
+
confusion_matrix,
|
| 43 |
+
precision_score,
|
| 44 |
+
recall_score,
|
| 45 |
+
roc_auc_score,
|
| 46 |
+
)
|
| 47 |
+
from torch.optim.lr_scheduler import StepLR
|
| 48 |
+
|
| 49 |
+
from torch_geometric import data as DATA
|
| 50 |
+
from torch_geometric.data import InMemoryDataset
|
| 51 |
+
from torch_geometric.loader import DataLoader
|
| 52 |
+
from torch_geometric.nn import GCNConv, global_max_pool as gmp
|
| 53 |
+
|
| 54 |
+
# ===================== Path setup =====================
|
| 55 |
+
SCRIPT_DIR = Path(__file__).resolve().parent
|
| 56 |
+
PERFORMER_MODULE_ROOT = SCRIPT_DIR.parent
|
| 57 |
+
SCLONG_ROOT = PERFORMER_MODULE_ROOT.parent
|
| 58 |
+
|
| 59 |
+
if str(PERFORMER_MODULE_ROOT) not in sys.path:
|
| 60 |
+
sys.path.insert(0, str(PERFORMER_MODULE_ROOT))
|
| 61 |
+
if str(SCLONG_ROOT) not in sys.path:
|
| 62 |
+
sys.path.insert(0, str(SCLONG_ROOT))
|
| 63 |
+
|
| 64 |
+
import importlib.util
|
| 65 |
+
|
| 66 |
+
# Import PerformerModule model
|
| 67 |
+
_models_path = os.path.join(PERFORMER_MODULE_ROOT, 'models_performer_module.py')
|
| 68 |
+
_spec = importlib.util.spec_from_file_location("models_performer_module", _models_path)
|
| 69 |
+
_models_mod = importlib.util.module_from_spec(_spec)
|
| 70 |
+
_spec.loader.exec_module(_models_mod)
|
| 71 |
+
PerformerEncoderOnly_Module = _models_mod.PerformerEncoderOnly_Module
|
| 72 |
+
sanitize_performer_module_config = _models_mod.sanitize_performer_module_config
|
| 73 |
+
|
| 74 |
+
# Import module_dataset
|
| 75 |
+
_dataset_path = os.path.join(PERFORMER_MODULE_ROOT, 'module_dataset.py')
|
| 76 |
+
_spec2 = importlib.util.spec_from_file_location("module_dataset", _dataset_path)
|
| 77 |
+
_module_mod = importlib.util.module_from_spec(_spec2)
|
| 78 |
+
_spec2.loader.exec_module(_module_mod)
|
| 79 |
+
load_module_data = _module_mod.load_module_data
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
# ===================== Utilities =====================
|
| 83 |
+
|
| 84 |
+
def print_sys(*args, **kwargs):
|
| 85 |
+
print(*args, **kwargs, flush=True)
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def reindex_tensor_universal(tensor, index_positions, dim, filler="0", device="cpu"):
|
| 89 |
+
index_tensor = torch.tensor(index_positions, device=device)
|
| 90 |
+
tensor_shape = list(tensor.shape)
|
| 91 |
+
tensor_shape[dim] = len(index_positions)
|
| 92 |
+
index_shape = [1] * len(tensor_shape)
|
| 93 |
+
index_shape[dim] = len(index_positions)
|
| 94 |
+
index_tensor = index_tensor.view(*index_shape).expand(*tensor_shape)
|
| 95 |
+
if filler == "0":
|
| 96 |
+
padder_shape = list(tensor_shape)
|
| 97 |
+
padder_shape[dim] = 1
|
| 98 |
+
padder = torch.zeros(padder_shape, dtype=tensor.dtype, device=device)
|
| 99 |
+
elif filler == "mean":
|
| 100 |
+
padder = tensor.mean(dim=dim, keepdim=True)
|
| 101 |
+
else:
|
| 102 |
+
raise ValueError("filler should be '0' or 'mean'")
|
| 103 |
+
expanded_tensor = torch.cat((tensor, padder), dim=dim)
|
| 104 |
+
reindexed = torch.gather(expanded_tensor, dim, index_tensor)
|
| 105 |
+
return reindexed
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
# ===================== PerformerModule Loading =====================
|
| 109 |
+
|
| 110 |
+
def load_performer_module_model(
|
| 111 |
+
checkpoint_path, hyper_params_path, module_data_dir, module_data_path,
|
| 112 |
+
gene_list_path, gene_mapping_path, gene2vec_path, device,
|
| 113 |
+
shuffle_module_genes=False, shuffle_seed=2021,
|
| 114 |
+
):
|
| 115 |
+
print_sys(f"Loading PerformerModule model from: {checkpoint_path}")
|
| 116 |
+
|
| 117 |
+
with open(hyper_params_path, 'rb') as f:
|
| 118 |
+
model_params = pickle.load(f)
|
| 119 |
+
model_params.pop('module_embed_chunk_padded_gene_budget', None)
|
| 120 |
+
model_params = sanitize_performer_module_config(
|
| 121 |
+
model_params, source=hyper_params_path, log_fn=print_sys,
|
| 122 |
+
)
|
| 123 |
+
|
| 124 |
+
if gene2vec_path:
|
| 125 |
+
model_params['gene2vec_file'] = gene2vec_path
|
| 126 |
+
model_params['use_gene2vec'] = model_params.get('use_gene2vec', True)
|
| 127 |
+
|
| 128 |
+
if 'module_sets' not in model_params or model_params.get('module_sets') is None:
|
| 129 |
+
if module_data_dir or module_data_path:
|
| 130 |
+
module_sets = load_module_data(
|
| 131 |
+
module_data_path=module_data_path,
|
| 132 |
+
module_data_dir=module_data_dir,
|
| 133 |
+
gene_list_path=gene_list_path,
|
| 134 |
+
gene_mapping_path=gene_mapping_path,
|
| 135 |
+
verbose=True,
|
| 136 |
+
)
|
| 137 |
+
model_params['module_sets'] = module_sets
|
| 138 |
+
else:
|
| 139 |
+
raise ValueError(
|
| 140 |
+
"Model needs module_sets but neither --module_data_dir "
|
| 141 |
+
"nor --module_data_path provided"
|
| 142 |
+
)
|
| 143 |
+
|
| 144 |
+
if shuffle_module_genes:
|
| 145 |
+
print_sys(f"Shuffling genes across modules with seed={shuffle_seed}")
|
| 146 |
+
all_gene_indices = []
|
| 147 |
+
module_sizes = []
|
| 148 |
+
for module in model_params['module_sets']:
|
| 149 |
+
all_gene_indices.extend(module)
|
| 150 |
+
module_sizes.append(len(module))
|
| 151 |
+
rng = random.Random(shuffle_seed)
|
| 152 |
+
rng.shuffle(all_gene_indices)
|
| 153 |
+
shuffled_module_sets = []
|
| 154 |
+
start_idx = 0
|
| 155 |
+
for size in module_sizes:
|
| 156 |
+
shuffled_module_sets.append(all_gene_indices[start_idx:start_idx + size])
|
| 157 |
+
start_idx += size
|
| 158 |
+
model_params['module_sets'] = shuffled_module_sets
|
| 159 |
+
print_sys(f"Shuffled {len(all_gene_indices)} genes across {len(shuffled_module_sets)} modules")
|
| 160 |
+
|
| 161 |
+
model = PerformerEncoderOnly_Module(**model_params).to(device)
|
| 162 |
+
|
| 163 |
+
checkpoint = torch.load(checkpoint_path, map_location=device, weights_only=False)
|
| 164 |
+
state_dict = checkpoint.get('model_state_dict', checkpoint)
|
| 165 |
+
filtered = {
|
| 166 |
+
k: v for k, v in state_dict.items()
|
| 167 |
+
if 'go_conv' not in k and 'G_go' not in k.lower()
|
| 168 |
+
}
|
| 169 |
+
model.load_state_dict(filtered, strict=True)
|
| 170 |
+
print_sys("Loaded pretrained PerformerModule weights.")
|
| 171 |
+
|
| 172 |
+
model.eval()
|
| 173 |
+
for p in model.parameters():
|
| 174 |
+
p.requires_grad = False
|
| 175 |
+
|
| 176 |
+
return model, len(model_params['module_sets']), model_params.get('base_dim', 1024)
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
def build_performer_module_gene_mapping(symbol2ens_path, gene_list_path, expr_genes):
|
| 180 |
+
gene2ens = pd.read_csv(symbol2ens_path, index_col=0)
|
| 181 |
+
symbol2ens = gene2ens["Ensembl gene ID"].to_dict()
|
| 182 |
+
|
| 183 |
+
input_genes = [symbol2ens.get(sym, sym) for sym in expr_genes]
|
| 184 |
+
input_gene_num = len(input_genes)
|
| 185 |
+
input_mapping = {gid: i for i, gid in enumerate(input_genes)}
|
| 186 |
+
|
| 187 |
+
with open(gene_list_path, "r") as f:
|
| 188 |
+
performer_genes = [line.rstrip("\n") for line in f.readlines()]
|
| 189 |
+
performer_genes_pad = performer_genes + ["PAD"]
|
| 190 |
+
|
| 191 |
+
performer_index_positions = [
|
| 192 |
+
input_mapping.get(gid, input_gene_num) for gid in performer_genes_pad
|
| 193 |
+
]
|
| 194 |
+
intersect = np.intersect1d(input_genes, performer_genes)
|
| 195 |
+
print_sys(f"Target and PerformerModule genes intersect: {len(intersect)}")
|
| 196 |
+
return input_mapping, performer_index_positions
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
def build_cell_embeddings(
|
| 200 |
+
cell_features_df, symbol2ens_path, gene_list_path, gene_mapping_path,
|
| 201 |
+
gene2vec_path, checkpoint_path, hyper_params_path, module_data_dir,
|
| 202 |
+
module_data_path, device, pool_method="mean",
|
| 203 |
+
shuffle_module_genes=False, shuffle_seed=2021,
|
| 204 |
+
):
|
| 205 |
+
model, num_modules, encoder_embed_dim = load_performer_module_model(
|
| 206 |
+
checkpoint_path, hyper_params_path, module_data_dir, module_data_path,
|
| 207 |
+
gene_list_path, gene_mapping_path, gene2vec_path, device,
|
| 208 |
+
shuffle_module_genes=shuffle_module_genes, shuffle_seed=shuffle_seed,
|
| 209 |
+
)
|
| 210 |
+
|
| 211 |
+
gene_columns = list(cell_features_df.columns)
|
| 212 |
+
_, performer_index_positions = build_performer_module_gene_mapping(
|
| 213 |
+
symbol2ens_path, gene_list_path, gene_columns,
|
| 214 |
+
)
|
| 215 |
+
|
| 216 |
+
cell_emb_dict: Dict[str, np.ndarray] = {}
|
| 217 |
+
first_emb_dim = -1
|
| 218 |
+
|
| 219 |
+
with torch.no_grad():
|
| 220 |
+
for cell_line_id, row in cell_features_df.iterrows():
|
| 221 |
+
x = torch.tensor(row.values[None, :], dtype=torch.float32, device=device)
|
| 222 |
+
x_performer = reindex_tensor_universal(
|
| 223 |
+
x, performer_index_positions, dim=1, filler="0", device=device,
|
| 224 |
+
)
|
| 225 |
+
out = model(x_performer, mask_ratio=0.0, return_encodings=True)
|
| 226 |
+
|
| 227 |
+
if 'module_encodings' in out:
|
| 228 |
+
module_enc = out['module_encodings']
|
| 229 |
+
if pool_method == "mean":
|
| 230 |
+
cell_emb = module_enc.mean(dim=1)
|
| 231 |
+
elif pool_method == "max":
|
| 232 |
+
cell_emb = module_enc.max(dim=1)[0]
|
| 233 |
+
elif pool_method == "concat":
|
| 234 |
+
cell_emb = module_enc.reshape(1, -1)
|
| 235 |
+
else:
|
| 236 |
+
cell_emb = module_enc.mean(dim=1)
|
| 237 |
+
else:
|
| 238 |
+
raise ValueError(
|
| 239 |
+
f"Expected 'module_encodings' in output, got: {list(out.keys())}"
|
| 240 |
+
)
|
| 241 |
+
|
| 242 |
+
cell_emb_np = cell_emb.squeeze(0).cpu().numpy()
|
| 243 |
+
cell_emb_dict[cell_line_id] = cell_emb_np
|
| 244 |
+
if first_emb_dim < 0:
|
| 245 |
+
first_emb_dim = cell_emb_np.shape[0]
|
| 246 |
+
|
| 247 |
+
print_sys(
|
| 248 |
+
f"Built PerformerModule embeddings for {len(cell_emb_dict)} cell lines, "
|
| 249 |
+
f"dim={first_emb_dim}, pool_method={pool_method}"
|
| 250 |
+
)
|
| 251 |
+
return cell_emb_dict, first_emb_dim
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
# ===================== Drug Graph (scLong-aligned: edge_index format) =====================
|
| 255 |
+
|
| 256 |
+
def one_of_k_encoding(x, allowable_set):
|
| 257 |
+
if x not in allowable_set:
|
| 258 |
+
raise Exception(f"input {x} not in allowable set {allowable_set}")
|
| 259 |
+
return list(map(lambda s: x == s, allowable_set))
|
| 260 |
+
|
| 261 |
+
|
| 262 |
+
def one_of_k_encoding_unk(x, allowable_set):
|
| 263 |
+
if x not in allowable_set:
|
| 264 |
+
x = allowable_set[-1]
|
| 265 |
+
return list(map(lambda s: x == s, allowable_set))
|
| 266 |
+
|
| 267 |
+
|
| 268 |
+
def atom_features(atom):
|
| 269 |
+
return np.array(
|
| 270 |
+
one_of_k_encoding_unk(atom.GetSymbol(), [
|
| 271 |
+
'C', 'N', 'O', 'S', 'F', 'Si', 'P', 'Cl', 'Br', 'Mg', 'Na', 'Ca',
|
| 272 |
+
'Fe', 'As', 'Al', 'I', 'B', 'V', 'K', 'Tl', 'Yb', 'Sb', 'Sn', 'Ag',
|
| 273 |
+
'Pd', 'Co', 'Se', 'Ti', 'Zn', 'H', 'Li', 'Ge', 'Cu', 'Au', 'Ni', 'Cd',
|
| 274 |
+
'In', 'Mn', 'Zr', 'Cr', 'Pt', 'Hg', 'Pb', 'Unknown',
|
| 275 |
+
]) +
|
| 276 |
+
one_of_k_encoding(atom.GetDegree(), [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) +
|
| 277 |
+
one_of_k_encoding_unk(atom.GetTotalNumHs(), [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) +
|
| 278 |
+
one_of_k_encoding_unk(atom.GetImplicitValence(), [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) +
|
| 279 |
+
[atom.GetIsAromatic()]
|
| 280 |
+
)
|
| 281 |
+
|
| 282 |
+
|
| 283 |
+
ATOM_FEAT_DIM = 78 # 44 + 11 + 11 + 11 + 1
|
| 284 |
+
|
| 285 |
+
|
| 286 |
+
def smile_to_graph(smile):
|
| 287 |
+
"""Convert SMILES to (c_size, features, edge_index) — identical to scLong's creat_data_DC.py."""
|
| 288 |
+
mol = Chem.MolFromSmiles(smile)
|
| 289 |
+
if mol is None:
|
| 290 |
+
return None
|
| 291 |
+
|
| 292 |
+
c_size = mol.GetNumAtoms()
|
| 293 |
+
|
| 294 |
+
features = []
|
| 295 |
+
for atom in mol.GetAtoms():
|
| 296 |
+
feature = atom_features(atom)
|
| 297 |
+
features.append(feature / (sum(feature) + 1e-8))
|
| 298 |
+
|
| 299 |
+
edges = []
|
| 300 |
+
for bond in mol.GetBonds():
|
| 301 |
+
edges.append([bond.GetBeginAtomIdx(), bond.GetEndAtomIdx()])
|
| 302 |
+
g = nx.Graph(edges).to_directed()
|
| 303 |
+
edge_index = []
|
| 304 |
+
for e1, e2 in g.edges:
|
| 305 |
+
edge_index.append([e1, e2])
|
| 306 |
+
|
| 307 |
+
return c_size, features, edge_index
|
| 308 |
+
|
| 309 |
+
|
| 310 |
+
# ===================== TestbedDataset (PyG InMemoryDataset, scLong-aligned) =====================
|
| 311 |
+
|
| 312 |
+
class TestbedDataset(InMemoryDataset):
|
| 313 |
+
"""
|
| 314 |
+
PyG InMemoryDataset for drug combination prediction.
|
| 315 |
+
Same pattern as scLong/drug_combination/utils_test.py::TestbedDataset,
|
| 316 |
+
but uses PerformerModule cell embeddings instead of scfm/geneformer.
|
| 317 |
+
"""
|
| 318 |
+
|
| 319 |
+
def __init__(
|
| 320 |
+
self, root='data_summary', dataset='drug1',
|
| 321 |
+
xd=None, xt=None, y=None,
|
| 322 |
+
cell_emb_dict=None, smile_graph=None,
|
| 323 |
+
transform=None, pre_transform=None,
|
| 324 |
+
):
|
| 325 |
+
self.dataset = dataset
|
| 326 |
+
self._xd = xd
|
| 327 |
+
self._xt = xt
|
| 328 |
+
self._y = y
|
| 329 |
+
self._cell_emb_dict = cell_emb_dict
|
| 330 |
+
self._smile_graph = smile_graph
|
| 331 |
+
super(TestbedDataset, self).__init__(root, transform, pre_transform)
|
| 332 |
+
|
| 333 |
+
if os.path.isfile(self.processed_paths[0]):
|
| 334 |
+
print_sys(f'Loading preprocessed data: {self.processed_paths[0]}')
|
| 335 |
+
self.data, self.slices = torch.load(self.processed_paths[0], weights_only=False)
|
| 336 |
+
else:
|
| 337 |
+
print_sys(f'Preprocessing data: {self.processed_paths[0]}')
|
| 338 |
+
self.process(xd, xt, y, cell_emb_dict, smile_graph)
|
| 339 |
+
self.data, self.slices = torch.load(self.processed_paths[0], weights_only=False)
|
| 340 |
+
|
| 341 |
+
@property
|
| 342 |
+
def raw_file_names(self):
|
| 343 |
+
return []
|
| 344 |
+
|
| 345 |
+
@property
|
| 346 |
+
def processed_file_names(self):
|
| 347 |
+
return [self.dataset + '.pt']
|
| 348 |
+
|
| 349 |
+
def download(self):
|
| 350 |
+
pass
|
| 351 |
+
|
| 352 |
+
def _download(self):
|
| 353 |
+
pass
|
| 354 |
+
|
| 355 |
+
def _process(self):
|
| 356 |
+
if not os.path.exists(self.processed_dir):
|
| 357 |
+
os.makedirs(self.processed_dir)
|
| 358 |
+
|
| 359 |
+
def process(self, xd, xt, y, cell_emb_dict, smile_graph):
|
| 360 |
+
assert len(xd) == len(xt) == len(y), "xd, xt, y must have the same length"
|
| 361 |
+
data_list = []
|
| 362 |
+
skipped = 0
|
| 363 |
+
|
| 364 |
+
for i in range(len(xd)):
|
| 365 |
+
smiles = xd[i]
|
| 366 |
+
cell_name = xt[i]
|
| 367 |
+
label = y[i]
|
| 368 |
+
|
| 369 |
+
if smiles not in smile_graph:
|
| 370 |
+
skipped += 1
|
| 371 |
+
continue
|
| 372 |
+
if cell_name not in cell_emb_dict:
|
| 373 |
+
skipped += 1
|
| 374 |
+
continue
|
| 375 |
+
|
| 376 |
+
c_size, features, edge_index = smile_graph[smiles]
|
| 377 |
+
|
| 378 |
+
GCNData = DATA.Data(
|
| 379 |
+
x=torch.Tensor(features),
|
| 380 |
+
edge_index=torch.LongTensor(edge_index).transpose(1, 0),
|
| 381 |
+
y=torch.Tensor([label]),
|
| 382 |
+
)
|
| 383 |
+
cell_emb = cell_emb_dict[cell_name]
|
| 384 |
+
GCNData.cell = torch.FloatTensor([cell_emb])
|
| 385 |
+
GCNData.__setitem__('c_size', torch.LongTensor([c_size]))
|
| 386 |
+
data_list.append(GCNData)
|
| 387 |
+
|
| 388 |
+
if skipped > 0:
|
| 389 |
+
print_sys(f'Skipped {skipped} samples (missing graph or cell embedding)')
|
| 390 |
+
|
| 391 |
+
if self.pre_filter is not None:
|
| 392 |
+
data_list = [d for d in data_list if self.pre_filter(d)]
|
| 393 |
+
if self.pre_transform is not None:
|
| 394 |
+
data_list = [self.pre_transform(d) for d in data_list]
|
| 395 |
+
|
| 396 |
+
print_sys(f'Processed {len(data_list)} samples. Saving to {self.processed_paths[0]}')
|
| 397 |
+
data, slices = self.collate(data_list)
|
| 398 |
+
torch.save((data, slices), self.processed_paths[0])
|
| 399 |
+
|
| 400 |
+
|
| 401 |
+
# ===================== Model: GCNNet (PyG, scLong-aligned) =====================
|
| 402 |
+
|
| 403 |
+
class GCNNet(torch.nn.Module):
|
| 404 |
+
"""
|
| 405 |
+
GCN-based drug combination model aligned with scLong/drug_combination/models/gcn.py.
|
| 406 |
+
|
| 407 |
+
Drug branch: 3-layer GCNConv (78->78->156->312) + global_max_pool + 2 FC layers
|
| 408 |
+
Drug1 and drug2 share weights (drug2 uses drug1_conv* layers, same as scLong).
|
| 409 |
+
Cell branch: PerformerModule embedding -> MLP -> output_dim
|
| 410 |
+
Fusion: concat(drug1, drug2, cell) -> fc1(256) -> fc2(128) -> out(2)
|
| 411 |
+
"""
|
| 412 |
+
|
| 413 |
+
def __init__(
|
| 414 |
+
self, n_output=2, num_features_xd=78, cell_emb_dim=1024,
|
| 415 |
+
output_dim=128, dropout=0.2,
|
| 416 |
+
):
|
| 417 |
+
super(GCNNet, self).__init__()
|
| 418 |
+
self.relu = nn.ReLU()
|
| 419 |
+
self.dropout = nn.Dropout(dropout)
|
| 420 |
+
self.n_output = n_output
|
| 421 |
+
|
| 422 |
+
# Drug graph branch (shared weights for drug1 & drug2)
|
| 423 |
+
self.drug1_conv1 = GCNConv(num_features_xd, num_features_xd)
|
| 424 |
+
self.drug1_conv2 = GCNConv(num_features_xd, num_features_xd * 2)
|
| 425 |
+
self.drug1_conv3 = GCNConv(num_features_xd * 2, num_features_xd * 4)
|
| 426 |
+
self.drug1_fc_g1 = nn.Linear(num_features_xd * 4, num_features_xd * 2)
|
| 427 |
+
self.drug1_fc_g2 = nn.Linear(num_features_xd * 2, output_dim)
|
| 428 |
+
|
| 429 |
+
# Cell branch (PerformerModule embedding -> MLP)
|
| 430 |
+
self.reduction = nn.Sequential(
|
| 431 |
+
nn.Linear(cell_emb_dim, 512),
|
| 432 |
+
nn.ReLU(),
|
| 433 |
+
nn.Linear(512, 256),
|
| 434 |
+
nn.ReLU(),
|
| 435 |
+
nn.Linear(256, output_dim),
|
| 436 |
+
)
|
| 437 |
+
|
| 438 |
+
# Fusion layers
|
| 439 |
+
self.fc1 = nn.Linear(3 * output_dim, 256)
|
| 440 |
+
self.fc2 = nn.Linear(256, 128)
|
| 441 |
+
self.out = nn.Linear(128, self.n_output)
|
| 442 |
+
|
| 443 |
+
def forward(self, data1, data2):
|
| 444 |
+
x1, edge_index1, batch1, cell = (
|
| 445 |
+
data1.x, data1.edge_index, data1.batch, data1.cell
|
| 446 |
+
)
|
| 447 |
+
x2, edge_index2, batch2 = data2.x, data2.edge_index, data2.batch
|
| 448 |
+
|
| 449 |
+
# Drug1 branch
|
| 450 |
+
x1 = self.relu(self.drug1_conv1(x1, edge_index1))
|
| 451 |
+
x1 = self.relu(self.drug1_conv2(x1, edge_index1))
|
| 452 |
+
x1 = self.relu(self.drug1_conv3(x1, edge_index1))
|
| 453 |
+
x1 = gmp(x1, batch1)
|
| 454 |
+
x1 = self.relu(self.drug1_fc_g1(x1))
|
| 455 |
+
x1 = self.dropout(x1)
|
| 456 |
+
x1 = self.drug1_fc_g2(x1)
|
| 457 |
+
x1 = self.dropout(x1)
|
| 458 |
+
|
| 459 |
+
# Drug2 branch (shared weights — uses drug1_conv* layers, same as scLong)
|
| 460 |
+
x2 = self.relu(self.drug1_conv1(x2, edge_index2))
|
| 461 |
+
x2 = self.relu(self.drug1_conv2(x2, edge_index2))
|
| 462 |
+
x2 = self.relu(self.drug1_conv3(x2, edge_index2))
|
| 463 |
+
x2 = gmp(x2, batch2)
|
| 464 |
+
x2 = self.relu(self.drug1_fc_g1(x2))
|
| 465 |
+
x2 = self.dropout(x2)
|
| 466 |
+
x2 = self.drug1_fc_g2(x2)
|
| 467 |
+
x2 = self.dropout(x2)
|
| 468 |
+
|
| 469 |
+
# Cell branch
|
| 470 |
+
cell_vector = F.normalize(cell, 2, 1)
|
| 471 |
+
cell_vector = self.reduction(cell_vector)
|
| 472 |
+
|
| 473 |
+
# Fusion
|
| 474 |
+
xc = torch.cat((x1, x2, cell_vector), 1)
|
| 475 |
+
xc = self.relu(self.fc1(xc))
|
| 476 |
+
xc = self.dropout(xc)
|
| 477 |
+
xc = self.relu(self.fc2(xc))
|
| 478 |
+
xc = self.dropout(xc)
|
| 479 |
+
out = self.out(xc)
|
| 480 |
+
return out
|
| 481 |
+
|
| 482 |
+
|
| 483 |
+
# ===================== Training & Evaluation =====================
|
| 484 |
+
|
| 485 |
+
def train_epoch(model, device, drug1_loader, drug2_loader, optimizer, epoch):
|
| 486 |
+
"""Train one epoch. Returns average loss."""
|
| 487 |
+
model.train()
|
| 488 |
+
total_loss = 0.0
|
| 489 |
+
n_batches = 0
|
| 490 |
+
pbar = tqdm(
|
| 491 |
+
zip(drug1_loader, drug2_loader),
|
| 492 |
+
total=len(drug1_loader),
|
| 493 |
+
desc=f'Epoch {epoch}',
|
| 494 |
+
leave=True,
|
| 495 |
+
)
|
| 496 |
+
for data1, data2 in pbar:
|
| 497 |
+
data1 = data1.to(device)
|
| 498 |
+
data2 = data2.to(device)
|
| 499 |
+
y = data1.y.view(-1, 1).long().to(device).squeeze(1)
|
| 500 |
+
optimizer.zero_grad()
|
| 501 |
+
output = model(data1, data2)
|
| 502 |
+
loss = F.cross_entropy(output, y)
|
| 503 |
+
loss.backward()
|
| 504 |
+
optimizer.step()
|
| 505 |
+
total_loss += loss.item()
|
| 506 |
+
n_batches += 1
|
| 507 |
+
pbar.set_postfix(loss=f'{loss.item():.4f}')
|
| 508 |
+
avg_loss = total_loss / max(n_batches, 1)
|
| 509 |
+
return avg_loss
|
| 510 |
+
|
| 511 |
+
|
| 512 |
+
def predict(model, device, drug1_loader, drug2_loader):
|
| 513 |
+
model.eval()
|
| 514 |
+
total_preds = torch.Tensor()
|
| 515 |
+
total_labels = torch.Tensor()
|
| 516 |
+
total_prelabels = torch.Tensor()
|
| 517 |
+
print_sys(f'Make prediction for {len(drug1_loader.dataset)} samples...')
|
| 518 |
+
|
| 519 |
+
with torch.no_grad():
|
| 520 |
+
for data in zip(drug1_loader, drug2_loader):
|
| 521 |
+
data1 = data[0].to(device)
|
| 522 |
+
data2 = data[1].to(device)
|
| 523 |
+
output = model(data1, data2)
|
| 524 |
+
ys = F.softmax(output, 1).to('cpu').data.numpy()
|
| 525 |
+
predicted_labels = list(map(lambda x: np.argmax(x), ys))
|
| 526 |
+
predicted_scores = list(map(lambda x: x[1], ys))
|
| 527 |
+
total_preds = torch.cat((total_preds, torch.Tensor(predicted_scores)), 0)
|
| 528 |
+
total_prelabels = torch.cat(
|
| 529 |
+
(total_prelabels, torch.Tensor(predicted_labels)), 0
|
| 530 |
+
)
|
| 531 |
+
total_labels = torch.cat((total_labels, data1.y.view(-1, 1).cpu()), 0)
|
| 532 |
+
|
| 533 |
+
return (
|
| 534 |
+
total_labels.numpy().flatten(),
|
| 535 |
+
total_preds.numpy().flatten(),
|
| 536 |
+
total_prelabels.numpy().flatten(),
|
| 537 |
+
)
|
| 538 |
+
|
| 539 |
+
|
| 540 |
+
def evaluate_metrics(T, S, Y):
|
| 541 |
+
"""Compute all metrics matching scLong's evaluation."""
|
| 542 |
+
try:
|
| 543 |
+
AUC = roc_auc_score(T, S)
|
| 544 |
+
except Exception:
|
| 545 |
+
AUC = 0.0
|
| 546 |
+
precision_arr, recall_arr, _ = metrics.precision_recall_curve(T, S)
|
| 547 |
+
PR_AUC = metrics.auc(recall_arr, precision_arr)
|
| 548 |
+
BACC = balanced_accuracy_score(T, Y)
|
| 549 |
+
tn, fp, fn, tp = confusion_matrix(T, Y).ravel()
|
| 550 |
+
TPR = tp / (tp + fn)
|
| 551 |
+
PREC = precision_score(T, Y)
|
| 552 |
+
ACC = accuracy_score(T, Y)
|
| 553 |
+
KAPPA = cohen_kappa_score(T, Y)
|
| 554 |
+
RECALL = recall_score(T, Y)
|
| 555 |
+
return {
|
| 556 |
+
'AUC': AUC, 'PR_AUC': PR_AUC, 'ACC': ACC, 'BACC': BACC,
|
| 557 |
+
'PREC': PREC, 'TPR': TPR, 'KAPPA': KAPPA, 'RECALL': RECALL,
|
| 558 |
+
}
|
| 559 |
+
|
| 560 |
+
|
| 561 |
+
# ===================== Cell Feature Loading =====================
|
| 562 |
+
|
| 563 |
+
def load_cell_features(cell_file):
|
| 564 |
+
"""Load cell features CSV. First row is header (gene_id), second is transcript_id (skipped)."""
|
| 565 |
+
df = pd.read_csv(cell_file)
|
| 566 |
+
df = df.iloc[1:].copy() # skip transcript_id row
|
| 567 |
+
df = df.set_index(df.columns[0])
|
| 568 |
+
df = df.astype(float)
|
| 569 |
+
# Clean cell names: "X22RV1_PROSTATE" -> "22RV1"
|
| 570 |
+
df.index = [
|
| 571 |
+
idx.lstrip('X').split('_')[0] if '_' in idx else idx
|
| 572 |
+
for idx in df.index
|
| 573 |
+
]
|
| 574 |
+
return df
|
| 575 |
+
|
| 576 |
+
|
| 577 |
+
# ===================== Main =====================
|
| 578 |
+
|
| 579 |
+
def main():
|
| 580 |
+
parser = argparse.ArgumentParser(
|
| 581 |
+
description='Drug Combination Synergy Prediction with PerformerModule'
|
| 582 |
+
)
|
| 583 |
+
|
| 584 |
+
# PerformerModule model args
|
| 585 |
+
parser.add_argument('--checkpoint_path', type=str, required=True,
|
| 586 |
+
help='Path to PerformerModule pretrained checkpoint')
|
| 587 |
+
parser.add_argument('--hyper_params_path', type=str, required=True,
|
| 588 |
+
help='Path to PerformerModule hyper parameters pkl')
|
| 589 |
+
parser.add_argument('--module_data_dir', type=str, default=None,
|
| 590 |
+
help='Directory containing module data files')
|
| 591 |
+
parser.add_argument('--module_data_path', type=str, default=None,
|
| 592 |
+
help='Path to single module data file')
|
| 593 |
+
parser.add_argument('--gene_list_path', type=str,
|
| 594 |
+
default=os.path.join(PERFORMER_MODULE_ROOT, 'gene_list_27k.txt'))
|
| 595 |
+
parser.add_argument('--gene_mapping_path', type=str,
|
| 596 |
+
default=os.path.join(PERFORMER_MODULE_ROOT, 'gene_mapping.pkl'))
|
| 597 |
+
parser.add_argument('--gene2vec_path', type=str,
|
| 598 |
+
default=os.path.join(PERFORMER_MODULE_ROOT, 'gene2vec_27k.npy'))
|
| 599 |
+
parser.add_argument('--symbol2ens_path', type=str,
|
| 600 |
+
default=os.path.join(SCLONG_ROOT, 'gene2ensemble_ID.csv'))
|
| 601 |
+
parser.add_argument('--pool_method', type=str, default='mean',
|
| 602 |
+
choices=['mean', 'max', 'concat'])
|
| 603 |
+
parser.add_argument('--shuffle_module_genes', action='store_true',
|
| 604 |
+
help='Disrupt the gene allocation within the module (used for comparative experiments to verify the effectiveness of module division)')
|
| 605 |
+
parser.add_argument('--shuffle_seed', type=int, default=2021,
|
| 606 |
+
help='The random seed used when scrambling the module gene (needs to be consistent with the value used during pre-training)')
|
| 607 |
+
|
| 608 |
+
# Data args
|
| 609 |
+
parser.add_argument('--data_dir', type=str,
|
| 610 |
+
default=os.path.join(SCRIPT_DIR, 'data_summary'))
|
| 611 |
+
parser.add_argument('--label_file', type=str, default='new_labels_0_10.csv')
|
| 612 |
+
parser.add_argument('--cell_file', type=str, default='new_cell_features_954.csv')
|
| 613 |
+
parser.add_argument('--test_label_file', type=str,
|
| 614 |
+
default='test_data/independent_input.csv')
|
| 615 |
+
parser.add_argument('--test_cell_file', type=str,
|
| 616 |
+
default='test_data/independent_cell_features_954.csv')
|
| 617 |
+
|
| 618 |
+
# Training args
|
| 619 |
+
parser.add_argument('--batch_size', type=int, default=256)
|
| 620 |
+
parser.add_argument('--lr', type=float, default=1e-4)
|
| 621 |
+
parser.add_argument('--epochs', type=int, default=100)
|
| 622 |
+
parser.add_argument('--n_folds', type=int, default=5)
|
| 623 |
+
parser.add_argument('--domain_gen', action='store_true', default=True,
|
| 624 |
+
help='Domain generalization: train full set, test on independent set')
|
| 625 |
+
parser.add_argument('--no_domain_gen', dest='domain_gen', action='store_false')
|
| 626 |
+
parser.add_argument('--eval_interval', type=int, default=5)
|
| 627 |
+
parser.add_argument('--output_dir', type=str,
|
| 628 |
+
default=os.path.join(SCRIPT_DIR, 'results'))
|
| 629 |
+
parser.add_argument('--seed', type=int, default=42)
|
| 630 |
+
parser.add_argument('--cache_emb', action='store_true', default=True,
|
| 631 |
+
help='Cache PerformerModule cell embeddings to .npy')
|
| 632 |
+
parser.add_argument('--no_cache_emb', dest='cache_emb', action='store_false')
|
| 633 |
+
parser.add_argument('--force_preprocess', action='store_true', default=False,
|
| 634 |
+
help='Force re-creation of PyG .pt datasets')
|
| 635 |
+
|
| 636 |
+
args = parser.parse_args()
|
| 637 |
+
|
| 638 |
+
# Device
|
| 639 |
+
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
|
| 640 |
+
print_sys(f'Using device: {device}')
|
| 641 |
+
|
| 642 |
+
# Seed
|
| 643 |
+
random.seed(args.seed)
|
| 644 |
+
np.random.seed(args.seed)
|
| 645 |
+
torch.manual_seed(args.seed)
|
| 646 |
+
|
| 647 |
+
os.makedirs(args.output_dir, exist_ok=True)
|
| 648 |
+
|
| 649 |
+
# -------- Step 1: Build PerformerModule cell embeddings --------
|
| 650 |
+
train_cell_file = os.path.join(args.data_dir, args.cell_file)
|
| 651 |
+
train_cell_df = load_cell_features(train_cell_file)
|
| 652 |
+
print_sys(f'Loaded train cell features: {train_cell_df.shape}')
|
| 653 |
+
|
| 654 |
+
test_cell_file = os.path.join(args.data_dir, args.test_cell_file)
|
| 655 |
+
test_cell_df = load_cell_features(test_cell_file)
|
| 656 |
+
print_sys(f'Loaded test cell features: {test_cell_df.shape}')
|
| 657 |
+
|
| 658 |
+
# Combine all cell lines and remove duplicates
|
| 659 |
+
all_cell_df = pd.concat([train_cell_df, test_cell_df])
|
| 660 |
+
all_cell_df = all_cell_df[~all_cell_df.index.duplicated(keep='first')]
|
| 661 |
+
print_sys(f'Total unique cell lines: {len(all_cell_df)}')
|
| 662 |
+
|
| 663 |
+
# Cache embeddings
|
| 664 |
+
cache_path = os.path.join(args.output_dir, f'cell_emb_cache_{args.pool_method}.npy')
|
| 665 |
+
cache_dim_path = os.path.join(
|
| 666 |
+
args.output_dir, f'cell_emb_dim_{args.pool_method}.txt'
|
| 667 |
+
)
|
| 668 |
+
|
| 669 |
+
if args.cache_emb and os.path.exists(cache_path) and not args.force_preprocess:
|
| 670 |
+
print_sys(f'Loading cached cell embeddings from {cache_path}')
|
| 671 |
+
cell_emb_dict = np.load(cache_path, allow_pickle=True).item()
|
| 672 |
+
with open(cache_dim_path) as f:
|
| 673 |
+
cell_emb_dim = int(f.read().strip())
|
| 674 |
+
else:
|
| 675 |
+
cell_emb_dict, cell_emb_dim = build_cell_embeddings(
|
| 676 |
+
all_cell_df, args.symbol2ens_path, args.gene_list_path,
|
| 677 |
+
args.gene_mapping_path, args.gene2vec_path, args.checkpoint_path,
|
| 678 |
+
args.hyper_params_path, args.module_data_dir, args.module_data_path,
|
| 679 |
+
device, args.pool_method,
|
| 680 |
+
shuffle_module_genes=args.shuffle_module_genes,
|
| 681 |
+
shuffle_seed=args.shuffle_seed,
|
| 682 |
+
)
|
| 683 |
+
if args.cache_emb:
|
| 684 |
+
np.save(cache_path, cell_emb_dict)
|
| 685 |
+
with open(cache_dim_path, 'w') as f:
|
| 686 |
+
f.write(str(cell_emb_dim))
|
| 687 |
+
print_sys(f'Cached cell embeddings to {cache_path}')
|
| 688 |
+
|
| 689 |
+
# -------- Step 2: Build SMILES graphs --------
|
| 690 |
+
train_label_path = os.path.join(args.data_dir, args.label_file)
|
| 691 |
+
train_labels = pd.read_csv(train_label_path)
|
| 692 |
+
print_sys(f'Train labels: {len(train_labels)} samples')
|
| 693 |
+
|
| 694 |
+
test_label_path = os.path.join(args.data_dir, args.test_label_file)
|
| 695 |
+
test_labels = pd.read_csv(test_label_path)
|
| 696 |
+
print_sys(f'Test labels: {len(test_labels)} samples')
|
| 697 |
+
|
| 698 |
+
all_smiles = set(
|
| 699 |
+
train_labels['drug1'].tolist() + train_labels['drug2'].tolist()
|
| 700 |
+
+ test_labels['drug1'].tolist() + test_labels['drug2'].tolist()
|
| 701 |
+
)
|
| 702 |
+
smile_graph = {}
|
| 703 |
+
failed = []
|
| 704 |
+
for smi in all_smiles:
|
| 705 |
+
result = smile_to_graph(smi)
|
| 706 |
+
if result is not None:
|
| 707 |
+
smile_graph[smi] = result
|
| 708 |
+
else:
|
| 709 |
+
failed.append(smi)
|
| 710 |
+
if failed:
|
| 711 |
+
print_sys(f'Warning: {len(failed)} SMILES failed to parse')
|
| 712 |
+
print_sys(f'Built graphs for {len(smile_graph)} unique SMILES')
|
| 713 |
+
|
| 714 |
+
# -------- Step 3: Create PyG datasets --------
|
| 715 |
+
# Delete cached .pt files if force_preprocess
|
| 716 |
+
if args.force_preprocess:
|
| 717 |
+
processed_dir = os.path.join(args.data_dir, 'processed')
|
| 718 |
+
if os.path.exists(processed_dir):
|
| 719 |
+
import shutil
|
| 720 |
+
shutil.rmtree(processed_dir)
|
| 721 |
+
print_sys(f'Removed cached processed data: {processed_dir}')
|
| 722 |
+
|
| 723 |
+
# Train datasets (drug1 and drug2 separate, scLong pattern)
|
| 724 |
+
drug1_train = train_labels['drug1'].tolist()
|
| 725 |
+
drug2_train = train_labels['drug2'].tolist()
|
| 726 |
+
cells_train = train_labels['cell'].tolist()
|
| 727 |
+
labels_train = train_labels['label'].tolist()
|
| 728 |
+
|
| 729 |
+
train_d1_dataset = TestbedDataset(
|
| 730 |
+
root=args.data_dir, dataset='train_drug1_performer_module',
|
| 731 |
+
xd=drug1_train, xt=cells_train, y=labels_train,
|
| 732 |
+
cell_emb_dict=cell_emb_dict, smile_graph=smile_graph,
|
| 733 |
+
)
|
| 734 |
+
train_d2_dataset = TestbedDataset(
|
| 735 |
+
root=args.data_dir, dataset='train_drug2_performer_module',
|
| 736 |
+
xd=drug2_train, xt=cells_train, y=labels_train,
|
| 737 |
+
cell_emb_dict=cell_emb_dict, smile_graph=smile_graph,
|
| 738 |
+
)
|
| 739 |
+
|
| 740 |
+
# Test datasets (independent set)
|
| 741 |
+
drug1_test = test_labels['drug1'].tolist()
|
| 742 |
+
drug2_test = test_labels['drug2'].tolist()
|
| 743 |
+
cells_test = test_labels['cell'].tolist()
|
| 744 |
+
labels_test = test_labels['label'].tolist()
|
| 745 |
+
|
| 746 |
+
test_d1_dataset = TestbedDataset(
|
| 747 |
+
root=args.data_dir, dataset='test_drug1_performer_module',
|
| 748 |
+
xd=drug1_test, xt=cells_test, y=labels_test,
|
| 749 |
+
cell_emb_dict=cell_emb_dict, smile_graph=smile_graph,
|
| 750 |
+
)
|
| 751 |
+
test_d2_dataset = TestbedDataset(
|
| 752 |
+
root=args.data_dir, dataset='test_drug2_performer_module',
|
| 753 |
+
xd=drug2_test, xt=cells_test, y=labels_test,
|
| 754 |
+
cell_emb_dict=cell_emb_dict, smile_graph=smile_graph,
|
| 755 |
+
)
|
| 756 |
+
|
| 757 |
+
# -------- Step 4: Training (5-fold, scLong-aligned) --------
|
| 758 |
+
lenth = len(train_d1_dataset)
|
| 759 |
+
pot = lenth // args.n_folds
|
| 760 |
+
random_num = random.sample(range(lenth), lenth)
|
| 761 |
+
all_fold_aucs = []
|
| 762 |
+
|
| 763 |
+
for fold_i in range(args.n_folds):
|
| 764 |
+
print_sys(f'\n{"=" * 60}')
|
| 765 |
+
print_sys(f'Fold {fold_i + 1}/{args.n_folds}')
|
| 766 |
+
print_sys(f'{"=" * 60}')
|
| 767 |
+
|
| 768 |
+
if args.domain_gen:
|
| 769 |
+
# Domain generalization: train on full train set, test on independent set
|
| 770 |
+
d1_train_data = train_d1_dataset
|
| 771 |
+
d1_test_data = test_d1_dataset
|
| 772 |
+
d2_train_data = train_d2_dataset
|
| 773 |
+
d2_test_data = test_d2_dataset
|
| 774 |
+
else:
|
| 775 |
+
# Standard k-fold cross-validation
|
| 776 |
+
test_num = random_num[pot * fold_i : pot * (fold_i + 1)]
|
| 777 |
+
train_num = (
|
| 778 |
+
random_num[: pot * fold_i] + random_num[pot * (fold_i + 1) :]
|
| 779 |
+
)
|
| 780 |
+
d1_train_data = train_d1_dataset[train_num]
|
| 781 |
+
d1_test_data = train_d1_dataset[test_num]
|
| 782 |
+
d2_train_data = train_d2_dataset[train_num]
|
| 783 |
+
d2_test_data = train_d2_dataset[test_num]
|
| 784 |
+
|
| 785 |
+
d1_loader_train = DataLoader(
|
| 786 |
+
d1_train_data, batch_size=args.batch_size, shuffle=None, pin_memory=True,
|
| 787 |
+
)
|
| 788 |
+
d1_loader_test = DataLoader(
|
| 789 |
+
d1_test_data, batch_size=args.batch_size, shuffle=None, pin_memory=True,
|
| 790 |
+
)
|
| 791 |
+
d2_loader_train = DataLoader(
|
| 792 |
+
d2_train_data, batch_size=args.batch_size, shuffle=None, pin_memory=True,
|
| 793 |
+
)
|
| 794 |
+
d2_loader_test = DataLoader(
|
| 795 |
+
d2_test_data, batch_size=args.batch_size, shuffle=None, pin_memory=True,
|
| 796 |
+
)
|
| 797 |
+
|
| 798 |
+
model = GCNNet(cell_emb_dim=cell_emb_dim).to(device)
|
| 799 |
+
|
| 800 |
+
if fold_i == 0:
|
| 801 |
+
for name, param in model.named_parameters():
|
| 802 |
+
print_sys(f" Parameter: {name}, Shape: {param.size()}")
|
| 803 |
+
total_params = sum(p.numel() for p in model.parameters())
|
| 804 |
+
print_sys(f'Total model parameters: {total_params}')
|
| 805 |
+
|
| 806 |
+
optimizer = torch.optim.Adam(model.parameters(), lr=args.lr)
|
| 807 |
+
scheduler = StepLR(optimizer, step_size=1000, gamma=0.9)
|
| 808 |
+
|
| 809 |
+
best_auc = 0
|
| 810 |
+
loss_history = []
|
| 811 |
+
metric_history = []
|
| 812 |
+
|
| 813 |
+
for epoch in range(1, args.epochs + 1):
|
| 814 |
+
avg_loss = train_epoch(
|
| 815 |
+
model, device, d1_loader_train, d2_loader_train,
|
| 816 |
+
optimizer, epoch,
|
| 817 |
+
)
|
| 818 |
+
scheduler.step()
|
| 819 |
+
loss_history.append(avg_loss)
|
| 820 |
+
|
| 821 |
+
if epoch % args.eval_interval == 0:
|
| 822 |
+
T, S, Y = predict(model, device, d1_loader_test, d2_loader_test)
|
| 823 |
+
m = evaluate_metrics(T, S, Y)
|
| 824 |
+
metric_history.append({'epoch': epoch, **m})
|
| 825 |
+
|
| 826 |
+
if m['AUC'] > best_auc:
|
| 827 |
+
best_auc = m['AUC']
|
| 828 |
+
best_fpr, best_tpr = metrics.roc_curve(T, S)[:2]
|
| 829 |
+
save_path = os.path.join(
|
| 830 |
+
args.output_dir, f'best_model_fold{fold_i}.pt'
|
| 831 |
+
)
|
| 832 |
+
torch.save(model.state_dict(), save_path)
|
| 833 |
+
print_sys(f'** New best AUROC: {best_auc:.4f} (saved)')
|
| 834 |
+
|
| 835 |
+
print_sys(
|
| 836 |
+
f'Epoch {epoch} | '
|
| 837 |
+
f'AUROC: {m["AUC"]:.4f} PR_AUC: {m["PR_AUC"]:.4f} '
|
| 838 |
+
f'ACC: {m["ACC"]:.4f} BACC: {m["BACC"]:.4f} '
|
| 839 |
+
f'PREC: {m["PREC"]:.4f} TPR: {m["TPR"]:.4f} '
|
| 840 |
+
f'KAPPA: {m["KAPPA"]:.4f}'
|
| 841 |
+
)
|
| 842 |
+
|
| 843 |
+
# Save loss curve
|
| 844 |
+
loss_csv_path = os.path.join(args.output_dir, f'loss_fold{fold_i}.csv')
|
| 845 |
+
loss_df = pd.DataFrame({'epoch': range(1, len(loss_history) + 1), 'avg_loss': loss_history})
|
| 846 |
+
loss_df.to_csv(loss_csv_path, index=False)
|
| 847 |
+
|
| 848 |
+
# Plot loss curve
|
| 849 |
+
fig, ax = plt.subplots(figsize=(8, 5))
|
| 850 |
+
ax.plot(loss_df['epoch'], loss_df['avg_loss'], linewidth=1.5)
|
| 851 |
+
ax.set_xlabel('Epoch')
|
| 852 |
+
ax.set_ylabel('Average Loss')
|
| 853 |
+
ax.set_title(f'Training Loss - Fold {fold_i + 1}')
|
| 854 |
+
ax.grid(True, alpha=0.3)
|
| 855 |
+
fig.tight_layout()
|
| 856 |
+
fig.savefig(os.path.join(args.output_dir, f'loss_curve_fold{fold_i}.png'), dpi=150)
|
| 857 |
+
plt.close(fig)
|
| 858 |
+
|
| 859 |
+
# Save metrics history
|
| 860 |
+
if metric_history:
|
| 861 |
+
metric_df = pd.DataFrame(metric_history)
|
| 862 |
+
metric_df.to_csv(os.path.join(args.output_dir, f'metrics_fold{fold_i}.csv'), index=False)
|
| 863 |
+
|
| 864 |
+
# Plot ROC curve (using best model's predictions)
|
| 865 |
+
fig, ax = plt.subplots(figsize=(6, 6))
|
| 866 |
+
ax.plot(best_fpr, best_tpr, linewidth=2, label=f'AUROC = {best_auc:.4f}')
|
| 867 |
+
ax.plot([0, 1], [0, 1], 'k--', linewidth=1, alpha=0.5)
|
| 868 |
+
ax.set_xlim([0, 1])
|
| 869 |
+
ax.set_ylim([0, 1.02])
|
| 870 |
+
ax.set_xlabel('False Positive Rate')
|
| 871 |
+
ax.set_ylabel('True Positive Rate')
|
| 872 |
+
ax.set_title(f'ROC Curve - Fold {fold_i + 1}')
|
| 873 |
+
ax.legend(loc='lower right')
|
| 874 |
+
ax.grid(True, alpha=0.3)
|
| 875 |
+
fig.tight_layout()
|
| 876 |
+
fig.savefig(os.path.join(args.output_dir, f'roc_curve_fold{fold_i}.png'), dpi=150)
|
| 877 |
+
plt.close(fig)
|
| 878 |
+
|
| 879 |
+
# Save fpr/tpr data
|
| 880 |
+
fpr_tpr_path = os.path.join(args.output_dir, f'fpr_tpr_fold{fold_i}.pkl')
|
| 881 |
+
with open(fpr_tpr_path, 'wb') as fp:
|
| 882 |
+
pickle.dump((best_fpr, best_tpr), fp)
|
| 883 |
+
|
| 884 |
+
print_sys(f'Fold {fold_i + 1} best AUROC: {best_auc:.4f}')
|
| 885 |
+
all_fold_aucs.append(best_auc)
|
| 886 |
+
|
| 887 |
+
# Summary
|
| 888 |
+
print_sys(f'\n{"=" * 60}')
|
| 889 |
+
print_sys(f'All folds AUROC: {all_fold_aucs}')
|
| 890 |
+
print_sys(f'Mean AUROC: {np.mean(all_fold_aucs):.4f} +/- {np.std(all_fold_aucs):.4f}')
|
| 891 |
+
print_sys('Training complete.')
|
| 892 |
+
|
| 893 |
+
|
| 894 |
+
if __name__ == '__main__':
|
| 895 |
+
main()
|
BioMod/drug_response/cmd.txt
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
python train_drug_performer_module.py \
|
| 2 |
+
--gpu_id 0 \
|
| 3 |
+
--data_path ../../drug_response_pytorch/data \
|
| 4 |
+
--checkpoint_path ../performer_module_multiply_all_module_48Mcell/performer_module_multiply_all_module_48Mcell_epoch2.pth \
|
| 5 |
+
--hyper_params_path ../performer_module_multiply_all_module_48Mcell/performer_module_multiply_all_module_48Mcell.pkl \
|
| 6 |
+
--epochs 300 \
|
| 7 |
+
--module_data_dir ../module_data_all \
|
| 8 |
+
--save_dir ./performer_module_multiply_all_module_48Mcell \
|
| 9 |
+
--fusion_method multiply \
|
| 10 |
+
--pool_method max
|
BioMod/drug_response/train_drug_performer_module.py
ADDED
|
@@ -0,0 +1,1065 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python
|
| 2 |
+
# -*- coding: utf-8 -*-
|
| 3 |
+
"""
|
| 4 |
+
Drug response prediction: using drug molecular structure + PerformerModule cell embedding
|
| 5 |
+
|
| 6 |
+
Architecture:
|
| 7 |
+
- Drug branch: 3-layer GCN + residual connection + double pooling
|
| 8 |
+
- Cell branch: extract module-level features from pre-trained PerformerModule
|
| 9 |
+
- Fusion layer: attention mechanism + deep MLP
|
| 10 |
+
- Optimization strategy: AdamW + cosine annealing + early stopping
|
| 11 |
+
|
| 12 |
+
How to use:
|
| 13 |
+
python train_drug_performer_module.py --gpu_id 0 --data_path ../drug_response_pytorch/data --epochs 300
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
import argparse
|
| 17 |
+
import os
|
| 18 |
+
import random
|
| 19 |
+
import sys
|
| 20 |
+
import pickle
|
| 21 |
+
from typing import Dict, List, Tuple, Union, Optional
|
| 22 |
+
|
| 23 |
+
import hickle as hkl
|
| 24 |
+
import numpy as np
|
| 25 |
+
import pandas as pd
|
| 26 |
+
import scipy.sparse as sp
|
| 27 |
+
import torch
|
| 28 |
+
import torch.nn as nn
|
| 29 |
+
from scipy.stats import pearsonr
|
| 30 |
+
from torch.utils.data import DataLoader, Dataset
|
| 31 |
+
|
| 32 |
+
# Import PerformerModule model
|
| 33 |
+
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 34 |
+
if project_root not in sys.path:
|
| 35 |
+
sys.path.insert(0, project_root)
|
| 36 |
+
sys.path.insert(0, os.path.dirname(project_root)) # Add scLong root directory
|
| 37 |
+
|
| 38 |
+
# Use direct path import to avoid conflicts with files with the same name
|
| 39 |
+
import importlib.util
|
| 40 |
+
performer_module_dir = project_root
|
| 41 |
+
|
| 42 |
+
# import models_performer_module
|
| 43 |
+
models_path = os.path.join(performer_module_dir, 'models_performer_module.py')
|
| 44 |
+
if not os.path.exists(models_path):
|
| 45 |
+
raise FileNotFoundError(f"Model file not found: {models_path}")
|
| 46 |
+
spec = importlib.util.spec_from_file_location("models_performer_module", models_path)
|
| 47 |
+
models_performer_module = importlib.util.module_from_spec(spec)
|
| 48 |
+
spec.loader.exec_module(models_performer_module)
|
| 49 |
+
PerformerEncoderOnly_Module = models_performer_module.PerformerEncoderOnly_Module
|
| 50 |
+
sanitize_performer_module_config = models_performer_module.sanitize_performer_module_config
|
| 51 |
+
|
| 52 |
+
# Import module_dataset
|
| 53 |
+
dataset_path = os.path.join(performer_module_dir, 'module_dataset.py')
|
| 54 |
+
if not os.path.exists(dataset_path):
|
| 55 |
+
raise FileNotFoundError(f"Dataset file not found: {dataset_path}")
|
| 56 |
+
spec = importlib.util.spec_from_file_location("module_dataset", dataset_path)
|
| 57 |
+
module_dataset_module = importlib.util.module_from_spec(spec)
|
| 58 |
+
spec.loader.exec_module(module_dataset_module)
|
| 59 |
+
load_module_data = module_dataset_module.load_module_data
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
# ===================== Utility functions =====================
|
| 63 |
+
|
| 64 |
+
def print_sys(*args, **kwargs):
|
| 65 |
+
"""Print and refresh output"""
|
| 66 |
+
print(*args, **kwargs, flush=True)
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def reindex_tensor_universal(
|
| 70 |
+
tensor: torch.Tensor,
|
| 71 |
+
index_positions: List[int],
|
| 72 |
+
dim: int,
|
| 73 |
+
filler: str = "0",
|
| 74 |
+
device: Union[torch.device, str] = "cpu",
|
| 75 |
+
) -> torch.Tensor:
|
| 76 |
+
"""Universal reordering operation"""
|
| 77 |
+
index_tensor = torch.tensor(index_positions, device=device)
|
| 78 |
+
tensor_shape = list(tensor.shape)
|
| 79 |
+
tensor_shape[dim] = len(index_positions)
|
| 80 |
+
index_shape = [1] * len(tensor_shape)
|
| 81 |
+
index_shape[dim] = len(index_positions)
|
| 82 |
+
index_tensor = index_tensor.view(*index_shape).expand(*tensor_shape)
|
| 83 |
+
|
| 84 |
+
if filler == "0":
|
| 85 |
+
padder_shape = list(tensor_shape)
|
| 86 |
+
padder_shape[dim] = 1
|
| 87 |
+
padder = torch.zeros(padder_shape, dtype=tensor.dtype, device=device)
|
| 88 |
+
elif filler == "mean":
|
| 89 |
+
padder = tensor.mean(dim=dim, keepdim=True)
|
| 90 |
+
else:
|
| 91 |
+
raise ValueError("filler should be '0' or 'mean'")
|
| 92 |
+
|
| 93 |
+
expanded_tensor = torch.cat((tensor, padder), dim=dim)
|
| 94 |
+
reindexed = torch.gather(expanded_tensor, dim, index_tensor)
|
| 95 |
+
return reindexed
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
# ===================== PerformerModule related =====================
|
| 99 |
+
|
| 100 |
+
def load_performer_module_model(
|
| 101 |
+
checkpoint_path: str,
|
| 102 |
+
hyper_params_path: str,
|
| 103 |
+
module_data_dir: Optional[str],
|
| 104 |
+
module_data_path: Optional[str],
|
| 105 |
+
gene_list_path: str,
|
| 106 |
+
gene_mapping_path: str,
|
| 107 |
+
gene2vec_path: str,
|
| 108 |
+
device: torch.device,
|
| 109 |
+
shuffle_module_genes: bool = False,
|
| 110 |
+
seed: int = 0,
|
| 111 |
+
) -> PerformerEncoderOnly_Module:
|
| 112 |
+
"""Load a pretrained PerformerModule model"""
|
| 113 |
+
print_sys(f"Loading PerformerModule model from: {checkpoint_path}")
|
| 114 |
+
|
| 115 |
+
# Load model hyperparameters
|
| 116 |
+
print_sys(f"Loading hyper parameters from: {hyper_params_path}")
|
| 117 |
+
with open(hyper_params_path, 'rb') as f:
|
| 118 |
+
model_params = pickle.load(f)
|
| 119 |
+
model_params.pop('module_embed_chunk_padded_gene_budget', None)
|
| 120 |
+
model_params = sanitize_performer_module_config(
|
| 121 |
+
model_params,
|
| 122 |
+
source=hyper_params_path,
|
| 123 |
+
log_fn=print_sys,
|
| 124 |
+
)
|
| 125 |
+
|
| 126 |
+
# Update gene2vec path
|
| 127 |
+
if gene2vec_path:
|
| 128 |
+
model_params['gene2vec_file'] = gene2vec_path
|
| 129 |
+
|
| 130 |
+
# Read fusion_method from pkl to ensure consistency with pre-training
|
| 131 |
+
fusion_method = model_params.get('fusion_method', 'multiply')
|
| 132 |
+
print_sys(f"Using fusion_method from pkl: {fusion_method}")
|
| 133 |
+
|
| 134 |
+
# use_gene2vec strictly follows the pre-training pkl to avoid conflicts caused by downstream coverage.
|
| 135 |
+
pkl_use_gene2vec = model_params.get('use_gene2vec', True)
|
| 136 |
+
model_params['use_gene2vec'] = pkl_use_gene2vec
|
| 137 |
+
print_sys(f"use_gene2vec={pkl_use_gene2vec} (from PKL)")
|
| 138 |
+
|
| 139 |
+
# Load module data (if not present in hyperparameters)
|
| 140 |
+
if 'module_sets' not in model_params or model_params.get('module_sets') is None:
|
| 141 |
+
if module_data_dir or module_data_path:
|
| 142 |
+
print_sys("Loading module data...")
|
| 143 |
+
module_sets = load_module_data(
|
| 144 |
+
module_data_path=module_data_path,
|
| 145 |
+
module_data_dir=module_data_dir,
|
| 146 |
+
gene_list_path=gene_list_path,
|
| 147 |
+
gene_mapping_path=gene_mapping_path,
|
| 148 |
+
verbose=True
|
| 149 |
+
)
|
| 150 |
+
model_params['module_sets'] = module_sets
|
| 151 |
+
else:
|
| 152 |
+
raise ValueError("Model requires module_sets, but --module_data_dir or --module_data_path was not provided")
|
| 153 |
+
else:
|
| 154 |
+
module_sets = model_params['module_sets']
|
| 155 |
+
print_sys(f"Using module_sets from hyper parameters ({len(module_sets)} modules)")
|
| 156 |
+
|
| 157 |
+
# If shuffle_module_genes is enabled, disrupt the gene distribution within the module.
|
| 158 |
+
if shuffle_module_genes:
|
| 159 |
+
print_sys(
|
| 160 |
+
f"Applying downstream-only extra module reshuffle (seed={seed}). "
|
| 161 |
+
f"Pretraining-time module_sets from PKL will be reshuffled again."
|
| 162 |
+
)
|
| 163 |
+
# Collect all gene indices
|
| 164 |
+
all_gene_indices = []
|
| 165 |
+
module_sizes = []
|
| 166 |
+
for module in module_sets:
|
| 167 |
+
all_gene_indices.extend(module)
|
| 168 |
+
module_sizes.append(len(module))
|
| 169 |
+
|
| 170 |
+
# Scramble the gene index using a fixed random seed
|
| 171 |
+
rng = random.Random(seed)
|
| 172 |
+
rng.shuffle(all_gene_indices)
|
| 173 |
+
|
| 174 |
+
# Redistribute genes according to original module size
|
| 175 |
+
shuffled_module_sets = []
|
| 176 |
+
start_idx = 0
|
| 177 |
+
for size in module_sizes:
|
| 178 |
+
shuffled_module_sets.append(all_gene_indices[start_idx:start_idx + size])
|
| 179 |
+
start_idx += size
|
| 180 |
+
module_sets = shuffled_module_sets
|
| 181 |
+
model_params['module_sets'] = module_sets
|
| 182 |
+
print_sys(f"Shuffled {len(all_gene_indices)} genes across {len(module_sets)} modules")
|
| 183 |
+
|
| 184 |
+
print_sys(f"PerformerModule config: base_dim={model_params.get('base_dim', 'N/A')}, "
|
| 185 |
+
f"num_modules={len(module_sets)}")
|
| 186 |
+
|
| 187 |
+
# Build model
|
| 188 |
+
model = PerformerEncoderOnly_Module(**model_params).to(device)
|
| 189 |
+
|
| 190 |
+
# Load weights
|
| 191 |
+
print_sys(f"Loading checkpoint from: {checkpoint_path}")
|
| 192 |
+
checkpoint = torch.load(checkpoint_path, map_location=device)
|
| 193 |
+
|
| 194 |
+
# Handle parameters in checkpoint
|
| 195 |
+
if 'model_state_dict' in checkpoint:
|
| 196 |
+
state_dict = checkpoint['model_state_dict']
|
| 197 |
+
else:
|
| 198 |
+
state_dict = checkpoint
|
| 199 |
+
|
| 200 |
+
# Filter out possible GO related parameters
|
| 201 |
+
filtered_state_dict = {}
|
| 202 |
+
for k, v in state_dict.items():
|
| 203 |
+
if 'go_conv' not in k and 'G_go' not in k.lower():
|
| 204 |
+
filtered_state_dict[k] = v
|
| 205 |
+
|
| 206 |
+
# Use strict=True to ensure the weights match exactly (except for filtered GO parameters)
|
| 207 |
+
model.load_state_dict(filtered_state_dict, strict=True)
|
| 208 |
+
|
| 209 |
+
print_sys("Loaded pretrained PerformerModule weights.")
|
| 210 |
+
|
| 211 |
+
# freeze model
|
| 212 |
+
model.eval()
|
| 213 |
+
for p in model.parameters():
|
| 214 |
+
p.requires_grad = False
|
| 215 |
+
|
| 216 |
+
return model, len(module_sets), model_params.get('base_dim', 1024)
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
def build_performer_module_gene_mapping(
|
| 220 |
+
symbol2ens_path: str,
|
| 221 |
+
gene_list_path: str,
|
| 222 |
+
expr_genes: List[str],
|
| 223 |
+
) -> Tuple[Dict[str, int], List[int]]:
|
| 224 |
+
"""Build a gene map"""
|
| 225 |
+
gene2ens = pd.read_csv(symbol2ens_path, index_col=0)
|
| 226 |
+
symbol2ens = gene2ens["Ensembl gene ID"].to_dict()
|
| 227 |
+
|
| 228 |
+
input_genes = [symbol2ens.get(sym, sym) for sym in expr_genes]
|
| 229 |
+
input_gene_num = len(input_genes)
|
| 230 |
+
input_mapping = {gid: i for i, gid in enumerate(input_genes)}
|
| 231 |
+
|
| 232 |
+
with open(gene_list_path, "r") as f:
|
| 233 |
+
performer_genes = [line.rstrip("\n") for line in f.readlines()]
|
| 234 |
+
performer_genes_pad = performer_genes + ["PAD"]
|
| 235 |
+
|
| 236 |
+
performer_index_positions = [
|
| 237 |
+
input_mapping.get(gid, input_gene_num) for gid in performer_genes_pad
|
| 238 |
+
]
|
| 239 |
+
intersect = np.intersect1d(input_genes, performer_genes)
|
| 240 |
+
print_sys(f"Target and PerformerModule genes intersect: {len(intersect)}")
|
| 241 |
+
return input_mapping, performer_index_positions
|
| 242 |
+
|
| 243 |
+
|
| 244 |
+
def build_cell_embeddings_with_performer_module(
|
| 245 |
+
gexpr_feature: pd.DataFrame,
|
| 246 |
+
symbol2ens_path: str,
|
| 247 |
+
gene_list_path: str,
|
| 248 |
+
gene_mapping_path: str,
|
| 249 |
+
gene2vec_path: str,
|
| 250 |
+
checkpoint_path: str,
|
| 251 |
+
hyper_params_path: str,
|
| 252 |
+
module_data_dir: Optional[str],
|
| 253 |
+
module_data_path: Optional[str],
|
| 254 |
+
device: torch.device,
|
| 255 |
+
pool_method: str = "mean",
|
| 256 |
+
shuffle_module_genes: bool = False,
|
| 257 |
+
seed: int = 0,
|
| 258 |
+
) -> Tuple[Dict[str, np.ndarray], int]:
|
| 259 |
+
"""
|
| 260 |
+
Use PerformerModule to construct embedding for each cell line
|
| 261 |
+
|
| 262 |
+
Args:
|
| 263 |
+
pool_method: pooling method, 'mean', 'max', 'cls', or 'concat'
|
| 264 |
+
- 'mean': average all modules (default)
|
| 265 |
+
- 'max': take the maximum value for all modules
|
| 266 |
+
- 'cls': only use CLS token (if available)
|
| 267 |
+
- 'concat': concatenate all modules (the dimension will be very large)
|
| 268 |
+
use_gene2vec: automatically restored by pre-trained PKL, no longer covered downstream
|
| 269 |
+
"""
|
| 270 |
+
model, num_modules, encoder_embed_dim = load_performer_module_model(
|
| 271 |
+
checkpoint_path=checkpoint_path,
|
| 272 |
+
hyper_params_path=hyper_params_path,
|
| 273 |
+
module_data_dir=module_data_dir,
|
| 274 |
+
module_data_path=module_data_path,
|
| 275 |
+
gene_list_path=gene_list_path,
|
| 276 |
+
gene_mapping_path=gene_mapping_path,
|
| 277 |
+
gene2vec_path=gene2vec_path,
|
| 278 |
+
device=device,
|
| 279 |
+
shuffle_module_genes=shuffle_module_genes,
|
| 280 |
+
seed=seed,
|
| 281 |
+
)
|
| 282 |
+
|
| 283 |
+
_, performer_index_positions = build_performer_module_gene_mapping(
|
| 284 |
+
symbol2ens_path=symbol2ens_path,
|
| 285 |
+
gene_list_path=gene_list_path,
|
| 286 |
+
expr_genes=list(gexpr_feature.columns),
|
| 287 |
+
)
|
| 288 |
+
|
| 289 |
+
cell_emb_dict: Dict[str, np.ndarray] = {}
|
| 290 |
+
first_emb_dim: int = -1
|
| 291 |
+
|
| 292 |
+
with torch.no_grad():
|
| 293 |
+
for cell_line_id, row in gexpr_feature.iterrows():
|
| 294 |
+
x = torch.tensor(row.values[None, :], dtype=torch.float32, device=device)
|
| 295 |
+
x_performer = reindex_tensor_universal(
|
| 296 |
+
x, performer_index_positions, dim=1, filler="0", device=device
|
| 297 |
+
)
|
| 298 |
+
|
| 299 |
+
# Extract features through encoder (without masking, mask_ratio=0.0)
|
| 300 |
+
out = model(x_performer, mask_ratio=0.0, return_encodings=True)
|
| 301 |
+
|
| 302 |
+
# Extract module encodings
|
| 303 |
+
if 'module_encodings' in out:
|
| 304 |
+
module_encodings = out['module_encodings'] # (1, num_modules, encoder_embed_dim)
|
| 305 |
+
|
| 306 |
+
# Processed according to pooling method
|
| 307 |
+
if pool_method == "cls":
|
| 308 |
+
if model.use_cls:
|
| 309 |
+
# Use CLS token (in latent, but module_encodings does not contain CLS)
|
| 310 |
+
# Need to get the complete latent from forward_encoder
|
| 311 |
+
enc_out = model.forward_encoder(x_performer, mask_ratio=0.0)
|
| 312 |
+
latent = enc_out[0]
|
| 313 |
+
if model.use_cls:
|
| 314 |
+
cell_emb = latent[:, 0, :] # (1, encoder_embed_dim)
|
| 315 |
+
else:
|
| 316 |
+
print_sys("Warning: pool_method='cls' but model has no CLS token, using 'mean' instead")
|
| 317 |
+
cell_emb = module_encodings.mean(dim=1)
|
| 318 |
+
else:
|
| 319 |
+
print_sys("Warning: pool_method='cls' but model has no CLS token, using 'mean' instead")
|
| 320 |
+
cell_emb = module_encodings.mean(dim=1)
|
| 321 |
+
elif pool_method == "mean":
|
| 322 |
+
cell_emb = module_encodings.mean(dim=1) # (1, encoder_embed_dim)
|
| 323 |
+
elif pool_method == "max":
|
| 324 |
+
cell_emb = module_encodings.max(dim=1)[0] # (1, encoder_embed_dim)
|
| 325 |
+
elif pool_method == "concat":
|
| 326 |
+
cell_emb = module_encodings.reshape(1, -1) # (1, num_modules * encoder_embed_dim)
|
| 327 |
+
else:
|
| 328 |
+
raise ValueError(f"Unknown pool_method: {pool_method}")
|
| 329 |
+
else:
|
| 330 |
+
raise ValueError(f"Expected 'module_encodings' in output, got: {list(out.keys())}")
|
| 331 |
+
|
| 332 |
+
cell_emb_np = cell_emb.squeeze(0).cpu().numpy()
|
| 333 |
+
cell_emb_dict[cell_line_id] = cell_emb_np
|
| 334 |
+
if first_emb_dim < 0:
|
| 335 |
+
first_emb_dim = cell_emb_np.shape[0]
|
| 336 |
+
|
| 337 |
+
print_sys(
|
| 338 |
+
f"Built PerformerModule embeddings for {len(cell_emb_dict)} cell lines, "
|
| 339 |
+
f"dim={first_emb_dim}, pool_method={pool_method}"
|
| 340 |
+
)
|
| 341 |
+
return cell_emb_dict, first_emb_dim
|
| 342 |
+
|
| 343 |
+
|
| 344 |
+
# ===================== Data processing =====================
|
| 345 |
+
|
| 346 |
+
TCGA_LABEL_SET = [
|
| 347 |
+
"ALL", "BLCA", "BRCA", "CESC", "DLBC", "LIHC", "LUAD", "ESCA", "GBM",
|
| 348 |
+
"HNSC", "KIRC", "LAML", "LCML", "LGG", "LUSC", "MESO", "MM", "NB",
|
| 349 |
+
"OV", "PAAD", "SCLC", "SKCM", "STAD", "THCA", "COAD/READ",
|
| 350 |
+
]
|
| 351 |
+
|
| 352 |
+
|
| 353 |
+
def normalize_adj(adj: np.ndarray) -> np.ndarray:
|
| 354 |
+
"""Normalized adjacency matrix"""
|
| 355 |
+
adj = adj + np.eye(adj.shape[0])
|
| 356 |
+
d = sp.diags(np.power(np.array(adj.sum(1)), -0.5).flatten(), 0).toarray()
|
| 357 |
+
a_norm = adj.dot(d).transpose().dot(d)
|
| 358 |
+
return a_norm
|
| 359 |
+
|
| 360 |
+
|
| 361 |
+
def calculate_graph_feat(
|
| 362 |
+
feat_mat: np.ndarray,
|
| 363 |
+
adj_list: List[List[int]],
|
| 364 |
+
max_atoms: int,
|
| 365 |
+
) -> Tuple[np.ndarray, np.ndarray]:
|
| 366 |
+
"""Compute drug graph features"""
|
| 367 |
+
assert feat_mat.shape[0] == len(adj_list)
|
| 368 |
+
feat = np.zeros((max_atoms, feat_mat.shape[-1]), dtype=np.float32)
|
| 369 |
+
adj_mat = np.zeros((max_atoms, max_atoms), dtype=np.float32)
|
| 370 |
+
|
| 371 |
+
feat[: feat_mat.shape[0], :] = feat_mat
|
| 372 |
+
for i, nodes in enumerate(adj_list):
|
| 373 |
+
for each in nodes:
|
| 374 |
+
adj_mat[i, int(each)] = 1
|
| 375 |
+
|
| 376 |
+
assert np.allclose(adj_mat, adj_mat.T)
|
| 377 |
+
adj_ = adj_mat[: len(adj_list), : len(adj_list)]
|
| 378 |
+
norm_adj_ = normalize_adj(adj_)
|
| 379 |
+
adj_mat[: len(adj_list), : len(adj_list)] = norm_adj_
|
| 380 |
+
return feat, adj_mat
|
| 381 |
+
|
| 382 |
+
|
| 383 |
+
def metadata_generate(
|
| 384 |
+
drug_info_file: str,
|
| 385 |
+
cell_line_info_file: str,
|
| 386 |
+
drug_feature_dir: str,
|
| 387 |
+
gene_expression_file: str,
|
| 388 |
+
) -> Tuple[dict, pd.DataFrame, list]:
|
| 389 |
+
"""Generate metadata"""
|
| 390 |
+
# drug_id --> pubchem_id
|
| 391 |
+
reader = pd.read_csv(drug_info_file)
|
| 392 |
+
drugid2pubchemid = {# Build drug_id → pubchem_id mapping
|
| 393 |
+
str(row.iloc[0]): str(row.iloc[5])# Mapping from column 0 of drug information CSV to column 5 (pubchem_id)
|
| 394 |
+
for _, row in reader.iterrows()
|
| 395 |
+
if str(row.iloc[5]).isdigit()
|
| 396 |
+
}
|
| 397 |
+
|
| 398 |
+
# cellline --> cancer type # Read from the TSV file, the first column is cellline_id, the last column is the cancer type
|
| 399 |
+
cellline2cancertype: Dict[str, str] = {}
|
| 400 |
+
with open(cell_line_info_file, "r") as f:
|
| 401 |
+
for line in f.readlines()[1:]:
|
| 402 |
+
cellline_id = line.split("\t")[1]
|
| 403 |
+
tcga_label = line.strip().split("\t")[-1]
|
| 404 |
+
cellline2cancertype[cellline_id] = tcga_label
|
| 405 |
+
|
| 406 |
+
gexpr_feature = pd.read_csv(# Gene expression matrix for cell lines
|
| 407 |
+
gene_expression_file, sep=",", header=0, index_col=0
|
| 408 |
+
)
|
| 409 |
+
|
| 410 |
+
# load drug graph features
|
| 411 |
+
drug_feature: Dict[str, list] = {}
|
| 412 |
+
drug_pubchem_id_set = []
|
| 413 |
+
for fname in os.listdir(drug_feature_dir):
|
| 414 |
+
if not fname.endswith(".hkl"):
|
| 415 |
+
continue
|
| 416 |
+
pubchem_id = fname.split(".")[0]
|
| 417 |
+
feat_mat, adj_list, degree_list = hkl.load(
|
| 418 |
+
os.path.join(drug_feature_dir, fname)
|
| 419 |
+
)
|
| 420 |
+
drug_feature[pubchem_id] = [feat_mat, adj_list, degree_list]# [Feature matrix, adjacency list, degree list]
|
| 421 |
+
drug_pubchem_id_set.append(pubchem_id)
|
| 422 |
+
|
| 423 |
+
# IC50
|
| 424 |
+
# Read IC50 response data from GDSC database
|
| 425 |
+
# Filter out drug-cell line pairs with corresponding pubchem_id and gene expression data
|
| 426 |
+
cancer_response_exp_file = os.path.join(
|
| 427 |
+
os.path.dirname(gene_expression_file), "GDSC_IC50.csv"
|
| 428 |
+
)
|
| 429 |
+
experiment_data = pd.read_csv(
|
| 430 |
+
cancer_response_exp_file, sep=",", header=0, index_col=0
|
| 431 |
+
)
|
| 432 |
+
drug_match_list = [
|
| 433 |
+
item
|
| 434 |
+
for item in experiment_data.index
|
| 435 |
+
if item.split(":")[1] in drugid2pubchemid.keys()
|
| 436 |
+
]
|
| 437 |
+
experiment_data_filtered = experiment_data.loc[drug_match_list]
|
| 438 |
+
|
| 439 |
+
data_idx: list = []# Each entry contains: cell line ID, drug pubchem ID, IC50 value, cancer type.
|
| 440 |
+
for each_drug in experiment_data_filtered.index:
|
| 441 |
+
for each_cellline in experiment_data_filtered.columns:
|
| 442 |
+
pubchem_id = drugid2pubchemid[each_drug.split(":")[-1]]
|
| 443 |
+
if (
|
| 444 |
+
str(pubchem_id) in drug_pubchem_id_set
|
| 445 |
+
and each_cellline in gexpr_feature.index
|
| 446 |
+
):
|
| 447 |
+
if (
|
| 448 |
+
not np.isnan(
|
| 449 |
+
experiment_data_filtered.loc[each_drug, each_cellline]
|
| 450 |
+
)
|
| 451 |
+
and each_cellline in cellline2cancertype.keys()
|
| 452 |
+
):
|
| 453 |
+
ln_ic50 = float(
|
| 454 |
+
experiment_data_filtered.loc[each_drug, each_cellline]
|
| 455 |
+
)
|
| 456 |
+
data_idx.append(
|
| 457 |
+
(
|
| 458 |
+
each_cellline,
|
| 459 |
+
pubchem_id,
|
| 460 |
+
ln_ic50,
|
| 461 |
+
cellline2cancertype[each_cellline],
|
| 462 |
+
)
|
| 463 |
+
)
|
| 464 |
+
|
| 465 |
+
nb_celllines = len(set([item[0] for item in data_idx]))
|
| 466 |
+
nb_drugs = len(set([item[1] for item in data_idx]))
|
| 467 |
+
print_sys(
|
| 468 |
+
f"{len(data_idx)} instances across {nb_celllines} cell lines and {nb_drugs} drugs."
|
| 469 |
+
)
|
| 470 |
+
return drug_feature, gexpr_feature, data_idx # Drug molecular graph features, cell line gene expression features, effective (cell line, drug, IC50, cancer type) four-tuple list
|
| 471 |
+
|
| 472 |
+
|
| 473 |
+
def data_split(data_idx: list, ratio: float = 0.95):
|
| 474 |
+
"""Stratified by cancer type"""
|
| 475 |
+
train_idx, test_idx = [], []
|
| 476 |
+
for each_type in TCGA_LABEL_SET:
|
| 477 |
+
data_subtype_idx = [item for item in data_idx if item[-1] == each_type]
|
| 478 |
+
if len(data_subtype_idx) == 0:
|
| 479 |
+
continue
|
| 480 |
+
k = max(1, int(ratio * len(data_subtype_idx)))
|
| 481 |
+
train_list = random.sample(data_subtype_idx, k)
|
| 482 |
+
test_list = [item for item in data_subtype_idx if item not in train_list]
|
| 483 |
+
train_idx += train_list
|
| 484 |
+
test_idx += test_list
|
| 485 |
+
return train_idx, test_idx
|
| 486 |
+
|
| 487 |
+
|
| 488 |
+
def feature_extract(
|
| 489 |
+
data_idx: list,
|
| 490 |
+
drug_feature: dict,
|
| 491 |
+
max_atoms: int,
|
| 492 |
+
) -> Tuple[list, np.ndarray, list, list]:
|
| 493 |
+
"""Extract drug map features"""
|
| 494 |
+
nb_instance = len(data_idx)
|
| 495 |
+
|
| 496 |
+
drug_data = [[] for _ in range(nb_instance)]
|
| 497 |
+
target = np.zeros(nb_instance, dtype=np.float32)
|
| 498 |
+
cancer_type_list = []
|
| 499 |
+
|
| 500 |
+
for idx in range(nb_instance):
|
| 501 |
+
cell_line_id, pubchem_id, ln_ic50, cancer_type = data_idx[idx]
|
| 502 |
+
|
| 503 |
+
# Drug map features
|
| 504 |
+
feat_mat, adj_list, _ = drug_feature[str(pubchem_id)]
|
| 505 |
+
feat, adj = calculate_graph_feat(feat_mat, adj_list, max_atoms)
|
| 506 |
+
drug_data[idx] = [feat, adj]
|
| 507 |
+
|
| 508 |
+
# target value
|
| 509 |
+
target[idx] = ln_ic50
|
| 510 |
+
|
| 511 |
+
cancer_type_list.append([cancer_type, cell_line_id, pubchem_id])
|
| 512 |
+
|
| 513 |
+
return drug_data, target, cancer_type_list, data_idx
|
| 514 |
+
|
| 515 |
+
|
| 516 |
+
# ===================== Dataset =====================
|
| 517 |
+
|
| 518 |
+
class DrugPerformerModuleDataset(Dataset):
|
| 519 |
+
"""Drug + PerformerModule Dataset"""
|
| 520 |
+
|
| 521 |
+
def __init__(
|
| 522 |
+
self,
|
| 523 |
+
drug_data: list,
|
| 524 |
+
targets: np.ndarray,
|
| 525 |
+
index_list: list,
|
| 526 |
+
cell_emb_dict: Dict[str, np.ndarray],
|
| 527 |
+
):
|
| 528 |
+
super().__init__()
|
| 529 |
+
self.drug_data = drug_data
|
| 530 |
+
self.targets = targets.astype(np.float32)
|
| 531 |
+
self.index_list = index_list
|
| 532 |
+
self.cell_emb_dict = cell_emb_dict
|
| 533 |
+
|
| 534 |
+
def __len__(self) -> int:
|
| 535 |
+
return int(self.targets.shape[0])
|
| 536 |
+
|
| 537 |
+
def __getitem__(self, idx: int):
|
| 538 |
+
# Drug map features
|
| 539 |
+
feat, adj = self.drug_data[idx]
|
| 540 |
+
drug_feat = torch.from_numpy(feat).float()
|
| 541 |
+
drug_adj = torch.from_numpy(adj).float()
|
| 542 |
+
|
| 543 |
+
# PerformerModule cell embedding
|
| 544 |
+
cell_line_id = self.index_list[idx][0]
|
| 545 |
+
cell_emb_np = self.cell_emb_dict[cell_line_id]
|
| 546 |
+
cell_emb = torch.from_numpy(cell_emb_np).float()
|
| 547 |
+
|
| 548 |
+
# target value
|
| 549 |
+
target = torch.tensor(self.targets[idx], dtype=torch.float32)
|
| 550 |
+
|
| 551 |
+
return {
|
| 552 |
+
"drug_feat": drug_feat,
|
| 553 |
+
"drug_adj": drug_adj,
|
| 554 |
+
"cell_emb": cell_emb,
|
| 555 |
+
"target": target,
|
| 556 |
+
}
|
| 557 |
+
|
| 558 |
+
|
| 559 |
+
# ===================== Model Definition =====================
|
| 560 |
+
|
| 561 |
+
class GraphConv(nn.Module):
|
| 562 |
+
"""graph convolution layer"""
|
| 563 |
+
|
| 564 |
+
def __init__(self, in_features: int, out_features: int, bias: bool = True):
|
| 565 |
+
super().__init__()
|
| 566 |
+
self.in_features = in_features
|
| 567 |
+
self.out_features = out_features
|
| 568 |
+
self.weight = nn.Parameter(torch.Tensor(in_features, out_features))
|
| 569 |
+
if bias:
|
| 570 |
+
self.bias = nn.Parameter(torch.Tensor(out_features))
|
| 571 |
+
else:
|
| 572 |
+
self.bias = None
|
| 573 |
+
self.reset_parameters()
|
| 574 |
+
|
| 575 |
+
def reset_parameters(self):
|
| 576 |
+
nn.init.xavier_uniform_(self.weight)
|
| 577 |
+
if self.bias is not None:
|
| 578 |
+
nn.init.zeros_(self.bias)
|
| 579 |
+
|
| 580 |
+
def forward(self, x: torch.Tensor, adj: torch.Tensor) -> torch.Tensor:
|
| 581 |
+
xw = torch.matmul(x, self.weight)
|
| 582 |
+
if self.bias is not None:
|
| 583 |
+
xw = xw + self.bias
|
| 584 |
+
out = torch.bmm(adj.transpose(1, 2), xw)
|
| 585 |
+
return out
|
| 586 |
+
|
| 587 |
+
|
| 588 |
+
class DrugPerformerModuleModel(nn.Module):
|
| 589 |
+
"""
|
| 590 |
+
Drugs + PerformerModule Predictive Model
|
| 591 |
+
|
| 592 |
+
Architecture:
|
| 593 |
+
- Drug branch: 3-layer GraphConv + residual connection + double pooling
|
| 594 |
+
- Cell branch: 2-layer MLP (processing PerformerModule module embeddings)
|
| 595 |
+
- Fusion layer: 4-layer MLP + attention mechanism
|
| 596 |
+
"""
|
| 597 |
+
|
| 598 |
+
def __init__(
|
| 599 |
+
self,
|
| 600 |
+
drug_feat_dim: int,
|
| 601 |
+
cell_emb_dim: int,
|
| 602 |
+
hidden_dim: int = 256,
|
| 603 |
+
dropout: float = 0.1,
|
| 604 |
+
):
|
| 605 |
+
super().__init__()
|
| 606 |
+
self.relu = nn.ReLU()
|
| 607 |
+
self.dropout = nn.Dropout(dropout)
|
| 608 |
+
|
| 609 |
+
# Drug branch: enhanced 3-layer GraphConv + residual connection
|
| 610 |
+
self.graph_conv1 = GraphConv(drug_feat_dim, 256)
|
| 611 |
+
self.graph_conv2 = GraphConv(256, 256)
|
| 612 |
+
self.graph_conv3 = GraphConv(256, 128)
|
| 613 |
+
self.drug_bn1 = nn.BatchNorm1d(256)
|
| 614 |
+
self.drug_bn2 = nn.BatchNorm1d(256)
|
| 615 |
+
self.drug_bn3 = nn.BatchNorm1d(128)
|
| 616 |
+
|
| 617 |
+
# Cell branch: 2-layer MLP (processing PerformerModule embeddings)
|
| 618 |
+
self.cell_fc1 = nn.Linear(cell_emb_dim, 256)
|
| 619 |
+
self.cell_bn1 = nn.BatchNorm1d(256)
|
| 620 |
+
self.cell_fc2 = nn.Linear(256, 128)
|
| 621 |
+
self.cell_bn2 = nn.BatchNorm1d(128)
|
| 622 |
+
|
| 623 |
+
# Fusion dimension: 128 (drug) + 128 (cell) = 256
|
| 624 |
+
fusion_dim = 256
|
| 625 |
+
|
| 626 |
+
# attention mechanism
|
| 627 |
+
self.attention = nn.Sequential(
|
| 628 |
+
nn.Linear(fusion_dim, fusion_dim // 2),
|
| 629 |
+
nn.ReLU(),
|
| 630 |
+
nn.Linear(fusion_dim // 2, fusion_dim),
|
| 631 |
+
nn.Sigmoid()
|
| 632 |
+
)
|
| 633 |
+
|
| 634 |
+
# Fusion layer: 4-layer deep MLP
|
| 635 |
+
self.fusion_fc1 = nn.Linear(fusion_dim, hidden_dim * 2)
|
| 636 |
+
self.fusion_bn1 = nn.BatchNorm1d(hidden_dim * 2)
|
| 637 |
+
self.fusion_fc2 = nn.Linear(hidden_dim * 2, hidden_dim)
|
| 638 |
+
self.fusion_bn2 = nn.BatchNorm1d(hidden_dim)
|
| 639 |
+
self.fusion_fc3 = nn.Linear(hidden_dim, hidden_dim // 2)
|
| 640 |
+
self.fusion_bn3 = nn.BatchNorm1d(hidden_dim // 2)
|
| 641 |
+
self.output_fc = nn.Linear(hidden_dim // 2, 1)
|
| 642 |
+
|
| 643 |
+
print_sys(f"Model initialized: drug_feat_dim={drug_feat_dim}, "
|
| 644 |
+
f"cell_emb_dim={cell_emb_dim}, hidden_dim={hidden_dim}")
|
| 645 |
+
|
| 646 |
+
def forward(
|
| 647 |
+
self,
|
| 648 |
+
drug_feat: torch.Tensor,
|
| 649 |
+
drug_adj: torch.Tensor,
|
| 650 |
+
cell_emb: torch.Tensor,
|
| 651 |
+
) -> torch.Tensor:
|
| 652 |
+
# Drug branch (enhanced version)
|
| 653 |
+
# first floor
|
| 654 |
+
x_drug = self.graph_conv1(drug_feat, drug_adj) # (B, N, 256)
|
| 655 |
+
x_drug = x_drug.transpose(1, 2) # (B, 256, N) for BatchNorm
|
| 656 |
+
x_drug = self.drug_bn1(x_drug)
|
| 657 |
+
x_drug = x_drug.transpose(1, 2) # (B, N, 256)
|
| 658 |
+
x_drug = self.relu(x_drug)
|
| 659 |
+
x_drug = self.dropout(x_drug)
|
| 660 |
+
|
| 661 |
+
# Second layer + residual connection
|
| 662 |
+
x_drug_res = x_drug
|
| 663 |
+
x_drug = self.graph_conv2(x_drug, drug_adj) # (B, N, 256)
|
| 664 |
+
x_drug = x_drug.transpose(1, 2)
|
| 665 |
+
x_drug = self.drug_bn2(x_drug)
|
| 666 |
+
x_drug = x_drug.transpose(1, 2)
|
| 667 |
+
x_drug = self.relu(x_drug + x_drug_res) # residual connection
|
| 668 |
+
x_drug = self.dropout(x_drug)
|
| 669 |
+
|
| 670 |
+
# third floor
|
| 671 |
+
x_drug = self.graph_conv3(x_drug, drug_adj) # (B, N, 128)
|
| 672 |
+
x_drug = x_drug.transpose(1, 2)
|
| 673 |
+
x_drug = self.drug_bn3(x_drug)
|
| 674 |
+
x_drug = x_drug.transpose(1, 2)
|
| 675 |
+
x_drug = self.relu(x_drug)
|
| 676 |
+
|
| 677 |
+
# Aggregation: max pooling + mean pooling
|
| 678 |
+
x_drug_max = x_drug.max(dim=1)[0] # (B, 128)
|
| 679 |
+
x_drug_mean = x_drug.mean(dim=1) # (B, 128)
|
| 680 |
+
x_drug = x_drug_max + x_drug_mean # Fusion
|
| 681 |
+
|
| 682 |
+
# Cell branching (handling PerformerModule embeddings)
|
| 683 |
+
x_cell = self.cell_fc1(cell_emb) # (B, 256)
|
| 684 |
+
x_cell = self.cell_bn1(x_cell)
|
| 685 |
+
x_cell = self.relu(x_cell)
|
| 686 |
+
x_cell = self.dropout(x_cell)
|
| 687 |
+
|
| 688 |
+
x_cell = self.cell_fc2(x_cell) # (B, 128)
|
| 689 |
+
x_cell = self.cell_bn2(x_cell)
|
| 690 |
+
x_cell = self.relu(x_cell)
|
| 691 |
+
|
| 692 |
+
# Feature fusion + attention
|
| 693 |
+
h = torch.cat([x_drug, x_cell], dim=1) # (B, 256)
|
| 694 |
+
|
| 695 |
+
# Apply attention mechanism
|
| 696 |
+
att_weights = self.attention(h)
|
| 697 |
+
h = h * att_weights # weighted features
|
| 698 |
+
|
| 699 |
+
# Deep Fusion MLP
|
| 700 |
+
h = self.fusion_fc1(h) # (B, hidden_dim * 2)
|
| 701 |
+
h = self.fusion_bn1(h)
|
| 702 |
+
h = self.relu(h)
|
| 703 |
+
h = self.dropout(h)
|
| 704 |
+
|
| 705 |
+
h = self.fusion_fc2(h) # (B, hidden_dim)
|
| 706 |
+
h = self.fusion_bn2(h)
|
| 707 |
+
h = self.relu(h)
|
| 708 |
+
h = self.dropout(h)
|
| 709 |
+
|
| 710 |
+
h = self.fusion_fc3(h) # (B, hidden_dim // 2)
|
| 711 |
+
h = self.fusion_bn3(h)
|
| 712 |
+
h = self.relu(h)
|
| 713 |
+
h = self.dropout(h)
|
| 714 |
+
|
| 715 |
+
# output layer
|
| 716 |
+
out = self.output_fc(h) # (B, 1)
|
| 717 |
+
return out
|
| 718 |
+
|
| 719 |
+
|
| 720 |
+
# ===================== Training and Evaluation =====================
|
| 721 |
+
|
| 722 |
+
def train_epoch(model, loader, optimizer, criterion, device):
|
| 723 |
+
"""train for one epoch"""
|
| 724 |
+
model.train()
|
| 725 |
+
total_loss = 0.0
|
| 726 |
+
total = 0
|
| 727 |
+
for batch in loader:
|
| 728 |
+
drug_feat = batch["drug_feat"].to(device)
|
| 729 |
+
drug_adj = batch["drug_adj"].to(device)
|
| 730 |
+
cell_emb = batch["cell_emb"].to(device)
|
| 731 |
+
target = batch["target"].to(device)
|
| 732 |
+
|
| 733 |
+
optimizer.zero_grad(set_to_none=True)
|
| 734 |
+
pred = model(drug_feat, drug_adj, cell_emb).squeeze(-1)
|
| 735 |
+
|
| 736 |
+
loss = criterion(pred, target)
|
| 737 |
+
loss.backward()
|
| 738 |
+
|
| 739 |
+
# gradient crop
|
| 740 |
+
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
|
| 741 |
+
|
| 742 |
+
optimizer.step()
|
| 743 |
+
|
| 744 |
+
total_loss += float(loss.item()) * target.size(0)
|
| 745 |
+
total += target.size(0)
|
| 746 |
+
return total_loss / total if total > 0 else 0.0
|
| 747 |
+
|
| 748 |
+
|
| 749 |
+
def eval_epoch(model, loader, device):
|
| 750 |
+
"""Evaluate an epoch"""
|
| 751 |
+
model.eval()
|
| 752 |
+
preds = []
|
| 753 |
+
trues = []
|
| 754 |
+
with torch.no_grad():
|
| 755 |
+
for batch in loader:
|
| 756 |
+
drug_feat = batch["drug_feat"].to(device)
|
| 757 |
+
drug_adj = batch["drug_adj"].to(device)
|
| 758 |
+
cell_emb = batch["cell_emb"].to(device)
|
| 759 |
+
target = batch["target"].to(device)
|
| 760 |
+
|
| 761 |
+
pred = model(drug_feat, drug_adj, cell_emb).squeeze(-1)
|
| 762 |
+
|
| 763 |
+
preds.append(pred.cpu().numpy())
|
| 764 |
+
trues.append(target.cpu().numpy())
|
| 765 |
+
|
| 766 |
+
preds = np.concatenate(preds, axis=0)
|
| 767 |
+
trues = np.concatenate(trues, axis=0)
|
| 768 |
+
pcc = pearsonr(preds, trues)[0]
|
| 769 |
+
return preds, trues, pcc
|
| 770 |
+
|
| 771 |
+
|
| 772 |
+
# ===================== Main =====================
|
| 773 |
+
|
| 774 |
+
def main():
|
| 775 |
+
parser = argparse.ArgumentParser(
|
| 776 |
+
description="Drug response prediction using Drug + PerformerModule"
|
| 777 |
+
)
|
| 778 |
+
parser.add_argument("--gpu_id", type=str, default="0")
|
| 779 |
+
parser.add_argument("--data_path", type=str, default="../drug_response_pytorch/data")
|
| 780 |
+
parser.add_argument("--epochs", type=int, default=300)
|
| 781 |
+
parser.add_argument("--batch_size", type=int, default=32)
|
| 782 |
+
parser.add_argument("--max_atoms", type=int, default=100)
|
| 783 |
+
parser.add_argument("--hidden_dim", type=int, default=256)
|
| 784 |
+
parser.add_argument("--learning_rate", type=float, default=1e-3)
|
| 785 |
+
parser.add_argument("--dropout", type=float, default=0.1)
|
| 786 |
+
parser.add_argument("--patience", type=int, default=30)
|
| 787 |
+
|
| 788 |
+
# PerformerModule related paths
|
| 789 |
+
parser.add_argument(
|
| 790 |
+
"--checkpoint_path",
|
| 791 |
+
type=str,
|
| 792 |
+
required=True,
|
| 793 |
+
help="Path to pre-trained PerformerModule checkpoint"
|
| 794 |
+
)
|
| 795 |
+
parser.add_argument(
|
| 796 |
+
"--hyper_params_path",
|
| 797 |
+
type=str,
|
| 798 |
+
required=True,
|
| 799 |
+
help="Path to PerformerModule model hyper parameters pkl file"
|
| 800 |
+
)
|
| 801 |
+
parser.add_argument(
|
| 802 |
+
"--module_data_dir",
|
| 803 |
+
type=str,
|
| 804 |
+
default=None,
|
| 805 |
+
help="Directory containing module JSON files"
|
| 806 |
+
)
|
| 807 |
+
parser.add_argument(
|
| 808 |
+
"--module_data_path",
|
| 809 |
+
type=str,
|
| 810 |
+
default=None,
|
| 811 |
+
help="Path to module JSON file (single file)"
|
| 812 |
+
)
|
| 813 |
+
parser.add_argument(
|
| 814 |
+
"--gene_list_path",
|
| 815 |
+
type=str,
|
| 816 |
+
default="../../selected_genes_27k.txt",
|
| 817 |
+
help="Path to gene list file"
|
| 818 |
+
)
|
| 819 |
+
parser.add_argument(
|
| 820 |
+
"--gene_mapping_path",
|
| 821 |
+
type=str,
|
| 822 |
+
default="../../gene2ens.csv",
|
| 823 |
+
help="Path to gene symbol to Ensembl ID mapping"
|
| 824 |
+
)
|
| 825 |
+
parser.add_argument(
|
| 826 |
+
"--gene2vec_path",
|
| 827 |
+
type=str,
|
| 828 |
+
default="../../selected_gene2vec_27k.npy",
|
| 829 |
+
help="Path to gene2vec file"
|
| 830 |
+
)
|
| 831 |
+
parser.add_argument(
|
| 832 |
+
"--symbol2ens_path",
|
| 833 |
+
type=str,
|
| 834 |
+
default="../../gene2ens.csv",
|
| 835 |
+
help="Path to symbol to Ensembl mapping (for data processing)"
|
| 836 |
+
)
|
| 837 |
+
parser.add_argument(
|
| 838 |
+
"--pool_method",
|
| 839 |
+
type=str,
|
| 840 |
+
default="mean",
|
| 841 |
+
choices=["mean", "max", "cls", "concat"],
|
| 842 |
+
help="Pooling method for PerformerModule module embeddings"
|
| 843 |
+
)
|
| 844 |
+
parser.add_argument(
|
| 845 |
+
"--shuffle_module_genes",
|
| 846 |
+
action="store_true",
|
| 847 |
+
help="Optional downstream-only extra reshuffle on top of module_sets restored from PKL. "
|
| 848 |
+
"Do not pass this when reproducing the pretrained backbone as-is."
|
| 849 |
+
)
|
| 850 |
+
parser.add_argument(
|
| 851 |
+
"--shuffle_seed",
|
| 852 |
+
type=int,
|
| 853 |
+
default=2021,
|
| 854 |
+
help="Random seed for the downstream extra reshuffle when --shuffle_module_genes is enabled."
|
| 855 |
+
)
|
| 856 |
+
# Output path
|
| 857 |
+
parser.add_argument(
|
| 858 |
+
"--save_dir",
|
| 859 |
+
type=str,
|
| 860 |
+
default="./models_drug_performer_module",
|
| 861 |
+
help="Directory to save the model"
|
| 862 |
+
)
|
| 863 |
+
|
| 864 |
+
args = parser.parse_args()
|
| 865 |
+
|
| 866 |
+
os.environ["CUDA_VISIBLE_DEVICES"] = args.gpu_id
|
| 867 |
+
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 868 |
+
|
| 869 |
+
print_sys("=" * 70)
|
| 870 |
+
print_sys("Drug Response Prediction: Drug + PerformerModule")
|
| 871 |
+
print_sys("=" * 70)
|
| 872 |
+
print_sys(f"Device: {device}")
|
| 873 |
+
print_sys(f"Epochs: {args.epochs}")
|
| 874 |
+
print_sys(f"Batch size: {args.batch_size}")
|
| 875 |
+
print_sys(f"Learning rate: {args.learning_rate}")
|
| 876 |
+
print_sys(f"Hidden dim: {args.hidden_dim}")
|
| 877 |
+
print_sys(f"Dropout: {args.dropout}")
|
| 878 |
+
print_sys(f"Pool method: {args.pool_method}")
|
| 879 |
+
print_sys(f"Early stopping patience: {args.patience}")
|
| 880 |
+
print_sys("=" * 70)
|
| 881 |
+
|
| 882 |
+
# Create save directory
|
| 883 |
+
os.makedirs(args.save_dir, exist_ok=True)
|
| 884 |
+
|
| 885 |
+
dpath = args.data_path
|
| 886 |
+
drug_info_file = f"{dpath}/GDSC/1.Drug_listMon Jun 24 09_00_55 2019.csv"
|
| 887 |
+
cell_line_info_file = f"{dpath}/CCLE/Cell_lines_annotations_20181226.txt"
|
| 888 |
+
drug_feature_dir = f"{dpath}/GDSC/drug_graph_feat"
|
| 889 |
+
gene_expression_file = (
|
| 890 |
+
f"{dpath}/CCLE/genomic_expression_561celllines_697genes_demap_features.csv"
|
| 891 |
+
)
|
| 892 |
+
|
| 893 |
+
# fixed random seed
|
| 894 |
+
random.seed(0)
|
| 895 |
+
np.random.seed(0)
|
| 896 |
+
torch.manual_seed(0)
|
| 897 |
+
if torch.cuda.is_available():
|
| 898 |
+
torch.cuda.manual_seed_all(0)
|
| 899 |
+
|
| 900 |
+
# 1) Read metadata
|
| 901 |
+
print_sys("\n[Step 1/6] Loading metadata...")
|
| 902 |
+
drug_feature, gexpr_feature, data_idx = metadata_generate(
|
| 903 |
+
drug_info_file,
|
| 904 |
+
cell_line_info_file,
|
| 905 |
+
drug_feature_dir,
|
| 906 |
+
gene_expression_file,
|
| 907 |
+
)
|
| 908 |
+
|
| 909 |
+
# 2) Build PerformerModule embeddings
|
| 910 |
+
print_sys("\n[Step 2/6] Building PerformerModule embeddings...")
|
| 911 |
+
cell_emb_dict, cell_emb_dim = build_cell_embeddings_with_performer_module( # cell_emb_dict │ Dict[str, np.ndarray] │ {cell_line_id: embedding} mapping dictionary
|
| 912 |
+
gexpr_feature=gexpr_feature,
|
| 913 |
+
symbol2ens_path=args.symbol2ens_path,
|
| 914 |
+
gene_list_path=args.gene_list_path,
|
| 915 |
+
gene_mapping_path=args.gene_mapping_path,
|
| 916 |
+
gene2vec_path=args.gene2vec_path,
|
| 917 |
+
checkpoint_path=args.checkpoint_path,
|
| 918 |
+
hyper_params_path=args.hyper_params_path,
|
| 919 |
+
module_data_dir=args.module_data_dir,
|
| 920 |
+
module_data_path=args.module_data_path,
|
| 921 |
+
device=device,
|
| 922 |
+
pool_method=args.pool_method,
|
| 923 |
+
shuffle_module_genes=args.shuffle_module_genes,
|
| 924 |
+
seed=args.shuffle_seed,
|
| 925 |
+
)
|
| 926 |
+
|
| 927 |
+
# 3) Divide the data set
|
| 928 |
+
print_sys("\n[Step 3/6] Splitting dataset...")
|
| 929 |
+
train_idx, test_idx = data_split(data_idx)
|
| 930 |
+
print_sys(f"Train samples: {len(train_idx)}, Test samples: {len(test_idx)}")
|
| 931 |
+
|
| 932 |
+
# 4) Extract features
|
| 933 |
+
print_sys("\n[Step 4/6] Extracting features...")
|
| 934 |
+
X_drug_train, Y_train, train_cancer_info, train_index_list = feature_extract(
|
| 935 |
+
train_idx, drug_feature, args.max_atoms
|
| 936 |
+
)
|
| 937 |
+
X_drug_test, Y_test, test_cancer_info, test_index_list = feature_extract(
|
| 938 |
+
test_idx, drug_feature, args.max_atoms
|
| 939 |
+
)
|
| 940 |
+
|
| 941 |
+
# 5) Build a data set
|
| 942 |
+
print_sys("\n[Step 5/6] Creating datasets...")
|
| 943 |
+
train_ds = DrugPerformerModuleDataset(
|
| 944 |
+
X_drug_train, Y_train, train_index_list, cell_emb_dict
|
| 945 |
+
)
|
| 946 |
+
test_ds = DrugPerformerModuleDataset(
|
| 947 |
+
X_drug_test, Y_test, test_index_list, cell_emb_dict
|
| 948 |
+
)
|
| 949 |
+
|
| 950 |
+
drug_dim = X_drug_train[0][0].shape[-1]
|
| 951 |
+
|
| 952 |
+
num_workers = min(4, os.cpu_count() or 1)
|
| 953 |
+
train_loader = DataLoader(
|
| 954 |
+
train_ds,
|
| 955 |
+
batch_size=args.batch_size,
|
| 956 |
+
shuffle=True,
|
| 957 |
+
num_workers=num_workers,
|
| 958 |
+
pin_memory=torch.cuda.is_available(),
|
| 959 |
+
)
|
| 960 |
+
test_loader = DataLoader(
|
| 961 |
+
test_ds,
|
| 962 |
+
batch_size=args.batch_size,
|
| 963 |
+
shuffle=False,
|
| 964 |
+
num_workers=num_workers,
|
| 965 |
+
pin_memory=torch.cuda.is_available(),
|
| 966 |
+
)
|
| 967 |
+
|
| 968 |
+
# 6) Build the model
|
| 969 |
+
print_sys("\n[Step 6/6] Building model...")
|
| 970 |
+
model = DrugPerformerModuleModel(
|
| 971 |
+
drug_feat_dim=drug_dim,
|
| 972 |
+
cell_emb_dim=cell_emb_dim,
|
| 973 |
+
hidden_dim=args.hidden_dim,
|
| 974 |
+
dropout=args.dropout,
|
| 975 |
+
).to(device)
|
| 976 |
+
|
| 977 |
+
# Statistical parameters
|
| 978 |
+
total_params = sum(p.numel() for p in model.parameters())
|
| 979 |
+
trainable_params = sum(p.numel() for p in model.parameters() if p.requires_grad)
|
| 980 |
+
print_sys(f"Total parameters: {total_params:,}")
|
| 981 |
+
print_sys(f"Trainable parameters: {trainable_params:,}")
|
| 982 |
+
|
| 983 |
+
# Optimizers and schedulers
|
| 984 |
+
optimizer = torch.optim.AdamW(
|
| 985 |
+
model.parameters(), lr=args.learning_rate, weight_decay=1e-5
|
| 986 |
+
)
|
| 987 |
+
scheduler = torch.optim.lr_scheduler.CosineAnnealingWarmRestarts(
|
| 988 |
+
optimizer, T_0=20, T_mult=2, eta_min=1e-6
|
| 989 |
+
)
|
| 990 |
+
criterion = torch.nn.HuberLoss(delta=1.0)
|
| 991 |
+
|
| 992 |
+
# train
|
| 993 |
+
print_sys("\n" + "=" * 70)
|
| 994 |
+
print_sys("Starting training...")
|
| 995 |
+
print_sys("=" * 70)
|
| 996 |
+
|
| 997 |
+
best_pcc = -1.0
|
| 998 |
+
best_pred = None
|
| 999 |
+
best_true = None
|
| 1000 |
+
best_epoch = 0
|
| 1001 |
+
patience_counter = 0
|
| 1002 |
+
|
| 1003 |
+
for epoch in range(1, args.epochs + 1):
|
| 1004 |
+
train_loss = train_epoch(model, train_loader, optimizer, criterion, device)
|
| 1005 |
+
preds, trues, pcc = eval_epoch(model, test_loader, device)
|
| 1006 |
+
|
| 1007 |
+
scheduler.step()
|
| 1008 |
+
current_lr = optimizer.param_groups[0]["lr"]
|
| 1009 |
+
|
| 1010 |
+
print_sys(
|
| 1011 |
+
f"Epoch {epoch:03d}/{args.epochs} | "
|
| 1012 |
+
f"train_loss={train_loss:.4f} | "
|
| 1013 |
+
f"val_pcc={pcc:.4f} | "
|
| 1014 |
+
f"lr={current_lr:.6f}"
|
| 1015 |
+
)
|
| 1016 |
+
|
| 1017 |
+
if pcc > best_pcc:
|
| 1018 |
+
best_pcc = pcc
|
| 1019 |
+
best_pred = preds
|
| 1020 |
+
best_true = trues
|
| 1021 |
+
best_epoch = epoch
|
| 1022 |
+
patience_counter = 0
|
| 1023 |
+
|
| 1024 |
+
# Save the best model
|
| 1025 |
+
model_save_path = os.path.join(args.save_dir, "best_model.pth")
|
| 1026 |
+
torch.save(
|
| 1027 |
+
{
|
| 1028 |
+
"epoch": epoch,
|
| 1029 |
+
"model_state_dict": model.state_dict(),
|
| 1030 |
+
"optimizer_state_dict": optimizer.state_dict(),
|
| 1031 |
+
"scheduler_state_dict": scheduler.state_dict(),
|
| 1032 |
+
"best_pcc": best_pcc,
|
| 1033 |
+
"train_loss": train_loss,
|
| 1034 |
+
"args": vars(args),
|
| 1035 |
+
},
|
| 1036 |
+
model_save_path,
|
| 1037 |
+
)
|
| 1038 |
+
print_sys(f" → Best model saved! PCC improved to {best_pcc:.4f}")
|
| 1039 |
+
else:
|
| 1040 |
+
patience_counter += 1
|
| 1041 |
+
if patience_counter >= args.patience:
|
| 1042 |
+
print_sys(
|
| 1043 |
+
f"\n[Early Stopping] No improvement for {args.patience} epochs."
|
| 1044 |
+
)
|
| 1045 |
+
break
|
| 1046 |
+
|
| 1047 |
+
# Save final result
|
| 1048 |
+
print_sys("\n" + "=" * 70)
|
| 1049 |
+
print_sys("Training completed!")
|
| 1050 |
+
print_sys("=" * 70)
|
| 1051 |
+
print_sys(f"Best epoch: {best_epoch}")
|
| 1052 |
+
print_sys(f"Best PCC: {best_pcc:.4f}")
|
| 1053 |
+
|
| 1054 |
+
if best_pred is not None:
|
| 1055 |
+
result_path = os.path.join(args.save_dir, "predictions.npz")
|
| 1056 |
+
np.savez(result_path, pred=best_pred, test=best_true)
|
| 1057 |
+
print_sys(f"Predictions saved to: {result_path}")
|
| 1058 |
+
|
| 1059 |
+
print_sys(f"Best model saved to: {os.path.join(args.save_dir, 'best_model.pth')}")
|
| 1060 |
+
print_sys("=" * 70)
|
| 1061 |
+
|
| 1062 |
+
|
| 1063 |
+
if __name__ == "__main__":
|
| 1064 |
+
main()
|
| 1065 |
+
|
BioMod/environment.yml
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: performer_module
|
| 2 |
+
channels:
|
| 3 |
+
- pytorch
|
| 4 |
+
- nvidia
|
| 5 |
+
- pyg
|
| 6 |
+
- conda-forge
|
| 7 |
+
- defaults
|
| 8 |
+
dependencies:
|
| 9 |
+
- python=3.9
|
| 10 |
+
- pip>=24.0
|
| 11 |
+
|
| 12 |
+
# GPU stack matching the current server environment.
|
| 13 |
+
# For a different CUDA driver/toolkit, adjust pytorch-cuda and the PyG notes
|
| 14 |
+
# in requirements.txt accordingly.
|
| 15 |
+
- pytorch=2.6.*
|
| 16 |
+
- torchvision=0.21.*
|
| 17 |
+
- torchaudio=2.6.*
|
| 18 |
+
- pytorch-cuda=12.4
|
| 19 |
+
- pyg=2.6.*
|
| 20 |
+
|
| 21 |
+
# Core scientific stack
|
| 22 |
+
- numpy=1.26.*
|
| 23 |
+
- scipy=1.13.*
|
| 24 |
+
- pandas>=2.2,<2.4
|
| 25 |
+
- scikit-learn=1.6.*
|
| 26 |
+
- h5py>=3.10
|
| 27 |
+
- anndata=0.10.*
|
| 28 |
+
- scanpy=1.10.*
|
| 29 |
+
- matplotlib=3.9.*
|
| 30 |
+
- seaborn=0.13.*
|
| 31 |
+
- tqdm>=4.66
|
| 32 |
+
- requests>=2.31
|
| 33 |
+
- pyyaml>=6.0
|
| 34 |
+
- networkx>=3.2
|
| 35 |
+
- statsmodels>=0.14
|
| 36 |
+
- umap-learn>=0.5
|
| 37 |
+
- numba
|
| 38 |
+
- openpyxl
|
| 39 |
+
- jupyterlab
|
| 40 |
+
- ipykernel
|
| 41 |
+
|
| 42 |
+
# Single-cell and graph-analysis helpers
|
| 43 |
+
- python-igraph>=0.11
|
| 44 |
+
- leidenalg>=0.10
|
| 45 |
+
- rdkit
|
| 46 |
+
|
| 47 |
+
- pip:
|
| 48 |
+
# PerformerModule model utilities
|
| 49 |
+
- einops==0.8.1
|
| 50 |
+
- local-attention==1.11.2
|
| 51 |
+
- hickle>=5.0.0
|
| 52 |
+
- dcor==0.6
|
| 53 |
+
|
| 54 |
+
# Optional task modules used by downstream scripts.
|
| 55 |
+
- scib>=1.1.5
|
| 56 |
+
- decoupler>=1.8.0
|
| 57 |
+
- gseapy>=1.1.0
|
| 58 |
+
- mygene>=3.2.2
|
| 59 |
+
- lifelines>=0.27.0
|
| 60 |
+
|
| 61 |
+
# Text-alignment and LLM-assisted annotation utilities.
|
| 62 |
+
- sentence-transformers>=2.7.0
|
| 63 |
+
- transformers>=4.40.0
|
| 64 |
+
- openai>=1.40.0
|
| 65 |
+
- wandb==0.23.0
|
| 66 |
+
|
| 67 |
+
# Only needed if running legacy pickles on Python < 3.8.
|
| 68 |
+
- pickle5>=0.0.12; python_version < "3.8"
|
BioMod/eval_new_data/cmd.txtx
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# masked 模式(与预训练 valid loss 直接可比)
|
| 2 |
+
python evaluate_new_data.py \
|
| 3 |
+
--ckpt_dir ../mae_tost_48M_cross_attn_only_pure_memory/ \
|
| 4 |
+
--model_name mae_tost_48M_cross_attn_only_pure_memory \
|
| 5 |
+
--data_dir ./npz \
|
| 6 |
+
--module_data_dir ../module_data_all/ \
|
| 7 |
+
--eval_mode masked \
|
| 8 |
+
--batch_size 320 \
|
| 9 |
+
--output_csv eval_masked.csv
|
| 10 |
+
|
| 11 |
+
# full_recon 模式(不 mask,全 module 重建 loss)
|
| 12 |
+
python evaluate_new_data.py \
|
| 13 |
+
--ckpt_dir /path/to/mae_tost_48M_cross_attn_only_pure_memory/ \
|
| 14 |
+
--model_name mae_tost_48M_cross_attn_only_pure_memory \
|
| 15 |
+
--data_dir /path/to/20260423测试集/npz/npz \
|
| 16 |
+
--module_data_dir ./module_data_all/ \
|
| 17 |
+
--eval_mode full_recon \
|
| 18 |
+
--batch_size 32 \
|
| 19 |
+
--output_csv eval_full_recon.csv
|
BioMod/eval_new_data/evaluate_new_data.py
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# -*- coding: utf-8 -*-
|
| 2 |
+
"""
|
| 3 |
+
Evaluate the reconstruction loss of a pre-trained model on a new dataset (DDP multi-card version).
|
| 4 |
+
Used to verify the generalization ability of the model on unseen donor data, compared with pre-training valid loss.
|
| 5 |
+
|
| 6 |
+
Two evaluation modes are supported:
|
| 7 |
+
masked — consistent with pre-training validation, using the same mask_ratio, for direct comparison
|
| 8 |
+
full_recon — without masking, calculate the reconstruction loss of all modules
|
| 9 |
+
|
| 10 |
+
Usage example (single card):
|
| 11 |
+
torchrun --nproc_per_node=1 evaluate_new_data.py \
|
| 12 |
+
--ckpt_dir ./ckpts/ \
|
| 13 |
+
--model_name mae_tost_48M_cross_attn_only_pure_memory \
|
| 14 |
+
--data_dir /path/to/20260423test set/npz/npz \
|
| 15 |
+
--eval_mode masked --batch_size 32
|
| 16 |
+
|
| 17 |
+
Usage example (4 card DDP):
|
| 18 |
+
torchrun --nproc_per_node=4 evaluate_new_data.py \
|
| 19 |
+
--ckpt_dir ./ckpts/ \
|
| 20 |
+
--model_name mae_tost_48M_cross_attn_only_pure_memory \
|
| 21 |
+
--data_dir /path/to/20260423test set/npz/npz \
|
| 22 |
+
--eval_mode masked --batch_size 32
|
| 23 |
+
"""
|
| 24 |
+
|
| 25 |
+
import os
|
| 26 |
+
import re
|
| 27 |
+
import csv
|
| 28 |
+
import sys
|
| 29 |
+
import glob
|
| 30 |
+
import time
|
| 31 |
+
import pickle
|
| 32 |
+
import inspect
|
| 33 |
+
import argparse
|
| 34 |
+
from collections import defaultdict
|
| 35 |
+
|
| 36 |
+
import numpy as np
|
| 37 |
+
import torch
|
| 38 |
+
import torch.distributed as dist
|
| 39 |
+
from torch.nn.parallel import DistributedDataParallel as DDP
|
| 40 |
+
from torch.utils.data import Dataset, DataLoader
|
| 41 |
+
from torch.utils.data.distributed import DistributedSampler
|
| 42 |
+
from scipy import sparse
|
| 43 |
+
|
| 44 |
+
sys.path.append(os.path.join(os.path.dirname(os.path.abspath(__file__)), '..'))
|
| 45 |
+
|
| 46 |
+
from models_performer_module import PerformerEncoderOnly_Module
|
| 47 |
+
from module_dataset import load_module_data
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
# ---------------------------------------------------------------------------
|
| 51 |
+
# Dataset
|
| 52 |
+
# ---------------------------------------------------------------------------
|
| 53 |
+
|
| 54 |
+
class DonorSCDataset(Dataset):
|
| 55 |
+
"""Dataset of a single donor, returning single-cell expression vectors row-wise from dense numpy."""
|
| 56 |
+
|
| 57 |
+
def __init__(self, csr_matrix):
|
| 58 |
+
self.data = csr_matrix.toarray()
|
| 59 |
+
|
| 60 |
+
def __len__(self):
|
| 61 |
+
return self.data.shape[0]
|
| 62 |
+
|
| 63 |
+
def __getitem__(self, idx):
|
| 64 |
+
return torch.from_numpy(self.data[idx].astype(np.float32))
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
# ---------------------------------------------------------------------------
|
| 68 |
+
# Checkpoint discovery
|
| 69 |
+
# ---------------------------------------------------------------------------
|
| 70 |
+
|
| 71 |
+
def discover_checkpoints(ckpt_dir, model_name, epoch_filter=None):
|
| 72 |
+
pattern = os.path.join(ckpt_dir, f"{model_name}_epoch*.pth")
|
| 73 |
+
files = glob.glob(pattern)
|
| 74 |
+
results = []
|
| 75 |
+
for f in files:
|
| 76 |
+
match = re.search(r'epoch(\d+)', os.path.basename(f))
|
| 77 |
+
if match:
|
| 78 |
+
epoch_num = int(match.group(1))
|
| 79 |
+
if epoch_filter is None or epoch_num in epoch_filter:
|
| 80 |
+
results.append((epoch_num, f))
|
| 81 |
+
results.sort(key=lambda x: x[0])
|
| 82 |
+
return results
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def load_model_config(ckpt_dir, model_name):
|
| 86 |
+
pkl_path = os.path.join(ckpt_dir, f"{model_name}.pkl")
|
| 87 |
+
if not os.path.isfile(pkl_path):
|
| 88 |
+
raise FileNotFoundError(f"Model configuration file not found: {pkl_path}")
|
| 89 |
+
with open(pkl_path, 'rb') as f:
|
| 90 |
+
return pickle.load(f)
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def filter_model_config(config):
|
| 94 |
+
"""Filter out obsolete parameters in pkl that are not accepted by the model __init__."""
|
| 95 |
+
valid = set(inspect.signature(PerformerEncoderOnly_Module.__init__).parameters.keys())
|
| 96 |
+
removed = {k: config.pop(k) for k in list(config) if k not in valid and k != "self"}
|
| 97 |
+
return removed
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
# ---------------------------------------------------------------------------
|
| 101 |
+
# Evaluation
|
| 102 |
+
# ---------------------------------------------------------------------------
|
| 103 |
+
|
| 104 |
+
def evaluate_donor_ddp(model, dataloader, device, eval_mode, mask_ratio, ptdtype, seed=None):
|
| 105 |
+
"""
|
| 106 |
+
Calculate the loss for this rank shard of a single donor and return (loss_sum, batch_count).
|
| 107 |
+
Aggregation is done by the caller via all_reduce.
|
| 108 |
+
"""
|
| 109 |
+
model.eval()
|
| 110 |
+
loss_sum = 0.0
|
| 111 |
+
batch_count = 0
|
| 112 |
+
|
| 113 |
+
raw_model = model.module if isinstance(model, DDP) else model
|
| 114 |
+
|
| 115 |
+
with torch.no_grad():
|
| 116 |
+
for batch_idx, data in enumerate(dataloader):
|
| 117 |
+
data = data.to(device, non_blocking=True)
|
| 118 |
+
if torch.isnan(data).any() or torch.isinf(data).any():
|
| 119 |
+
continue
|
| 120 |
+
|
| 121 |
+
if eval_mode == "masked" and seed is not None:
|
| 122 |
+
torch.manual_seed(seed + batch_idx)
|
| 123 |
+
torch.cuda.manual_seed(seed + batch_idx)
|
| 124 |
+
|
| 125 |
+
with torch.autocast(device_type='cuda', dtype=ptdtype):
|
| 126 |
+
if eval_mode == "masked":
|
| 127 |
+
loss, _, _ = raw_model(data, mask_ratio=mask_ratio)
|
| 128 |
+
else:
|
| 129 |
+
latent, mask_zero, ids_restore, module_targets = raw_model.forward_encoder(
|
| 130 |
+
data, mask_ratio=0.0
|
| 131 |
+
)
|
| 132 |
+
dt = raw_model.decoder_type
|
| 133 |
+
if dt == "performer":
|
| 134 |
+
preds = raw_model.forward_decoder_performer(latent, ids_restore)
|
| 135 |
+
elif dt == "cross_attn":
|
| 136 |
+
preds = raw_model.forward_decoder_cross_attn(latent, mask_zero, ids_restore)
|
| 137 |
+
elif dt == "simple":
|
| 138 |
+
preds = raw_model.forward_decoder_simple(latent, ids_restore)
|
| 139 |
+
elif dt == "none":
|
| 140 |
+
preds = raw_model.forward_predictor_from_encoder(latent)
|
| 141 |
+
else:
|
| 142 |
+
raise RuntimeError(f"Unsupported decoder_type: {dt}")
|
| 143 |
+
mask_all = torch.ones_like(mask_zero)
|
| 144 |
+
loss = raw_model.compute_loss(preds, module_targets, mask_all)
|
| 145 |
+
|
| 146 |
+
if torch.isnan(loss) or torch.isinf(loss):
|
| 147 |
+
continue
|
| 148 |
+
loss_sum += loss.item()
|
| 149 |
+
batch_count += 1
|
| 150 |
+
|
| 151 |
+
return loss_sum, batch_count
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def reduce_scalar(value, device):
|
| 155 |
+
"""all_reduce is a scalar and returns the global summation result."""
|
| 156 |
+
t = torch.tensor(value, dtype=torch.float64, device=device)
|
| 157 |
+
dist.all_reduce(t, op=dist.ReduceOp.SUM)
|
| 158 |
+
return t.item()
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
def discover_datasets(data_dir):
|
| 162 |
+
datasets = []
|
| 163 |
+
for name in sorted(os.listdir(data_dir)):
|
| 164 |
+
subdir = os.path.join(data_dir, name)
|
| 165 |
+
if os.path.isdir(subdir):
|
| 166 |
+
npz_files = sorted([f for f in os.listdir(subdir) if f.endswith('.npz')])
|
| 167 |
+
if npz_files:
|
| 168 |
+
datasets.append((name, subdir, npz_files))
|
| 169 |
+
return datasets
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
# ---------------------------------------------------------------------------
|
| 173 |
+
# Main
|
| 174 |
+
# ---------------------------------------------------------------------------
|
| 175 |
+
|
| 176 |
+
def main():
|
| 177 |
+
parser = argparse.ArgumentParser(description="Evaluate pre-trained model reconstruction loss (DDP) on new datasets")
|
| 178 |
+
parser.add_argument("--ckpt_dir", type=str, required=True)
|
| 179 |
+
parser.add_argument("--model_name", type=str, required=True)
|
| 180 |
+
parser.add_argument("--data_dir", type=str, required=True)
|
| 181 |
+
parser.add_argument("--gene_list_path", type=str, default="../../selected_genes_27k.txt")
|
| 182 |
+
parser.add_argument("--gene_mapping_path", type=str, default="../../gene2ens.csv")
|
| 183 |
+
parser.add_argument("--module_data_dir", type=str, default=None)
|
| 184 |
+
parser.add_argument("--gene2vec_data_path", type=str, default="../../selected_gene2vec_27k.npy")
|
| 185 |
+
parser.add_argument("--batch_size", type=int, default=32)
|
| 186 |
+
parser.add_argument("--output_csv", type=str, default="eval_new_data_results.csv")
|
| 187 |
+
parser.add_argument("--epochs", type=str, default=None,
|
| 188 |
+
help="Comma separated list of epochs (e.g. '1,5,10'), default is all")
|
| 189 |
+
parser.add_argument("--eval_mode", type=str, default="masked",
|
| 190 |
+
choices=["masked", "full_recon"])
|
| 191 |
+
parser.add_argument("--mask_ratio", type=float, default=0.75)
|
| 192 |
+
parser.add_argument("--seed", type=int, default=2021)
|
| 193 |
+
parser.add_argument("--num_workers", type=int, default=4)
|
| 194 |
+
parser.add_argument("--amp_dtype", type=str, default="bfloat16",
|
| 195 |
+
choices=["float32", "bfloat16", "float16"])
|
| 196 |
+
args = parser.parse_args()
|
| 197 |
+
|
| 198 |
+
# ---- DDP initialization ----
|
| 199 |
+
dist.init_process_group(backend='nccl')
|
| 200 |
+
local_rank = int(os.environ.get('LOCAL_RANK', 0))
|
| 201 |
+
rank = dist.get_rank()
|
| 202 |
+
world_size = dist.get_world_size()
|
| 203 |
+
device = torch.device(f'cuda:{local_rank}')
|
| 204 |
+
torch.cuda.set_device(device)
|
| 205 |
+
is_master = (rank == 0)
|
| 206 |
+
|
| 207 |
+
ptdtype = {'float32': torch.float32, 'bfloat16': torch.bfloat16, 'float16': torch.float16}[args.amp_dtype]
|
| 208 |
+
|
| 209 |
+
if is_master:
|
| 210 |
+
print(f"World size: {world_size}", flush=True)
|
| 211 |
+
print(f"Eval mode: {args.eval_mode}", flush=True)
|
| 212 |
+
if args.eval_mode == "masked":
|
| 213 |
+
print(f"Mask ratio: {args.mask_ratio}", flush=True)
|
| 214 |
+
|
| 215 |
+
# ---- Load model configuration ----
|
| 216 |
+
if is_master:
|
| 217 |
+
print(f"\nLoad model configuration: {args.ckpt_dir}/{args.model_name}.pkl", flush=True)
|
| 218 |
+
model_config = load_model_config(args.ckpt_dir, args.model_name)
|
| 219 |
+
removed = filter_model_config(model_config)
|
| 220 |
+
if is_master and removed:
|
| 221 |
+
for k, v in removed.items():
|
| 222 |
+
print(f" Skip obsolete parameters: {k}={v!r}", flush=True)
|
| 223 |
+
|
| 224 |
+
if args.gene2vec_data_path:
|
| 225 |
+
model_config["gene2vec_file"] = args.gene2vec_data_path
|
| 226 |
+
|
| 227 |
+
if "module_sets" in model_config and model_config["module_sets"]:
|
| 228 |
+
if is_master:
|
| 229 |
+
print(f"Loaded from pkl config {len(model_config['module_sets'])} modules", flush=True)
|
| 230 |
+
else:
|
| 231 |
+
if args.module_data_dir is None:
|
| 232 |
+
raise ValueError("There are no module_sets in the model configuration, please specify via --module_data_dir")
|
| 233 |
+
model_config["module_sets"] = load_module_data(
|
| 234 |
+
module_data_dir=args.module_data_dir,
|
| 235 |
+
gene_list_path=args.gene_list_path,
|
| 236 |
+
gene_mapping_path=args.gene_mapping_path,
|
| 237 |
+
verbose=is_master,
|
| 238 |
+
)
|
| 239 |
+
|
| 240 |
+
# ---- Discover checkpoints & data sets ----
|
| 241 |
+
epoch_filter = set(int(e) for e in args.epochs.split(",")) if args.epochs else None
|
| 242 |
+
checkpoints = discover_checkpoints(args.ckpt_dir, args.model_name, epoch_filter)
|
| 243 |
+
datasets = discover_datasets(args.data_dir)
|
| 244 |
+
|
| 245 |
+
if is_master:
|
| 246 |
+
if not checkpoints:
|
| 247 |
+
print(f"checkpoint not found: {args.ckpt_dir}/{args.model_name}_epoch*.pth", flush=True)
|
| 248 |
+
else:
|
| 249 |
+
print(f"turn up {len(checkpoints)} checkpoints: {[e for e, _ in checkpoints]}", flush=True)
|
| 250 |
+
for ds_name, _, npz_files in datasets:
|
| 251 |
+
print(f"Dataset {ds_name}: {len(npz_files)} donors", flush=True)
|
| 252 |
+
if not datasets:
|
| 253 |
+
print(f"Warning: in {args.data_dir} The subdirectory containing the .npz file was not found!", flush=True)
|
| 254 |
+
|
| 255 |
+
if not checkpoints or not datasets:
|
| 256 |
+
dist.destroy_process_group()
|
| 257 |
+
return
|
| 258 |
+
|
| 259 |
+
# ----CSV (master write only)----
|
| 260 |
+
csv_file, writer = None, None
|
| 261 |
+
if is_master:
|
| 262 |
+
csv_file = open(args.output_csv, 'w', newline='', encoding='utf-8')
|
| 263 |
+
writer = csv.writer(csv_file)
|
| 264 |
+
writer.writerow(["epoch", "dataset", "donor", "num_cells", "mean_loss", "eval_mode"])
|
| 265 |
+
|
| 266 |
+
# ----Build the model (create it only once, only reload the weights every epoch)----
|
| 267 |
+
model = PerformerEncoderOnly_Module(**model_config)
|
| 268 |
+
model.to(device)
|
| 269 |
+
|
| 270 |
+
# ---- Main loop ----
|
| 271 |
+
summary = defaultdict(float)
|
| 272 |
+
|
| 273 |
+
for epoch_num, ckpt_path in checkpoints:
|
| 274 |
+
if is_master:
|
| 275 |
+
print(f"\n{'='*60}", flush=True)
|
| 276 |
+
print(f"Epoch {epoch_num}: {os.path.basename(ckpt_path)}", flush=True)
|
| 277 |
+
print(f"{'='*60}", flush=True)
|
| 278 |
+
|
| 279 |
+
ckpt = torch.load(ckpt_path, map_location=device)
|
| 280 |
+
model.load_state_dict(ckpt['model_state_dict'])
|
| 281 |
+
del ckpt
|
| 282 |
+
torch.cuda.empty_cache()
|
| 283 |
+
model.eval()
|
| 284 |
+
|
| 285 |
+
for ds_name, ds_dir, npz_files in datasets:
|
| 286 |
+
ds_losses = []
|
| 287 |
+
ds_cells_total = 0
|
| 288 |
+
t_ds_start = time.time()
|
| 289 |
+
|
| 290 |
+
for donor_idx, npz_file in enumerate(npz_files):
|
| 291 |
+
donor_id = npz_file.replace('.npz', '')
|
| 292 |
+
npz_path = os.path.join(ds_dir, npz_file)
|
| 293 |
+
|
| 294 |
+
data_npz = np.load(npz_path, allow_pickle=True)
|
| 295 |
+
csr = sparse.csr_matrix(
|
| 296 |
+
(data_npz['data'], data_npz['indices'], data_npz['indptr']),
|
| 297 |
+
shape=tuple(data_npz['shape'])
|
| 298 |
+
)
|
| 299 |
+
num_cells = csr.shape[0]
|
| 300 |
+
|
| 301 |
+
dataset_obj = DonorSCDataset(csr)
|
| 302 |
+
sampler = DistributedSampler(dataset_obj, num_replicas=world_size,
|
| 303 |
+
rank=rank, shuffle=False, drop_last=False)
|
| 304 |
+
loader = DataLoader(
|
| 305 |
+
dataset_obj, batch_size=args.batch_size, sampler=sampler,
|
| 306 |
+
num_workers=args.num_workers, pin_memory=True, drop_last=False,
|
| 307 |
+
)
|
| 308 |
+
|
| 309 |
+
donor_seed = args.seed + donor_idx if args.eval_mode == "masked" else None
|
| 310 |
+
local_loss_sum, local_batch_count = evaluate_donor_ddp(
|
| 311 |
+
model, loader, device, args.eval_mode,
|
| 312 |
+
args.mask_ratio, ptdtype, seed=donor_seed,
|
| 313 |
+
)
|
| 314 |
+
|
| 315 |
+
global_loss_sum = reduce_scalar(local_loss_sum, device)
|
| 316 |
+
global_batch_count = reduce_scalar(local_batch_count, device)
|
| 317 |
+
|
| 318 |
+
if global_batch_count > 0:
|
| 319 |
+
mean_loss = global_loss_sum / global_batch_count
|
| 320 |
+
else:
|
| 321 |
+
mean_loss = 0.0
|
| 322 |
+
|
| 323 |
+
if is_master:
|
| 324 |
+
writer.writerow([epoch_num, ds_name, donor_id, num_cells,
|
| 325 |
+
f"{mean_loss:.6f}", args.eval_mode])
|
| 326 |
+
csv_file.flush()
|
| 327 |
+
ds_losses.append(mean_loss)
|
| 328 |
+
ds_cells_total += num_cells
|
| 329 |
+
|
| 330 |
+
if (donor_idx + 1) % 10 == 0 or (donor_idx + 1) == len(npz_files):
|
| 331 |
+
print(f" {ds_name}: {donor_idx+1}/{len(npz_files)} donors done", flush=True)
|
| 332 |
+
|
| 333 |
+
dist.barrier()
|
| 334 |
+
|
| 335 |
+
if is_master:
|
| 336 |
+
ds_mean = np.mean(ds_losses) if ds_losses else 0.0
|
| 337 |
+
print(f" {ds_name}: mean_loss={ds_mean:.6f}, "
|
| 338 |
+
f"total_cells={ds_cells_total}, time={time.time()-t_ds_start:.1f}s", flush=True)
|
| 339 |
+
summary[(epoch_num, ds_name)] = ds_mean
|
| 340 |
+
|
| 341 |
+
torch.cuda.empty_cache()
|
| 342 |
+
dist.barrier()
|
| 343 |
+
|
| 344 |
+
del model
|
| 345 |
+
torch.cuda.empty_cache()
|
| 346 |
+
|
| 347 |
+
# ---- Summary table ----
|
| 348 |
+
if is_master:
|
| 349 |
+
csv_file.close()
|
| 350 |
+
print(f"\nDetailed results have been saved to: {args.output_csv}")
|
| 351 |
+
print(f"\n{'='*60}")
|
| 352 |
+
print("summary table")
|
| 353 |
+
print(f"{'='*60}")
|
| 354 |
+
ds_names = sorted(set(ds for _, ds in summary.keys()))
|
| 355 |
+
header = f"{'Epoch':>8}" + "".join(f"{ds:>16}" for ds in ds_names)
|
| 356 |
+
print(header)
|
| 357 |
+
print("-" * len(header))
|
| 358 |
+
for epoch_num, _ in checkpoints:
|
| 359 |
+
row = f"{epoch_num:>8}"
|
| 360 |
+
for ds in ds_names:
|
| 361 |
+
row += f"{summary.get((epoch_num, ds), float('nan')):>16.6f}"
|
| 362 |
+
print(row)
|
| 363 |
+
|
| 364 |
+
dist.destroy_process_group()
|
| 365 |
+
|
| 366 |
+
|
| 367 |
+
if __name__ == "__main__":
|
| 368 |
+
import traceback
|
| 369 |
+
try:
|
| 370 |
+
main()
|
| 371 |
+
except Exception:
|
| 372 |
+
traceback.print_exc()
|
| 373 |
+
sys.stdout.flush()
|
| 374 |
+
sys.stderr.flush()
|
| 375 |
+
sys.exit(1)
|
BioMod/gene_inverse_norman/README.md
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Norman Reverse Perturbation Prediction (scGPT / CellFM Protocol Alignment)
|
| 2 |
+
|
| 3 |
+
This directory implements reverse perturbation prediction:
|
| 4 |
+
|
| 5 |
+
- Input: single-cell expression after perturbation.
|
| 6 |
+
- Candidate space: all single and double combinations from the selected gene pool; 20 genes produce 210 candidates.
|
| 7 |
+
- Training objective: identify the true perturbation within the candidate universe.
|
| 8 |
+
- Split strategy: split by perturbation case, not by cell.
|
| 9 |
+
- Main evaluation metrics: `condition_top1_acc` to `condition_top10_acc` for exact hits, plus `condition_relevant_top1_acc` to `condition_relevant_top10_acc` for predictions that hit at least one true gene.
|
| 10 |
+
- Paper-style supplementary metrics: `case_cellmean_top1_acc` to `case_cellmean_top10_acc`, which first compute cell-level hit rates within each condition and then average conditions equally; `case_cellmean_relevant_topk_acc` is also supported.
|
| 11 |
+
|
| 12 |
+
Two built-in protocols are supported and can be selected with `--protocol scgpt`
|
| 13 |
+
or `--protocol cellfm`.
|
| 14 |
+
|
| 15 |
+
## Protocol Alignment
|
| 16 |
+
|
| 17 |
+
Both protocols come from the exact condition lists reported in the papers.
|
| 18 |
+
Canonicalization maps `GENE+GENE`, `ctrl+GENE`, and `GENE+ctrl` single-gene
|
| 19 |
+
forms to `GENE`, matching the Norman annotations.
|
| 20 |
+
|
| 21 |
+
| Item | scGPT | CellFM |
|
| 22 |
+
| --- | --- | --- |
|
| 23 |
+
| Gene pool | 20 | 20 |
|
| 24 |
+
| Candidate universe | 210 (20 singles + 190 doubles) | 210 (20 singles + 190 doubles) |
|
| 25 |
+
| Train cases | 39 (20 doubles + 19 singles) | 33 (16 doubles + 17 singles) |
|
| 26 |
+
| Val cases | 3 doubles | 3 doubles |
|
| 27 |
+
| Test cases | 7 doubles | 11 doubles |
|
| 28 |
+
| Total observed cases used | 49 | 47 |
|
| 29 |
+
| Missing in local Norman data | 0 | 0 |
|
| 30 |
+
|
| 31 |
+
In the scGPT gene pool, FOSB is not included as a single-gene training case. In
|
| 32 |
+
the CellFM gene pool, FOSB, FOXA3, and MAP2K3 also have no single-gene training
|
| 33 |
+
entries. These singles exist in Norman and remain in the candidate universe as
|
| 34 |
+
distractors, matching the known-vs-unseen design in the papers.
|
| 35 |
+
|
| 36 |
+
The exact gene and split lists are stored in:
|
| 37 |
+
|
| 38 |
+
- `protocols/scgpt/genes.txt` and `protocols/scgpt/split.json`
|
| 39 |
+
- `protocols/cellfm/genes.txt` and `protocols/cellfm/split.json`
|
| 40 |
+
|
| 41 |
+
## 1. Build the Benchmark
|
| 42 |
+
|
| 43 |
+
```bash
|
| 44 |
+
# scGPT
|
| 45 |
+
python performer_module/gene_inverse_norman/build_norman_gene_inverse_benchmark.py \
|
| 46 |
+
--adata_path GEARS/data/norman/perturb_processed.h5ad \
|
| 47 |
+
--protocol scgpt \
|
| 48 |
+
--output_dir performer_module/gene_inverse_norman/outputs/bench_scgpt
|
| 49 |
+
|
| 50 |
+
# CellFM
|
| 51 |
+
python performer_module/gene_inverse_norman/build_norman_gene_inverse_benchmark.py \
|
| 52 |
+
--adata_path GEARS/data/norman/perturb_processed.h5ad \
|
| 53 |
+
--protocol cellfm \
|
| 54 |
+
--output_dir performer_module/gene_inverse_norman/outputs/bench_cellfm
|
| 55 |
+
```
|
| 56 |
+
|
| 57 |
+
Main outputs:
|
| 58 |
+
|
| 59 |
+
- `gene_inverse_conditions.csv`: full candidate universe with `is_observed`.
|
| 60 |
+
- `class_mapping.csv`: `class_id -> condition`.
|
| 61 |
+
- `benchmark_summary.json`: protocol, observed counts, and `protocol_conditions_missing_in_data`.
|
| 62 |
+
- `protocol_split.json`: backup copy of the original protocol split.
|
| 63 |
+
|
| 64 |
+
## 2. Training
|
| 65 |
+
|
| 66 |
+
```bash
|
| 67 |
+
# scGPT
|
| 68 |
+
python finetune_gene_inverse.py \
|
| 69 |
+
--scfm_hyper_params_path ../mae_tost_48M_cross_attn_only_pure_memory/mae_tost_48M_cross_attn_only_pure_memory.pkl \
|
| 70 |
+
--scfm_ckpt_path ../mae_tost_48M_cross_attn_only_pure_memory/mae_tost_48M_cross_attn_only_pure_memory_epoch12.pth \
|
| 71 |
+
--benchmark_csv ./outputs/bench_scgpt/gene_inverse_conditions.csv \
|
| 72 |
+
--adata_path ../../GEARS/data/norman/perturb_processed.h5ad \
|
| 73 |
+
--gene_mapping_path ../../gene2ens.csv \
|
| 74 |
+
--scfm_genes_file ../../selected_genes_27k.txt \
|
| 75 |
+
--gene2vec_path ../../selected_gene2vec_27k.npy \
|
| 76 |
+
--protocol scgpt \
|
| 77 |
+
--finetune_mode full \
|
| 78 |
+
--pool_method max \
|
| 79 |
+
--epochs 20 \
|
| 80 |
+
--batch_size 24 \
|
| 81 |
+
--lr_head 1e-3 \
|
| 82 |
+
--lr_backbone 1e-4 \
|
| 83 |
+
--algorithm_name scLong_scgpt_protocol \
|
| 84 |
+
--export_test_logits \
|
| 85 |
+
--save_best_checkpoint \
|
| 86 |
+
--output_dir performer_module/gene_inverse_norman/outputs/run_scgpt
|
| 87 |
+
|
| 88 |
+
# CellFM
|
| 89 |
+
python performer_module/gene_inverse_norman/finetune_gene_inverse.py \
|
| 90 |
+
--scfm_hyper_params_path mae_tost_48M_cross_attn_only_pure_memory/mae_tost_48M_cross_attn_only_pure_memory.pkl \
|
| 91 |
+
--scfm_ckpt_path mae_tost_48M_cross_attn_only_pure_memory/mae_tost_48M_cross_attn_only_pure_memory.pth \
|
| 92 |
+
--benchmark_csv performer_module/gene_inverse_norman/outputs/bench_cellfm/gene_inverse_conditions.csv \
|
| 93 |
+
--adata_path GEARS/data/norman/perturb_processed.h5ad \
|
| 94 |
+
--gene_mapping_path gene2ens.csv \
|
| 95 |
+
--scfm_genes_file selected_genes_27k.txt \
|
| 96 |
+
--gene2vec_path selected_gene2vec_27k.npy \
|
| 97 |
+
--protocol cellfm \
|
| 98 |
+
--finetune_mode frozen \
|
| 99 |
+
--pool_method max \
|
| 100 |
+
--epochs 5 \
|
| 101 |
+
--batch_size 4 \
|
| 102 |
+
--lr_head 1e-4 \
|
| 103 |
+
--lr_backbone 1e-5 \
|
| 104 |
+
--algorithm_name scLong_cellfm_protocol \
|
| 105 |
+
--export_test_logits \
|
| 106 |
+
--save_best_checkpoint \
|
| 107 |
+
--output_dir performer_module/gene_inverse_norman/outputs/run_cellfm
|
| 108 |
+
```
|
| 109 |
+
|
| 110 |
+
Once `--protocol` is specified, the fixed train/val/test lists from
|
| 111 |
+
`protocols/<name>/split.json` are used. Random splitting is disabled. `--seed`
|
| 112 |
+
only affects initialization and dataloader order.
|
| 113 |
+
|
| 114 |
+
DDP training on multiple GPUs:
|
| 115 |
+
|
| 116 |
+
```bash
|
| 117 |
+
torchrun --nproc_per_node=4 \
|
| 118 |
+
performer_module/gene_inverse_norman/finetune_gene_inverse.py \
|
| 119 |
+
--protocol cellfm \
|
| 120 |
+
... # Other arguments are the same as above.
|
| 121 |
+
```
|
| 122 |
+
|
| 123 |
+
Training outputs:
|
| 124 |
+
|
| 125 |
+
- `best_finetune_gene_inverse.pth`, saved only when `--save_best_checkpoint` is passed.
|
| 126 |
+
- `summary_metrics.json`, including `test_condition_top1_acc` to `test_condition_top10_acc`, `test_condition_relevant_top1_acc` to `test_condition_relevant_top10_acc`, and `case_cellmean` metrics such as `test_case_cellmean_top1_acc` and `test_case_cellmean_relevant_top1_acc`.
|
| 127 |
+
- `test_summary_metrics.json`, which keeps only the test-set view and key configuration for reporting, without all `best_val_*` fields.
|
| 128 |
+
- `metrics_table.csv`, a flattened top1-to-top10 table grouped by metric family and including the `algorithm` field.
|
| 129 |
+
- `train_history.csv`.
|
| 130 |
+
- `test_predictions.csv` for per-cell predictions.
|
| 131 |
+
- `test_logits.csv` when `--export_test_logits` is enabled.
|
| 132 |
+
- `test_condition_rankings.jsonl`, containing top-10 rankings and the true rank for each test perturbation.
|
| 133 |
+
- `condition_splits.npz`, `condition_splits.json`, and `split_indices.npz`.
|
| 134 |
+
|
| 135 |
+
Notes:
|
| 136 |
+
|
| 137 |
+
- Full finetuned checkpoints are not saved by default to save disk space. Add `--save_best_checkpoint` if later re-evaluation with `evaluate_gene_inverse.py` is needed.
|
| 138 |
+
- `--select_metric` is used only to choose the best epoch by one metric; it is not a multi-metric selector. Other metrics are still fully exported.
|
| 139 |
+
- When `--select_metric` ties, `condition_top1_acc`, `case_cellmean_top5_acc`, and `val_loss` are used as tie-breakers.
|
| 140 |
+
- Optional training stabilization: `--class_balance_power 0.5` for inverse-square-root weighting by condition sample count, and `--label_smoothing 0.05`.
|
| 141 |
+
|
| 142 |
+
## 3. Re-Evaluation
|
| 143 |
+
|
| 144 |
+
```bash
|
| 145 |
+
python performer_module/gene_inverse_norman/evaluate_gene_inverse.py \
|
| 146 |
+
--trained_head_ckpt performer_module/gene_inverse_norman/outputs/run_cellfm/best_finetune_gene_inverse.pth \
|
| 147 |
+
--scfm_hyper_params_path mae_tost_48M_cross_attn_only_pure_memory/mae_tost_48M_cross_attn_only_pure_memory.pkl \
|
| 148 |
+
--scfm_ckpt_path mae_tost_48M_cross_attn_only_pure_memory/mae_tost_48M_cross_attn_only_pure_memory.pth \
|
| 149 |
+
--benchmark_csv performer_module/gene_inverse_norman/outputs/bench_cellfm/gene_inverse_conditions.csv \
|
| 150 |
+
--condition_splits_npz performer_module/gene_inverse_norman/outputs/run_cellfm/condition_splits.npz \
|
| 151 |
+
--split test \
|
| 152 |
+
--export_logits \
|
| 153 |
+
--output_dir performer_module/gene_inverse_norman/outputs/eval_cellfm
|
| 154 |
+
```
|
| 155 |
+
|
| 156 |
+
## 4. Evaluation Definition
|
| 157 |
+
|
| 158 |
+
### Main Metrics Aligned with the Papers
|
| 159 |
+
|
| 160 |
+
`condition_top1_acc` ... `condition_top10_acc`: exact-hit metrics.
|
| 161 |
+
|
| 162 |
+
`condition_relevant_top1_acc` ... `condition_relevant_top10_acc`: relevant-hit
|
| 163 |
+
metrics, where the predicted set shares at least one gene with the true
|
| 164 |
+
perturbation.
|
| 165 |
+
|
| 166 |
+
`case_cellmean_top1_acc` ... `case_cellmean_top10_acc`: first compute cell-level
|
| 167 |
+
hit@k within each condition, then average conditions equally. This can produce
|
| 168 |
+
decimal values that are not simply `x/7` or `x/11`.
|
| 169 |
+
|
| 170 |
+
`case_cellmean_relevant_top1_acc` ... `case_cellmean_relevant_top10_acc`: same
|
| 171 |
+
idea as above, but using the relevant-hit definition.
|
| 172 |
+
|
| 173 |
+
Workflow:
|
| 174 |
+
|
| 175 |
+
1. The model outputs logits over 210 candidates for each test cell.
|
| 176 |
+
2. Logits from all cells under the same perturbation are averaged.
|
| 177 |
+
3. The true perturbation is checked against the top-k ranking.
|
| 178 |
+
|
| 179 |
+
### Auxiliary Metrics
|
| 180 |
+
|
| 181 |
+
`top1_acc` ... `top10_acc`: cell-level top-k metrics for reference only.
|
| 182 |
+
|
| 183 |
+
`--select_metric` can be one of `condition_top{k}_acc`,
|
| 184 |
+
`condition_relevant_top{k}_acc`, `case_cellmean_top{k}_acc`,
|
| 185 |
+
`case_cellmean_relevant_top{k}_acc`, or `top{k}_acc`, where `k=1..10`.
|
| 186 |
+
|
| 187 |
+
Recommendations:
|
| 188 |
+
|
| 189 |
+
- Use `condition_topk_acc` and `condition_relevant_topk_acc` for the main conclusion.
|
| 190 |
+
- Also report `case_cellmean_*` when aligning with paper wording about non-integer average hit counts.
|
| 191 |
+
- The classification head uses residual connections by default; add `--no_head_residual` to return to the old head.
|
| 192 |
+
- Use only `max` or `mean` for `pool_method`; other historical options have been removed from the current training entry point.
|
| 193 |
+
|
| 194 |
+
## 5. Points to Watch
|
| 195 |
+
|
| 196 |
+
- The candidate space is the full set of 210 singles and doubles. Paper-style Top-k hit rates are ranked over this full set by default. If a baseline ranks only a subset, report that separately because the numbers are not directly comparable.
|
| 197 |
+
- The two protocols use different gene pools: scGPT has 19 unique genes plus FEV, while CellFM has 20 unique genes. Do not directly cross-compare scGPT-train and CellFM-test without explaining this.
|
| 198 |
+
- `seed` does not affect the split because splits come from fixed protocol lists, but it still affects initialization and dataloader order. Multi-seed runs can estimate variance.
|
| 199 |
+
|
| 200 |
+
## 6. Custom Splits Without a Protocol
|
| 201 |
+
|
| 202 |
+
Custom gene pools and random splits are still supported:
|
| 203 |
+
|
| 204 |
+
```bash
|
| 205 |
+
python performer_module/gene_inverse_norman/build_norman_gene_inverse_benchmark.py \
|
| 206 |
+
--adata_path GEARS/data/norman/perturb_processed.h5ad \
|
| 207 |
+
--gene_pool_file my_genes.txt \
|
| 208 |
+
--output_dir performer_module/gene_inverse_norman/outputs/bench_custom
|
| 209 |
+
|
| 210 |
+
python performer_module/gene_inverse_norman/finetune_gene_inverse.py \
|
| 211 |
+
... \
|
| 212 |
+
--train_cases 33 --val_cases 3 --test_cases 11 \
|
| 213 |
+
--allow_observed_count_mismatch
|
| 214 |
+
```
|
| 215 |
+
|
| 216 |
+
When `--protocol` is not passed, observed conditions are randomly split by
|
| 217 |
+
`--train_cases`, `--val_cases`, `--test_cases`, and `--seed`.
|
BioMod/gene_inverse_norman/build_norman_gene_inverse_benchmark.py
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import argparse
|
| 2 |
+
import itertools
|
| 3 |
+
import json
|
| 4 |
+
import sys
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
from typing import Dict, List, Optional
|
| 7 |
+
|
| 8 |
+
import anndata as ad
|
| 9 |
+
import numpy as np
|
| 10 |
+
import pandas as pd
|
| 11 |
+
|
| 12 |
+
CURRENT_DIR = Path(__file__).resolve().parent
|
| 13 |
+
if str(CURRENT_DIR) not in sys.path:
|
| 14 |
+
sys.path.insert(0, str(CURRENT_DIR))
|
| 15 |
+
|
| 16 |
+
from utils import canonical_condition_name, default_repo_path, parse_condition, print_sys
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
PROTOCOLS_DIR = CURRENT_DIR / "protocols"
|
| 20 |
+
SUPPORTED_PROTOCOLS = ("scgpt", "cellfm")
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def protocol_dir(protocol: str) -> Path:
|
| 24 |
+
if protocol not in SUPPORTED_PROTOCOLS:
|
| 25 |
+
raise ValueError(f"Unsupported protocol '{protocol}'. Expected one of {SUPPORTED_PROTOCOLS}.")
|
| 26 |
+
return PROTOCOLS_DIR / protocol
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def load_protocol_genes(protocol: str) -> List[str]:
|
| 30 |
+
genes_path = protocol_dir(protocol) / "genes.txt"
|
| 31 |
+
with open(genes_path, "r", encoding="utf-8") as handle:
|
| 32 |
+
genes = [line.strip() for line in handle.readlines() if line.strip()]
|
| 33 |
+
return sorted(dict.fromkeys(genes))
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def load_protocol_split(protocol: str) -> Dict[str, object]:
|
| 37 |
+
split_path = protocol_dir(protocol) / "split.json"
|
| 38 |
+
with open(split_path, "r", encoding="utf-8") as handle:
|
| 39 |
+
return json.load(handle)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def load_gene_pool(args) -> List[str]:
|
| 43 |
+
if args.protocol:
|
| 44 |
+
return load_protocol_genes(args.protocol)
|
| 45 |
+
if args.gene_pool_file:
|
| 46 |
+
with open(args.gene_pool_file, "r", encoding="utf-8") as handle:
|
| 47 |
+
genes = [line.strip() for line in handle.readlines() if line.strip()]
|
| 48 |
+
return sorted(dict.fromkeys(genes))
|
| 49 |
+
raise ValueError("Must specify --protocol or --gene_pool_file")
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def build_candidate_conditions(genes: List[str], include_singles: bool = True, include_doubles: bool = True) -> List[str]:
|
| 53 |
+
candidates: List[str] = []
|
| 54 |
+
if include_singles:
|
| 55 |
+
candidates.extend(genes)
|
| 56 |
+
if include_doubles:
|
| 57 |
+
candidates.extend([f"{a}+{b}" for a, b in itertools.combinations(genes, 2)])
|
| 58 |
+
return sorted(candidates)
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def summarize_observed_conditions(
|
| 62 |
+
adata,
|
| 63 |
+
condition_col: str,
|
| 64 |
+
condition_name_col: Optional[str],
|
| 65 |
+
candidate_set: set,
|
| 66 |
+
) -> Dict[str, Dict[str, object]]:
|
| 67 |
+
if condition_col not in adata.obs.columns:
|
| 68 |
+
raise KeyError(f"condition_col '{condition_col}' not found in adata.obs")
|
| 69 |
+
|
| 70 |
+
condition_series = adata.obs[condition_col].astype(str)
|
| 71 |
+
raw_condition_counts = condition_series.value_counts().to_dict()
|
| 72 |
+
|
| 73 |
+
raw_condition_name_map: Dict[str, str] = {}
|
| 74 |
+
if condition_name_col and condition_name_col in adata.obs.columns:
|
| 75 |
+
raw_condition_name_map = (
|
| 76 |
+
adata.obs[[condition_col, condition_name_col]]
|
| 77 |
+
.astype(str)
|
| 78 |
+
.drop_duplicates(subset=[condition_col])
|
| 79 |
+
.set_index(condition_col)[condition_name_col]
|
| 80 |
+
.to_dict()
|
| 81 |
+
)
|
| 82 |
+
|
| 83 |
+
candidate_to_raw: Dict[str, List[str]] = {candidate: [] for candidate in candidate_set}
|
| 84 |
+
for raw_condition in sorted(raw_condition_counts.keys()):
|
| 85 |
+
if raw_condition == "ctrl":
|
| 86 |
+
continue
|
| 87 |
+
canonical = canonical_condition_name(raw_condition)
|
| 88 |
+
if canonical in candidate_set:
|
| 89 |
+
candidate_to_raw[canonical].append(raw_condition)
|
| 90 |
+
|
| 91 |
+
candidate_summary: Dict[str, Dict[str, object]] = {}
|
| 92 |
+
for candidate in sorted(candidate_set):
|
| 93 |
+
raw_conditions = candidate_to_raw.get(candidate, [])
|
| 94 |
+
n_cells = int(sum(raw_condition_counts[raw] for raw in raw_conditions)) if raw_conditions else 0
|
| 95 |
+
candidate_summary[candidate] = {
|
| 96 |
+
"raw_conditions": raw_conditions,
|
| 97 |
+
"condition_names": [raw_condition_name_map.get(raw, "") for raw in raw_conditions],
|
| 98 |
+
"n_cells": n_cells,
|
| 99 |
+
"is_observed": int(n_cells > 0),
|
| 100 |
+
}
|
| 101 |
+
return candidate_summary
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def main() -> None:
|
| 105 |
+
parser = argparse.ArgumentParser(
|
| 106 |
+
description="Build a CellFM-style Norman reverse-perturbation benchmark table."
|
| 107 |
+
)
|
| 108 |
+
parser.add_argument(
|
| 109 |
+
"--adata_path",
|
| 110 |
+
type=str,
|
| 111 |
+
default=default_repo_path("GEARS", "data", "norman", "perturb_processed.h5ad"),
|
| 112 |
+
help="Path to Norman perturb_processed.h5ad",
|
| 113 |
+
)
|
| 114 |
+
parser.add_argument("--condition_col", type=str, default="condition", help="Condition column in adata.obs")
|
| 115 |
+
parser.add_argument(
|
| 116 |
+
"--condition_name_col",
|
| 117 |
+
type=str,
|
| 118 |
+
default="condition_name",
|
| 119 |
+
help="Optional condition-name column in adata.obs",
|
| 120 |
+
)
|
| 121 |
+
parser.add_argument(
|
| 122 |
+
"--protocol",
|
| 123 |
+
type=str,
|
| 124 |
+
default="",
|
| 125 |
+
choices=["", "scgpt", "cellfm"],
|
| 126 |
+
help="Use a built-in protocol (scgpt or cellfm) for gene pool and split. Overrides --gene_pool_file.",
|
| 127 |
+
)
|
| 128 |
+
parser.add_argument(
|
| 129 |
+
"--gene_pool_file",
|
| 130 |
+
type=str,
|
| 131 |
+
default="",
|
| 132 |
+
help="Optional newline-delimited gene pool. Used only when --protocol is empty.",
|
| 133 |
+
)
|
| 134 |
+
parser.add_argument(
|
| 135 |
+
"--include_singles",
|
| 136 |
+
action="store_true",
|
| 137 |
+
help="Include single-gene candidates. Defaults follow the protocol when --protocol is set.",
|
| 138 |
+
)
|
| 139 |
+
parser.add_argument(
|
| 140 |
+
"--include_doubles",
|
| 141 |
+
action="store_true",
|
| 142 |
+
help="Include double-gene candidates. Defaults follow the protocol when --protocol is set.",
|
| 143 |
+
)
|
| 144 |
+
parser.add_argument("--singles_only", action="store_true", help="Only keep single-gene candidates.")
|
| 145 |
+
parser.add_argument("--doubles_only", action="store_true", help="Only keep double-gene candidates.")
|
| 146 |
+
parser.add_argument(
|
| 147 |
+
"--min_cells_per_condition",
|
| 148 |
+
type=int,
|
| 149 |
+
default=1,
|
| 150 |
+
help="Observed candidates with fewer cells than this threshold are downgraded to unobserved.",
|
| 151 |
+
)
|
| 152 |
+
parser.add_argument(
|
| 153 |
+
"--output_dir",
|
| 154 |
+
type=str,
|
| 155 |
+
default=default_repo_path(
|
| 156 |
+
"performer_module",
|
| 157 |
+
"gene_inverse_norman",
|
| 158 |
+
"outputs",
|
| 159 |
+
"norman_gene_inverse_benchmark",
|
| 160 |
+
),
|
| 161 |
+
help="Output directory",
|
| 162 |
+
)
|
| 163 |
+
args = parser.parse_args()
|
| 164 |
+
|
| 165 |
+
if args.singles_only and args.doubles_only:
|
| 166 |
+
raise ValueError("--singles_only and --doubles_only cannot be used together")
|
| 167 |
+
|
| 168 |
+
protocol_split: Optional[Dict[str, object]] = None
|
| 169 |
+
if args.protocol:
|
| 170 |
+
protocol_split = load_protocol_split(args.protocol)
|
| 171 |
+
include_singles = bool(protocol_split.get("include_singles", False))
|
| 172 |
+
include_doubles = bool(protocol_split.get("include_doubles", True))
|
| 173 |
+
else:
|
| 174 |
+
include_singles = True
|
| 175 |
+
include_doubles = True
|
| 176 |
+
|
| 177 |
+
if args.singles_only:
|
| 178 |
+
include_doubles = False
|
| 179 |
+
if args.doubles_only:
|
| 180 |
+
include_singles = False
|
| 181 |
+
if args.include_singles:
|
| 182 |
+
include_singles = True
|
| 183 |
+
if args.include_doubles:
|
| 184 |
+
include_doubles = True
|
| 185 |
+
if not include_singles and not include_doubles:
|
| 186 |
+
raise ValueError("Candidate space cannot be empty")
|
| 187 |
+
|
| 188 |
+
output_dir = Path(args.output_dir)
|
| 189 |
+
output_dir.mkdir(parents=True, exist_ok=True)
|
| 190 |
+
|
| 191 |
+
selected_genes = load_gene_pool(args)
|
| 192 |
+
candidate_conditions = build_candidate_conditions(
|
| 193 |
+
genes=selected_genes,
|
| 194 |
+
include_singles=include_singles,
|
| 195 |
+
include_doubles=include_doubles,
|
| 196 |
+
)
|
| 197 |
+
candidate_set = set(candidate_conditions)
|
| 198 |
+
|
| 199 |
+
print_sys(f"Loading Norman data from {args.adata_path}")
|
| 200 |
+
adata = ad.read_h5ad(args.adata_path, backed="r")
|
| 201 |
+
|
| 202 |
+
candidate_summary = summarize_observed_conditions(
|
| 203 |
+
adata=adata,
|
| 204 |
+
condition_col=args.condition_col,
|
| 205 |
+
condition_name_col=args.condition_name_col,
|
| 206 |
+
candidate_set=candidate_set,
|
| 207 |
+
)
|
| 208 |
+
|
| 209 |
+
rows = []
|
| 210 |
+
min_cells = int(args.min_cells_per_condition)
|
| 211 |
+
for candidate in candidate_conditions:
|
| 212 |
+
genes = parse_condition(candidate)
|
| 213 |
+
perturbation_type = "single" if len(genes) == 1 else "double"
|
| 214 |
+
gene_a = genes[0]
|
| 215 |
+
gene_b = genes[1] if len(genes) > 1 else ""
|
| 216 |
+
|
| 217 |
+
summary = candidate_summary[candidate]
|
| 218 |
+
n_cells = int(summary["n_cells"])
|
| 219 |
+
is_observed = int(summary["is_observed"] and n_cells >= min_cells)
|
| 220 |
+
if not is_observed:
|
| 221 |
+
n_cells = 0
|
| 222 |
+
|
| 223 |
+
rows.append(
|
| 224 |
+
{
|
| 225 |
+
"condition": candidate,
|
| 226 |
+
"perturbation_type": perturbation_type,
|
| 227 |
+
"gene_a": gene_a,
|
| 228 |
+
"gene_b": gene_b,
|
| 229 |
+
"perturb_gene": candidate if perturbation_type == "double" else gene_a,
|
| 230 |
+
"n_cells": n_cells,
|
| 231 |
+
"is_observed": is_observed,
|
| 232 |
+
"n_raw_conditions_merged": int(len(summary["raw_conditions"])) if is_observed else 0,
|
| 233 |
+
"raw_conditions_json": json.dumps(summary["raw_conditions"] if is_observed else [], ensure_ascii=False),
|
| 234 |
+
"condition_name_json": json.dumps(summary["condition_names"] if is_observed else [], ensure_ascii=False),
|
| 235 |
+
}
|
| 236 |
+
)
|
| 237 |
+
|
| 238 |
+
benchmark_df = pd.DataFrame(rows)
|
| 239 |
+
benchmark_df = benchmark_df.sort_values(["perturbation_type", "condition"]).reset_index(drop=True)
|
| 240 |
+
benchmark_df["class_id"] = np.arange(len(benchmark_df), dtype=np.int64)
|
| 241 |
+
benchmark_df["class_id"] = benchmark_df["class_id"].astype(int)
|
| 242 |
+
|
| 243 |
+
observed_df = benchmark_df[benchmark_df["is_observed"] == 1].copy()
|
| 244 |
+
observed_single = int((observed_df["perturbation_type"] == "single").sum())
|
| 245 |
+
observed_double = int((observed_df["perturbation_type"] == "double").sum())
|
| 246 |
+
|
| 247 |
+
summary = {
|
| 248 |
+
"n_cells_total": int(adata.n_obs),
|
| 249 |
+
"n_genes_total": int(adata.n_vars),
|
| 250 |
+
"protocol": args.protocol or None,
|
| 251 |
+
"selected_genes": selected_genes,
|
| 252 |
+
"n_selected_genes": int(len(selected_genes)),
|
| 253 |
+
"n_candidate_conditions": int(len(benchmark_df)),
|
| 254 |
+
"n_candidate_single": int((benchmark_df["perturbation_type"] == "single").sum()),
|
| 255 |
+
"n_candidate_double": int((benchmark_df["perturbation_type"] == "double").sum()),
|
| 256 |
+
"n_observed_conditions": int(len(observed_df)),
|
| 257 |
+
"n_observed_single": observed_single,
|
| 258 |
+
"n_observed_double": observed_double,
|
| 259 |
+
"min_cells_per_condition": min_cells,
|
| 260 |
+
"condition_col": args.condition_col,
|
| 261 |
+
"condition_name_col": args.condition_name_col,
|
| 262 |
+
"alignment_note": (
|
| 263 |
+
f"Built from protocol '{args.protocol}'." if args.protocol
|
| 264 |
+
else "Built from custom gene pool."
|
| 265 |
+
),
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
if protocol_split is not None:
|
| 269 |
+
with (output_dir / "protocol_split.json").open("w", encoding="utf-8") as handle:
|
| 270 |
+
json.dump(protocol_split, handle, indent=2, ensure_ascii=False)
|
| 271 |
+
|
| 272 |
+
protocol_conditions = set()
|
| 273 |
+
for split_name in ("train", "val", "test"):
|
| 274 |
+
for cond in protocol_split.get(split_name, []):
|
| 275 |
+
protocol_conditions.add(canonical_condition_name(cond))
|
| 276 |
+
observed_protocol = protocol_conditions & set(
|
| 277 |
+
observed_df["condition"].astype(str).tolist()
|
| 278 |
+
)
|
| 279 |
+
missing_in_data = sorted(protocol_conditions - observed_protocol)
|
| 280 |
+
summary["n_protocol_conditions"] = int(len(protocol_conditions))
|
| 281 |
+
summary["n_protocol_conditions_observed"] = int(len(observed_protocol))
|
| 282 |
+
summary["protocol_conditions_missing_in_data"] = missing_in_data
|
| 283 |
+
if missing_in_data:
|
| 284 |
+
print_sys(
|
| 285 |
+
f"WARNING: {len(missing_in_data)} protocol conditions missing in Norman data: {missing_in_data}"
|
| 286 |
+
)
|
| 287 |
+
|
| 288 |
+
benchmark_df.to_csv(output_dir / "gene_inverse_conditions.csv", index=False)
|
| 289 |
+
benchmark_df[
|
| 290 |
+
["class_id", "condition", "perturbation_type", "gene_a", "gene_b", "is_observed", "n_cells"]
|
| 291 |
+
].to_csv(output_dir / "class_mapping.csv", index=False)
|
| 292 |
+
with (output_dir / "benchmark_summary.json").open("w", encoding="utf-8") as handle:
|
| 293 |
+
json.dump(summary, handle, indent=2, ensure_ascii=False)
|
| 294 |
+
|
| 295 |
+
print_sys(json.dumps(summary, indent=2, ensure_ascii=False))
|
| 296 |
+
print_sys(f"Saved benchmark files to {output_dir}")
|
| 297 |
+
|
| 298 |
+
|
| 299 |
+
if __name__ == "__main__":
|
| 300 |
+
main()
|
BioMod/gene_inverse_norman/common.py
ADDED
|
@@ -0,0 +1,660 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import random
|
| 3 |
+
import sys
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
from typing import Dict, List, Optional, Sequence, Tuple
|
| 6 |
+
|
| 7 |
+
import numpy as np
|
| 8 |
+
import pandas as pd
|
| 9 |
+
import torch
|
| 10 |
+
import torch.distributed as dist
|
| 11 |
+
from scipy import sparse
|
| 12 |
+
from torch import nn
|
| 13 |
+
from torch.utils.data import Dataset
|
| 14 |
+
|
| 15 |
+
CURRENT_DIR = Path(__file__).resolve().parent
|
| 16 |
+
if str(CURRENT_DIR) not in sys.path:
|
| 17 |
+
sys.path.insert(0, str(CURRENT_DIR))
|
| 18 |
+
|
| 19 |
+
from utils import canonical_condition_name, parse_condition, print_sys, reindex_tensor_universal
|
| 20 |
+
|
| 21 |
+
TOPK_METRICS: Tuple[int, ...] = tuple(range(1, 11))
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def seed_all(seed: int) -> None:
|
| 25 |
+
random.seed(seed)
|
| 26 |
+
np.random.seed(seed)
|
| 27 |
+
torch.manual_seed(seed)
|
| 28 |
+
torch.cuda.manual_seed_all(seed)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def parse_raw_conditions_json(raw_value: object, fallback_condition: str) -> List[str]:
|
| 32 |
+
if isinstance(raw_value, list):
|
| 33 |
+
return [str(item) for item in raw_value]
|
| 34 |
+
|
| 35 |
+
if pd.isna(raw_value):
|
| 36 |
+
return [fallback_condition]
|
| 37 |
+
|
| 38 |
+
raw_str = str(raw_value).strip()
|
| 39 |
+
if not raw_str:
|
| 40 |
+
return [fallback_condition]
|
| 41 |
+
|
| 42 |
+
try:
|
| 43 |
+
parsed = json.loads(raw_str)
|
| 44 |
+
if isinstance(parsed, list) and parsed:
|
| 45 |
+
return [str(item) for item in parsed]
|
| 46 |
+
except json.JSONDecodeError:
|
| 47 |
+
pass
|
| 48 |
+
return [fallback_condition]
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def normalize_benchmark_table(benchmark_df: pd.DataFrame, min_cells_per_condition: int = 1) -> pd.DataFrame:
|
| 52 |
+
if "condition" not in benchmark_df.columns:
|
| 53 |
+
raise KeyError("benchmark_csv must contain column 'condition'")
|
| 54 |
+
|
| 55 |
+
table = benchmark_df.copy()
|
| 56 |
+
table["condition"] = table["condition"].astype(str)
|
| 57 |
+
table = table.drop_duplicates(subset=["condition"]).reset_index(drop=True)
|
| 58 |
+
|
| 59 |
+
if "n_cells" not in table.columns:
|
| 60 |
+
table["n_cells"] = 0
|
| 61 |
+
table["n_cells"] = table["n_cells"].fillna(0).astype(int)
|
| 62 |
+
|
| 63 |
+
if "is_observed" not in table.columns:
|
| 64 |
+
table["is_observed"] = (table["n_cells"] > 0).astype(int)
|
| 65 |
+
table["is_observed"] = table["is_observed"].fillna(0).astype(int)
|
| 66 |
+
|
| 67 |
+
if "perturbation_type" not in table.columns:
|
| 68 |
+
table["perturbation_type"] = table["condition"].apply(
|
| 69 |
+
lambda condition: "single" if len(parse_condition(condition)) == 1 else "double"
|
| 70 |
+
)
|
| 71 |
+
table["perturbation_type"] = table["perturbation_type"].astype(str)
|
| 72 |
+
|
| 73 |
+
if "gene_a" not in table.columns:
|
| 74 |
+
table["gene_a"] = table["condition"].apply(lambda condition: parse_condition(condition)[0])
|
| 75 |
+
if "gene_b" not in table.columns:
|
| 76 |
+
table["gene_b"] = table["condition"].apply(
|
| 77 |
+
lambda condition: parse_condition(condition)[1] if len(parse_condition(condition)) > 1 else ""
|
| 78 |
+
)
|
| 79 |
+
table["gene_a"] = table["gene_a"].fillna("").astype(str)
|
| 80 |
+
table["gene_b"] = table["gene_b"].fillna("").astype(str)
|
| 81 |
+
|
| 82 |
+
if "perturb_gene" not in table.columns:
|
| 83 |
+
table["perturb_gene"] = table.apply(
|
| 84 |
+
lambda row: row["condition"] if row["perturbation_type"] == "double" else row["gene_a"],
|
| 85 |
+
axis=1,
|
| 86 |
+
)
|
| 87 |
+
table["perturb_gene"] = table["perturb_gene"].astype(str)
|
| 88 |
+
|
| 89 |
+
if min_cells_per_condition > 1:
|
| 90 |
+
observed_mask = (table["is_observed"] == 0) | (table["n_cells"] >= int(min_cells_per_condition))
|
| 91 |
+
table = table[observed_mask].copy()
|
| 92 |
+
table.loc[table["n_cells"] < int(min_cells_per_condition), "is_observed"] = 0
|
| 93 |
+
table.loc[table["n_cells"] < int(min_cells_per_condition), "n_cells"] = 0
|
| 94 |
+
|
| 95 |
+
if "class_id" in table.columns:
|
| 96 |
+
table = table.sort_values("class_id").reset_index(drop=True)
|
| 97 |
+
else:
|
| 98 |
+
table = table.sort_values(["perturbation_type", "condition"]).reset_index(drop=True)
|
| 99 |
+
table["class_id"] = np.arange(len(table), dtype=np.int64)
|
| 100 |
+
table["class_id"] = table["class_id"].astype(int)
|
| 101 |
+
|
| 102 |
+
return table
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def build_raw_to_canonical_map(benchmark_df: pd.DataFrame) -> Dict[str, str]:
|
| 106 |
+
raw_to_canonical: Dict[str, str] = {}
|
| 107 |
+
for _, row in benchmark_df.iterrows():
|
| 108 |
+
canonical = str(row["condition"])
|
| 109 |
+
raw_conditions = parse_raw_conditions_json(row.get("raw_conditions_json", np.nan), canonical)
|
| 110 |
+
for raw in raw_conditions:
|
| 111 |
+
raw_to_canonical[str(raw)] = canonical
|
| 112 |
+
raw_to_canonical[canonical] = canonical
|
| 113 |
+
return raw_to_canonical
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def collect_labeled_cells(
|
| 117 |
+
adata,
|
| 118 |
+
condition_col: str,
|
| 119 |
+
raw_to_canonical: Dict[str, str],
|
| 120 |
+
canonical_to_id: Dict[str, int],
|
| 121 |
+
) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
|
| 122 |
+
if condition_col not in adata.obs.columns:
|
| 123 |
+
raise KeyError(f"condition_col '{condition_col}' not found in adata.obs")
|
| 124 |
+
|
| 125 |
+
condition_series = adata.obs[condition_col].astype(str)
|
| 126 |
+
canonical_series = condition_series.map(raw_to_canonical)
|
| 127 |
+
keep_mask = canonical_series.notna().to_numpy()
|
| 128 |
+
row_indices = np.where(keep_mask)[0]
|
| 129 |
+
canonical_labels = canonical_series.iloc[row_indices].astype(str).to_numpy()
|
| 130 |
+
class_ids = np.array([canonical_to_id[label] for label in canonical_labels.tolist()], dtype=np.int64)
|
| 131 |
+
return row_indices, class_ids, canonical_labels
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def split_observed_conditions(
|
| 135 |
+
class_df: pd.DataFrame,
|
| 136 |
+
seed: int,
|
| 137 |
+
train_cases: int,
|
| 138 |
+
val_cases: int,
|
| 139 |
+
test_cases: int,
|
| 140 |
+
allow_observed_count_mismatch: bool = False,
|
| 141 |
+
) -> Dict[str, List[str]]:
|
| 142 |
+
observed_conditions = class_df.loc[class_df["is_observed"] == 1, "condition"].astype(str).tolist()
|
| 143 |
+
expected_cases = int(train_cases) + int(val_cases) + int(test_cases)
|
| 144 |
+
observed_count = len(observed_conditions)
|
| 145 |
+
|
| 146 |
+
if observed_count < expected_cases:
|
| 147 |
+
raise ValueError(
|
| 148 |
+
f"Observed perturbation cases ({observed_count}) are fewer than requested split size ({expected_cases})."
|
| 149 |
+
)
|
| 150 |
+
if observed_count != expected_cases and not allow_observed_count_mismatch:
|
| 151 |
+
raise ValueError(
|
| 152 |
+
"Observed perturbation case count does not match the requested CellFM-style split. "
|
| 153 |
+
f"observed={observed_count}, expected={expected_cases}. "
|
| 154 |
+
"Use the exact CellFM gene pool or pass --allow_observed_count_mismatch to continue."
|
| 155 |
+
)
|
| 156 |
+
|
| 157 |
+
rng = np.random.default_rng(seed)
|
| 158 |
+
observed_conditions = observed_conditions.copy()
|
| 159 |
+
rng.shuffle(observed_conditions)
|
| 160 |
+
|
| 161 |
+
usable_conditions = observed_conditions[:expected_cases]
|
| 162 |
+
unused_conditions = observed_conditions[expected_cases:]
|
| 163 |
+
|
| 164 |
+
train_conditions = sorted(usable_conditions[:train_cases])
|
| 165 |
+
val_conditions = sorted(usable_conditions[train_cases : train_cases + val_cases])
|
| 166 |
+
test_conditions = sorted(usable_conditions[train_cases + val_cases : expected_cases])
|
| 167 |
+
|
| 168 |
+
return {
|
| 169 |
+
"train": train_conditions,
|
| 170 |
+
"val": val_conditions,
|
| 171 |
+
"test": test_conditions,
|
| 172 |
+
"unused_observed": sorted(unused_conditions),
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def split_from_protocol(
|
| 177 |
+
class_df: pd.DataFrame,
|
| 178 |
+
protocol_split: Dict[str, object],
|
| 179 |
+
require_all_observed: bool = True,
|
| 180 |
+
) -> Dict[str, List[str]]:
|
| 181 |
+
observed_set = set(class_df.loc[class_df["is_observed"] == 1, "condition"].astype(str).tolist())
|
| 182 |
+
candidate_set = set(class_df["condition"].astype(str).tolist())
|
| 183 |
+
|
| 184 |
+
def canonicalize_list(items: Sequence[str]) -> List[str]:
|
| 185 |
+
return [canonical_condition_name(str(item)) for item in items]
|
| 186 |
+
|
| 187 |
+
train = canonicalize_list(protocol_split.get("train", []))
|
| 188 |
+
val = canonicalize_list(protocol_split.get("val", []))
|
| 189 |
+
test = canonicalize_list(protocol_split.get("test", []))
|
| 190 |
+
|
| 191 |
+
all_listed = train + val + test
|
| 192 |
+
missing_in_candidates = sorted(set(all_listed) - candidate_set)
|
| 193 |
+
if missing_in_candidates:
|
| 194 |
+
raise ValueError(
|
| 195 |
+
f"Protocol conditions missing from benchmark candidate space: {missing_in_candidates}"
|
| 196 |
+
)
|
| 197 |
+
|
| 198 |
+
missing_in_data = sorted(set(all_listed) - observed_set)
|
| 199 |
+
if missing_in_data and require_all_observed:
|
| 200 |
+
raise ValueError(
|
| 201 |
+
"Protocol conditions are not observed in the local Norman data: "
|
| 202 |
+
f"{missing_in_data}. Re-run benchmark or pass --allow_protocol_unobserved."
|
| 203 |
+
)
|
| 204 |
+
|
| 205 |
+
return {
|
| 206 |
+
"train": sorted(train),
|
| 207 |
+
"val": sorted(val),
|
| 208 |
+
"test": sorted(test),
|
| 209 |
+
"unused_observed": sorted(observed_set - set(all_listed)),
|
| 210 |
+
"missing_in_data": missing_in_data,
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
def extract_candidate_gene_names(class_df: pd.DataFrame) -> List[str]:
|
| 215 |
+
genes = set(class_df["gene_a"].astype(str).tolist())
|
| 216 |
+
genes.update(gene for gene in class_df["gene_b"].astype(str).tolist() if gene)
|
| 217 |
+
genes.discard("")
|
| 218 |
+
return sorted(genes)
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
class GeneInverseCellDataset(Dataset):
|
| 222 |
+
def __init__(self, matrix, row_indices: np.ndarray, class_ids: np.ndarray):
|
| 223 |
+
if row_indices.shape[0] != class_ids.shape[0]:
|
| 224 |
+
raise ValueError("row_indices and class_ids must have the same length")
|
| 225 |
+
self.matrix = matrix
|
| 226 |
+
self.row_indices = row_indices.astype(np.int64, copy=False)
|
| 227 |
+
self.class_ids = class_ids.astype(np.int64, copy=False)
|
| 228 |
+
|
| 229 |
+
def __len__(self) -> int:
|
| 230 |
+
return int(self.row_indices.shape[0])
|
| 231 |
+
|
| 232 |
+
def __getitem__(self, idx: int):
|
| 233 |
+
row_idx = int(self.row_indices[idx])
|
| 234 |
+
row = self.matrix[row_idx]
|
| 235 |
+
if sparse.issparse(row):
|
| 236 |
+
row = row.toarray().ravel()
|
| 237 |
+
else:
|
| 238 |
+
row = np.asarray(row).reshape(-1)
|
| 239 |
+
x = torch.from_numpy(row.astype(np.float32, copy=False))
|
| 240 |
+
y = torch.tensor(int(self.class_ids[idx]), dtype=torch.long)
|
| 241 |
+
return x, y, row_idx
|
| 242 |
+
|
| 243 |
+
|
| 244 |
+
class GeneInverseCompositionalRetrievalModel(nn.Module):
|
| 245 |
+
def __init__(
|
| 246 |
+
self,
|
| 247 |
+
backbone: nn.Module,
|
| 248 |
+
class_df: pd.DataFrame,
|
| 249 |
+
pool_method: str = "max",
|
| 250 |
+
head_hidden_dim: int = 0,
|
| 251 |
+
head_dropout: float = 0.1,
|
| 252 |
+
head_residual: bool = True,
|
| 253 |
+
temperature: float = 0.07,
|
| 254 |
+
):
|
| 255 |
+
super().__init__()
|
| 256 |
+
self.backbone = backbone
|
| 257 |
+
self.pool_method = pool_method
|
| 258 |
+
self.head_residual = bool(head_residual)
|
| 259 |
+
self.temperature = float(temperature)
|
| 260 |
+
|
| 261 |
+
embed_dim = int(backbone.base_dim)
|
| 262 |
+
hidden_dim = head_hidden_dim if head_hidden_dim > 0 else embed_dim * 4
|
| 263 |
+
self.cell_head = nn.Sequential(
|
| 264 |
+
nn.LayerNorm(embed_dim),
|
| 265 |
+
nn.Dropout(head_dropout),
|
| 266 |
+
nn.Linear(embed_dim, hidden_dim),
|
| 267 |
+
nn.GELU(),
|
| 268 |
+
nn.Dropout(head_dropout),
|
| 269 |
+
nn.Linear(hidden_dim, embed_dim),
|
| 270 |
+
)
|
| 271 |
+
|
| 272 |
+
candidate_gene_names = extract_candidate_gene_names(class_df)
|
| 273 |
+
gene_to_index = {gene: idx for idx, gene in enumerate(candidate_gene_names)}
|
| 274 |
+
candidate_index = np.full((len(class_df), 2), -1, dtype=np.int64)
|
| 275 |
+
candidate_mask = np.zeros((len(class_df), 2), dtype=np.float32)
|
| 276 |
+
for row_idx, (_, row) in enumerate(class_df.iterrows()):
|
| 277 |
+
genes = [gene for gene in [str(row["gene_a"]), str(row["gene_b"])] if gene and gene != "nan"]
|
| 278 |
+
for gene_pos, gene in enumerate(genes[:2]):
|
| 279 |
+
candidate_index[row_idx, gene_pos] = gene_to_index[gene]
|
| 280 |
+
candidate_mask[row_idx, gene_pos] = 1.0
|
| 281 |
+
|
| 282 |
+
self.candidate_gene_names = candidate_gene_names
|
| 283 |
+
self.candidate_gene_embeddings = nn.Embedding(len(candidate_gene_names), embed_dim)
|
| 284 |
+
self.register_buffer("candidate_gene_indices", torch.tensor(candidate_index, dtype=torch.long), persistent=False)
|
| 285 |
+
self.register_buffer("candidate_gene_mask", torch.tensor(candidate_mask, dtype=torch.float32), persistent=False)
|
| 286 |
+
|
| 287 |
+
def _pool_cell_embedding(self, outputs: Dict[str, torch.Tensor]) -> torch.Tensor:
|
| 288 |
+
module_encodings = outputs["module_encodings"]
|
| 289 |
+
|
| 290 |
+
if self.pool_method == "mean":
|
| 291 |
+
return module_encodings.mean(dim=1)
|
| 292 |
+
if self.pool_method == "max":
|
| 293 |
+
return module_encodings.max(dim=1)[0]
|
| 294 |
+
raise ValueError(f"Unsupported pool_method: {self.pool_method}. Expected one of ['mean', 'max'].")
|
| 295 |
+
|
| 296 |
+
def get_candidate_embeddings(self) -> torch.Tensor:
|
| 297 |
+
safe_indices = self.candidate_gene_indices.clamp_min(0)
|
| 298 |
+
gene_embeddings = self.candidate_gene_embeddings(safe_indices)
|
| 299 |
+
mask = self.candidate_gene_mask.unsqueeze(-1)
|
| 300 |
+
summed = (gene_embeddings * mask).sum(dim=1)
|
| 301 |
+
denom = mask.sum(dim=1).clamp_min(1.0)
|
| 302 |
+
candidate_embeddings = summed / denom
|
| 303 |
+
return nn.functional.normalize(candidate_embeddings, dim=-1)
|
| 304 |
+
|
| 305 |
+
def forward(self, x_model: torch.Tensor) -> torch.Tensor:
|
| 306 |
+
outputs = self.backbone(x_model, mask_ratio=0.0, return_encodings=True)
|
| 307 |
+
pooled_embedding = self._pool_cell_embedding(outputs)
|
| 308 |
+
cell_embedding = self.cell_head(pooled_embedding)
|
| 309 |
+
if self.head_residual:
|
| 310 |
+
cell_embedding = pooled_embedding + cell_embedding
|
| 311 |
+
cell_embedding = nn.functional.normalize(cell_embedding, dim=-1)
|
| 312 |
+
candidate_embeddings = self.get_candidate_embeddings()
|
| 313 |
+
logits = cell_embedding @ candidate_embeddings.T
|
| 314 |
+
return logits / self.temperature
|
| 315 |
+
|
| 316 |
+
|
| 317 |
+
def set_requires_grad(module: nn.Module, flag: bool) -> None:
|
| 318 |
+
for param in module.parameters():
|
| 319 |
+
param.requires_grad = flag
|
| 320 |
+
|
| 321 |
+
|
| 322 |
+
def configure_backbone_trainability(backbone: nn.Module, finetune_mode: str) -> Dict[str, int]:
|
| 323 |
+
set_requires_grad(backbone, False)
|
| 324 |
+
|
| 325 |
+
if finetune_mode == "full":
|
| 326 |
+
set_requires_grad(backbone.module_embed, True)
|
| 327 |
+
set_requires_grad(backbone.encoder, True)
|
| 328 |
+
set_requires_grad(backbone.encoder_norm, True)
|
| 329 |
+
backbone.pos_emb.requires_grad = True
|
| 330 |
+
if getattr(backbone, "cls_token", None) is not None:
|
| 331 |
+
backbone.cls_token.requires_grad = True
|
| 332 |
+
elif finetune_mode != "frozen":
|
| 333 |
+
raise ValueError(f"Unsupported finetune_mode: {finetune_mode}")
|
| 334 |
+
|
| 335 |
+
total_params = sum(param.numel() for param in backbone.parameters())
|
| 336 |
+
trainable_params = sum(param.numel() for param in backbone.parameters() if param.requires_grad)
|
| 337 |
+
return {"total": int(total_params), "trainable": int(trainable_params)}
|
| 338 |
+
|
| 339 |
+
|
| 340 |
+
def build_optimizer(
|
| 341 |
+
model: nn.Module,
|
| 342 |
+
lr_head: float,
|
| 343 |
+
lr_backbone: float,
|
| 344 |
+
weight_decay: float,
|
| 345 |
+
) -> torch.optim.Optimizer:
|
| 346 |
+
backbone_params = []
|
| 347 |
+
head_params = []
|
| 348 |
+
for name, param in model.named_parameters():
|
| 349 |
+
if not param.requires_grad:
|
| 350 |
+
continue
|
| 351 |
+
if name.startswith("backbone."):
|
| 352 |
+
backbone_params.append(param)
|
| 353 |
+
else:
|
| 354 |
+
head_params.append(param)
|
| 355 |
+
|
| 356 |
+
param_groups = []
|
| 357 |
+
if backbone_params:
|
| 358 |
+
param_groups.append({"params": backbone_params, "lr": lr_backbone})
|
| 359 |
+
if head_params:
|
| 360 |
+
param_groups.append({"params": head_params, "lr": lr_head})
|
| 361 |
+
if not param_groups:
|
| 362 |
+
raise RuntimeError("No trainable parameters found")
|
| 363 |
+
|
| 364 |
+
return torch.optim.AdamW(
|
| 365 |
+
param_groups,
|
| 366 |
+
weight_decay=weight_decay,
|
| 367 |
+
betas=(0.9, 0.999),
|
| 368 |
+
eps=1e-8,
|
| 369 |
+
)
|
| 370 |
+
|
| 371 |
+
|
| 372 |
+
def compute_topk_metrics(
|
| 373 |
+
logits: torch.Tensor,
|
| 374 |
+
targets: torch.Tensor,
|
| 375 |
+
topk_values: Sequence[int] = TOPK_METRICS,
|
| 376 |
+
prefix: str = "",
|
| 377 |
+
) -> Dict[str, float]:
|
| 378 |
+
if logits.numel() == 0:
|
| 379 |
+
raise ValueError("logits is empty")
|
| 380 |
+
|
| 381 |
+
num_classes = int(logits.shape[1])
|
| 382 |
+
maxk = min(max(topk_values), num_classes)
|
| 383 |
+
topk_pred = logits.topk(maxk, dim=1).indices
|
| 384 |
+
targets_expanded = targets.unsqueeze(1)
|
| 385 |
+
correct = topk_pred.eq(targets_expanded)
|
| 386 |
+
|
| 387 |
+
metrics: Dict[str, float] = {}
|
| 388 |
+
metric_prefix = f"{prefix}_" if prefix else ""
|
| 389 |
+
for k in topk_values:
|
| 390 |
+
k_eff = min(k, num_classes)
|
| 391 |
+
value = correct[:, :k_eff].any(dim=1).float().mean().item()
|
| 392 |
+
metrics[f"{metric_prefix}top{k}_acc"] = float(value)
|
| 393 |
+
return metrics
|
| 394 |
+
|
| 395 |
+
|
| 396 |
+
def compute_condition_hit_rates(
|
| 397 |
+
logits: torch.Tensor,
|
| 398 |
+
targets: torch.Tensor,
|
| 399 |
+
condition_names: Sequence[str],
|
| 400 |
+
topk_values: Sequence[int] = TOPK_METRICS,
|
| 401 |
+
) -> Tuple[Dict[str, float], torch.Tensor, torch.Tensor, List[str]]:
|
| 402 |
+
grouped_logits: Dict[str, List[torch.Tensor]] = {}
|
| 403 |
+
grouped_targets: Dict[str, List[int]] = {}
|
| 404 |
+
for logit_row, target, condition_name in zip(logits, targets.tolist(), condition_names):
|
| 405 |
+
grouped_logits.setdefault(str(condition_name), []).append(logit_row.detach().cpu())
|
| 406 |
+
grouped_targets.setdefault(str(condition_name), []).append(int(target))
|
| 407 |
+
|
| 408 |
+
aggregated_logits = []
|
| 409 |
+
aggregated_targets = []
|
| 410 |
+
ordered_conditions = sorted(grouped_logits.keys())
|
| 411 |
+
for condition_name in ordered_conditions:
|
| 412 |
+
target_ids = sorted(set(grouped_targets[condition_name]))
|
| 413 |
+
if len(target_ids) != 1:
|
| 414 |
+
raise ValueError(f"Condition {condition_name} maps to multiple targets: {target_ids}")
|
| 415 |
+
aggregated_targets.append(target_ids[0])
|
| 416 |
+
stacked = torch.stack(grouped_logits[condition_name], dim=0)
|
| 417 |
+
aggregated_logits.append(stacked.mean(dim=0))
|
| 418 |
+
|
| 419 |
+
aggregated_logits_tensor = torch.stack(aggregated_logits, dim=0)
|
| 420 |
+
aggregated_targets_tensor = torch.tensor(aggregated_targets, dtype=torch.long)
|
| 421 |
+
metrics = compute_topk_metrics(
|
| 422 |
+
aggregated_logits_tensor,
|
| 423 |
+
aggregated_targets_tensor,
|
| 424 |
+
topk_values=topk_values,
|
| 425 |
+
prefix="condition",
|
| 426 |
+
)
|
| 427 |
+
metrics["n_conditions"] = float(len(ordered_conditions))
|
| 428 |
+
return metrics, aggregated_logits_tensor, aggregated_targets_tensor, ordered_conditions
|
| 429 |
+
|
| 430 |
+
|
| 431 |
+
def compute_condition_relevant_hit_rates(
|
| 432 |
+
aggregated_logits: torch.Tensor,
|
| 433 |
+
aggregated_targets: torch.Tensor,
|
| 434 |
+
id_to_condition: Sequence[str],
|
| 435 |
+
topk_values: Sequence[int] = TOPK_METRICS,
|
| 436 |
+
prefix: str = "condition_relevant",
|
| 437 |
+
) -> Dict[str, float]:
|
| 438 |
+
if aggregated_logits.numel() == 0:
|
| 439 |
+
raise ValueError("aggregated_logits is empty")
|
| 440 |
+
if aggregated_logits.shape[0] != aggregated_targets.shape[0]:
|
| 441 |
+
raise ValueError(
|
| 442 |
+
"aggregated_logits and aggregated_targets must have matching first dimension"
|
| 443 |
+
)
|
| 444 |
+
|
| 445 |
+
num_classes = int(aggregated_logits.shape[1])
|
| 446 |
+
maxk = min(max(topk_values), num_classes)
|
| 447 |
+
topk_pred = aggregated_logits.topk(maxk, dim=1).indices.cpu().numpy()
|
| 448 |
+
target_ids = aggregated_targets.cpu().numpy()
|
| 449 |
+
|
| 450 |
+
relevant_hits = {int(k): 0 for k in topk_values}
|
| 451 |
+
for row_idx, true_id in enumerate(target_ids.tolist()):
|
| 452 |
+
true_genes = set(parse_condition(id_to_condition[int(true_id)]))
|
| 453 |
+
for k in topk_values:
|
| 454 |
+
k_eff = min(int(k), num_classes)
|
| 455 |
+
pred_ids = topk_pred[row_idx, :k_eff].tolist()
|
| 456 |
+
hit = any(
|
| 457 |
+
bool(true_genes & set(parse_condition(id_to_condition[int(pred_id)])))
|
| 458 |
+
for pred_id in pred_ids
|
| 459 |
+
)
|
| 460 |
+
if hit:
|
| 461 |
+
relevant_hits[int(k)] += 1
|
| 462 |
+
|
| 463 |
+
n_conditions = max(int(aggregated_logits.shape[0]), 1)
|
| 464 |
+
metrics: Dict[str, float] = {}
|
| 465 |
+
for k in topk_values:
|
| 466 |
+
metrics[f"{prefix}_top{int(k)}_acc"] = float(relevant_hits[int(k)] / n_conditions)
|
| 467 |
+
return metrics
|
| 468 |
+
|
| 469 |
+
|
| 470 |
+
def compute_case_cellmean_hit_rates(
|
| 471 |
+
logits: torch.Tensor,
|
| 472 |
+
targets: torch.Tensor,
|
| 473 |
+
condition_names: Sequence[str],
|
| 474 |
+
id_to_condition: Sequence[str],
|
| 475 |
+
topk_values: Sequence[int] = TOPK_METRICS,
|
| 476 |
+
prefix: str = "case_cellmean",
|
| 477 |
+
) -> Dict[str, float]:
|
| 478 |
+
if logits.numel() == 0:
|
| 479 |
+
raise ValueError("logits is empty")
|
| 480 |
+
if logits.shape[0] != targets.shape[0]:
|
| 481 |
+
raise ValueError("logits and targets must have matching first dimension")
|
| 482 |
+
if logits.shape[0] != len(condition_names):
|
| 483 |
+
raise ValueError("condition_names length must match number of logits rows")
|
| 484 |
+
|
| 485 |
+
num_classes = int(logits.shape[1])
|
| 486 |
+
maxk = min(max(topk_values), num_classes)
|
| 487 |
+
topk_pred = logits.topk(maxk, dim=1).indices.cpu().numpy()
|
| 488 |
+
target_ids = targets.cpu().numpy().tolist()
|
| 489 |
+
|
| 490 |
+
condition_counts: Dict[str, int] = {}
|
| 491 |
+
condition_exact_hits: Dict[str, Dict[int, int]] = {}
|
| 492 |
+
condition_relevant_hits: Dict[str, Dict[int, int]] = {}
|
| 493 |
+
|
| 494 |
+
for row_idx, condition_name in enumerate(condition_names):
|
| 495 |
+
condition = str(condition_name)
|
| 496 |
+
condition_counts[condition] = condition_counts.get(condition, 0) + 1
|
| 497 |
+
if condition not in condition_exact_hits:
|
| 498 |
+
condition_exact_hits[condition] = {int(k): 0 for k in topk_values}
|
| 499 |
+
condition_relevant_hits[condition] = {int(k): 0 for k in topk_values}
|
| 500 |
+
|
| 501 |
+
true_id = int(target_ids[row_idx])
|
| 502 |
+
true_genes = set(parse_condition(id_to_condition[true_id]))
|
| 503 |
+
for k in topk_values:
|
| 504 |
+
k_eff = min(int(k), num_classes)
|
| 505 |
+
pred_ids = topk_pred[row_idx, :k_eff].tolist()
|
| 506 |
+
exact_hit = true_id in pred_ids
|
| 507 |
+
relevant_hit = any(
|
| 508 |
+
bool(true_genes & set(parse_condition(id_to_condition[int(pred_id)])))
|
| 509 |
+
for pred_id in pred_ids
|
| 510 |
+
)
|
| 511 |
+
if exact_hit:
|
| 512 |
+
condition_exact_hits[condition][int(k)] += 1
|
| 513 |
+
if relevant_hit:
|
| 514 |
+
condition_relevant_hits[condition][int(k)] += 1
|
| 515 |
+
|
| 516 |
+
ordered_conditions = sorted(condition_counts.keys())
|
| 517 |
+
n_conditions = max(len(ordered_conditions), 1)
|
| 518 |
+
metrics: Dict[str, float] = {}
|
| 519 |
+
for k in topk_values:
|
| 520 |
+
exact_case_rates = [
|
| 521 |
+
float(condition_exact_hits[condition][int(k)] / condition_counts[condition])
|
| 522 |
+
for condition in ordered_conditions
|
| 523 |
+
]
|
| 524 |
+
relevant_case_rates = [
|
| 525 |
+
float(condition_relevant_hits[condition][int(k)] / condition_counts[condition])
|
| 526 |
+
for condition in ordered_conditions
|
| 527 |
+
]
|
| 528 |
+
metrics[f"{prefix}_top{int(k)}_acc"] = float(np.mean(exact_case_rates))
|
| 529 |
+
metrics[f"{prefix}_relevant_top{int(k)}_acc"] = float(np.mean(relevant_case_rates))
|
| 530 |
+
metrics[f"{prefix}_n_conditions"] = float(n_conditions)
|
| 531 |
+
return metrics
|
| 532 |
+
|
| 533 |
+
|
| 534 |
+
def build_condition_ranking_records(
|
| 535 |
+
aggregated_logits: torch.Tensor,
|
| 536 |
+
aggregated_targets: torch.Tensor,
|
| 537 |
+
ordered_conditions: Sequence[str],
|
| 538 |
+
id_to_condition: Sequence[str],
|
| 539 |
+
top_k: int = 10,
|
| 540 |
+
) -> List[Dict[str, object]]:
|
| 541 |
+
records: List[Dict[str, object]] = []
|
| 542 |
+
top_indices = aggregated_logits.topk(min(top_k, aggregated_logits.shape[1]), dim=1).indices.cpu().numpy()
|
| 543 |
+
sorted_indices = aggregated_logits.argsort(dim=1, descending=True).cpu().numpy()
|
| 544 |
+
|
| 545 |
+
for idx, condition_name in enumerate(ordered_conditions):
|
| 546 |
+
true_id = int(aggregated_targets[idx].item())
|
| 547 |
+
ranking = sorted_indices[idx]
|
| 548 |
+
true_rank = int(np.where(ranking == true_id)[0][0]) + 1
|
| 549 |
+
pred_ids = top_indices[idx].tolist()
|
| 550 |
+
records.append(
|
| 551 |
+
{
|
| 552 |
+
"condition": str(condition_name),
|
| 553 |
+
"true_class_id": true_id,
|
| 554 |
+
"true_condition": str(id_to_condition[true_id]),
|
| 555 |
+
"true_rank": true_rank,
|
| 556 |
+
"top_predictions": [str(id_to_condition[pred_id]) for pred_id in pred_ids],
|
| 557 |
+
}
|
| 558 |
+
)
|
| 559 |
+
return records
|
| 560 |
+
|
| 561 |
+
|
| 562 |
+
def evaluate_loader(
|
| 563 |
+
model: nn.Module,
|
| 564 |
+
dataloader,
|
| 565 |
+
device: torch.device,
|
| 566 |
+
scfm_index_positions: Sequence[int],
|
| 567 |
+
loss_fn: Optional[nn.Module] = None,
|
| 568 |
+
return_outputs: bool = False,
|
| 569 |
+
is_distributed: bool = False,
|
| 570 |
+
world_size: int = 1,
|
| 571 |
+
rank: int = 0,
|
| 572 |
+
) -> Tuple[Dict[str, float], Optional[torch.Tensor], Optional[torch.Tensor], Optional[np.ndarray]]:
|
| 573 |
+
model.eval()
|
| 574 |
+
total_loss = 0.0
|
| 575 |
+
total_samples = 0
|
| 576 |
+
logits_all = []
|
| 577 |
+
targets_all = []
|
| 578 |
+
row_ids_all = []
|
| 579 |
+
|
| 580 |
+
with torch.no_grad():
|
| 581 |
+
for x, y, row_ids in dataloader:
|
| 582 |
+
x = x.to(device, non_blocking=True)
|
| 583 |
+
y = y.to(device, non_blocking=True)
|
| 584 |
+
x_model = reindex_tensor_universal(x, scfm_index_positions, dim=1, filler="0", device=device)
|
| 585 |
+
logits = model(x_model)
|
| 586 |
+
|
| 587 |
+
if loss_fn is not None:
|
| 588 |
+
loss = loss_fn(logits, y)
|
| 589 |
+
total_loss += float(loss.item()) * int(x.shape[0])
|
| 590 |
+
total_samples += int(x.shape[0])
|
| 591 |
+
|
| 592 |
+
logits_all.append(logits.detach().cpu())
|
| 593 |
+
targets_all.append(y.detach().cpu())
|
| 594 |
+
row_ids_all.append(np.asarray(row_ids, dtype=np.int64))
|
| 595 |
+
|
| 596 |
+
if logits_all:
|
| 597 |
+
logits_cat = torch.cat(logits_all, dim=0)
|
| 598 |
+
targets_cat = torch.cat(targets_all, dim=0)
|
| 599 |
+
row_ids_cat = np.concatenate(row_ids_all, axis=0)
|
| 600 |
+
else:
|
| 601 |
+
logits_cat = torch.empty((0, 0), dtype=torch.float32)
|
| 602 |
+
targets_cat = torch.empty((0,), dtype=torch.long)
|
| 603 |
+
row_ids_cat = np.empty((0,), dtype=np.int64)
|
| 604 |
+
|
| 605 |
+
if is_distributed:
|
| 606 |
+
loss_tensor = torch.tensor([total_loss], device=device)
|
| 607 |
+
samples_tensor = torch.tensor([total_samples], device=device)
|
| 608 |
+
dist.all_reduce(loss_tensor, op=dist.ReduceOp.SUM)
|
| 609 |
+
dist.all_reduce(samples_tensor, op=dist.ReduceOp.SUM)
|
| 610 |
+
total_loss = float(loss_tensor.item())
|
| 611 |
+
total_samples = int(samples_tensor.item())
|
| 612 |
+
|
| 613 |
+
gather_payload = {
|
| 614 |
+
"logits": logits_cat.numpy(),
|
| 615 |
+
"targets": targets_cat.numpy(),
|
| 616 |
+
"row_ids": row_ids_cat,
|
| 617 |
+
}
|
| 618 |
+
gathered_payloads = [None for _ in range(world_size)]
|
| 619 |
+
dist.all_gather_object(gathered_payloads, gather_payload)
|
| 620 |
+
|
| 621 |
+
if rank != 0:
|
| 622 |
+
return {}, None, None, None
|
| 623 |
+
|
| 624 |
+
logits_list = [torch.from_numpy(payload["logits"]).float() for payload in gathered_payloads if payload["logits"].size > 0]
|
| 625 |
+
targets_list = [torch.from_numpy(payload["targets"]).long() for payload in gathered_payloads if payload["targets"].size > 0]
|
| 626 |
+
row_list = [payload["row_ids"].astype(np.int64) for payload in gathered_payloads if payload["row_ids"].size > 0]
|
| 627 |
+
|
| 628 |
+
if logits_list:
|
| 629 |
+
logits_cat = torch.cat(logits_list, dim=0)
|
| 630 |
+
targets_cat = torch.cat(targets_list, dim=0)
|
| 631 |
+
row_ids_cat = np.concatenate(row_list, axis=0)
|
| 632 |
+
else:
|
| 633 |
+
logits_cat = torch.empty((0, 0), dtype=torch.float32)
|
| 634 |
+
targets_cat = torch.empty((0,), dtype=torch.long)
|
| 635 |
+
row_ids_cat = np.empty((0,), dtype=np.int64)
|
| 636 |
+
|
| 637 |
+
metrics = compute_topk_metrics(logits_cat, targets_cat, topk_values=TOPK_METRICS)
|
| 638 |
+
metrics["loss"] = float(total_loss / max(total_samples, 1)) if loss_fn is not None else 0.0
|
| 639 |
+
|
| 640 |
+
if return_outputs:
|
| 641 |
+
return metrics, logits_cat, targets_cat, row_ids_cat
|
| 642 |
+
return metrics, None, None, None
|
| 643 |
+
|
| 644 |
+
|
| 645 |
+
def fallback_gene_col(adata, gene_col: str) -> str:
|
| 646 |
+
if gene_col in adata.var.columns:
|
| 647 |
+
return gene_col
|
| 648 |
+
fallback_col = "_gene_inverse_var_index"
|
| 649 |
+
adata.var[fallback_col] = adata.var.index.astype(str)
|
| 650 |
+
print_sys(
|
| 651 |
+
f"gene_col '{gene_col}' not found in adata.var.columns; "
|
| 652 |
+
f"fallback to adata.var.index via temporary column '{fallback_col}'."
|
| 653 |
+
)
|
| 654 |
+
return fallback_col
|
| 655 |
+
|
| 656 |
+
|
| 657 |
+
def rebuild_canonical_from_raw(raw_condition: str, collapse_ctrl_orientation: bool) -> str:
|
| 658 |
+
if collapse_ctrl_orientation:
|
| 659 |
+
return canonical_condition_name(raw_condition)
|
| 660 |
+
return str(raw_condition)
|
BioMod/gene_inverse_norman/evaluate_gene_inverse.py
ADDED
|
@@ -0,0 +1,405 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import argparse
|
| 2 |
+
import json
|
| 3 |
+
import sys
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
from typing import Dict, List
|
| 6 |
+
|
| 7 |
+
import anndata as ad
|
| 8 |
+
import numpy as np
|
| 9 |
+
import pandas as pd
|
| 10 |
+
import torch
|
| 11 |
+
from torch import nn
|
| 12 |
+
from torch.utils.data import DataLoader
|
| 13 |
+
|
| 14 |
+
CURRENT_DIR = Path(__file__).resolve().parent
|
| 15 |
+
if str(CURRENT_DIR) not in sys.path:
|
| 16 |
+
sys.path.insert(0, str(CURRENT_DIR))
|
| 17 |
+
|
| 18 |
+
from common import (
|
| 19 |
+
GeneInverseCellDataset,
|
| 20 |
+
GeneInverseCompositionalRetrievalModel,
|
| 21 |
+
build_condition_ranking_records,
|
| 22 |
+
build_raw_to_canonical_map,
|
| 23 |
+
collect_labeled_cells,
|
| 24 |
+
compute_case_cellmean_hit_rates,
|
| 25 |
+
compute_condition_hit_rates,
|
| 26 |
+
compute_condition_relevant_hit_rates,
|
| 27 |
+
evaluate_loader,
|
| 28 |
+
fallback_gene_col,
|
| 29 |
+
normalize_benchmark_table,
|
| 30 |
+
seed_all,
|
| 31 |
+
)
|
| 32 |
+
from utils import (
|
| 33 |
+
default_repo_path,
|
| 34 |
+
load_pretrained_backbone,
|
| 35 |
+
print_sys,
|
| 36 |
+
resolve_input_gene_ids,
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
TOPK_RANGE = list(range(1, 11))
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def build_topk_table_rows(metrics: Dict[str, float], split_name: str, algorithm_name: str) -> pd.DataFrame:
|
| 44 |
+
families = [
|
| 45 |
+
("top", "cell_correct"),
|
| 46 |
+
("condition_top", "condition_correct"),
|
| 47 |
+
("condition_relevant_top", "condition_relevant"),
|
| 48 |
+
("case_cellmean_top", "case_cellmean_correct"),
|
| 49 |
+
("case_cellmean_relevant_top", "case_cellmean_relevant"),
|
| 50 |
+
]
|
| 51 |
+
rows = []
|
| 52 |
+
for key_prefix, family_name in families:
|
| 53 |
+
row = {
|
| 54 |
+
"algorithm": str(algorithm_name),
|
| 55 |
+
"split": str(split_name),
|
| 56 |
+
"metric_family": family_name,
|
| 57 |
+
}
|
| 58 |
+
has_value = False
|
| 59 |
+
for k in TOPK_RANGE:
|
| 60 |
+
key = f"{key_prefix}{k}_acc"
|
| 61 |
+
value = metrics.get(key)
|
| 62 |
+
if value is not None:
|
| 63 |
+
has_value = True
|
| 64 |
+
row[f"top{k}"] = float(value) if value is not None else np.nan
|
| 65 |
+
if has_value:
|
| 66 |
+
rows.append(row)
|
| 67 |
+
return pd.DataFrame(rows)
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def print_topk_table(metrics_df: pd.DataFrame, title: str) -> None:
|
| 71 |
+
if metrics_df.empty:
|
| 72 |
+
return
|
| 73 |
+
printable_df = metrics_df.copy()
|
| 74 |
+
for k in TOPK_RANGE:
|
| 75 |
+
col = f"top{k}"
|
| 76 |
+
printable_df[col] = printable_df[col].map(lambda x: "" if pd.isna(x) else f"{x:.4f}")
|
| 77 |
+
print_sys(title)
|
| 78 |
+
print_sys(printable_df.to_string(index=False))
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def main() -> None:
|
| 82 |
+
parser = argparse.ArgumentParser(
|
| 83 |
+
description="Evaluate a CellFM-style reverse-perturbation retrieval head and optionally export logits."
|
| 84 |
+
)
|
| 85 |
+
parser.add_argument("--trained_head_ckpt", type=str, required=True, help="Path to best_finetune_gene_inverse.pth")
|
| 86 |
+
parser.add_argument("--scfm_hyper_params_path", type=str, required=True, help="Path to pretrained PKL")
|
| 87 |
+
parser.add_argument("--scfm_ckpt_path", type=str, required=True, help="Path to pretrained checkpoint")
|
| 88 |
+
parser.add_argument(
|
| 89 |
+
"--gene2vec_path",
|
| 90 |
+
type=str,
|
| 91 |
+
default=default_repo_path("selected_gene2vec_27k.npy"),
|
| 92 |
+
help="Path to gene2vec file",
|
| 93 |
+
)
|
| 94 |
+
parser.add_argument(
|
| 95 |
+
"--gene_mapping_path",
|
| 96 |
+
type=str,
|
| 97 |
+
default=default_repo_path("gene2ens.csv"),
|
| 98 |
+
help="Path to gene symbol -> Ensembl CSV",
|
| 99 |
+
)
|
| 100 |
+
parser.add_argument(
|
| 101 |
+
"--scfm_genes_file",
|
| 102 |
+
type=str,
|
| 103 |
+
default=default_repo_path("selected_genes_27k.txt"),
|
| 104 |
+
help="Path to selected model genes",
|
| 105 |
+
)
|
| 106 |
+
parser.add_argument(
|
| 107 |
+
"--adata_path",
|
| 108 |
+
type=str,
|
| 109 |
+
default=default_repo_path("GEARS", "data", "norman", "perturb_processed.h5ad"),
|
| 110 |
+
help="Path to Norman h5ad",
|
| 111 |
+
)
|
| 112 |
+
parser.add_argument(
|
| 113 |
+
"--benchmark_csv",
|
| 114 |
+
type=str,
|
| 115 |
+
default=default_repo_path(
|
| 116 |
+
"performer_module",
|
| 117 |
+
"gene_inverse_norman",
|
| 118 |
+
"outputs",
|
| 119 |
+
"norman_gene_inverse_benchmark",
|
| 120 |
+
"gene_inverse_conditions.csv",
|
| 121 |
+
),
|
| 122 |
+
help="Benchmark CSV produced by build_norman_gene_inverse_benchmark.py",
|
| 123 |
+
)
|
| 124 |
+
parser.add_argument("--condition_col", type=str, default="condition")
|
| 125 |
+
parser.add_argument("--gene_col", type=str, default="gene_name")
|
| 126 |
+
parser.add_argument("--min_cells_per_condition", type=int, default=1)
|
| 127 |
+
parser.add_argument("--batch_size", type=int, default=128)
|
| 128 |
+
parser.add_argument("--num_workers", type=int, default=4)
|
| 129 |
+
parser.add_argument("--seed", type=int, default=42)
|
| 130 |
+
parser.add_argument(
|
| 131 |
+
"--split",
|
| 132 |
+
type=str,
|
| 133 |
+
default="test",
|
| 134 |
+
choices=["train", "val", "test", "all"],
|
| 135 |
+
help="Which split to evaluate.",
|
| 136 |
+
)
|
| 137 |
+
parser.add_argument(
|
| 138 |
+
"--split_indices_npz",
|
| 139 |
+
type=str,
|
| 140 |
+
default="",
|
| 141 |
+
help="Optional split_indices.npz from finetune run for cell rows.",
|
| 142 |
+
)
|
| 143 |
+
parser.add_argument(
|
| 144 |
+
"--condition_splits_npz",
|
| 145 |
+
type=str,
|
| 146 |
+
default="",
|
| 147 |
+
help="Optional condition_splits.npz from finetune run for perturbation-level splits.",
|
| 148 |
+
)
|
| 149 |
+
parser.add_argument(
|
| 150 |
+
"--export_logits",
|
| 151 |
+
action="store_true",
|
| 152 |
+
help="Export per-cell raw logits CSV.",
|
| 153 |
+
)
|
| 154 |
+
parser.add_argument(
|
| 155 |
+
"--output_dir",
|
| 156 |
+
type=str,
|
| 157 |
+
default=default_repo_path("performer_module", "gene_inverse_norman", "outputs", "norman_gene_inverse_eval"),
|
| 158 |
+
)
|
| 159 |
+
parser.add_argument(
|
| 160 |
+
"--algorithm_name",
|
| 161 |
+
type=str,
|
| 162 |
+
default="",
|
| 163 |
+
help="Optional algorithm label in output tables; falls back to checkpoint value or scLong.",
|
| 164 |
+
)
|
| 165 |
+
args = parser.parse_args()
|
| 166 |
+
|
| 167 |
+
seed_all(args.seed)
|
| 168 |
+
output_dir = Path(args.output_dir)
|
| 169 |
+
output_dir.mkdir(parents=True, exist_ok=True)
|
| 170 |
+
|
| 171 |
+
head_ckpt = torch.load(args.trained_head_ckpt, map_location="cpu")
|
| 172 |
+
run_config = head_ckpt.get("run_config", {})
|
| 173 |
+
pool_method = str(head_ckpt.get("pool_method", run_config.get("pool_method", "max")))
|
| 174 |
+
if pool_method not in {"mean", "max"}:
|
| 175 |
+
print_sys(f"pool_method '{pool_method}' is deprecated in current evaluator; fallback to 'max'.")
|
| 176 |
+
pool_method = "max"
|
| 177 |
+
head_hidden_dim = int(head_ckpt.get("head_hidden_dim", run_config.get("head_hidden_dim", 0)))
|
| 178 |
+
head_dropout = float(head_ckpt.get("head_dropout", run_config.get("head_dropout", 0.1)))
|
| 179 |
+
head_residual = bool(head_ckpt.get("head_residual", run_config.get("head_residual", False)))
|
| 180 |
+
temperature = float(head_ckpt.get("temperature", run_config.get("temperature", 0.07)))
|
| 181 |
+
algorithm_name = args.algorithm_name or str(run_config.get("algorithm_name", "scLong"))
|
| 182 |
+
|
| 183 |
+
class_table = head_ckpt.get("class_table")
|
| 184 |
+
if class_table:
|
| 185 |
+
class_df = normalize_benchmark_table(
|
| 186 |
+
benchmark_df=pd.DataFrame(class_table),
|
| 187 |
+
min_cells_per_condition=1,
|
| 188 |
+
)
|
| 189 |
+
print_sys("Using class_table from trained checkpoint for label mapping.")
|
| 190 |
+
else:
|
| 191 |
+
benchmark_df = pd.read_csv(args.benchmark_csv)
|
| 192 |
+
class_df = normalize_benchmark_table(
|
| 193 |
+
benchmark_df=benchmark_df,
|
| 194 |
+
min_cells_per_condition=args.min_cells_per_condition,
|
| 195 |
+
)
|
| 196 |
+
print_sys("Checkpoint has no class_table; fallback to benchmark_csv for label mapping.")
|
| 197 |
+
class_df = class_df.sort_values("class_id").reset_index(drop=True)
|
| 198 |
+
canonical_to_id: Dict[str, int] = dict(zip(class_df["condition"].tolist(), class_df["class_id"].tolist()))
|
| 199 |
+
id_to_condition: List[str] = class_df["condition"].astype(str).tolist()
|
| 200 |
+
id_to_gene: List[str] = class_df["perturb_gene"].astype(str).tolist()
|
| 201 |
+
|
| 202 |
+
print_sys(f"Loading Norman data from {args.adata_path}")
|
| 203 |
+
adata = ad.read_h5ad(args.adata_path)
|
| 204 |
+
raw_to_canonical = build_raw_to_canonical_map(class_df[class_df["is_observed"] == 1])
|
| 205 |
+
labeled_rows, labeled_class_ids, canonical_labels = collect_labeled_cells(
|
| 206 |
+
adata=adata,
|
| 207 |
+
condition_col=args.condition_col,
|
| 208 |
+
raw_to_canonical=raw_to_canonical,
|
| 209 |
+
canonical_to_id=canonical_to_id,
|
| 210 |
+
)
|
| 211 |
+
if labeled_rows.size == 0:
|
| 212 |
+
raise RuntimeError("No labeled cells found after mapping conditions to classes")
|
| 213 |
+
|
| 214 |
+
row_to_class = dict(zip(labeled_rows.tolist(), labeled_class_ids.tolist()))
|
| 215 |
+
row_to_condition = dict(zip(labeled_rows.tolist(), canonical_labels.tolist()))
|
| 216 |
+
|
| 217 |
+
split_npz = args.split_indices_npz
|
| 218 |
+
if not split_npz:
|
| 219 |
+
candidate = Path(args.trained_head_ckpt).resolve().parent / "split_indices.npz"
|
| 220 |
+
if candidate.exists():
|
| 221 |
+
split_npz = str(candidate)
|
| 222 |
+
|
| 223 |
+
condition_splits_npz = args.condition_splits_npz
|
| 224 |
+
if not condition_splits_npz:
|
| 225 |
+
candidate = Path(args.trained_head_ckpt).resolve().parent / "condition_splits.npz"
|
| 226 |
+
if candidate.exists():
|
| 227 |
+
condition_splits_npz = str(candidate)
|
| 228 |
+
|
| 229 |
+
if split_npz:
|
| 230 |
+
split_data = np.load(split_npz, allow_pickle=True)
|
| 231 |
+
train_rows = split_data["train_rows"].astype(np.int64)
|
| 232 |
+
val_rows = split_data["val_rows"].astype(np.int64)
|
| 233 |
+
test_rows = split_data["test_rows"].astype(np.int64)
|
| 234 |
+
print_sys(f"Loaded cell split indices from {split_npz}")
|
| 235 |
+
elif condition_splits_npz:
|
| 236 |
+
condition_data = np.load(condition_splits_npz, allow_pickle=True)
|
| 237 |
+
train_conditions = set(condition_data["train_conditions"].tolist())
|
| 238 |
+
val_conditions = set(condition_data["val_conditions"].tolist())
|
| 239 |
+
test_conditions = set(condition_data["test_conditions"].tolist())
|
| 240 |
+
train_rows = np.array([row for row in labeled_rows.tolist() if row_to_condition[row] in train_conditions], dtype=np.int64)
|
| 241 |
+
val_rows = np.array([row for row in labeled_rows.tolist() if row_to_condition[row] in val_conditions], dtype=np.int64)
|
| 242 |
+
test_rows = np.array([row for row in labeled_rows.tolist() if row_to_condition[row] in test_conditions], dtype=np.int64)
|
| 243 |
+
print_sys(f"Loaded perturbation splits from {condition_splits_npz}")
|
| 244 |
+
else:
|
| 245 |
+
raise RuntimeError("No split artifacts found. Provide --split_indices_npz or --condition_splits_npz.")
|
| 246 |
+
|
| 247 |
+
if args.split == "train":
|
| 248 |
+
eval_rows = train_rows
|
| 249 |
+
elif args.split == "val":
|
| 250 |
+
eval_rows = val_rows
|
| 251 |
+
elif args.split == "test":
|
| 252 |
+
eval_rows = test_rows
|
| 253 |
+
else:
|
| 254 |
+
eval_rows = np.concatenate([train_rows, val_rows, test_rows], axis=0)
|
| 255 |
+
|
| 256 |
+
eval_labels = np.array([row_to_class[int(idx)] for idx in eval_rows], dtype=np.int64)
|
| 257 |
+
eval_condition_names = [row_to_condition[int(idx)] for idx in eval_rows]
|
| 258 |
+
eval_dataset = GeneInverseCellDataset(adata.X, eval_rows, eval_labels)
|
| 259 |
+
eval_loader = DataLoader(
|
| 260 |
+
eval_dataset,
|
| 261 |
+
batch_size=args.batch_size,
|
| 262 |
+
shuffle=False,
|
| 263 |
+
num_workers=args.num_workers,
|
| 264 |
+
pin_memory=True,
|
| 265 |
+
drop_last=False,
|
| 266 |
+
)
|
| 267 |
+
|
| 268 |
+
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 269 |
+
backbone, _ = load_pretrained_backbone(
|
| 270 |
+
scfm_hyper_params_path=args.scfm_hyper_params_path,
|
| 271 |
+
scfm_ckpt_path=args.scfm_ckpt_path,
|
| 272 |
+
gene2vec_path=args.gene2vec_path,
|
| 273 |
+
device=device,
|
| 274 |
+
)
|
| 275 |
+
model = GeneInverseCompositionalRetrievalModel(
|
| 276 |
+
backbone=backbone,
|
| 277 |
+
class_df=class_df,
|
| 278 |
+
pool_method=pool_method,
|
| 279 |
+
head_hidden_dim=head_hidden_dim,
|
| 280 |
+
head_dropout=head_dropout,
|
| 281 |
+
head_residual=head_residual,
|
| 282 |
+
temperature=temperature,
|
| 283 |
+
).to(device)
|
| 284 |
+
model.load_state_dict(head_ckpt["model_state_dict"], strict=True)
|
| 285 |
+
|
| 286 |
+
gene2ens = pd.read_csv(args.gene_mapping_path, index_col=0)
|
| 287 |
+
symbol_to_ensembl = gene2ens["Ensembl gene ID"].to_dict()
|
| 288 |
+
with open(args.scfm_genes_file, "r", encoding="utf-8") as handle:
|
| 289 |
+
scfm_genes = [line.rstrip("\n") for line in handle.readlines()]
|
| 290 |
+
scfm_gene_set = set(scfm_genes)
|
| 291 |
+
|
| 292 |
+
resolved_gene_col = fallback_gene_col(adata, args.gene_col)
|
| 293 |
+
input_gene_ids = resolve_input_gene_ids(
|
| 294 |
+
adata=adata,
|
| 295 |
+
gene_col=resolved_gene_col,
|
| 296 |
+
symbol_to_ensembl=symbol_to_ensembl,
|
| 297 |
+
target_gene_set=scfm_gene_set,
|
| 298 |
+
)
|
| 299 |
+
input_mapping = {gene_id: index for index, gene_id in enumerate(input_gene_ids)}
|
| 300 |
+
input_gene_num = len(input_gene_ids)
|
| 301 |
+
scfm_genes_pad = scfm_genes + ["PAD"]
|
| 302 |
+
scfm_index_positions = [input_mapping.get(gene_id, input_gene_num) for gene_id in scfm_genes_pad]
|
| 303 |
+
|
| 304 |
+
loss_fn = nn.CrossEntropyLoss()
|
| 305 |
+
metrics, logits, targets, row_ids = evaluate_loader(
|
| 306 |
+
model=model,
|
| 307 |
+
dataloader=eval_loader,
|
| 308 |
+
device=device,
|
| 309 |
+
scfm_index_positions=scfm_index_positions,
|
| 310 |
+
loss_fn=loss_fn,
|
| 311 |
+
return_outputs=True,
|
| 312 |
+
)
|
| 313 |
+
|
| 314 |
+
pred_ids = logits.argmax(dim=1).numpy()
|
| 315 |
+
true_ids = targets.numpy()
|
| 316 |
+
pred_df = pd.DataFrame(
|
| 317 |
+
{
|
| 318 |
+
"row_index": row_ids.astype(int),
|
| 319 |
+
"cell_name": adata.obs_names[row_ids].astype(str).tolist(),
|
| 320 |
+
"true_class_id": true_ids.astype(int),
|
| 321 |
+
"pred_class_id": pred_ids.astype(int),
|
| 322 |
+
"true_condition": [id_to_condition[idx] for idx in true_ids],
|
| 323 |
+
"pred_condition": [id_to_condition[idx] for idx in pred_ids],
|
| 324 |
+
"true_gene": [id_to_gene[idx] for idx in true_ids],
|
| 325 |
+
"pred_gene": [id_to_gene[idx] for idx in pred_ids],
|
| 326 |
+
"correct": (pred_ids == true_ids).astype(int),
|
| 327 |
+
}
|
| 328 |
+
)
|
| 329 |
+
pred_df.to_csv(output_dir / f"{args.split}_predictions.csv", index=False)
|
| 330 |
+
|
| 331 |
+
if args.export_logits:
|
| 332 |
+
logits_df = pd.DataFrame(logits.numpy(), columns=id_to_condition)
|
| 333 |
+
logits_df.insert(0, "cell_name", adata.obs_names[row_ids].astype(str).tolist())
|
| 334 |
+
logits_df.insert(1, "row_index", row_ids.astype(int))
|
| 335 |
+
logits_df.to_csv(output_dir / f"{args.split}_logits.csv", index=False)
|
| 336 |
+
|
| 337 |
+
eval_condition_names_in_order = [row_to_condition[int(row)] for row in row_ids.tolist()]
|
| 338 |
+
condition_metrics, aggregated_logits, aggregated_targets, ordered_conditions = compute_condition_hit_rates(
|
| 339 |
+
logits=logits,
|
| 340 |
+
targets=targets,
|
| 341 |
+
condition_names=eval_condition_names_in_order,
|
| 342 |
+
)
|
| 343 |
+
metrics.update(condition_metrics)
|
| 344 |
+
relevant_metrics = compute_condition_relevant_hit_rates(
|
| 345 |
+
aggregated_logits=aggregated_logits,
|
| 346 |
+
aggregated_targets=aggregated_targets,
|
| 347 |
+
id_to_condition=id_to_condition,
|
| 348 |
+
)
|
| 349 |
+
metrics.update(relevant_metrics)
|
| 350 |
+
case_cellmean_metrics = compute_case_cellmean_hit_rates(
|
| 351 |
+
logits=logits,
|
| 352 |
+
targets=targets,
|
| 353 |
+
condition_names=eval_condition_names_in_order,
|
| 354 |
+
id_to_condition=id_to_condition,
|
| 355 |
+
)
|
| 356 |
+
metrics.update(case_cellmean_metrics)
|
| 357 |
+
|
| 358 |
+
ranking_records = build_condition_ranking_records(
|
| 359 |
+
aggregated_logits=aggregated_logits,
|
| 360 |
+
aggregated_targets=aggregated_targets,
|
| 361 |
+
ordered_conditions=ordered_conditions,
|
| 362 |
+
id_to_condition=id_to_condition,
|
| 363 |
+
top_k=10,
|
| 364 |
+
)
|
| 365 |
+
with (output_dir / f"{args.split}_condition_rankings.jsonl").open("w", encoding="utf-8") as handle:
|
| 366 |
+
for record in ranking_records:
|
| 367 |
+
handle.write(json.dumps(record, ensure_ascii=False) + "\n")
|
| 368 |
+
|
| 369 |
+
summary = {
|
| 370 |
+
"algorithm_name": algorithm_name,
|
| 371 |
+
"protocol": run_config.get("protocol", None),
|
| 372 |
+
"split": args.split,
|
| 373 |
+
"n_cells": int(eval_rows.size),
|
| 374 |
+
"n_conditions": int(len(ordered_conditions)),
|
| 375 |
+
"n_classes": int(len(class_df)),
|
| 376 |
+
"pool_method": pool_method,
|
| 377 |
+
"head_hidden_dim": head_hidden_dim,
|
| 378 |
+
"head_dropout": head_dropout,
|
| 379 |
+
"head_residual": head_residual,
|
| 380 |
+
"temperature": temperature,
|
| 381 |
+
**{f"{args.split}_{k}": float(v) for k, v in metrics.items()},
|
| 382 |
+
"alignment_notes": [
|
| 383 |
+
"Primary CellFM-style comparison should use condition_top1_acc through condition_top10_acc.",
|
| 384 |
+
"Metrics aggregate cell logits to perturbation-level mean logits before computing condition hit rates.",
|
| 385 |
+
"scGPT-style relevant-hit metrics are also exported as condition_relevant_topk_acc.",
|
| 386 |
+
"Paper-style non-integer hit rates can be approximated via case_cellmean_topk_acc metrics.",
|
| 387 |
+
"Exact CellFM gene-pool identity must be verified separately if strict paper reproduction is required.",
|
| 388 |
+
],
|
| 389 |
+
}
|
| 390 |
+
metrics_table_df = build_topk_table_rows(
|
| 391 |
+
metrics=metrics,
|
| 392 |
+
split_name=args.split,
|
| 393 |
+
algorithm_name=algorithm_name,
|
| 394 |
+
)
|
| 395 |
+
metrics_table_df.to_csv(output_dir / f"{args.split}_metrics_table.csv", index=False)
|
| 396 |
+
print_topk_table(metrics_table_df, title=f"Top-k Metrics Table ({args.split})")
|
| 397 |
+
with (output_dir / f"{args.split}_summary_metrics.json").open("w", encoding="utf-8") as handle:
|
| 398 |
+
json.dump(summary, handle, indent=2, ensure_ascii=False)
|
| 399 |
+
|
| 400 |
+
print_sys(json.dumps(summary, indent=2, ensure_ascii=False))
|
| 401 |
+
print_sys(f"Saved evaluation outputs to {output_dir}")
|
| 402 |
+
|
| 403 |
+
|
| 404 |
+
if __name__ == "__main__":
|
| 405 |
+
main()
|
BioMod/gene_inverse_norman/finetune_gene_inverse.py
ADDED
|
@@ -0,0 +1,837 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import argparse
|
| 2 |
+
import json
|
| 3 |
+
import os
|
| 4 |
+
import sys
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
from typing import Dict, List
|
| 7 |
+
|
| 8 |
+
import anndata as ad
|
| 9 |
+
import numpy as np
|
| 10 |
+
import pandas as pd
|
| 11 |
+
import torch
|
| 12 |
+
import torch.distributed as dist
|
| 13 |
+
from torch import nn
|
| 14 |
+
from torch.nn.parallel import DistributedDataParallel as DDP
|
| 15 |
+
from torch.utils.data import DataLoader, DistributedSampler
|
| 16 |
+
|
| 17 |
+
CURRENT_DIR = Path(__file__).resolve().parent
|
| 18 |
+
if str(CURRENT_DIR) not in sys.path:
|
| 19 |
+
sys.path.insert(0, str(CURRENT_DIR))
|
| 20 |
+
|
| 21 |
+
from common import (
|
| 22 |
+
GeneInverseCellDataset,
|
| 23 |
+
GeneInverseCompositionalRetrievalModel,
|
| 24 |
+
build_condition_ranking_records,
|
| 25 |
+
build_optimizer,
|
| 26 |
+
build_raw_to_canonical_map,
|
| 27 |
+
collect_labeled_cells,
|
| 28 |
+
compute_case_cellmean_hit_rates,
|
| 29 |
+
compute_condition_hit_rates,
|
| 30 |
+
compute_condition_relevant_hit_rates,
|
| 31 |
+
configure_backbone_trainability,
|
| 32 |
+
evaluate_loader,
|
| 33 |
+
fallback_gene_col,
|
| 34 |
+
normalize_benchmark_table,
|
| 35 |
+
seed_all,
|
| 36 |
+
split_from_protocol,
|
| 37 |
+
split_observed_conditions,
|
| 38 |
+
)
|
| 39 |
+
from utils import (
|
| 40 |
+
default_repo_path,
|
| 41 |
+
load_pretrained_backbone,
|
| 42 |
+
print_sys,
|
| 43 |
+
reindex_tensor_universal,
|
| 44 |
+
resolve_input_gene_ids,
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
TOPK_RANGE = list(range(1, 11))
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def build_topk_table_rows(metrics: Dict[str, float], split_name: str, algorithm_name: str) -> pd.DataFrame:
|
| 52 |
+
families = [
|
| 53 |
+
("top", "cell_correct"),
|
| 54 |
+
("condition_top", "condition_correct"),
|
| 55 |
+
("condition_relevant_top", "condition_relevant"),
|
| 56 |
+
("case_cellmean_top", "case_cellmean_correct"),
|
| 57 |
+
("case_cellmean_relevant_top", "case_cellmean_relevant"),
|
| 58 |
+
]
|
| 59 |
+
rows = []
|
| 60 |
+
for key_prefix, family_name in families:
|
| 61 |
+
row = {
|
| 62 |
+
"algorithm": str(algorithm_name),
|
| 63 |
+
"split": str(split_name),
|
| 64 |
+
"metric_family": family_name,
|
| 65 |
+
}
|
| 66 |
+
has_value = False
|
| 67 |
+
for k in TOPK_RANGE:
|
| 68 |
+
key = f"{key_prefix}{k}_acc"
|
| 69 |
+
value = metrics.get(key)
|
| 70 |
+
if value is not None:
|
| 71 |
+
has_value = True
|
| 72 |
+
row[f"top{k}"] = float(value) if value is not None else np.nan
|
| 73 |
+
if has_value:
|
| 74 |
+
rows.append(row)
|
| 75 |
+
return pd.DataFrame(rows)
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def print_topk_table(metrics_df: pd.DataFrame, title: str) -> None:
|
| 79 |
+
if metrics_df.empty:
|
| 80 |
+
return
|
| 81 |
+
printable_df = metrics_df.copy()
|
| 82 |
+
for k in TOPK_RANGE:
|
| 83 |
+
col = f"top{k}"
|
| 84 |
+
printable_df[col] = printable_df[col].map(lambda x: "" if pd.isna(x) else f"{x:.4f}")
|
| 85 |
+
print_sys(title)
|
| 86 |
+
print_sys(printable_df.to_string(index=False))
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def main() -> None:
|
| 90 |
+
parser = argparse.ArgumentParser(
|
| 91 |
+
description="Fine-tune performer_module for CellFM-style Norman reverse perturbation retrieval."
|
| 92 |
+
)
|
| 93 |
+
parser.add_argument("--scfm_hyper_params_path", type=str, required=True, help="Path to pretrained PKL")
|
| 94 |
+
parser.add_argument("--scfm_ckpt_path", type=str, required=True, help="Path to pretrained checkpoint")
|
| 95 |
+
parser.add_argument(
|
| 96 |
+
"--gene2vec_path",
|
| 97 |
+
type=str,
|
| 98 |
+
default=default_repo_path("selected_gene2vec_27k.npy"),
|
| 99 |
+
help="Path to gene2vec file",
|
| 100 |
+
)
|
| 101 |
+
parser.add_argument(
|
| 102 |
+
"--gene_mapping_path",
|
| 103 |
+
type=str,
|
| 104 |
+
default=default_repo_path("gene2ens.csv"),
|
| 105 |
+
help="Path to gene symbol -> Ensembl CSV",
|
| 106 |
+
)
|
| 107 |
+
parser.add_argument(
|
| 108 |
+
"--scfm_genes_file",
|
| 109 |
+
type=str,
|
| 110 |
+
default=default_repo_path("selected_genes_27k.txt"),
|
| 111 |
+
help="Path to selected model genes",
|
| 112 |
+
)
|
| 113 |
+
parser.add_argument(
|
| 114 |
+
"--adata_path",
|
| 115 |
+
type=str,
|
| 116 |
+
default=default_repo_path("GEARS", "data", "norman", "perturb_processed.h5ad"),
|
| 117 |
+
help="Path to Norman h5ad",
|
| 118 |
+
)
|
| 119 |
+
parser.add_argument(
|
| 120 |
+
"--benchmark_csv",
|
| 121 |
+
type=str,
|
| 122 |
+
default=default_repo_path(
|
| 123 |
+
"performer_module",
|
| 124 |
+
"gene_inverse_norman",
|
| 125 |
+
"outputs",
|
| 126 |
+
"norman_gene_inverse_benchmark",
|
| 127 |
+
"gene_inverse_conditions.csv",
|
| 128 |
+
),
|
| 129 |
+
help="Benchmark CSV produced by build_norman_gene_inverse_benchmark.py",
|
| 130 |
+
)
|
| 131 |
+
parser.add_argument("--condition_col", type=str, default="condition", help="Condition column in adata.obs")
|
| 132 |
+
parser.add_argument("--gene_col", type=str, default="gene_name", help="Gene column in adata.var")
|
| 133 |
+
parser.add_argument("--min_cells_per_condition", type=int, default=1)
|
| 134 |
+
parser.add_argument("--epochs", type=int, default=5)
|
| 135 |
+
parser.add_argument("--batch_size", type=int, default=4)
|
| 136 |
+
parser.add_argument("--num_workers", type=int, default=6)
|
| 137 |
+
parser.add_argument("--lr_head", type=float, default=1e-4)
|
| 138 |
+
parser.add_argument("--lr_backbone", type=float, default=1e-4)
|
| 139 |
+
parser.add_argument("--weight_decay", type=float, default=1e-3)
|
| 140 |
+
parser.add_argument(
|
| 141 |
+
"--class_balance_power",
|
| 142 |
+
type=float,
|
| 143 |
+
default=0.0,
|
| 144 |
+
help="If >0, use train class weights = count^(-power) in CE loss (e.g., 0.5 for inverse-sqrt).",
|
| 145 |
+
)
|
| 146 |
+
parser.add_argument(
|
| 147 |
+
"--label_smoothing",
|
| 148 |
+
type=float,
|
| 149 |
+
default=0.0,
|
| 150 |
+
help="Label smoothing for training CE loss. 0 disables it.",
|
| 151 |
+
)
|
| 152 |
+
parser.add_argument("--head_hidden_dim", type=int, default=0, help="0 means 4x embed dim")
|
| 153 |
+
parser.add_argument(
|
| 154 |
+
"--no_head_residual",
|
| 155 |
+
dest="head_residual",
|
| 156 |
+
action="store_false",
|
| 157 |
+
help="Disable residual connection in classification head.",
|
| 158 |
+
)
|
| 159 |
+
parser.add_argument("--head_dropout", type=float, default=0.1)
|
| 160 |
+
parser.add_argument("--temperature", type=float, default=0.07)
|
| 161 |
+
parser.add_argument(
|
| 162 |
+
"--pool_method",
|
| 163 |
+
type=str,
|
| 164 |
+
default="max",
|
| 165 |
+
choices=["mean", "max"],
|
| 166 |
+
help="Cell embedding pooling over module encodings.",
|
| 167 |
+
)
|
| 168 |
+
parser.add_argument("--seed", type=int, default=42)
|
| 169 |
+
parser.add_argument("--grad_clip", type=float, default=1.0)
|
| 170 |
+
parser.add_argument("--finetune_mode", type=str, default="frozen", choices=["frozen", "full"])
|
| 171 |
+
parser.add_argument(
|
| 172 |
+
"--protocol",
|
| 173 |
+
type=str,
|
| 174 |
+
default="",
|
| 175 |
+
choices=["", "scgpt", "cellfm"],
|
| 176 |
+
help="Use a built-in protocol split (scgpt or cellfm). Overrides --train_cases/--val_cases/--test_cases.",
|
| 177 |
+
)
|
| 178 |
+
parser.add_argument("--train_cases", type=int, default=33)
|
| 179 |
+
parser.add_argument("--val_cases", type=int, default=3)
|
| 180 |
+
parser.add_argument("--test_cases", type=int, default=11)
|
| 181 |
+
parser.add_argument(
|
| 182 |
+
"--allow_observed_count_mismatch",
|
| 183 |
+
action="store_true",
|
| 184 |
+
help="Allow observed perturbation count to differ from train+val+test total when not using a protocol.",
|
| 185 |
+
)
|
| 186 |
+
parser.add_argument(
|
| 187 |
+
"--allow_protocol_unobserved",
|
| 188 |
+
action="store_true",
|
| 189 |
+
help="Allow protocol conditions to be missing in the local data (uses fewer cells).",
|
| 190 |
+
)
|
| 191 |
+
parser.add_argument(
|
| 192 |
+
"--local_rank",
|
| 193 |
+
type=int,
|
| 194 |
+
default=-1,
|
| 195 |
+
help="Local rank for distributed training. Set by torchrun automatically.",
|
| 196 |
+
)
|
| 197 |
+
parser.add_argument(
|
| 198 |
+
"--select_metric",
|
| 199 |
+
type=str,
|
| 200 |
+
default="condition_top10_acc",
|
| 201 |
+
choices=[f"condition_top{k}_acc" for k in range(1, 11)]
|
| 202 |
+
+ [f"condition_relevant_top{k}_acc" for k in range(1, 11)]
|
| 203 |
+
+ [f"case_cellmean_top{k}_acc" for k in range(1, 11)]
|
| 204 |
+
+ [f"case_cellmean_relevant_top{k}_acc" for k in range(1, 11)]
|
| 205 |
+
+ [f"top{k}_acc" for k in range(1, 11)],
|
| 206 |
+
help="Single validation metric used to select best checkpoint; all metrics are still reported.",
|
| 207 |
+
)
|
| 208 |
+
parser.add_argument("--early_stop_patience", type=int, default=0, help="0 disables early stopping")
|
| 209 |
+
parser.add_argument(
|
| 210 |
+
"--algorithm_name",
|
| 211 |
+
type=str,
|
| 212 |
+
default="scLong",
|
| 213 |
+
help="Name tag written to outputs for easier cross-algorithm comparison.",
|
| 214 |
+
)
|
| 215 |
+
parser.add_argument(
|
| 216 |
+
"--save_best_checkpoint",
|
| 217 |
+
action="store_true",
|
| 218 |
+
help="Save best_finetune_gene_inverse.pth. Disabled by default to reduce disk usage.",
|
| 219 |
+
)
|
| 220 |
+
parser.add_argument(
|
| 221 |
+
"--amp",
|
| 222 |
+
dest="amp",
|
| 223 |
+
action="store_true",
|
| 224 |
+
help="Enable AMP mixed precision (default).",
|
| 225 |
+
)
|
| 226 |
+
parser.add_argument(
|
| 227 |
+
"--no_amp",
|
| 228 |
+
dest="amp",
|
| 229 |
+
action="store_false",
|
| 230 |
+
help="Disable AMP mixed precision.",
|
| 231 |
+
)
|
| 232 |
+
parser.set_defaults(amp=True, head_residual=True)
|
| 233 |
+
parser.add_argument(
|
| 234 |
+
"--export_test_logits",
|
| 235 |
+
action="store_true",
|
| 236 |
+
help="Export per-cell raw logits CSV for the test split.",
|
| 237 |
+
)
|
| 238 |
+
parser.add_argument(
|
| 239 |
+
"--output_dir",
|
| 240 |
+
type=str,
|
| 241 |
+
default=default_repo_path("performer_module", "gene_inverse_norman", "outputs", "norman_gene_inverse_finetune"),
|
| 242 |
+
help="Output directory",
|
| 243 |
+
)
|
| 244 |
+
args = parser.parse_args()
|
| 245 |
+
|
| 246 |
+
is_distributed = args.local_rank != -1 or "LOCAL_RANK" in os.environ or "RANK" in os.environ
|
| 247 |
+
if is_distributed:
|
| 248 |
+
if "LOCAL_RANK" in os.environ:
|
| 249 |
+
local_rank = int(os.environ["LOCAL_RANK"])
|
| 250 |
+
elif "RANK" in os.environ:
|
| 251 |
+
local_rank = int(os.environ["RANK"]) % torch.cuda.device_count()
|
| 252 |
+
else:
|
| 253 |
+
local_rank = args.local_rank
|
| 254 |
+
|
| 255 |
+
if not dist.is_initialized():
|
| 256 |
+
dist.init_process_group(backend="nccl")
|
| 257 |
+
world_size = dist.get_world_size()
|
| 258 |
+
rank = dist.get_rank()
|
| 259 |
+
is_main_process = rank == 0
|
| 260 |
+
device = torch.device(f"cuda:{local_rank}")
|
| 261 |
+
torch.cuda.set_device(device)
|
| 262 |
+
print_sys(f"[DDP] Initialized: rank={rank}, local_rank={local_rank}, world_size={world_size}")
|
| 263 |
+
else:
|
| 264 |
+
world_size = 1
|
| 265 |
+
rank = 0
|
| 266 |
+
local_rank = 0
|
| 267 |
+
is_main_process = True
|
| 268 |
+
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 269 |
+
print_sys(f"[Single GPU] Running on device: {device}")
|
| 270 |
+
|
| 271 |
+
seed_all(args.seed + rank)
|
| 272 |
+
output_dir = Path(args.output_dir)
|
| 273 |
+
if is_main_process:
|
| 274 |
+
output_dir.mkdir(parents=True, exist_ok=True)
|
| 275 |
+
if is_distributed:
|
| 276 |
+
dist.barrier()
|
| 277 |
+
|
| 278 |
+
benchmark_df = pd.read_csv(args.benchmark_csv)
|
| 279 |
+
class_df = normalize_benchmark_table(
|
| 280 |
+
benchmark_df=benchmark_df,
|
| 281 |
+
min_cells_per_condition=args.min_cells_per_condition,
|
| 282 |
+
)
|
| 283 |
+
if class_df.empty:
|
| 284 |
+
raise RuntimeError("No classes remain after benchmark filtering")
|
| 285 |
+
|
| 286 |
+
class_df = class_df.sort_values("class_id").reset_index(drop=True)
|
| 287 |
+
if is_main_process:
|
| 288 |
+
class_df.to_csv(output_dir / "class_mapping.csv", index=False)
|
| 289 |
+
|
| 290 |
+
canonical_to_id: Dict[str, int] = dict(zip(class_df["condition"].tolist(), class_df["class_id"].tolist()))
|
| 291 |
+
id_to_condition: List[str] = class_df.sort_values("class_id")["condition"].astype(str).tolist()
|
| 292 |
+
id_to_gene: List[str] = class_df.sort_values("class_id")["perturb_gene"].astype(str).tolist()
|
| 293 |
+
|
| 294 |
+
if is_main_process:
|
| 295 |
+
if args.protocol:
|
| 296 |
+
protocol_split_path = CURRENT_DIR / "protocols" / args.protocol / "split.json"
|
| 297 |
+
with open(protocol_split_path, "r", encoding="utf-8") as handle:
|
| 298 |
+
protocol_split = json.load(handle)
|
| 299 |
+
split_map = split_from_protocol(
|
| 300 |
+
class_df=class_df,
|
| 301 |
+
protocol_split=protocol_split,
|
| 302 |
+
require_all_observed=not args.allow_protocol_unobserved,
|
| 303 |
+
)
|
| 304 |
+
print_sys(
|
| 305 |
+
f"Using built-in protocol '{args.protocol}': "
|
| 306 |
+
f"train={len(split_map['train'])}, val={len(split_map['val'])}, test={len(split_map['test'])}"
|
| 307 |
+
)
|
| 308 |
+
if split_map.get("missing_in_data"):
|
| 309 |
+
print_sys(
|
| 310 |
+
f"WARNING: protocol conditions missing in data: {split_map['missing_in_data']}"
|
| 311 |
+
)
|
| 312 |
+
else:
|
| 313 |
+
split_map = split_observed_conditions(
|
| 314 |
+
class_df=class_df,
|
| 315 |
+
seed=args.seed,
|
| 316 |
+
train_cases=args.train_cases,
|
| 317 |
+
val_cases=args.val_cases,
|
| 318 |
+
test_cases=args.test_cases,
|
| 319 |
+
allow_observed_count_mismatch=args.allow_observed_count_mismatch,
|
| 320 |
+
)
|
| 321 |
+
split_payload = {
|
| 322 |
+
"train_conditions": np.array(split_map["train"], dtype=object),
|
| 323 |
+
"val_conditions": np.array(split_map["val"], dtype=object),
|
| 324 |
+
"test_conditions": np.array(split_map["test"], dtype=object),
|
| 325 |
+
"unused_observed_conditions": np.array(split_map.get("unused_observed", []), dtype=object),
|
| 326 |
+
}
|
| 327 |
+
np.savez(output_dir / "condition_splits.npz", **split_payload)
|
| 328 |
+
with (output_dir / "condition_splits.json").open("w", encoding="utf-8") as handle:
|
| 329 |
+
json.dump(
|
| 330 |
+
{k: list(v) if isinstance(v, list) else v for k, v in split_map.items()},
|
| 331 |
+
handle,
|
| 332 |
+
indent=2,
|
| 333 |
+
ensure_ascii=False,
|
| 334 |
+
)
|
| 335 |
+
if is_distributed:
|
| 336 |
+
dist.barrier()
|
| 337 |
+
|
| 338 |
+
split_npz = np.load(output_dir / "condition_splits.npz", allow_pickle=True)
|
| 339 |
+
train_conditions = set(split_npz["train_conditions"].tolist())
|
| 340 |
+
val_conditions = set(split_npz["val_conditions"].tolist())
|
| 341 |
+
test_conditions = set(split_npz["test_conditions"].tolist())
|
| 342 |
+
|
| 343 |
+
print_sys(f"Loading Norman data from {args.adata_path}")
|
| 344 |
+
adata = ad.read_h5ad(args.adata_path)
|
| 345 |
+
raw_to_canonical = build_raw_to_canonical_map(class_df[class_df["is_observed"] == 1])
|
| 346 |
+
row_indices, class_ids, canonical_labels = collect_labeled_cells(
|
| 347 |
+
adata=adata,
|
| 348 |
+
condition_col=args.condition_col,
|
| 349 |
+
raw_to_canonical=raw_to_canonical,
|
| 350 |
+
canonical_to_id=canonical_to_id,
|
| 351 |
+
)
|
| 352 |
+
if row_indices.size == 0:
|
| 353 |
+
raise RuntimeError("No labeled cells found after mapping conditions to classes")
|
| 354 |
+
|
| 355 |
+
train_mask = np.array([label in train_conditions for label in canonical_labels], dtype=bool)
|
| 356 |
+
val_mask = np.array([label in val_conditions for label in canonical_labels], dtype=bool)
|
| 357 |
+
test_mask = np.array([label in test_conditions for label in canonical_labels], dtype=bool)
|
| 358 |
+
|
| 359 |
+
train_rows = row_indices[train_mask]
|
| 360 |
+
val_rows = row_indices[val_mask]
|
| 361 |
+
test_rows = row_indices[test_mask]
|
| 362 |
+
train_labels = class_ids[train_mask]
|
| 363 |
+
val_labels = class_ids[val_mask]
|
| 364 |
+
test_labels = class_ids[test_mask]
|
| 365 |
+
|
| 366 |
+
if is_main_process:
|
| 367 |
+
np.savez(
|
| 368 |
+
output_dir / "split_indices.npz",
|
| 369 |
+
train_rows=train_rows,
|
| 370 |
+
val_rows=val_rows,
|
| 371 |
+
test_rows=test_rows,
|
| 372 |
+
)
|
| 373 |
+
|
| 374 |
+
split_summary = {
|
| 375 |
+
"n_classes_total": int(len(class_df)),
|
| 376 |
+
"n_observed_classes": int((class_df["is_observed"] == 1).sum()),
|
| 377 |
+
"n_train_conditions": int(len(train_conditions)),
|
| 378 |
+
"n_val_conditions": int(len(val_conditions)),
|
| 379 |
+
"n_test_conditions": int(len(test_conditions)),
|
| 380 |
+
"n_train_cells": int(train_rows.size),
|
| 381 |
+
"n_val_cells": int(val_rows.size),
|
| 382 |
+
"n_test_cells": int(test_rows.size),
|
| 383 |
+
}
|
| 384 |
+
if is_main_process:
|
| 385 |
+
print_sys(json.dumps(split_summary, indent=2, ensure_ascii=False))
|
| 386 |
+
|
| 387 |
+
train_dataset = GeneInverseCellDataset(adata.X, train_rows, train_labels)
|
| 388 |
+
val_dataset = GeneInverseCellDataset(adata.X, val_rows, val_labels)
|
| 389 |
+
test_dataset = GeneInverseCellDataset(adata.X, test_rows, test_labels)
|
| 390 |
+
|
| 391 |
+
if is_distributed:
|
| 392 |
+
train_sampler = DistributedSampler(train_dataset, num_replicas=world_size, rank=rank, shuffle=True)
|
| 393 |
+
val_sampler = DistributedSampler(val_dataset, num_replicas=world_size, rank=rank, shuffle=False)
|
| 394 |
+
test_sampler = DistributedSampler(test_dataset, num_replicas=world_size, rank=rank, shuffle=False)
|
| 395 |
+
train_shuffle = False
|
| 396 |
+
else:
|
| 397 |
+
train_sampler = None
|
| 398 |
+
val_sampler = None
|
| 399 |
+
test_sampler = None
|
| 400 |
+
train_shuffle = True
|
| 401 |
+
|
| 402 |
+
train_loader = DataLoader(
|
| 403 |
+
train_dataset,
|
| 404 |
+
batch_size=args.batch_size,
|
| 405 |
+
shuffle=train_shuffle,
|
| 406 |
+
sampler=train_sampler,
|
| 407 |
+
num_workers=args.num_workers,
|
| 408 |
+
pin_memory=True,
|
| 409 |
+
drop_last=False,
|
| 410 |
+
)
|
| 411 |
+
val_loader = DataLoader(
|
| 412 |
+
val_dataset,
|
| 413 |
+
batch_size=args.batch_size,
|
| 414 |
+
shuffle=False,
|
| 415 |
+
sampler=val_sampler,
|
| 416 |
+
num_workers=args.num_workers,
|
| 417 |
+
pin_memory=True,
|
| 418 |
+
drop_last=False,
|
| 419 |
+
)
|
| 420 |
+
test_loader = DataLoader(
|
| 421 |
+
test_dataset,
|
| 422 |
+
batch_size=args.batch_size,
|
| 423 |
+
shuffle=False,
|
| 424 |
+
sampler=test_sampler,
|
| 425 |
+
num_workers=args.num_workers,
|
| 426 |
+
pin_memory=True,
|
| 427 |
+
drop_last=False,
|
| 428 |
+
)
|
| 429 |
+
|
| 430 |
+
backbone, _ = load_pretrained_backbone(
|
| 431 |
+
scfm_hyper_params_path=args.scfm_hyper_params_path,
|
| 432 |
+
scfm_ckpt_path=args.scfm_ckpt_path,
|
| 433 |
+
gene2vec_path=args.gene2vec_path,
|
| 434 |
+
device=device,
|
| 435 |
+
)
|
| 436 |
+
trainability = configure_backbone_trainability(backbone=backbone, finetune_mode=args.finetune_mode)
|
| 437 |
+
model = GeneInverseCompositionalRetrievalModel(
|
| 438 |
+
backbone=backbone,
|
| 439 |
+
class_df=class_df,
|
| 440 |
+
pool_method=args.pool_method,
|
| 441 |
+
head_hidden_dim=args.head_hidden_dim,
|
| 442 |
+
head_dropout=args.head_dropout,
|
| 443 |
+
head_residual=args.head_residual,
|
| 444 |
+
temperature=args.temperature,
|
| 445 |
+
).to(device)
|
| 446 |
+
|
| 447 |
+
if is_distributed:
|
| 448 |
+
model = DDP(model, device_ids=[local_rank], output_device=local_rank, find_unused_parameters=False)
|
| 449 |
+
unwrapped_model = model.module
|
| 450 |
+
else:
|
| 451 |
+
unwrapped_model = model
|
| 452 |
+
|
| 453 |
+
if is_main_process:
|
| 454 |
+
print_sys(
|
| 455 |
+
f"Backbone params: trainable={trainability['trainable']} total={trainability['total']} | "
|
| 456 |
+
f"Head params: trainable={sum(p.numel() for p in unwrapped_model.cell_head.parameters()) + sum(p.numel() for p in unwrapped_model.candidate_gene_embeddings.parameters())}"
|
| 457 |
+
)
|
| 458 |
+
|
| 459 |
+
gene2ens = pd.read_csv(args.gene_mapping_path, index_col=0)
|
| 460 |
+
symbol_to_ensembl = gene2ens["Ensembl gene ID"].to_dict()
|
| 461 |
+
with open(args.scfm_genes_file, "r", encoding="utf-8") as handle:
|
| 462 |
+
scfm_genes = [line.rstrip("\n") for line in handle.readlines()]
|
| 463 |
+
scfm_gene_set = set(scfm_genes)
|
| 464 |
+
|
| 465 |
+
resolved_gene_col = fallback_gene_col(adata, args.gene_col)
|
| 466 |
+
input_gene_ids = resolve_input_gene_ids(
|
| 467 |
+
adata=adata,
|
| 468 |
+
gene_col=resolved_gene_col,
|
| 469 |
+
symbol_to_ensembl=symbol_to_ensembl,
|
| 470 |
+
target_gene_set=scfm_gene_set,
|
| 471 |
+
)
|
| 472 |
+
input_mapping = {gene_id: index for index, gene_id in enumerate(input_gene_ids)}
|
| 473 |
+
input_gene_num = len(input_gene_ids)
|
| 474 |
+
scfm_genes_pad = scfm_genes + ["PAD"]
|
| 475 |
+
scfm_index_positions = [input_mapping.get(gene_id, input_gene_num) for gene_id in scfm_genes_pad]
|
| 476 |
+
|
| 477 |
+
optimizer = build_optimizer(
|
| 478 |
+
model=model,
|
| 479 |
+
lr_head=args.lr_head,
|
| 480 |
+
lr_backbone=args.lr_backbone,
|
| 481 |
+
weight_decay=args.weight_decay,
|
| 482 |
+
)
|
| 483 |
+
scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=max(args.epochs, 1))
|
| 484 |
+
train_class_weights = None
|
| 485 |
+
if args.class_balance_power > 0:
|
| 486 |
+
train_counts = np.bincount(train_labels, minlength=len(class_df)).astype(np.float32)
|
| 487 |
+
class_weights = np.ones(len(class_df), dtype=np.float32)
|
| 488 |
+
nonzero = train_counts > 0
|
| 489 |
+
class_weights[nonzero] = np.power(train_counts[nonzero], -float(args.class_balance_power))
|
| 490 |
+
class_weights = class_weights / max(float(class_weights[nonzero].mean()), 1e-8)
|
| 491 |
+
train_class_weights = torch.tensor(class_weights, dtype=torch.float32, device=device)
|
| 492 |
+
if is_main_process:
|
| 493 |
+
print_sys(
|
| 494 |
+
f"Using class-balanced CE loss with power={args.class_balance_power:.3f} | "
|
| 495 |
+
f"weight range [{float(class_weights[nonzero].min()):.4f}, {float(class_weights[nonzero].max()):.4f}]"
|
| 496 |
+
)
|
| 497 |
+
|
| 498 |
+
train_loss_fn = nn.CrossEntropyLoss(
|
| 499 |
+
weight=train_class_weights,
|
| 500 |
+
label_smoothing=float(args.label_smoothing),
|
| 501 |
+
)
|
| 502 |
+
eval_loss_fn = nn.CrossEntropyLoss()
|
| 503 |
+
|
| 504 |
+
use_amp = bool(args.amp and device.type == "cuda")
|
| 505 |
+
scaler = torch.amp.GradScaler("cuda", enabled=use_amp)
|
| 506 |
+
|
| 507 |
+
history = []
|
| 508 |
+
best_score = -float("inf")
|
| 509 |
+
best_rank_tuple = (-float("inf"), -float("inf"), -float("inf"), -float("inf"))
|
| 510 |
+
best_epoch = -1
|
| 511 |
+
best_state = None
|
| 512 |
+
best_val_metrics = None
|
| 513 |
+
no_improve = 0
|
| 514 |
+
|
| 515 |
+
val_row_to_condition = {int(row): str(cond) for row, cond in zip(val_rows.tolist(), canonical_labels[val_mask].tolist())}
|
| 516 |
+
test_row_to_condition = {int(row): str(cond) for row, cond in zip(test_rows.tolist(), canonical_labels[test_mask].tolist())}
|
| 517 |
+
|
| 518 |
+
for epoch in range(1, args.epochs + 1):
|
| 519 |
+
if is_distributed and train_sampler is not None:
|
| 520 |
+
train_sampler.set_epoch(epoch)
|
| 521 |
+
|
| 522 |
+
model.train()
|
| 523 |
+
running_loss = 0.0
|
| 524 |
+
running_samples = 0
|
| 525 |
+
|
| 526 |
+
for x, y, _ in train_loader:
|
| 527 |
+
x = x.to(device, non_blocking=True)
|
| 528 |
+
y = y.to(device, non_blocking=True)
|
| 529 |
+
x_model = reindex_tensor_universal(x, scfm_index_positions, dim=1, filler="0", device=device)
|
| 530 |
+
|
| 531 |
+
optimizer.zero_grad(set_to_none=True)
|
| 532 |
+
with torch.amp.autocast("cuda", enabled=use_amp):
|
| 533 |
+
logits = model(x_model)
|
| 534 |
+
loss = train_loss_fn(logits, y)
|
| 535 |
+
|
| 536 |
+
if use_amp:
|
| 537 |
+
scaler.scale(loss).backward()
|
| 538 |
+
if args.grad_clip > 0:
|
| 539 |
+
scaler.unscale_(optimizer)
|
| 540 |
+
torch.nn.utils.clip_grad_norm_(model.parameters(), args.grad_clip)
|
| 541 |
+
scaler.step(optimizer)
|
| 542 |
+
scaler.update()
|
| 543 |
+
else:
|
| 544 |
+
loss.backward()
|
| 545 |
+
if args.grad_clip > 0:
|
| 546 |
+
torch.nn.utils.clip_grad_norm_(model.parameters(), args.grad_clip)
|
| 547 |
+
optimizer.step()
|
| 548 |
+
|
| 549 |
+
batch_size = int(x.shape[0])
|
| 550 |
+
running_loss += float(loss.item()) * batch_size
|
| 551 |
+
running_samples += batch_size
|
| 552 |
+
|
| 553 |
+
train_loss = running_loss / max(running_samples, 1)
|
| 554 |
+
scheduler.step()
|
| 555 |
+
if is_distributed:
|
| 556 |
+
loss_tensor = torch.tensor([train_loss], device=device)
|
| 557 |
+
dist.all_reduce(loss_tensor, op=dist.ReduceOp.SUM)
|
| 558 |
+
train_loss = loss_tensor.item() / world_size
|
| 559 |
+
|
| 560 |
+
val_metrics, val_logits, val_targets, val_row_ids = evaluate_loader(
|
| 561 |
+
model=unwrapped_model,
|
| 562 |
+
dataloader=val_loader,
|
| 563 |
+
device=device,
|
| 564 |
+
scfm_index_positions=scfm_index_positions,
|
| 565 |
+
loss_fn=eval_loss_fn,
|
| 566 |
+
return_outputs=True,
|
| 567 |
+
is_distributed=is_distributed,
|
| 568 |
+
world_size=world_size,
|
| 569 |
+
rank=rank,
|
| 570 |
+
)
|
| 571 |
+
|
| 572 |
+
if is_main_process:
|
| 573 |
+
val_condition_names = [val_row_to_condition[int(row)] for row in val_row_ids.tolist()]
|
| 574 |
+
(
|
| 575 |
+
val_condition_metrics,
|
| 576 |
+
val_aggregated_logits,
|
| 577 |
+
val_aggregated_targets,
|
| 578 |
+
_,
|
| 579 |
+
) = compute_condition_hit_rates(
|
| 580 |
+
logits=val_logits,
|
| 581 |
+
targets=val_targets,
|
| 582 |
+
condition_names=val_condition_names,
|
| 583 |
+
)
|
| 584 |
+
val_metrics.update(val_condition_metrics)
|
| 585 |
+
val_relevant_metrics = compute_condition_relevant_hit_rates(
|
| 586 |
+
aggregated_logits=val_aggregated_logits,
|
| 587 |
+
aggregated_targets=val_aggregated_targets,
|
| 588 |
+
id_to_condition=id_to_condition,
|
| 589 |
+
)
|
| 590 |
+
val_metrics.update(val_relevant_metrics)
|
| 591 |
+
val_case_cellmean_metrics = compute_case_cellmean_hit_rates(
|
| 592 |
+
logits=val_logits,
|
| 593 |
+
targets=val_targets,
|
| 594 |
+
condition_names=val_condition_names,
|
| 595 |
+
id_to_condition=id_to_condition,
|
| 596 |
+
)
|
| 597 |
+
val_metrics.update(val_case_cellmean_metrics)
|
| 598 |
+
record = {
|
| 599 |
+
"epoch": int(epoch),
|
| 600 |
+
"train_loss": float(train_loss),
|
| 601 |
+
**{f"val_{k}": float(v) for k, v in val_metrics.items()},
|
| 602 |
+
}
|
| 603 |
+
history.append(record)
|
| 604 |
+
print_sys(
|
| 605 |
+
f"Epoch {epoch} | train loss {train_loss:.4f} | "
|
| 606 |
+
f"val cond top1 {val_metrics['condition_top1_acc']:.4f} "
|
| 607 |
+
f"top5 {val_metrics['condition_top5_acc']:.4f} "
|
| 608 |
+
f"top10 {val_metrics['condition_top10_acc']:.4f} | "
|
| 609 |
+
f"val case top1 {val_metrics['case_cellmean_top1_acc']:.4f} "
|
| 610 |
+
f"top5 {val_metrics['case_cellmean_top5_acc']:.4f} | "
|
| 611 |
+
f"val rel top1 {val_metrics['condition_relevant_top1_acc']:.4f} | "
|
| 612 |
+
f"val cell top1 {val_metrics['top1_acc']:.4f}"
|
| 613 |
+
)
|
| 614 |
+
score = float(val_metrics[args.select_metric])
|
| 615 |
+
rank_tuple = (
|
| 616 |
+
score,
|
| 617 |
+
float(val_metrics.get("condition_top1_acc", 0.0)),
|
| 618 |
+
float(val_metrics.get("case_cellmean_top5_acc", 0.0)),
|
| 619 |
+
-float(val_metrics.get("loss", 0.0)),
|
| 620 |
+
)
|
| 621 |
+
if rank_tuple > best_rank_tuple:
|
| 622 |
+
best_score = score
|
| 623 |
+
best_rank_tuple = rank_tuple
|
| 624 |
+
best_epoch = epoch
|
| 625 |
+
best_state = {key: value.detach().cpu() for key, value in unwrapped_model.state_dict().items()}
|
| 626 |
+
best_val_metrics = dict(val_metrics)
|
| 627 |
+
no_improve = 0
|
| 628 |
+
else:
|
| 629 |
+
no_improve += 1
|
| 630 |
+
if is_distributed:
|
| 631 |
+
stop_flag = torch.tensor([0], device=device)
|
| 632 |
+
if is_main_process and args.early_stop_patience > 0 and no_improve >= args.early_stop_patience:
|
| 633 |
+
print_sys(f"Early stopping at epoch {epoch} (no improve for {no_improve} epochs)")
|
| 634 |
+
stop_flag[0] = 1
|
| 635 |
+
dist.broadcast(stop_flag, src=0)
|
| 636 |
+
if stop_flag.item() == 1:
|
| 637 |
+
break
|
| 638 |
+
else:
|
| 639 |
+
if args.early_stop_patience > 0 and no_improve >= args.early_stop_patience:
|
| 640 |
+
print_sys(f"Early stopping at epoch {epoch} (no improve for {no_improve} epochs)")
|
| 641 |
+
break
|
| 642 |
+
if is_distributed:
|
| 643 |
+
dist.barrier()
|
| 644 |
+
|
| 645 |
+
checkpoint_path = output_dir / "best_finetune_gene_inverse.pth"
|
| 646 |
+
checkpoint_saved = False
|
| 647 |
+
checkpoint_payload = None
|
| 648 |
+
if is_main_process:
|
| 649 |
+
if best_state is None:
|
| 650 |
+
raise RuntimeError("Training did not produce a valid checkpoint")
|
| 651 |
+
history_df = pd.DataFrame(history)
|
| 652 |
+
history_df.to_csv(output_dir / "train_history.csv", index=False)
|
| 653 |
+
checkpoint_payload = {
|
| 654 |
+
"model_state_dict": best_state,
|
| 655 |
+
"epoch": int(best_epoch),
|
| 656 |
+
"val_metrics": best_val_metrics,
|
| 657 |
+
"class_table": class_df.to_dict(orient="records"),
|
| 658 |
+
"pool_method": args.pool_method,
|
| 659 |
+
"head_hidden_dim": int(args.head_hidden_dim),
|
| 660 |
+
"head_dropout": float(args.head_dropout),
|
| 661 |
+
"head_residual": bool(args.head_residual),
|
| 662 |
+
"temperature": float(args.temperature),
|
| 663 |
+
"finetune_mode": args.finetune_mode,
|
| 664 |
+
"run_config": vars(args),
|
| 665 |
+
}
|
| 666 |
+
if args.save_best_checkpoint or is_distributed:
|
| 667 |
+
torch.save(checkpoint_payload, checkpoint_path)
|
| 668 |
+
checkpoint_saved = True
|
| 669 |
+
if args.save_best_checkpoint:
|
| 670 |
+
print_sys(f"Saved best checkpoint to {checkpoint_path}")
|
| 671 |
+
else:
|
| 672 |
+
print_sys(
|
| 673 |
+
f"Saved temporary checkpoint to {checkpoint_path} "
|
| 674 |
+
"(required for distributed test evaluation; file will be removed after evaluation)."
|
| 675 |
+
)
|
| 676 |
+
|
| 677 |
+
if is_distributed:
|
| 678 |
+
dist.barrier()
|
| 679 |
+
saved_checkpoint = torch.load(checkpoint_path, map_location="cpu")
|
| 680 |
+
unwrapped_model.load_state_dict(saved_checkpoint["model_state_dict"], strict=True)
|
| 681 |
+
dist.barrier()
|
| 682 |
+
if is_main_process and not args.save_best_checkpoint and checkpoint_path.exists():
|
| 683 |
+
checkpoint_path.unlink()
|
| 684 |
+
print_sys(f"Removed temporary checkpoint {checkpoint_path}")
|
| 685 |
+
else:
|
| 686 |
+
unwrapped_model.load_state_dict(best_state, strict=True)
|
| 687 |
+
test_metrics, test_logits, test_targets, test_row_ids = evaluate_loader(
|
| 688 |
+
model=unwrapped_model,
|
| 689 |
+
dataloader=test_loader,
|
| 690 |
+
device=device,
|
| 691 |
+
scfm_index_positions=scfm_index_positions,
|
| 692 |
+
loss_fn=eval_loss_fn,
|
| 693 |
+
return_outputs=True,
|
| 694 |
+
is_distributed=is_distributed,
|
| 695 |
+
world_size=world_size,
|
| 696 |
+
rank=rank,
|
| 697 |
+
)
|
| 698 |
+
|
| 699 |
+
if is_main_process:
|
| 700 |
+
test_condition_names = [test_row_to_condition[int(row)] for row in test_row_ids.tolist()]
|
| 701 |
+
test_condition_metrics, aggregated_logits, aggregated_targets, ordered_conditions = compute_condition_hit_rates(
|
| 702 |
+
logits=test_logits,
|
| 703 |
+
targets=test_targets,
|
| 704 |
+
condition_names=test_condition_names,
|
| 705 |
+
)
|
| 706 |
+
test_metrics.update(test_condition_metrics)
|
| 707 |
+
test_relevant_metrics = compute_condition_relevant_hit_rates(
|
| 708 |
+
aggregated_logits=aggregated_logits,
|
| 709 |
+
aggregated_targets=aggregated_targets,
|
| 710 |
+
id_to_condition=id_to_condition,
|
| 711 |
+
)
|
| 712 |
+
test_metrics.update(test_relevant_metrics)
|
| 713 |
+
test_case_cellmean_metrics = compute_case_cellmean_hit_rates(
|
| 714 |
+
logits=test_logits,
|
| 715 |
+
targets=test_targets,
|
| 716 |
+
condition_names=test_condition_names,
|
| 717 |
+
id_to_condition=id_to_condition,
|
| 718 |
+
)
|
| 719 |
+
test_metrics.update(test_case_cellmean_metrics)
|
| 720 |
+
|
| 721 |
+
if args.export_test_logits:
|
| 722 |
+
logits_df = pd.DataFrame(test_logits.numpy(), columns=id_to_condition)
|
| 723 |
+
logits_df.insert(0, "cell_name", adata.obs_names[test_row_ids].astype(str).tolist())
|
| 724 |
+
logits_df.insert(1, "row_index", test_row_ids.astype(int))
|
| 725 |
+
logits_path = output_dir / "test_logits.csv"
|
| 726 |
+
logits_df.to_csv(logits_path, index=False)
|
| 727 |
+
print_sys(f"Saved test logits to {logits_path}")
|
| 728 |
+
|
| 729 |
+
pred_ids = test_logits.argmax(dim=1).numpy()
|
| 730 |
+
true_ids = test_targets.numpy()
|
| 731 |
+
pred_df = pd.DataFrame(
|
| 732 |
+
{
|
| 733 |
+
"row_index": test_row_ids.astype(int),
|
| 734 |
+
"cell_name": adata.obs_names[test_row_ids].astype(str).tolist(),
|
| 735 |
+
"true_class_id": true_ids.astype(int),
|
| 736 |
+
"pred_class_id": pred_ids.astype(int),
|
| 737 |
+
"true_condition": [id_to_condition[idx] for idx in true_ids],
|
| 738 |
+
"pred_condition": [id_to_condition[idx] for idx in pred_ids],
|
| 739 |
+
"true_gene": [id_to_gene[idx] for idx in true_ids],
|
| 740 |
+
"pred_gene": [id_to_gene[idx] for idx in pred_ids],
|
| 741 |
+
"correct": (true_ids == pred_ids).astype(int),
|
| 742 |
+
}
|
| 743 |
+
)
|
| 744 |
+
pred_df.to_csv(output_dir / "test_predictions.csv", index=False)
|
| 745 |
+
|
| 746 |
+
condition_ranking_records = build_condition_ranking_records(
|
| 747 |
+
aggregated_logits=aggregated_logits,
|
| 748 |
+
aggregated_targets=aggregated_targets,
|
| 749 |
+
ordered_conditions=ordered_conditions,
|
| 750 |
+
id_to_condition=id_to_condition,
|
| 751 |
+
top_k=10,
|
| 752 |
+
)
|
| 753 |
+
with (output_dir / "test_condition_rankings.jsonl").open("w", encoding="utf-8") as handle:
|
| 754 |
+
for record in condition_ranking_records:
|
| 755 |
+
handle.write(json.dumps(record, ensure_ascii=False) + "\n")
|
| 756 |
+
|
| 757 |
+
summary = {
|
| 758 |
+
"algorithm_name": args.algorithm_name,
|
| 759 |
+
"protocol": args.protocol or None,
|
| 760 |
+
"best_epoch": int(best_epoch),
|
| 761 |
+
"selection_metric": args.select_metric,
|
| 762 |
+
"best_val_score": float(best_score),
|
| 763 |
+
"selection_tiebreaker": [
|
| 764 |
+
"condition_top1_acc",
|
| 765 |
+
"case_cellmean_top5_acc",
|
| 766 |
+
"lower_val_loss",
|
| 767 |
+
],
|
| 768 |
+
"pool_method": args.pool_method,
|
| 769 |
+
"finetune_mode": args.finetune_mode,
|
| 770 |
+
"head_residual": bool(args.head_residual),
|
| 771 |
+
"checkpoint_saved": bool(args.save_best_checkpoint and checkpoint_saved),
|
| 772 |
+
"temperature": float(args.temperature),
|
| 773 |
+
"class_balance_power": float(args.class_balance_power),
|
| 774 |
+
"label_smoothing": float(args.label_smoothing),
|
| 775 |
+
"n_classes": int(len(class_df)),
|
| 776 |
+
"n_train_conditions": int(len(train_conditions)),
|
| 777 |
+
"n_val_conditions": int(len(val_conditions)),
|
| 778 |
+
"n_test_conditions": int(len(test_conditions)),
|
| 779 |
+
"n_train_cells": int(train_rows.size),
|
| 780 |
+
"n_val_cells": int(val_rows.size),
|
| 781 |
+
"n_test_cells": int(test_rows.size),
|
| 782 |
+
**{f"best_val_{k}": float(v) for k, v in best_val_metrics.items()},
|
| 783 |
+
**{f"test_{k}": float(v) for k, v in test_metrics.items()},
|
| 784 |
+
"alignment_notes": [
|
| 785 |
+
"Candidate universe includes selected single and double perturbations.",
|
| 786 |
+
"Top-k metrics are reported for k=1..10.",
|
| 787 |
+
"Primary comparison metric should use condition_topk_acc on perturbation-level aggregated test conditions.",
|
| 788 |
+
"scGPT-style relevant-hit metrics are exported as condition_relevant_topk_acc.",
|
| 789 |
+
"Paper-style non-integer hit rates can be approximated via case_cellmean_topk_acc metrics.",
|
| 790 |
+
"The exact CellFM 20-gene list is not published in code here; benchmark gene pool must be checked before claiming exact reproduction.",
|
| 791 |
+
],
|
| 792 |
+
}
|
| 793 |
+
best_val_table = build_topk_table_rows(
|
| 794 |
+
metrics=best_val_metrics,
|
| 795 |
+
split_name="best_val",
|
| 796 |
+
algorithm_name=args.algorithm_name,
|
| 797 |
+
)
|
| 798 |
+
test_table = build_topk_table_rows(
|
| 799 |
+
metrics=test_metrics,
|
| 800 |
+
split_name="test",
|
| 801 |
+
algorithm_name=args.algorithm_name,
|
| 802 |
+
)
|
| 803 |
+
metrics_table_df = pd.concat([best_val_table, test_table], axis=0, ignore_index=True)
|
| 804 |
+
metrics_table_df.to_csv(output_dir / "metrics_table.csv", index=False)
|
| 805 |
+
print_topk_table(metrics_table_df, title="Top-k Metrics Table")
|
| 806 |
+
|
| 807 |
+
with (output_dir / "summary_metrics.json").open("w", encoding="utf-8") as handle:
|
| 808 |
+
json.dump(summary, handle, indent=2, ensure_ascii=False)
|
| 809 |
+
test_only_summary = {
|
| 810 |
+
"algorithm_name": args.algorithm_name,
|
| 811 |
+
"protocol": args.protocol or None,
|
| 812 |
+
"selection_metric": args.select_metric,
|
| 813 |
+
"best_epoch": int(best_epoch),
|
| 814 |
+
"pool_method": args.pool_method,
|
| 815 |
+
"finetune_mode": args.finetune_mode,
|
| 816 |
+
"head_residual": bool(args.head_residual),
|
| 817 |
+
"temperature": float(args.temperature),
|
| 818 |
+
"class_balance_power": float(args.class_balance_power),
|
| 819 |
+
"label_smoothing": float(args.label_smoothing),
|
| 820 |
+
"n_test_conditions": int(len(test_conditions)),
|
| 821 |
+
"n_test_cells": int(test_rows.size),
|
| 822 |
+
**{k: float(v) for k, v in test_metrics.items()},
|
| 823 |
+
}
|
| 824 |
+
with (output_dir / "test_summary_metrics.json").open("w", encoding="utf-8") as handle:
|
| 825 |
+
json.dump(test_only_summary, handle, indent=2, ensure_ascii=False)
|
| 826 |
+
with (output_dir / "run_config.json").open("w", encoding="utf-8") as handle:
|
| 827 |
+
json.dump(vars(args), handle, indent=2, ensure_ascii=False)
|
| 828 |
+
print_sys("Test Summary")
|
| 829 |
+
print_sys(json.dumps(test_only_summary, indent=2, ensure_ascii=False))
|
| 830 |
+
|
| 831 |
+
if is_distributed:
|
| 832 |
+
dist.barrier()
|
| 833 |
+
dist.destroy_process_group()
|
| 834 |
+
|
| 835 |
+
|
| 836 |
+
if __name__ == "__main__":
|
| 837 |
+
main()
|
BioMod/gene_inverse_norman/protocols/cellfm/genes.txt
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
CBFA2T3
|
| 2 |
+
CEBPA
|
| 3 |
+
CEBPE
|
| 4 |
+
CNN1
|
| 5 |
+
ETS2
|
| 6 |
+
FEV
|
| 7 |
+
FOXA1
|
| 8 |
+
FOXA3
|
| 9 |
+
FOXF1
|
| 10 |
+
FOSB
|
| 11 |
+
HOXB9
|
| 12 |
+
IGDCC3
|
| 13 |
+
IKZF3
|
| 14 |
+
MAP2K3
|
| 15 |
+
MAP2K6
|
| 16 |
+
MAPK1
|
| 17 |
+
PTPN12
|
| 18 |
+
SNAI1
|
| 19 |
+
TGFBR2
|
| 20 |
+
UBASH3A
|
BioMod/gene_inverse_norman/protocols/cellfm/split.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "cellfm",
|
| 3 |
+
"description": "CellFM-style reverse perturbation split on Norman (20 genes; 33 train = 16 doubles + 17 singles, 3 val doubles, 11 test doubles = 47 observed conditions).",
|
| 4 |
+
"include_singles": true,
|
| 5 |
+
"include_doubles": true,
|
| 6 |
+
"train": [
|
| 7 |
+
"CEBPA+CEBPE",
|
| 8 |
+
"CEBPE+ETS2",
|
| 9 |
+
"CEBPE+FOSB",
|
| 10 |
+
"FOXA3+FOXF1",
|
| 11 |
+
"FOXA1+HOXB9",
|
| 12 |
+
"FOXA3+HOXB9",
|
| 13 |
+
"ETS2+IKZF3",
|
| 14 |
+
"IKZF3+MAP2K3",
|
| 15 |
+
"IKZF3+MAP2K6",
|
| 16 |
+
"CNN1+MAPK1",
|
| 17 |
+
"IKZF3+MAPK1",
|
| 18 |
+
"CEBPE+PTPN12",
|
| 19 |
+
"PTPN12+SNAI1",
|
| 20 |
+
"ETS2+TGFBR2",
|
| 21 |
+
"IGDCC3+TGFBR2",
|
| 22 |
+
"MAPK1+TGFBR2",
|
| 23 |
+
"CBFA2T3",
|
| 24 |
+
"CEBPA",
|
| 25 |
+
"CEBPE",
|
| 26 |
+
"CNN1",
|
| 27 |
+
"ETS2",
|
| 28 |
+
"FEV",
|
| 29 |
+
"FOXA1",
|
| 30 |
+
"FOXF1",
|
| 31 |
+
"HOXB9",
|
| 32 |
+
"IGDCC3",
|
| 33 |
+
"IKZF3",
|
| 34 |
+
"MAP2K6",
|
| 35 |
+
"MAPK1",
|
| 36 |
+
"PTPN12",
|
| 37 |
+
"SNAI1",
|
| 38 |
+
"TGFBR2",
|
| 39 |
+
"UBASH3A"
|
| 40 |
+
],
|
| 41 |
+
"val": [
|
| 42 |
+
"FOXA1+FOXF1",
|
| 43 |
+
"ETS2+MAPK1",
|
| 44 |
+
"FOSB+PTPN12"
|
| 45 |
+
],
|
| 46 |
+
"test": [
|
| 47 |
+
"CEBPE+CNN1",
|
| 48 |
+
"CNN1+ETS2",
|
| 49 |
+
"CBFA2T3+FEV",
|
| 50 |
+
"FOXA1+FOXA3",
|
| 51 |
+
"FOXF1+HOXB9",
|
| 52 |
+
"ETS2+IGDCC3",
|
| 53 |
+
"FOSB+IKZF3",
|
| 54 |
+
"MAP2K3+MAP2K6",
|
| 55 |
+
"IGDCC3+MAPK1",
|
| 56 |
+
"CNN1+UBASH3A",
|
| 57 |
+
"PTPN12+UBASH3A"
|
| 58 |
+
]
|
| 59 |
+
}
|
BioMod/gene_inverse_norman/protocols/scgpt/genes.txt
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
CEBPA
|
| 2 |
+
CEBPE
|
| 3 |
+
CNN1
|
| 4 |
+
FEV
|
| 5 |
+
FOSB
|
| 6 |
+
FOXA1
|
| 7 |
+
FOXF1
|
| 8 |
+
HOXB9
|
| 9 |
+
IGDCC3
|
| 10 |
+
IKZF3
|
| 11 |
+
KLF1
|
| 12 |
+
MAP2K6
|
| 13 |
+
MAPK1
|
| 14 |
+
OSR2
|
| 15 |
+
PRTG
|
| 16 |
+
PTPN9
|
| 17 |
+
SAMD1
|
| 18 |
+
TGFBR2
|
| 19 |
+
UBASH3B
|
| 20 |
+
ZBTB25
|
BioMod/gene_inverse_norman/protocols/scgpt/split.json
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "scgpt",
|
| 3 |
+
"description": "scGPT-style reverse perturbation split on Norman (20 genes; 39 train = 20 doubles + 19 singles, 3 val doubles, 7 test doubles = 49 observed conditions).",
|
| 4 |
+
"include_singles": true,
|
| 5 |
+
"include_doubles": true,
|
| 6 |
+
"train": [
|
| 7 |
+
"UBASH3B+ZBTB25",
|
| 8 |
+
"SAMD1+UBASH3B",
|
| 9 |
+
"SAMD1+TGFBR2",
|
| 10 |
+
"PRTG+TGFBR2",
|
| 11 |
+
"OSR2+UBASH3B",
|
| 12 |
+
"MAPK1+TGFBR2",
|
| 13 |
+
"KLF1+TGFBR2",
|
| 14 |
+
"KLF1+MAP2K6",
|
| 15 |
+
"IKZF3+MAPK1",
|
| 16 |
+
"IKZF3+MAP2K6",
|
| 17 |
+
"IGDCC3+TGFBR2",
|
| 18 |
+
"FOXF1+HOXB9",
|
| 19 |
+
"FOXA1+KLF1",
|
| 20 |
+
"FOXA1+FOXF1",
|
| 21 |
+
"FOSB+OSR2",
|
| 22 |
+
"CEBPE+KLF1",
|
| 23 |
+
"CEBPE+FOSB",
|
| 24 |
+
"CEBPE+CNN1",
|
| 25 |
+
"CEBPA+KLF1",
|
| 26 |
+
"CEBPA+CEBPE",
|
| 27 |
+
"ZBTB25",
|
| 28 |
+
"UBASH3B",
|
| 29 |
+
"TGFBR2",
|
| 30 |
+
"SAMD1",
|
| 31 |
+
"PTPN9",
|
| 32 |
+
"PRTG",
|
| 33 |
+
"OSR2",
|
| 34 |
+
"MAPK1",
|
| 35 |
+
"MAP2K6",
|
| 36 |
+
"KLF1",
|
| 37 |
+
"IKZF3",
|
| 38 |
+
"IGDCC3",
|
| 39 |
+
"HOXB9",
|
| 40 |
+
"FOXF1",
|
| 41 |
+
"FOXA1",
|
| 42 |
+
"FEV",
|
| 43 |
+
"CNN1",
|
| 44 |
+
"CEBPE",
|
| 45 |
+
"CEBPA"
|
| 46 |
+
],
|
| 47 |
+
"val": [
|
| 48 |
+
"IGDCC3+ZBTB25",
|
| 49 |
+
"IGDCC3+PRTG",
|
| 50 |
+
"CNN1+UBASH3B"
|
| 51 |
+
],
|
| 52 |
+
"test": [
|
| 53 |
+
"PTPN9+UBASH3B",
|
| 54 |
+
"MAPK1+PRTG",
|
| 55 |
+
"IGDCC3+MAPK1",
|
| 56 |
+
"FOXA1+HOXB9",
|
| 57 |
+
"FOSB+UBASH3B",
|
| 58 |
+
"FOSB+IKZF3",
|
| 59 |
+
"CNN1+MAPK1"
|
| 60 |
+
]
|
| 61 |
+
}
|
BioMod/gene_inverse_norman/utils.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pickle
|
| 2 |
+
import sys
|
| 3 |
+
from copy import deepcopy
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
from typing import Dict, List, Optional, Sequence, Set, Union
|
| 6 |
+
|
| 7 |
+
import pandas as pd
|
| 8 |
+
import torch
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def print_sys(message: str) -> None:
|
| 12 |
+
print(message, flush=True, file=sys.stderr)
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def strip_gene_version(gene_id: str) -> str:
|
| 16 |
+
return gene_id.split(".", 1)[0]
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def canonicalize_target_gene_id(
|
| 20 |
+
raw_gene: object,
|
| 21 |
+
symbol_to_ensembl: Dict[str, str],
|
| 22 |
+
target_gene_set: Set[str],
|
| 23 |
+
) -> Optional[str]:
|
| 24 |
+
if pd.isna(raw_gene):
|
| 25 |
+
return None
|
| 26 |
+
|
| 27 |
+
raw_gene_str = str(raw_gene).strip()
|
| 28 |
+
if not raw_gene_str:
|
| 29 |
+
return None
|
| 30 |
+
|
| 31 |
+
direct_candidates = [raw_gene_str, strip_gene_version(raw_gene_str)]
|
| 32 |
+
for candidate in direct_candidates:
|
| 33 |
+
if candidate in target_gene_set:
|
| 34 |
+
return candidate
|
| 35 |
+
|
| 36 |
+
mapped_gene = symbol_to_ensembl.get(raw_gene_str)
|
| 37 |
+
if mapped_gene is None:
|
| 38 |
+
mapped_gene = symbol_to_ensembl.get(strip_gene_version(raw_gene_str))
|
| 39 |
+
|
| 40 |
+
if mapped_gene is None or pd.isna(mapped_gene):
|
| 41 |
+
return None
|
| 42 |
+
|
| 43 |
+
mapped_gene_str = strip_gene_version(str(mapped_gene).strip())
|
| 44 |
+
return mapped_gene_str or None
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def resolve_input_gene_ids(
|
| 48 |
+
adata,
|
| 49 |
+
gene_col: str,
|
| 50 |
+
symbol_to_ensembl: Dict[str, str],
|
| 51 |
+
target_gene_set: Set[str],
|
| 52 |
+
) -> List[str]:
|
| 53 |
+
if gene_col not in adata.var.columns:
|
| 54 |
+
raise KeyError(f"gene_col '{gene_col}' not found in adata.var.columns")
|
| 55 |
+
|
| 56 |
+
raw_genes = [str(gene) if not pd.isna(gene) else "" for gene in adata.var[gene_col].tolist()]
|
| 57 |
+
resolved_gene_ids: List[str] = []
|
| 58 |
+
|
| 59 |
+
direct_hits = 0
|
| 60 |
+
mapped_hits = 0
|
| 61 |
+
unresolved = 0
|
| 62 |
+
|
| 63 |
+
for index, raw_gene in enumerate(raw_genes):
|
| 64 |
+
resolved_gene = canonicalize_target_gene_id(raw_gene, symbol_to_ensembl, target_gene_set)
|
| 65 |
+
if resolved_gene is None:
|
| 66 |
+
resolved_gene = f"unknown{index}"
|
| 67 |
+
unresolved += 1
|
| 68 |
+
elif strip_gene_version(raw_gene) == resolved_gene:
|
| 69 |
+
direct_hits += 1
|
| 70 |
+
else:
|
| 71 |
+
mapped_hits += 1
|
| 72 |
+
resolved_gene_ids.append(resolved_gene)
|
| 73 |
+
|
| 74 |
+
print_sys(
|
| 75 |
+
"Gene ID resolution summary: "
|
| 76 |
+
f"direct_hits={direct_hits}, mapped_hits={mapped_hits}, unresolved={unresolved}"
|
| 77 |
+
)
|
| 78 |
+
return resolved_gene_ids
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def reindex_tensor_universal(
|
| 82 |
+
tensor: torch.Tensor,
|
| 83 |
+
index_positions: Sequence[int],
|
| 84 |
+
dim: int,
|
| 85 |
+
filler: str = "0",
|
| 86 |
+
device: Union[str, torch.device] = "cpu",
|
| 87 |
+
) -> torch.Tensor:
|
| 88 |
+
index_tensor = torch.tensor(index_positions, device=device)
|
| 89 |
+
tensor_shape = list(tensor.shape)
|
| 90 |
+
tensor_shape[dim] = len(index_positions)
|
| 91 |
+
index_shape = [1] * len(tensor_shape)
|
| 92 |
+
index_shape[dim] = len(index_positions)
|
| 93 |
+
index_tensor = index_tensor.view(*index_shape).expand(*tensor_shape)
|
| 94 |
+
|
| 95 |
+
if filler == "0":
|
| 96 |
+
padder_shape = deepcopy(tensor_shape)
|
| 97 |
+
padder_shape[dim] = 1
|
| 98 |
+
padder = torch.zeros(padder_shape, dtype=tensor.dtype, device=device)
|
| 99 |
+
elif filler == "mean":
|
| 100 |
+
padder = tensor.mean(dim=dim, keepdim=True)
|
| 101 |
+
else:
|
| 102 |
+
raise ValueError("filler should be 0 or mean")
|
| 103 |
+
|
| 104 |
+
expanded_tensor = torch.cat((tensor, padder), dim=dim)
|
| 105 |
+
return torch.gather(expanded_tensor, dim, index_tensor)
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def parse_condition(condition: str) -> List[str]:
|
| 109 |
+
return [gene for gene in str(condition).split("+") if gene and gene != "ctrl"]
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def canonical_condition_name(condition: str) -> str:
|
| 113 |
+
genes = sorted(set(parse_condition(condition)))
|
| 114 |
+
if not genes:
|
| 115 |
+
return "ctrl"
|
| 116 |
+
return "+".join(genes)
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def project_root() -> Path:
|
| 120 |
+
return Path(__file__).resolve().parents[2]
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def default_repo_path(*parts: str) -> str:
|
| 124 |
+
return str(project_root().joinpath(*parts))
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def load_pretrained_backbone(
|
| 128 |
+
scfm_hyper_params_path: Union[str, Path],
|
| 129 |
+
scfm_ckpt_path: Union[str, Path],
|
| 130 |
+
gene2vec_path: Union[str, Path],
|
| 131 |
+
device: torch.device,
|
| 132 |
+
):
|
| 133 |
+
repo_root = project_root()
|
| 134 |
+
if str(repo_root) not in sys.path:
|
| 135 |
+
sys.path.insert(0, str(repo_root))
|
| 136 |
+
|
| 137 |
+
from BioMod.models_performer_module import (
|
| 138 |
+
PerformerEncoderOnly_Module,
|
| 139 |
+
sanitize_performer_module_config,
|
| 140 |
+
)
|
| 141 |
+
|
| 142 |
+
with open(scfm_hyper_params_path, "rb") as handle:
|
| 143 |
+
scfm_hyper_params = pickle.load(handle)
|
| 144 |
+
|
| 145 |
+
scfm_hyper_params.pop("module_embed_chunk_padded_gene_budget", None)
|
| 146 |
+
scfm_hyper_params = sanitize_performer_module_config(
|
| 147 |
+
scfm_hyper_params,
|
| 148 |
+
source=str(scfm_hyper_params_path),
|
| 149 |
+
log_fn=print_sys,
|
| 150 |
+
)
|
| 151 |
+
scfm_hyper_params["gene2vec_file"] = str(gene2vec_path)
|
| 152 |
+
|
| 153 |
+
model = PerformerEncoderOnly_Module(**scfm_hyper_params).to(device)
|
| 154 |
+
ckpt = torch.load(scfm_ckpt_path, map_location=device)
|
| 155 |
+
model_state_dict = ckpt.get("model_state_dict", ckpt)
|
| 156 |
+
filtered_state_dict = {
|
| 157 |
+
key: value
|
| 158 |
+
for key, value in model_state_dict.items()
|
| 159 |
+
if "go_conv" not in key and "G_go" not in key.lower()
|
| 160 |
+
}
|
| 161 |
+
model.load_state_dict(filtered_state_dict, strict=True)
|
| 162 |
+
for parameter in model.parameters():
|
| 163 |
+
parameter.requires_grad = False
|
| 164 |
+
model.eval()
|
| 165 |
+
|
| 166 |
+
return model, scfm_hyper_params
|
BioMod/gene_perturbation/README.md
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Adapting PerformerEncoderOnly_Module to Gene Perturbation
|
| 2 |
+
|
| 3 |
+
This document explains how to adapt the PerformerEncoderOnly_Module model to the
|
| 4 |
+
gene perturbation task (GEARS).
|
| 5 |
+
|
| 6 |
+
## Overview
|
| 7 |
+
|
| 8 |
+
This adaptation replaces the original GEARS gene perturbation model based on
|
| 9 |
+
`DualEncoderSCFM` with `PerformerEncoderOnly_Module`. The main adaptation points
|
| 10 |
+
are:
|
| 11 |
+
|
| 12 |
+
1. **Model architecture adaptation**: replace the original dual-encoder model with a module-based encoder-only model.
|
| 13 |
+
2. **Data format adaptation**: handle module-based input data.
|
| 14 |
+
3. **Embedding extraction adaptation**: recover gene-level embeddings from module encodings.
|
| 15 |
+
|
| 16 |
+
## Main Adaptations
|
| 17 |
+
|
| 18 |
+
### 1. `SCFM_Module` (`model_module.py`)
|
| 19 |
+
|
| 20 |
+
`SCFM_Module` wraps `PerformerEncoderOnly_Module` and provides these functions:
|
| 21 |
+
|
| 22 |
+
- **Input handling**: reindex input gene expression values to the scfm gene order.
|
| 23 |
+
- **Module encoding**: obtain module-level encodings from `PerformerEncoderOnly_Module`.
|
| 24 |
+
- **Gene embedding recovery**: recover gene-level embeddings from module encodings.
|
| 25 |
+
- For each gene, find all modules that contain it.
|
| 26 |
+
- Average the encodings of those modules to obtain the gene embedding.
|
| 27 |
+
- If a gene is not included in any module, use the global average as a fallback.
|
| 28 |
+
- **Output reindexing**: reindex gene embeddings back to the input gene order.
|
| 29 |
+
|
| 30 |
+
**Key adaptation**:
|
| 31 |
+
|
| 32 |
+
```python
|
| 33 |
+
# Recover gene-level embeddings from module encodings.
|
| 34 |
+
for gene_idx in range(len(self.scfm_index_positions) - 1):
|
| 35 |
+
if gene_idx in self.gene_to_modules:
|
| 36 |
+
module_indices = self.gene_to_modules[gene_idx]
|
| 37 |
+
module_embs = module_encodings[:, module_indices, :] # B, num_modules_for_gene, D
|
| 38 |
+
gene_emb = module_embs.mean(dim=1) # B, D
|
| 39 |
+
else:
|
| 40 |
+
gene_emb = module_encodings.mean(dim=1) # B, D (global average)
|
| 41 |
+
```
|
| 42 |
+
|
| 43 |
+
### 2. `GEARS_Model_Module` (`model_module.py`)
|
| 44 |
+
|
| 45 |
+
`GEARS_Model_Module` inherits the original GEARS architecture. Main changes:
|
| 46 |
+
|
| 47 |
+
- **Use `SCFM_Module`** instead of the original `SCFM_different_genes`.
|
| 48 |
+
- **Module data loading** supports loading module data from hyper-parameters or file paths.
|
| 49 |
+
- **Other components remain unchanged**, including the GNN layers and decoder.
|
| 50 |
+
|
| 51 |
+
### 3. Training Script (`train_module.py`)
|
| 52 |
+
|
| 53 |
+
The training script is based on `GEARS/train_dual_ddp_reduce_dec.py`. Main changes:
|
| 54 |
+
|
| 55 |
+
- **Model class**: use `GEARS_Model_Module` instead of `GEARS_Model_Acc`.
|
| 56 |
+
- **New arguments**:
|
| 57 |
+
- `--module_data_path`: path to one module JSON file.
|
| 58 |
+
- `--module_data_dir`: directory containing module JSON files.
|
| 59 |
+
- `--gene_mapping_path`: CSV file mapping gene symbols to Ensembl IDs.
|
| 60 |
+
|
| 61 |
+
## Usage
|
| 62 |
+
|
| 63 |
+
### 1. Prepare Data
|
| 64 |
+
|
| 65 |
+
Make sure the following files exist:
|
| 66 |
+
|
| 67 |
+
- Perturbation data, such as Norman, Adamson, or Dixit.
|
| 68 |
+
- scfm model checkpoint and hyper-parameter file.
|
| 69 |
+
- Module data in JSON format.
|
| 70 |
+
- Gene mapping file, such as `gene2ens.csv`.
|
| 71 |
+
|
| 72 |
+
### 2. Run Training
|
| 73 |
+
|
| 74 |
+
```bash
|
| 75 |
+
# DDP training on multiple GPUs.
|
| 76 |
+
python -m torch.distributed.launch --nproc_per_node=4 \
|
| 77 |
+
train_module.py \
|
| 78 |
+
--data_dir /path/to/data \
|
| 79 |
+
--data_name norman \
|
| 80 |
+
--scfm_hyper_params_path /path/to/hyper_params.pkl \
|
| 81 |
+
--scfm_ckpt_path /path/to/checkpoint.pth \
|
| 82 |
+
--module_data_dir /path/to/module_data \
|
| 83 |
+
--gene_mapping_path /path/to/gene2ens.csv \
|
| 84 |
+
--scfm_genes_list_path /path/to/selected_genes_27k.txt \
|
| 85 |
+
--scfm_gene2vec_file /path/to/selected_gene2vec_27k.npy \
|
| 86 |
+
--result_dir ./results_module \
|
| 87 |
+
--run_name norman_module_experiment \
|
| 88 |
+
--epochs 20 \
|
| 89 |
+
--batch_size 32 \
|
| 90 |
+
--lr 1e-3
|
| 91 |
+
```
|
| 92 |
+
|
| 93 |
+
### 3. Arguments
|
| 94 |
+
|
| 95 |
+
**Required arguments**:
|
| 96 |
+
|
| 97 |
+
- `--scfm_hyper_params_path`: path to the scfm model hyper-parameter file.
|
| 98 |
+
- `--scfm_ckpt_path`: path to the scfm model checkpoint.
|
| 99 |
+
- `--module_data_path` or `--module_data_dir`: module data path; provide one of them.
|
| 100 |
+
|
| 101 |
+
**Recommended arguments**:
|
| 102 |
+
|
| 103 |
+
- `--gene_mapping_path`: gene mapping file, especially when module data use gene symbols.
|
| 104 |
+
- `--scfm_genes_list_path`: scfm gene list file.
|
| 105 |
+
- `--scfm_gene2vec_file`: path to the gene2vec file.
|
| 106 |
+
|
| 107 |
+
**Optional arguments**:
|
| 108 |
+
|
| 109 |
+
- `--use_module_level`: use module-level features. This is experimental and is not fully supported at the moment.
|
| 110 |
+
- Default: False, meaning gene-level features are used and recommended.
|
| 111 |
+
- See `METHODS_COMPARISON.md` for details.
|
| 112 |
+
|
| 113 |
+
## Comparison with Original sclong
|
| 114 |
+
|
| 115 |
+
### Main Differences
|
| 116 |
+
|
| 117 |
+
1. **Model architecture**:
|
| 118 |
+
- **Original sclong**: uses `DualEncoderSCFM` and directly processes gene sequences.
|
| 119 |
+
- **Module version**: uses `PerformerEncoderOnly_Module` and processes module-based data.
|
| 120 |
+
|
| 121 |
+
2. **Data representation**:
|
| 122 |
+
- **Original sclong**: gene-level embeddings come directly from encoder outputs.
|
| 123 |
+
- **Module version**: gene-level embeddings are recovered from module encodings by mean pooling.
|
| 124 |
+
|
| 125 |
+
3. **Input format**:
|
| 126 |
+
- **Original sclong**: directly inputs gene expression vectors.
|
| 127 |
+
- **Module version**: keeps the same external input format but internally processes data with module embeddings.
|
| 128 |
+
|
| 129 |
+
### Performance Comparison
|
| 130 |
+
|
| 131 |
+
To compare model performance:
|
| 132 |
+
|
| 133 |
+
1. **Run original sclong**:
|
| 134 |
+
|
| 135 |
+
```bash
|
| 136 |
+
cd GEARS
|
| 137 |
+
python train_dual_ddp_reduce_dec.py [arguments...]
|
| 138 |
+
```
|
| 139 |
+
|
| 140 |
+
2. **Run the module version**:
|
| 141 |
+
|
| 142 |
+
```bash
|
| 143 |
+
cd performer_module/gene_perturbation
|
| 144 |
+
python train_module.py [arguments...]
|
| 145 |
+
```
|
| 146 |
+
|
| 147 |
+
3. **Compare metrics**:
|
| 148 |
+
|
| 149 |
+
- MSE (Mean Squared Error)
|
| 150 |
+
- Pearson correlation
|
| 151 |
+
- MSE and Pearson correlation for the top 20 DE genes
|
| 152 |
+
- Direction accuracy and related metrics
|
| 153 |
+
|
| 154 |
+
Results are saved under each `result_dir`. Compare these metrics to evaluate the
|
| 155 |
+
module version.
|
| 156 |
+
|
| 157 |
+
## Technical Details
|
| 158 |
+
|
| 159 |
+
### Module-to-Gene Mapping
|
| 160 |
+
|
| 161 |
+
In `SCFM_Module`, `gene_to_modules` records which modules contain each gene:
|
| 162 |
+
|
| 163 |
+
```python
|
| 164 |
+
self.gene_to_modules = {} # {gene_idx: [module_idx1, module_idx2, ...]}
|
| 165 |
+
for module_idx, module_genes in enumerate(module_sets):
|
| 166 |
+
for gene_idx in module_genes:
|
| 167 |
+
if gene_idx < len(scfm_genes):
|
| 168 |
+
if gene_idx not in self.gene_to_modules:
|
| 169 |
+
self.gene_to_modules[gene_idx] = []
|
| 170 |
+
self.gene_to_modules[gene_idx].append(module_idx)
|
| 171 |
+
```
|
| 172 |
+
|
| 173 |
+
### Gene Embedding Recovery Strategy
|
| 174 |
+
|
| 175 |
+
For each gene:
|
| 176 |
+
|
| 177 |
+
1. If the gene appears in some modules, average the encodings of those modules.
|
| 178 |
+
2. If the gene does not appear in any module, use the global average of all modules as a fallback.
|
| 179 |
+
|
| 180 |
+
This strategy:
|
| 181 |
+
|
| 182 |
+
- Makes full use of module information.
|
| 183 |
+
- Provides a reasonable embedding for genes absent from all modules.
|
| 184 |
+
- Keeps embedding dimensionality similar to the original model.
|
| 185 |
+
|
| 186 |
+
## File Structure
|
| 187 |
+
|
| 188 |
+
```text
|
| 189 |
+
BioMod/gene_perturbation/ # Perturbation response prediction: processing, training and inference
|
| 190 |
+
├── data_utils_local.py
|
| 191 |
+
├── gears_reduce_local.py
|
| 192 |
+
├── inference_local.py
|
| 193 |
+
├── model_module.py
|
| 194 |
+
├── pertdata_local.py
|
| 195 |
+
├── README.md
|
| 196 |
+
├── scfm_utils_local.py
|
| 197 |
+
├── train_module.py
|
| 198 |
+
└── utils.py
|
| 199 |
+
```
|
| 200 |
+
|
| 201 |
+
## Notes
|
| 202 |
+
|
| 203 |
+
1. **Module data format**: make sure the gene indices or names in the module data match the scfm gene list.
|
| 204 |
+
2. **Gene mapping**: provide `gene_mapping_path` if the module data use gene symbols.
|
| 205 |
+
3. **Memory usage**: the module version may require more memory because it maintains the module-to-gene mapping.
|
| 206 |
+
4. **Training stability**: use the same learning rate and training strategy as original sclong when possible.
|
| 207 |
+
|
| 208 |
+
## Troubleshooting
|
| 209 |
+
|
| 210 |
+
### Issue 1: Module Data Not Found
|
| 211 |
+
|
| 212 |
+
- Check whether `--module_data_path` or `--module_data_dir` is correct.
|
| 213 |
+
- Make sure the module data file exists and has the expected format.
|
| 214 |
+
|
| 215 |
+
### Issue 2: Gene Mapping Failed
|
| 216 |
+
|
| 217 |
+
- Check whether `--gene_mapping_path` is correct.
|
| 218 |
+
- Make sure the gene mapping file is a CSV containing `Approved symbol` and `Ensembl gene ID` columns.
|
| 219 |
+
|
| 220 |
+
### Issue 3: Model Loading Failed
|
| 221 |
+
|
| 222 |
+
- Check whether the checkpoint path is correct.
|
| 223 |
+
- Make sure `hyper_params` contains the required module information.
|
| 224 |
+
|
| 225 |
+
## Gene-Based vs Module-Based Methods
|
| 226 |
+
|
| 227 |
+
This implementation supports two feature extraction modes controlled by
|
| 228 |
+
`--use_module_level`.
|
| 229 |
+
|
| 230 |
+
### Method 1: Gene-Based (Default, Recommended)
|
| 231 |
+
|
| 232 |
+
```bash
|
| 233 |
+
# Default mode; do not pass --use_module_level or set it to False.
|
| 234 |
+
python train_module.py [other arguments...]
|
| 235 |
+
```
|
| 236 |
+
|
| 237 |
+
**Characteristics**:
|
| 238 |
+
|
| 239 |
+
- Recovers gene-level embeddings from module encodings.
|
| 240 |
+
- Fully compatible with the original GEARS architecture.
|
| 241 |
+
- Downstream tasks operate at gene level, including the GNN and decoder.
|
| 242 |
+
- Outputs gene-level predictions.
|
| 243 |
+
|
| 244 |
+
### Method 2: Module-Based (Experimental, Not Fully Implemented)
|
| 245 |
+
|
| 246 |
+
```bash
|
| 247 |
+
# Enable module-level mode.
|
| 248 |
+
python train_module.py --use_module_level [other arguments...]
|
| 249 |
+
```
|
| 250 |
+
|
| 251 |
+
**Characteristics**:
|
| 252 |
+
|
| 253 |
+
- Directly uses module-level encodings.
|
| 254 |
+
- Preserves the full module-level information.
|
| 255 |
+
- **Note**: module-level downstream tasks are not fully implemented in the current version.
|
| 256 |
+
- Requires redesigning the downstream architecture, including the GNN and decoder.
|
| 257 |
+
|
| 258 |
+
See `METHODS_COMPARISON.md` for the detailed comparison and rationale.
|
| 259 |
+
|
| 260 |
+
## References
|
| 261 |
+
|
| 262 |
+
- Original GEARS code: `GEARS/gears_reduce/`
|
| 263 |
+
- PerformerEncoderOnly_Module model: `performer_module/models_performer_module.py`
|
| 264 |
+
- Module data processing: `performer_module/module_dataset.py`
|
| 265 |
+
- Method comparison: `METHODS_COMPARISON.md`
|
BioMod/gene_perturbation/data_utils_local.py
ADDED
|
@@ -0,0 +1,501 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
================================================================================
|
| 3 |
+
Data processing utility functions (data_utils_local.py)
|
| 4 |
+
================================================================================
|
| 5 |
+
|
| 6 |
+
[Module function]
|
| 7 |
+
Provides tool functions for genetic perturbation data preprocessing, including:
|
| 8 |
+
1. Calculation of differentially expressed genes (DE genes)
|
| 9 |
+
2. Data set partitioning strategy
|
| 10 |
+
3. Dropout gene identification
|
| 11 |
+
|
| 12 |
+
[Key concepts]
|
| 13 |
+
- Differentially expressed genes: genes with the most significant expression changes after perturbation, used to evaluate model prediction quality
|
| 14 |
+
- Dropout: Common technical noise in single-cell sequencing, some genes cannot be detected in some cells
|
| 15 |
+
- Simulation split: simulate the data division of real scenarios and test the model’s generalization ability to unseen disturbances
|
| 16 |
+
================================================================================
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
import pandas as pd
|
| 20 |
+
import numpy as np
|
| 21 |
+
import scanpy as sc
|
| 22 |
+
from random import shuffle
|
| 23 |
+
sc.settings.verbosity = 0
|
| 24 |
+
from tqdm import tqdm
|
| 25 |
+
import requests
|
| 26 |
+
import os, sys
|
| 27 |
+
|
| 28 |
+
import warnings
|
| 29 |
+
warnings.filterwarnings("ignore")
|
| 30 |
+
|
| 31 |
+
from utils import parse_single_pert, parse_combo_pert, parse_any_pert, print_sys
|
| 32 |
+
|
| 33 |
+
def rank_genes_groups_by_cov(
|
| 34 |
+
adata,
|
| 35 |
+
groupby,
|
| 36 |
+
control_group,
|
| 37 |
+
covariate,
|
| 38 |
+
pool_doses=False,
|
| 39 |
+
n_genes=50,
|
| 40 |
+
rankby_abs=True,
|
| 41 |
+
key_added='rank_genes_groups_cov',
|
| 42 |
+
return_dict=False,
|
| 43 |
+
):
|
| 44 |
+
"""
|
| 45 |
+
Calculate differentially expressed genes grouped by covariates
|
| 46 |
+
|
| 47 |
+
[Function]
|
| 48 |
+
For each cell type (covariate), the differentially expressed genes of each perturbation condition relative to the control group were calculated.
|
| 49 |
+
|
| 50 |
+
[Parameter description]
|
| 51 |
+
- groupby: grouping variable, usually disturbance condition
|
| 52 |
+
- control_group: control group name
|
| 53 |
+
- covariate: covariate, usually cell type
|
| 54 |
+
- rankby_abs: Sort by absolute value (both up-regulated and down-regulated genes are considered)
|
| 55 |
+
|
| 56 |
+
[Output]
|
| 57 |
+
The result is saved in adata.uns[key_added]
|
| 58 |
+
"""
|
| 59 |
+
gene_dict = {}
|
| 60 |
+
cov_categories = adata.obs[covariate].unique()
|
| 61 |
+
for cov_cat in cov_categories:
|
| 62 |
+
#name of the control group in the groupby obs column
|
| 63 |
+
control_group_cov = '_'.join([cov_cat, control_group])
|
| 64 |
+
|
| 65 |
+
#subset adata to cells belonging to a covariate category
|
| 66 |
+
adata_cov = adata[adata.obs[covariate]==cov_cat]
|
| 67 |
+
|
| 68 |
+
#compute DEGs
|
| 69 |
+
sc.tl.rank_genes_groups(
|
| 70 |
+
adata_cov,
|
| 71 |
+
groupby=groupby,
|
| 72 |
+
reference=control_group_cov,
|
| 73 |
+
rankby_abs=rankby_abs,
|
| 74 |
+
n_genes=n_genes,
|
| 75 |
+
use_raw=False
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
#add entries to dictionary of gene sets
|
| 79 |
+
de_genes = pd.DataFrame(adata_cov.uns['rank_genes_groups']['names'])
|
| 80 |
+
for group in de_genes:
|
| 81 |
+
gene_dict[group] = de_genes[group].tolist()
|
| 82 |
+
|
| 83 |
+
adata.uns[key_added] = gene_dict
|
| 84 |
+
|
| 85 |
+
if return_dict:
|
| 86 |
+
return gene_dict
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def get_DE_genes(adata):
|
| 90 |
+
"""
|
| 91 |
+
Calculate differentially expressed genes
|
| 92 |
+
|
| 93 |
+
[Function]
|
| 94 |
+
Differentially expressed genes relative to the control group were calculated for each perturbation condition.
|
| 95 |
+
This is one of the core indicators of GEARS evaluation: whether the DE genes predicted by the model are consistent with reality.
|
| 96 |
+
|
| 97 |
+
[Processing Process]
|
| 98 |
+
1. Construct the name of the disturbance condition (cell_type + condition + dose)
|
| 99 |
+
2. Calculate DE genes grouped by cell type
|
| 100 |
+
"""
|
| 101 |
+
adata.obs.loc[:, 'dose_val'] = adata.obs.condition.apply(lambda x: '1+1' if len(x.split('+')) == 2 else '1')
|
| 102 |
+
adata.obs.loc[:, 'control'] = adata.obs.condition.apply(lambda x: 0 if len(x.split('+')) == 2 else 1)
|
| 103 |
+
adata.obs.loc[:, 'condition_name'] = adata.obs.apply(lambda x: '_'.join([x.cell_type, x.condition, x.dose_val]), axis = 1)
|
| 104 |
+
rank_genes_groups_by_cov(adata,
|
| 105 |
+
groupby='condition_name',
|
| 106 |
+
covariate='cell_type',
|
| 107 |
+
control_group='ctrl_1',
|
| 108 |
+
n_genes=len(adata.var),
|
| 109 |
+
key_added = 'rank_genes_groups_cov_all')
|
| 110 |
+
return adata
|
| 111 |
+
|
| 112 |
+
def get_dropout_non_zero_genes(adata):
|
| 113 |
+
"""
|
| 114 |
+
Identify dropout and non-zero expression genes
|
| 115 |
+
|
| 116 |
+
[Function]
|
| 117 |
+
There are a large number of dropouts (genes that should be expressed but are not detected) in single-cell sequencing.
|
| 118 |
+
This function recognizes:
|
| 119 |
+
1. Non-zero expressed genes: genes detected in perturbed cells
|
| 120 |
+
2. Non-dropout genes: genes that are 0 in both the perturbation and control groups (may be truly not expressed)
|
| 121 |
+
|
| 122 |
+
[Why is it important]
|
| 123 |
+
- Dropout genes should be excluded from the evaluation as they introduce unpredictable noise
|
| 124 |
+
- top_non_dropout_de_20: used to evaluate the prediction ability of the model on reliable genes
|
| 125 |
+
"""
|
| 126 |
+
# calculate mean expression for each condition
|
| 127 |
+
unique_conditions = adata.obs.condition.unique()
|
| 128 |
+
conditions2index = {}
|
| 129 |
+
for i in unique_conditions:
|
| 130 |
+
conditions2index[i] = np.where(adata.obs.condition == i)[0]
|
| 131 |
+
|
| 132 |
+
condition2mean_expression = {}
|
| 133 |
+
for i, j in conditions2index.items():
|
| 134 |
+
condition2mean_expression[i] = np.mean(adata.X[j], axis = 0)
|
| 135 |
+
pert_list = np.array(list(condition2mean_expression.keys()))
|
| 136 |
+
mean_expression = np.array(list(condition2mean_expression.values())).reshape(len(adata.obs.condition.unique()), adata.X.toarray().shape[1])
|
| 137 |
+
ctrl = mean_expression[np.where(pert_list == 'ctrl')[0]]
|
| 138 |
+
|
| 139 |
+
## in silico modeling and upperbounding
|
| 140 |
+
pert2pert_full_id = dict(adata.obs[['condition', 'condition_name']].values)
|
| 141 |
+
pert_full_id2pert = dict(adata.obs[['condition_name', 'condition']].values)
|
| 142 |
+
|
| 143 |
+
gene_id2idx = dict(zip(adata.var.index.values, range(len(adata.var))))
|
| 144 |
+
gene_idx2id = dict(zip(range(len(adata.var)), adata.var.index.values))
|
| 145 |
+
|
| 146 |
+
non_zeros_gene_idx = {}
|
| 147 |
+
top_non_dropout_de_20 = {}
|
| 148 |
+
top_non_zero_de_20 = {}
|
| 149 |
+
non_dropout_gene_idx = {}
|
| 150 |
+
|
| 151 |
+
for pert in adata.uns['rank_genes_groups_cov_all'].keys():
|
| 152 |
+
p = pert_full_id2pert[pert]
|
| 153 |
+
X = np.mean(adata[adata.obs.condition == p].X, axis = 0)
|
| 154 |
+
|
| 155 |
+
# Identify non-zero and zero expressed genes
|
| 156 |
+
non_zero = np.where(np.array(X)[0] != 0)[0]
|
| 157 |
+
zero = np.where(np.array(X)[0] == 0)[0]
|
| 158 |
+
# True dropout: both disturbance and control groups are 0
|
| 159 |
+
true_zeros = np.intersect1d(zero, np.where(np.array(ctrl)[0] == 0)[0])
|
| 160 |
+
non_dropouts = np.concatenate((non_zero, true_zeros))
|
| 161 |
+
|
| 162 |
+
# Get non-dropout genes among top DE genes
|
| 163 |
+
top = adata.uns['rank_genes_groups_cov_all'][pert]
|
| 164 |
+
gene_idx_top = [gene_id2idx[i] for i in top]
|
| 165 |
+
|
| 166 |
+
non_dropout_20 = [i for i in gene_idx_top if i in non_dropouts][:20]
|
| 167 |
+
non_dropout_20_gene_id = [gene_idx2id[i] for i in non_dropout_20]
|
| 168 |
+
|
| 169 |
+
non_zero_20 = [i for i in gene_idx_top if i in non_zero][:20]
|
| 170 |
+
non_zero_20_gene_id = [gene_idx2id[i] for i in non_zero_20]
|
| 171 |
+
|
| 172 |
+
non_zeros_gene_idx[pert] = np.sort(non_zero)
|
| 173 |
+
non_dropout_gene_idx[pert] = np.sort(non_dropouts)
|
| 174 |
+
top_non_dropout_de_20[pert] = np.array(non_dropout_20_gene_id)
|
| 175 |
+
top_non_zero_de_20[pert] = np.array(non_zero_20_gene_id)
|
| 176 |
+
|
| 177 |
+
non_zero = np.where(np.array(X)[0] != 0)[0]
|
| 178 |
+
zero = np.where(np.array(X)[0] == 0)[0]
|
| 179 |
+
true_zeros = np.intersect1d(zero, np.where(np.array(ctrl)[0] == 0)[0])
|
| 180 |
+
non_dropouts = np.concatenate((non_zero, true_zeros))
|
| 181 |
+
|
| 182 |
+
adata.uns['top_non_dropout_de_20'] = top_non_dropout_de_20
|
| 183 |
+
adata.uns['non_dropout_gene_idx'] = non_dropout_gene_idx
|
| 184 |
+
adata.uns['non_zeros_gene_idx'] = non_zeros_gene_idx
|
| 185 |
+
adata.uns['top_non_zero_de_20'] = top_non_zero_de_20
|
| 186 |
+
|
| 187 |
+
return adata
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
class DataSplitter():
|
| 191 |
+
"""
|
| 192 |
+
Data classification class
|
| 193 |
+
|
| 194 |
+
[Function]
|
| 195 |
+
Handle training/validation/test partitioning of genetically perturbed datasets.
|
| 196 |
+
Different partitioning strategies evaluate the capabilities of different aspects of the model.
|
| 197 |
+
|
| 198 |
+
[Detailed explanation of partition strategy]
|
| 199 |
+
- simulation: the most comprehensive division, the test set includes:
|
| 200 |
+
* unseen_single: Unseen single gene perturbation
|
| 201 |
+
* combo_seen0: a combination that neither gene has seen
|
| 202 |
+
* combo_seen1: the combination seen by one of the genes
|
| 203 |
+
* combo_seen2: Both genes have been seen but the combination has not been seen
|
| 204 |
+
"""
|
| 205 |
+
def __init__(self, adata, split_type='single', seen=0):
|
| 206 |
+
self.adata = adata
|
| 207 |
+
self.split_type = split_type
|
| 208 |
+
self.seen = seen
|
| 209 |
+
|
| 210 |
+
def split_data(self, test_size=0.1, test_pert_genes=None,
|
| 211 |
+
test_perts=None, split_name='split', seed=None, val_size = 0.1,
|
| 212 |
+
train_gene_set_size = 0.75, combo_seen2_train_frac = 0.75, only_test_set_perts = False):
|
| 213 |
+
"""
|
| 214 |
+
Perform data partitioning
|
| 215 |
+
|
| 216 |
+
[Key parameters]
|
| 217 |
+
- train_gene_set_size: training set gene proportion
|
| 218 |
+
- combo_seen2_train_frac: The proportion of combo_seen2 used for training
|
| 219 |
+
"""
|
| 220 |
+
np.random.seed(seed=seed)
|
| 221 |
+
unique_perts = [p for p in self.adata.obs['condition'].unique() if
|
| 222 |
+
p != 'ctrl']
|
| 223 |
+
|
| 224 |
+
if self.split_type == 'simulation':
|
| 225 |
+
train, test, test_subgroup = self.get_simulation_split(unique_perts,
|
| 226 |
+
train_gene_set_size,
|
| 227 |
+
combo_seen2_train_frac,
|
| 228 |
+
seed, test_perts, only_test_set_perts)
|
| 229 |
+
train, val, val_subgroup = self.get_simulation_split(train,
|
| 230 |
+
0.9,
|
| 231 |
+
0.9,
|
| 232 |
+
seed)
|
| 233 |
+
## adding back ctrl to train...
|
| 234 |
+
train.append('ctrl')
|
| 235 |
+
elif self.split_type == 'simulation_single':
|
| 236 |
+
train, test, test_subgroup = self.get_simulation_split_single(unique_perts,
|
| 237 |
+
train_gene_set_size,
|
| 238 |
+
seed, test_perts, only_test_set_perts)
|
| 239 |
+
train, val, val_subgroup = self.get_simulation_split_single(train,
|
| 240 |
+
0.9,
|
| 241 |
+
seed)
|
| 242 |
+
elif self.split_type == 'no_test':
|
| 243 |
+
print('test_pert_genes',str(test_pert_genes))
|
| 244 |
+
print('test_perts',str(test_perts))
|
| 245 |
+
|
| 246 |
+
train, val = self.get_split_list(unique_perts,
|
| 247 |
+
test_pert_genes=test_pert_genes,
|
| 248 |
+
test_perts=test_perts,
|
| 249 |
+
test_size=test_size)
|
| 250 |
+
else:
|
| 251 |
+
train, test = self.get_split_list(unique_perts,
|
| 252 |
+
test_pert_genes=test_pert_genes,
|
| 253 |
+
test_perts=test_perts,
|
| 254 |
+
test_size=test_size)
|
| 255 |
+
|
| 256 |
+
train, val = self.get_split_list(train, test_size=val_size)
|
| 257 |
+
|
| 258 |
+
map_dict = {x: 'train' for x in train}
|
| 259 |
+
map_dict.update({x: 'val' for x in val})
|
| 260 |
+
if self.split_type != 'no_test':
|
| 261 |
+
map_dict.update({x: 'test' for x in test})
|
| 262 |
+
map_dict.update({'ctrl': 'train'})
|
| 263 |
+
|
| 264 |
+
self.adata.obs[split_name] = self.adata.obs['condition'].map(map_dict)
|
| 265 |
+
|
| 266 |
+
if self.split_type == 'simulation':
|
| 267 |
+
return self.adata, {'test_subgroup': test_subgroup,
|
| 268 |
+
'val_subgroup': val_subgroup
|
| 269 |
+
}
|
| 270 |
+
else:
|
| 271 |
+
return self.adata
|
| 272 |
+
|
| 273 |
+
def get_simulation_split_single(self, pert_list, train_gene_set_size = 0.85, seed = 1, test_set_perts = None, only_test_set_perts = False):
|
| 274 |
+
unique_pert_genes = self.get_genes_from_perts(pert_list)
|
| 275 |
+
|
| 276 |
+
pert_train = []
|
| 277 |
+
pert_test = []
|
| 278 |
+
np.random.seed(seed=seed)
|
| 279 |
+
|
| 280 |
+
if only_test_set_perts and (test_set_perts is not None):
|
| 281 |
+
ood_genes = np.array(test_set_perts)
|
| 282 |
+
train_gene_candidates = np.setdiff1d(unique_pert_genes, ood_genes)
|
| 283 |
+
else:
|
| 284 |
+
## a pre-specified list of genes
|
| 285 |
+
train_gene_candidates = np.random.choice(unique_pert_genes,
|
| 286 |
+
int(len(unique_pert_genes) * train_gene_set_size), replace = False)
|
| 287 |
+
|
| 288 |
+
if test_set_perts is not None:
|
| 289 |
+
num_overlap = len(np.intersect1d(train_gene_candidates, test_set_perts))
|
| 290 |
+
train_gene_candidates = train_gene_candidates[~np.isin(train_gene_candidates, test_set_perts)]
|
| 291 |
+
ood_genes_exclude_test_set = np.setdiff1d(unique_pert_genes, np.union1d(train_gene_candidates, test_set_perts))
|
| 292 |
+
train_set_addition = np.random.choice(ood_genes_exclude_test_set, num_overlap, replace = False)
|
| 293 |
+
train_gene_candidates = np.concatenate((train_gene_candidates, train_set_addition))
|
| 294 |
+
|
| 295 |
+
## ood genes
|
| 296 |
+
ood_genes = np.setdiff1d(unique_pert_genes, train_gene_candidates)
|
| 297 |
+
|
| 298 |
+
pert_single_train = self.get_perts_from_genes(train_gene_candidates, pert_list,'single')
|
| 299 |
+
unseen_single = self.get_perts_from_genes(ood_genes, pert_list, 'single')
|
| 300 |
+
assert len(unseen_single) + len(pert_single_train) == len(pert_list)
|
| 301 |
+
|
| 302 |
+
return pert_single_train, unseen_single, {'unseen_single': unseen_single}
|
| 303 |
+
|
| 304 |
+
def get_simulation_split(self, pert_list, train_gene_set_size = 0.85, combo_seen2_train_frac = 0.85, seed = 1, test_set_perts = None, only_test_set_perts = False):
|
| 305 |
+
"""
|
| 306 |
+
Create data partitions that simulate real-world scenarios
|
| 307 |
+
|
| 308 |
+
[Division strategy]
|
| 309 |
+
This is the most important evaluation method in the GEARS paper, testing the model's generalization ability to unseen perturbations.
|
| 310 |
+
|
| 311 |
+
The test set contains the following subtypes:
|
| 312 |
+
1. unseen_single: Single gene perturbation that has never been seen during training
|
| 313 |
+
2. combo_seen0: Combination perturbation that neither gene has seen
|
| 314 |
+
3. combo_seen1: Combination perturbation seen by one of the genes
|
| 315 |
+
4. combo_seen2: Both genes have been seen, but this specific combination has not been seen
|
| 316 |
+
|
| 317 |
+
[Assessment significance]
|
| 318 |
+
- unseen_single: Evaluate the model's predictive ability for novel genetic perturbations
|
| 319 |
+
- combo_seen0: Evaluate model predictions for completely unknown combinations (most difficult)
|
| 320 |
+
- combo_seen1: Evaluate whether the model can infer combination effects from single gene knowledge
|
| 321 |
+
- combo_seen2: Evaluate whether the model can infer new combinations from known combinations
|
| 322 |
+
"""
|
| 323 |
+
unique_pert_genes = self.get_genes_from_perts(pert_list)
|
| 324 |
+
|
| 325 |
+
pert_train = []
|
| 326 |
+
pert_test = []
|
| 327 |
+
np.random.seed(seed=seed)
|
| 328 |
+
|
| 329 |
+
if only_test_set_perts and (test_set_perts is not None):
|
| 330 |
+
ood_genes = np.array(test_set_perts)
|
| 331 |
+
train_gene_candidates = np.setdiff1d(unique_pert_genes, ood_genes)
|
| 332 |
+
else:
|
| 333 |
+
# Randomly select genes from the training set
|
| 334 |
+
train_gene_candidates = np.random.choice(unique_pert_genes,
|
| 335 |
+
int(len(unique_pert_genes) * train_gene_set_size), replace = False)
|
| 336 |
+
|
| 337 |
+
if test_set_perts is not None:
|
| 338 |
+
num_overlap = len(np.intersect1d(train_gene_candidates, test_set_perts))
|
| 339 |
+
train_gene_candidates = train_gene_candidates[~np.isin(train_gene_candidates, test_set_perts)]
|
| 340 |
+
ood_genes_exclude_test_set = np.setdiff1d(unique_pert_genes, np.union1d(train_gene_candidates, test_set_perts))
|
| 341 |
+
train_set_addition = np.random.choice(ood_genes_exclude_test_set, num_overlap, replace = False)
|
| 342 |
+
train_gene_candidates = np.concatenate((train_gene_candidates, train_set_addition))
|
| 343 |
+
|
| 344 |
+
# OOD genes: genes not in the training set
|
| 345 |
+
ood_genes = np.setdiff1d(unique_pert_genes, train_gene_candidates)
|
| 346 |
+
|
| 347 |
+
# Obtain single gene perturbations of the training set
|
| 348 |
+
pert_single_train = self.get_perts_from_genes(train_gene_candidates, pert_list,'single')
|
| 349 |
+
pert_combo = self.get_perts_from_genes(train_gene_candidates, pert_list,'combo')
|
| 350 |
+
pert_train.extend(pert_single_train)
|
| 351 |
+
|
| 352 |
+
# ============================================================
|
| 353 |
+
# [Key] Divide test subtypes of combined disturbances
|
| 354 |
+
# ============================================================
|
| 355 |
+
# combo_seen1: One of the genes is in the training set
|
| 356 |
+
combo_seen1 = [x for x in pert_combo if len([t for t in x.split('+') if
|
| 357 |
+
t in train_gene_candidates]) == 1]
|
| 358 |
+
pert_test.extend(combo_seen1)
|
| 359 |
+
|
| 360 |
+
pert_combo = np.setdiff1d(pert_combo, combo_seen1)
|
| 361 |
+
# combo_seen2: Both genes are in the training set, but this combination is not in the training set
|
| 362 |
+
np.random.seed(seed=seed)
|
| 363 |
+
pert_combo_train = np.random.choice(pert_combo, int(len(pert_combo) * combo_seen2_train_frac), replace = False)
|
| 364 |
+
|
| 365 |
+
combo_seen2 = np.setdiff1d(pert_combo, pert_combo_train).tolist()
|
| 366 |
+
pert_test.extend(combo_seen2)
|
| 367 |
+
pert_train.extend(pert_combo_train)
|
| 368 |
+
|
| 369 |
+
# unseen_single: Unseen single gene perturbation
|
| 370 |
+
unseen_single = self.get_perts_from_genes(ood_genes, pert_list, 'single')
|
| 371 |
+
combo_ood = self.get_perts_from_genes(ood_genes, pert_list, 'combo')
|
| 372 |
+
pert_test.extend(unseen_single)
|
| 373 |
+
|
| 374 |
+
# combo_seen0: Neither gene has been seen
|
| 375 |
+
combo_seen0 = [x for x in combo_ood if len([t for t in x.split('+') if
|
| 376 |
+
t in train_gene_candidates]) == 0]
|
| 377 |
+
pert_test.extend(combo_seen0)
|
| 378 |
+
|
| 379 |
+
# Verify partition integrity
|
| 380 |
+
assert len(combo_seen1) + len(combo_seen0) + len(unseen_single) + len(pert_train) + len(combo_seen2) == len(pert_list)
|
| 381 |
+
|
| 382 |
+
return pert_train, pert_test, {'combo_seen0': combo_seen0,
|
| 383 |
+
'combo_seen1': combo_seen1,
|
| 384 |
+
'combo_seen2': combo_seen2,
|
| 385 |
+
'unseen_single': unseen_single}
|
| 386 |
+
|
| 387 |
+
def get_split_list(self, pert_list, test_size=0.1,
|
| 388 |
+
test_pert_genes=None, test_perts=None,
|
| 389 |
+
hold_outs=True):
|
| 390 |
+
"""
|
| 391 |
+
Splits a given perturbation list into train and test with no shared
|
| 392 |
+
perturbations
|
| 393 |
+
"""
|
| 394 |
+
|
| 395 |
+
single_perts = [p for p in pert_list if 'ctrl' in p and p != 'ctrl']
|
| 396 |
+
combo_perts = [p for p in pert_list if 'ctrl' not in p]
|
| 397 |
+
unique_pert_genes = self.get_genes_from_perts(pert_list)
|
| 398 |
+
hold_out = []
|
| 399 |
+
|
| 400 |
+
if test_pert_genes is None:
|
| 401 |
+
test_pert_genes = np.random.choice(unique_pert_genes,
|
| 402 |
+
int(len(single_perts) * test_size))
|
| 403 |
+
|
| 404 |
+
# Only single unseen genes (in test set)
|
| 405 |
+
# Train contains both single and combos
|
| 406 |
+
if self.split_type == 'single' or self.split_type == 'single_only':
|
| 407 |
+
test_perts = self.get_perts_from_genes(test_pert_genes, pert_list,
|
| 408 |
+
'single')
|
| 409 |
+
if self.split_type == 'single_only':
|
| 410 |
+
# Discard all combos
|
| 411 |
+
hold_out = combo_perts
|
| 412 |
+
else:
|
| 413 |
+
# Discard only those combos which contain test genes
|
| 414 |
+
hold_out = self.get_perts_from_genes(test_pert_genes, pert_list,
|
| 415 |
+
'combo')
|
| 416 |
+
|
| 417 |
+
elif self.split_type == 'combo':
|
| 418 |
+
if self.seen == 0:
|
| 419 |
+
# NOTE: This can reduce the dataset size!
|
| 420 |
+
# To prevent this set 'holdouts' to False, this will cause
|
| 421 |
+
# the test set to have some perturbations with 1 gene seen
|
| 422 |
+
single_perts = self.get_perts_from_genes(test_pert_genes,
|
| 423 |
+
pert_list, 'single')
|
| 424 |
+
combo_perts = self.get_perts_from_genes(test_pert_genes,
|
| 425 |
+
pert_list, 'combo')
|
| 426 |
+
|
| 427 |
+
if hold_outs:
|
| 428 |
+
# This just checks that none of the combos have 2 seen genes
|
| 429 |
+
hold_out = [t for t in combo_perts if
|
| 430 |
+
len([t for t in t.split('+') if
|
| 431 |
+
t not in test_pert_genes]) > 0]
|
| 432 |
+
combo_perts = [c for c in combo_perts if c not in hold_out]
|
| 433 |
+
test_perts = single_perts + combo_perts
|
| 434 |
+
|
| 435 |
+
elif self.seen == 1:
|
| 436 |
+
# NOTE: This can reduce the dataset size!
|
| 437 |
+
# To prevent this set 'holdouts' to False, this will cause
|
| 438 |
+
# the test set to have some perturbations with 2 genes seen
|
| 439 |
+
single_perts = self.get_perts_from_genes(test_pert_genes,
|
| 440 |
+
pert_list, 'single')
|
| 441 |
+
combo_perts = self.get_perts_from_genes(test_pert_genes,
|
| 442 |
+
pert_list, 'combo')
|
| 443 |
+
|
| 444 |
+
if hold_outs:
|
| 445 |
+
# This just checks that none of the combos have 2 seen genes
|
| 446 |
+
hold_out = [t for t in combo_perts if
|
| 447 |
+
len([t for t in t.split('+') if
|
| 448 |
+
t not in test_pert_genes]) > 1]
|
| 449 |
+
combo_perts = [c for c in combo_perts if c not in hold_out]
|
| 450 |
+
test_perts = single_perts + combo_perts
|
| 451 |
+
|
| 452 |
+
elif self.seen == 2:
|
| 453 |
+
if test_perts is None:
|
| 454 |
+
test_perts = np.random.choice(combo_perts,
|
| 455 |
+
int(len(combo_perts) * test_size))
|
| 456 |
+
else:
|
| 457 |
+
test_perts = np.array(test_perts)
|
| 458 |
+
else:
|
| 459 |
+
if test_perts is None:
|
| 460 |
+
test_perts = np.random.choice(combo_perts,
|
| 461 |
+
int(len(combo_perts) * test_size))
|
| 462 |
+
|
| 463 |
+
train_perts = [p for p in pert_list if (p not in test_perts)
|
| 464 |
+
and (p not in hold_out)]
|
| 465 |
+
return train_perts, test_perts
|
| 466 |
+
|
| 467 |
+
def get_perts_from_genes(self, genes, pert_list, type_='both'):
|
| 468 |
+
"""
|
| 469 |
+
Returns all single/combo/both perturbations that include a gene
|
| 470 |
+
"""
|
| 471 |
+
|
| 472 |
+
single_perts = [p for p in pert_list if ('ctrl' in p) and (p != 'ctrl')]
|
| 473 |
+
combo_perts = [p for p in pert_list if 'ctrl' not in p]
|
| 474 |
+
|
| 475 |
+
perts = []
|
| 476 |
+
|
| 477 |
+
if type_ == 'single':
|
| 478 |
+
pert_candidate_list = single_perts
|
| 479 |
+
elif type_ == 'combo':
|
| 480 |
+
pert_candidate_list = combo_perts
|
| 481 |
+
elif type_ == 'both':
|
| 482 |
+
pert_candidate_list = pert_list
|
| 483 |
+
|
| 484 |
+
for p in pert_candidate_list:
|
| 485 |
+
for g in genes:
|
| 486 |
+
if g in parse_any_pert(p):
|
| 487 |
+
perts.append(p)
|
| 488 |
+
break
|
| 489 |
+
return perts
|
| 490 |
+
|
| 491 |
+
def get_genes_from_perts(self, perts):
|
| 492 |
+
"""
|
| 493 |
+
Returns list of genes involved in a given perturbation list
|
| 494 |
+
"""
|
| 495 |
+
|
| 496 |
+
if type(perts) is str:
|
| 497 |
+
perts = [perts]
|
| 498 |
+
gene_list = [p.split('+') for p in np.unique(perts)]
|
| 499 |
+
gene_list = [item for sublist in gene_list for item in sublist]
|
| 500 |
+
gene_list = [g for g in gene_list if g != 'ctrl']
|
| 501 |
+
return np.unique(gene_list)
|
BioMod/gene_perturbation/gears_reduce_local.py
ADDED
|
@@ -0,0 +1,761 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
================================================================================
|
| 3 |
+
GEARS training and evaluation controller (gears_reduce_local.py)
|
| 4 |
+
================================================================================
|
| 5 |
+
|
| 6 |
+
[Module function]
|
| 7 |
+
This is the core controller of the genetic perturbation mission, responsible for:
|
| 8 |
+
1. Data loader configuration
|
| 9 |
+
2. Model initialization and hyperparameter management
|
| 10 |
+
3. Training loop and validation
|
| 11 |
+
4. Test evaluation and result analysis
|
| 12 |
+
|
| 13 |
+
[Reading suggestions]
|
| 14 |
+
1. First look at `__init__`: understand the data loader configuration and key variable initialization
|
| 15 |
+
2. Look again at `model_initialize`: decision graph construction, model classes, hyperparameters and freezing strategies
|
| 16 |
+
3. Then look at `train`: determining optimization goals, gradient accumulation, verification indicators and optimal model selection criteria
|
| 17 |
+
4. Finally, look at the test section: determine the final report indicators and simulation subset analysis caliber
|
| 18 |
+
|
| 19 |
+
[Key parts that affect the results]
|
| 20 |
+
- dict_filter: training loss is only calculated on non-zero genes
|
| 21 |
+
- direction_lambda: direction consistency loss weight
|
| 22 |
+
- finetune_method: freeze vs full parameter fine-tuning
|
| 23 |
+
- G_coexpress/G_go: Genetic relationship graph construction parameters
|
| 24 |
+
- accumulation_steps: equivalent batch size of gradient accumulation influence
|
| 25 |
+
================================================================================
|
| 26 |
+
"""
|
| 27 |
+
|
| 28 |
+
from copy import deepcopy
|
| 29 |
+
import argparse
|
| 30 |
+
from time import time
|
| 31 |
+
import sys, os
|
| 32 |
+
import pickle
|
| 33 |
+
|
| 34 |
+
import scanpy as sc
|
| 35 |
+
import numpy as np
|
| 36 |
+
|
| 37 |
+
import torch
|
| 38 |
+
import torch.optim as optim
|
| 39 |
+
import torch.nn as nn
|
| 40 |
+
from torch.optim.lr_scheduler import StepLR
|
| 41 |
+
from torch.utils.data.distributed import DistributedSampler
|
| 42 |
+
from torch.nn.parallel import DistributedDataParallel as DDP
|
| 43 |
+
import torch.distributed as dist
|
| 44 |
+
from torch_geometric.data import DataLoader
|
| 45 |
+
|
| 46 |
+
# ============================================================
|
| 47 |
+
# Use native modules (optimized versions)
|
| 48 |
+
# ============================================================
|
| 49 |
+
from model_module import GEARS_Model_Module
|
| 50 |
+
from inference_local import evaluate, compute_metrics, deeper_analysis, \
|
| 51 |
+
non_dropout_analysis, compute_synergy_loss
|
| 52 |
+
from utils import loss_fct, uncertainty_loss_fct, parse_any_pert, \
|
| 53 |
+
get_similarity_network, print_sys, GeneSimNetwork, \
|
| 54 |
+
create_cell_graph_dataset_for_prediction, get_mean_control, \
|
| 55 |
+
get_GI_genes_idx, get_GI_params
|
| 56 |
+
from scfm_utils_local import *
|
| 57 |
+
|
| 58 |
+
torch.manual_seed(0)
|
| 59 |
+
|
| 60 |
+
import warnings
|
| 61 |
+
warnings.filterwarnings("ignore")
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
class GEARS:
|
| 65 |
+
"""
|
| 66 |
+
GEARS training and evaluation controller
|
| 67 |
+
|
| 68 |
+
[Core functions]
|
| 69 |
+
1. Manage the entire training process (data, model, optimization, evaluation)
|
| 70 |
+
2. Support distributed training (DDP)
|
| 71 |
+
3. Support Weights & Biases experiment tracking
|
| 72 |
+
4. Support model saving/loading
|
| 73 |
+
|
| 74 |
+
[Usage process]
|
| 75 |
+
```python
|
| 76 |
+
gears = GEARS(pert_data, local_rank, is_master, world_size, ...)
|
| 77 |
+
gears.model_initialize(hidden_size=64, model_class=GEARS_Model_Module, ...)
|
| 78 |
+
gears.train(epochs=20, result_dir='./results', lr=1e-3)
|
| 79 |
+
```
|
| 80 |
+
"""
|
| 81 |
+
|
| 82 |
+
def __init__(self, pert_data,
|
| 83 |
+
local_rank,
|
| 84 |
+
is_master: bool,
|
| 85 |
+
world_size,
|
| 86 |
+
train_bs = 48,
|
| 87 |
+
test_bs = 48,
|
| 88 |
+
device = 'cuda',
|
| 89 |
+
weight_bias_track = True,
|
| 90 |
+
proj_name = 'GEARS',
|
| 91 |
+
exp_name = 'GEARS',):
|
| 92 |
+
"""
|
| 93 |
+
Initialize the GEARS controller
|
| 94 |
+
|
| 95 |
+
[Key initialization]
|
| 96 |
+
1. Data loader configuration (optimizing performance parameters)
|
| 97 |
+
2. Control group expression value (used for direction loss calculation)
|
| 98 |
+
3. dict_filter: Non-zero gene index corresponding to each perturbation (affects the training loss calculation range)
|
| 99 |
+
"""
|
| 100 |
+
self.weight_bias_track = weight_bias_track
|
| 101 |
+
self.is_master = is_master
|
| 102 |
+
self.local_rank = local_rank
|
| 103 |
+
self.world_size = world_size
|
| 104 |
+
self.proj_name = proj_name
|
| 105 |
+
self.exp_name = exp_name
|
| 106 |
+
|
| 107 |
+
# Weights & Biases Experiment Tracking
|
| 108 |
+
if self.weight_bias_track:
|
| 109 |
+
import wandb
|
| 110 |
+
wandb.init(project=proj_name, name=exp_name, entity = "deanghoss")
|
| 111 |
+
self.wandb = wandb
|
| 112 |
+
else:
|
| 113 |
+
self.wandb = None
|
| 114 |
+
|
| 115 |
+
self.device = f'cuda:{local_rank}'
|
| 116 |
+
torch.cuda.set_device(device)
|
| 117 |
+
self.config = None
|
| 118 |
+
|
| 119 |
+
# ============================================================
|
| 120 |
+
# [Performance optimization] Data loader configuration
|
| 121 |
+
# pin_memory: Accelerate CPU->GPU transfer
|
| 122 |
+
# prefetch_factor: prefetch multiple batches
|
| 123 |
+
# persistent_workers: Keep worker processes alive to avoid restart overhead
|
| 124 |
+
# ============================================================
|
| 125 |
+
num_workers = min(os.cpu_count() // world_size, 8) # Up to 8 workers per GPU
|
| 126 |
+
|
| 127 |
+
self.dataloader = {}
|
| 128 |
+
for dl_key, dl in pert_data.dataloader.items():
|
| 129 |
+
dataset = dl.dataset
|
| 130 |
+
if 'train' in dl_key:
|
| 131 |
+
sampler = DistributedSampler(dataset, shuffle=True, drop_last=True)
|
| 132 |
+
self.dataloader[dl_key] = DataLoader(
|
| 133 |
+
dataset,
|
| 134 |
+
batch_size=train_bs,
|
| 135 |
+
sampler=sampler,
|
| 136 |
+
num_workers=num_workers,
|
| 137 |
+
pin_memory=True,
|
| 138 |
+
prefetch_factor=2,
|
| 139 |
+
persistent_workers=True if num_workers > 0 else False,
|
| 140 |
+
drop_last=True # Make sure all batch sizes are consistent
|
| 141 |
+
)
|
| 142 |
+
elif 'val' in dl_key:
|
| 143 |
+
sampler = SequentialDistributedSampler(dataset, batch_size=test_bs, world_size=self.world_size)
|
| 144 |
+
self.dataloader[dl_key] = DataLoader(
|
| 145 |
+
dataset,
|
| 146 |
+
batch_size=test_bs,
|
| 147 |
+
sampler=sampler,
|
| 148 |
+
num_workers=min(num_workers, 4),
|
| 149 |
+
pin_memory=True,
|
| 150 |
+
prefetch_factor=2,
|
| 151 |
+
persistent_workers=True if num_workers > 0 else False
|
| 152 |
+
)
|
| 153 |
+
else:
|
| 154 |
+
self.dataloader[dl_key] = dl
|
| 155 |
+
|
| 156 |
+
# Save data reference
|
| 157 |
+
self.adata = pert_data.adata
|
| 158 |
+
self.node_map = pert_data.node_map
|
| 159 |
+
self.data_path = pert_data.data_path
|
| 160 |
+
self.dataset_name = pert_data.dataset_name
|
| 161 |
+
self.split = pert_data.split
|
| 162 |
+
self.seed = pert_data.seed
|
| 163 |
+
self.train_gene_set_size = pert_data.train_gene_set_size
|
| 164 |
+
self.set2conditions = pert_data.set2conditions
|
| 165 |
+
self.subgroup = pert_data.subgroup
|
| 166 |
+
self.gene_list = list(pert_data.gene_names)
|
| 167 |
+
self.num_genes = len(self.gene_list)
|
| 168 |
+
|
| 169 |
+
try:
|
| 170 |
+
self.real_gene_list = list(pert_data.real_gene_names)
|
| 171 |
+
except AttributeError:
|
| 172 |
+
self.real_gene_list = list(pert_data.gene_names)
|
| 173 |
+
self.real_num_genes = len(self.real_gene_list)
|
| 174 |
+
|
| 175 |
+
if self.num_genes != self.real_num_genes:
|
| 176 |
+
print_sys("The gene list is augmented, larger than real gene list!")
|
| 177 |
+
|
| 178 |
+
# ============================================================
|
| 179 |
+
# [Key]Control group expression value
|
| 180 |
+
# Used to calculate direction loss (whether the predicted up/down direction is correct)
|
| 181 |
+
# ============================================================
|
| 182 |
+
self.ctrl_expression = torch.tensor(np.mean(self.adata.X[self.adata.obs.condition == 'ctrl'], axis = 0)).reshape(-1,).to(self.device)
|
| 183 |
+
|
| 184 |
+
# ============================================================
|
| 185 |
+
# [Key] dict_filter: Non-zero gene index of each perturbation
|
| 186 |
+
# The training loss is only calculated on these genes to avoid dropout noise
|
| 187 |
+
# This is an important setting that affects the results!
|
| 188 |
+
# ============================================================
|
| 189 |
+
pert_full_id2pert = dict(self.adata.obs[['condition_name', 'condition']].values)
|
| 190 |
+
self.dict_filter = {pert_full_id2pert[i]: j for i,j in
|
| 191 |
+
self.adata.uns['non_zeros_gene_idx'].items() if
|
| 192 |
+
i in pert_full_id2pert}
|
| 193 |
+
self.ctrl_adata = self.adata[self.adata.obs['condition'] == 'ctrl']
|
| 194 |
+
|
| 195 |
+
# Loss record
|
| 196 |
+
self.train_losses = []
|
| 197 |
+
self.train_overall_mses = []
|
| 198 |
+
self.val_overall_mses = []
|
| 199 |
+
self.train_de_mses = []
|
| 200 |
+
self.val_de_mses = []
|
| 201 |
+
|
| 202 |
+
def tunable_parameters(self):
|
| 203 |
+
"""
|
| 204 |
+
Returns a list of tunable parameters and their descriptions
|
| 205 |
+
|
| 206 |
+
[Description of key parameters]
|
| 207 |
+
- hidden_size: embedding dimension, affecting model capacity
|
| 208 |
+
- num_go_gnn_layers: Number of GO-GNN layers, which affects the propagation range of disturbance information
|
| 209 |
+
- coexpress_threshold: Threshold for co-expression graph construction, affecting the sparseness of the graph
|
| 210 |
+
- direction_lambda: direction loss weight, affecting the accuracy of upward/downward prediction
|
| 211 |
+
"""
|
| 212 |
+
return {'hidden_size': 'hidden dimension, default 64',
|
| 213 |
+
'num_go_gnn_layers': 'number of GNN layers for GO graph, default 1',
|
| 214 |
+
'num_gene_gnn_layers': 'number of GNN layers for co-expression gene graph, default 1',
|
| 215 |
+
'decoder_hidden_size': 'hidden dimension for gene-specific decoder, default 16',
|
| 216 |
+
'num_similar_genes_go_graph': 'number of maximum similar K genes in the GO graph, default 20',
|
| 217 |
+
'num_similar_genes_co_express_graph': 'number of maximum similar K genes in the co expression graph, default 20',
|
| 218 |
+
'coexpress_threshold': 'pearson correlation threshold when constructing coexpression graph, default 0.4',
|
| 219 |
+
'uncertainty': 'whether or not to turn on uncertainty mode, default False',
|
| 220 |
+
'uncertainty_reg': 'regularization term to balance uncertainty loss and prediction loss, default 1',
|
| 221 |
+
'direction_lambda': 'regularization term to balance direction loss and prediction loss, default 1'
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
def model_initialize(self, hidden_size = 64,
|
| 225 |
+
num_go_gnn_layers = 1,
|
| 226 |
+
num_gene_gnn_layers = 1,
|
| 227 |
+
decoder_hidden_size = 16,
|
| 228 |
+
num_similar_genes_go_graph = 20,
|
| 229 |
+
num_similar_genes_co_express_graph = 20,
|
| 230 |
+
coexpress_threshold = 0.4,
|
| 231 |
+
uncertainty = False,
|
| 232 |
+
uncertainty_reg = 1,
|
| 233 |
+
direction_lambda = 1e-1,
|
| 234 |
+
G_go = None,
|
| 235 |
+
G_go_weight = None,
|
| 236 |
+
G_coexpress = None,
|
| 237 |
+
G_coexpress_weight = None,
|
| 238 |
+
no_perturb = False,
|
| 239 |
+
cell_fitness_pred = False,
|
| 240 |
+
go_path = None,
|
| 241 |
+
model_type=None,
|
| 242 |
+
bin_set=None,
|
| 243 |
+
load_path=None,
|
| 244 |
+
finetune_method=None,
|
| 245 |
+
accumulation_steps=1,
|
| 246 |
+
mode='v1',
|
| 247 |
+
input_genes_ens_ids = None,
|
| 248 |
+
scfm_genes_ens_ids = None,
|
| 249 |
+
scfm_hyper_params_path = None,
|
| 250 |
+
scfm_ckpt_path = 'None',
|
| 251 |
+
scfm_class = None,
|
| 252 |
+
model_class = None,
|
| 253 |
+
key_enc = None,
|
| 254 |
+
**kwargs
|
| 255 |
+
):
|
| 256 |
+
"""
|
| 257 |
+
Model initialization
|
| 258 |
+
|
| 259 |
+
[Key steps]
|
| 260 |
+
1. Build a configuration dictionary (config is a single source of truth)
|
| 261 |
+
2. Construct co-expression graph and GO similarity graph (if not provided)
|
| 262 |
+
3. Instantiate the model
|
| 263 |
+
|
| 264 |
+
[Key parameters that affect the results]
|
| 265 |
+
- finetune_method: 'frozen' freezes pre-training parameters, None means fine-tuning all parameters
|
| 266 |
+
- accumulation_steps: the number of gradient accumulation steps, affecting the equivalent batch size
|
| 267 |
+
- coexpress_threshold/num_similar_genes_*: affects the structure of gene relationship graph
|
| 268 |
+
|
| 269 |
+
[Construction of genetic relationship diagram]
|
| 270 |
+
- G_coexpress: Data-driven co-expression relationship
|
| 271 |
+
- G_go: Functional similarity relationship based on GO annotation
|
| 272 |
+
These two graphs are important structural priors of the model and affect the way disturbance information is propagated.
|
| 273 |
+
"""
|
| 274 |
+
# config is a single source of truth, and subsequent train/test/save all rely on the keys here.
|
| 275 |
+
self.config = {'proj_name': self.proj_name,
|
| 276 |
+
'exp_name': self.exp_name,
|
| 277 |
+
'hidden_size': hidden_size,
|
| 278 |
+
'num_go_gnn_layers' : num_go_gnn_layers,
|
| 279 |
+
'num_gene_gnn_layers' : num_gene_gnn_layers,
|
| 280 |
+
'decoder_hidden_size' : decoder_hidden_size,
|
| 281 |
+
'num_similar_genes_go_graph' : num_similar_genes_go_graph,
|
| 282 |
+
'num_similar_genes_co_express_graph' : num_similar_genes_co_express_graph,
|
| 283 |
+
'coexpress_threshold': coexpress_threshold,
|
| 284 |
+
'uncertainty' : uncertainty,
|
| 285 |
+
'uncertainty_reg' : uncertainty_reg,
|
| 286 |
+
'direction_lambda' : direction_lambda,
|
| 287 |
+
'G_go': G_go,
|
| 288 |
+
'G_go_weight': G_go_weight,
|
| 289 |
+
'G_coexpress': G_coexpress,
|
| 290 |
+
'G_coexpress_weight': G_coexpress_weight,
|
| 291 |
+
'device': self.device,
|
| 292 |
+
'num_genes': self.num_genes,
|
| 293 |
+
'no_perturb': no_perturb,
|
| 294 |
+
'cell_fitness_pred': cell_fitness_pred,
|
| 295 |
+
'model_type': model_type,
|
| 296 |
+
'bin_set': bin_set,
|
| 297 |
+
'load_path': load_path,
|
| 298 |
+
'finetune_method': finetune_method,
|
| 299 |
+
'accumulation_steps': accumulation_steps,
|
| 300 |
+
'mode':mode,
|
| 301 |
+
'input_genes_ens_ids': input_genes_ens_ids,
|
| 302 |
+
'scfm_genes_ens_ids': scfm_genes_ens_ids,
|
| 303 |
+
"scfm_hyper_params_path": scfm_hyper_params_path,
|
| 304 |
+
"scfm_ckpt": scfm_ckpt_path,
|
| 305 |
+
"scfm_class": scfm_class,
|
| 306 |
+
"key_enc": key_enc
|
| 307 |
+
}
|
| 308 |
+
print('Use accumulation steps:',accumulation_steps)
|
| 309 |
+
print('Use mode:',mode)
|
| 310 |
+
self.config.update(kwargs)
|
| 311 |
+
|
| 312 |
+
if self.wandb:
|
| 313 |
+
self.wandb.config.update(self.config)
|
| 314 |
+
|
| 315 |
+
# ============================================================
|
| 316 |
+
# [Key] Constructing a co-expression graph
|
| 317 |
+
# Only use training set data to avoid information leakage
|
| 318 |
+
# The graph structure relies on split/seed, which affects the reproducibility of results.
|
| 319 |
+
# ============================================================
|
| 320 |
+
if self.config['G_coexpress'] is None:
|
| 321 |
+
edge_list = get_similarity_network(network_type = 'co-express', adata = self.adata, threshold = coexpress_threshold, k = num_similar_genes_co_express_graph, gene_list = self.gene_list, data_path = self.data_path, data_name = self.dataset_name, split = self.split, seed = self.seed, train_gene_set_size = self.train_gene_set_size, set2conditions = self.set2conditions)
|
| 322 |
+
sim_network = GeneSimNetwork(edge_list, self.gene_list, node_map = self.node_map)
|
| 323 |
+
self.config['G_coexpress'] = sim_network.edge_index
|
| 324 |
+
self.config['G_coexpress_weight'] = sim_network.edge_weight
|
| 325 |
+
|
| 326 |
+
# ============================================================
|
| 327 |
+
# [Key] Constructing GO similarity graph
|
| 328 |
+
# This is an important source of priors for model structure
|
| 329 |
+
# Help models understand gene functional relationships and guide the propagation of perturbation effects
|
| 330 |
+
# ============================================================
|
| 331 |
+
if self.config['G_go'] is None:
|
| 332 |
+
edge_list = get_similarity_network(network_type = 'go', adata = self.adata, threshold = coexpress_threshold, k = num_similar_genes_go_graph, gene_list = self.gene_list, data_path = self.data_path, data_name = self.dataset_name, split = self.split, seed = self.seed, train_gene_set_size = self.train_gene_set_size, set2conditions = self.set2conditions)
|
| 333 |
+
print_sys(f"The go_edge_list shape: {edge_list.shape}")
|
| 334 |
+
sim_network = GeneSimNetwork(edge_list, self.gene_list, node_map = self.node_map)
|
| 335 |
+
self.config['G_go'] = sim_network.edge_index
|
| 336 |
+
self.config['G_go_weight'] = sim_network.edge_weight
|
| 337 |
+
|
| 338 |
+
# Instantiate model
|
| 339 |
+
self.model = model_class(self.config).to(self.device)
|
| 340 |
+
|
| 341 |
+
def load_pretrained(self, path):
|
| 342 |
+
"""
|
| 343 |
+
Load pretrained model
|
| 344 |
+
|
| 345 |
+
[Function]
|
| 346 |
+
Load configuration and model weights from saved directory
|
| 347 |
+
|
| 348 |
+
[Processing DDP prefix]
|
| 349 |
+
If the key of state_dict starts with "module.", it means it is a DDP model.
|
| 350 |
+
Need to remove prefix to load correctly
|
| 351 |
+
"""
|
| 352 |
+
with open(os.path.join(path, 'config.pkl'), 'rb') as f:
|
| 353 |
+
config = pickle.load(f)
|
| 354 |
+
|
| 355 |
+
del config['device'], config['num_genes'], config['num_perts']
|
| 356 |
+
self.model_initialize(**config)
|
| 357 |
+
self.config = config
|
| 358 |
+
|
| 359 |
+
state_dict = torch.load(os.path.join(path, 'model.pt'), map_location = torch.device('cpu'))
|
| 360 |
+
# Handle the "module." prefix of DDP models
|
| 361 |
+
if next(iter(state_dict))[:7] == 'module.':
|
| 362 |
+
from collections import OrderedDict
|
| 363 |
+
new_state_dict = OrderedDict()
|
| 364 |
+
for k, v in state_dict.items():
|
| 365 |
+
name = k[7:] # remove `module.`
|
| 366 |
+
new_state_dict[name] = v
|
| 367 |
+
state_dict = new_state_dict
|
| 368 |
+
|
| 369 |
+
self.model.load_state_dict(state_dict)
|
| 370 |
+
self.model = self.model.to(self.device)
|
| 371 |
+
self.best_model = self.model
|
| 372 |
+
|
| 373 |
+
def save_model(self, path):
|
| 374 |
+
"""
|
| 375 |
+
Save model
|
| 376 |
+
|
| 377 |
+
[Save content]
|
| 378 |
+
1. config.pkl: configuration dictionary (hyperparameters, paths, etc.)
|
| 379 |
+
2. model.pt: model weights
|
| 380 |
+
|
| 381 |
+
[Notice]
|
| 382 |
+
What is saved is the module of the DDP model, not the DDP wrapper itself.
|
| 383 |
+
"""
|
| 384 |
+
if not os.path.exists(path):
|
| 385 |
+
os.mkdir(path)
|
| 386 |
+
|
| 387 |
+
if self.config is None:
|
| 388 |
+
raise ValueError('No model is initialized...')
|
| 389 |
+
|
| 390 |
+
with open(os.path.join(path, 'config.pkl'), 'wb') as f:
|
| 391 |
+
pickle.dump(self.config, f)
|
| 392 |
+
|
| 393 |
+
torch.save(self.model.module.state_dict(), os.path.join(path, 'model.pt'))
|
| 394 |
+
|
| 395 |
+
def predict(self, pert_list):
|
| 396 |
+
"""
|
| 397 |
+
Predicting transcriptome expression for a given combination of perturbations
|
| 398 |
+
|
| 399 |
+
[Function]
|
| 400 |
+
Given a list of perturbed genes, return the predicted expression value after perturbation
|
| 401 |
+
|
| 402 |
+
[parameter]
|
| 403 |
+
- pert_list: perturbation list, each element is a gene list
|
| 404 |
+
For example: [['MAPK1'], ['KRAS'], ['MAPK1', 'KRAS']]
|
| 405 |
+
|
| 406 |
+
[return]
|
| 407 |
+
- Predicted gene expression dictionary
|
| 408 |
+
- If uncertainty is turned on, the uncertainty score will also be returned
|
| 409 |
+
"""
|
| 410 |
+
self.ctrl_adata = self.adata[self.adata.obs['condition'] == 'ctrl']
|
| 411 |
+
for pert in pert_list:
|
| 412 |
+
for i in pert:
|
| 413 |
+
if i not in self.pert_list:
|
| 414 |
+
raise ValueError(i+ " not in the perturbation graph. Please select from PertNet.gene_list!")
|
| 415 |
+
|
| 416 |
+
if self.config['uncertainty']:
|
| 417 |
+
results_logvar = {}
|
| 418 |
+
|
| 419 |
+
self.best_model = self.best_model.to(self.device)
|
| 420 |
+
self.best_model.eval()
|
| 421 |
+
results_pred = {}
|
| 422 |
+
results_logvar_sum = {}
|
| 423 |
+
|
| 424 |
+
from torch_geometric.data import DataLoader
|
| 425 |
+
for pert in pert_list:
|
| 426 |
+
try:
|
| 427 |
+
# If prediction already exists, skip inference
|
| 428 |
+
results_pred['_'.join(pert)] = self.saved_pred['_'.join(pert)]
|
| 429 |
+
if self.config['uncertainty']:
|
| 430 |
+
results_logvar_sum['_'.join(pert)] = self.saved_logvar_sum['_'.join(pert)]
|
| 431 |
+
continue
|
| 432 |
+
except:
|
| 433 |
+
pass
|
| 434 |
+
|
| 435 |
+
# Create cytogram data for prediction
|
| 436 |
+
cg = create_cell_graph_dataset_for_prediction(pert, self.ctrl_adata, self.pert_list, self.device)
|
| 437 |
+
loader = DataLoader(cg, 5, shuffle = False)
|
| 438 |
+
|
| 439 |
+
predall=[]
|
| 440 |
+
for step, batch in enumerate(loader):
|
| 441 |
+
batch.to(self.device)
|
| 442 |
+
with torch.no_grad():
|
| 443 |
+
if self.config['uncertainty']:
|
| 444 |
+
p, unc = self.best_model(batch)
|
| 445 |
+
results_logvar['_'.join(pert)] = np.mean(unc.detach().cpu().numpy(), axis = 0)
|
| 446 |
+
results_logvar_sum['_'.join(pert)] = np.exp(-np.mean(results_logvar['_'.join(pert)]))
|
| 447 |
+
else:
|
| 448 |
+
p = self.best_model(batch)
|
| 449 |
+
predall.append(p.detach().cpu().numpy())
|
| 450 |
+
# Averaging predictions from multiple control group samples
|
| 451 |
+
preadall = np.concatenate(predall,axis=0)
|
| 452 |
+
results_pred['_'.join(pert)] = np.mean(preadall, axis = 0)
|
| 453 |
+
|
| 454 |
+
self.saved_pred.update(results_pred)
|
| 455 |
+
|
| 456 |
+
if self.config['uncertainty']:
|
| 457 |
+
self.saved_logvar_sum.update(results_logvar_sum)
|
| 458 |
+
return results_pred, results_logvar_sum
|
| 459 |
+
else:
|
| 460 |
+
return results_pred
|
| 461 |
+
|
| 462 |
+
def GI_predict(self, combo, GI_genes_file='./genes_with_hi_mean.npy'):
|
| 463 |
+
"""
|
| 464 |
+
Predict the genetic interaction effect of gene pairs
|
| 465 |
+
|
| 466 |
+
[Function]
|
| 467 |
+
Given two genes, predict the interactive effects of their combined perturbations
|
| 468 |
+
|
| 469 |
+
[Genetic interaction analysis]
|
| 470 |
+
- Analyze whether two genes act synergistically, antagonistically, or independently
|
| 471 |
+
- Calculate linear combination coefficients, distance correlation coefficients and other indicators
|
| 472 |
+
|
| 473 |
+
[parameter]
|
| 474 |
+
- combo: gene pair, such as ('MAPK1', 'KRAS')
|
| 475 |
+
- GI_genes_file: Gene list file used for GI analysis
|
| 476 |
+
"""
|
| 477 |
+
try:
|
| 478 |
+
# If there is already a prediction, use it directly
|
| 479 |
+
pred = {}
|
| 480 |
+
pred[combo[0]] = self.saved_pred[combo[0]]
|
| 481 |
+
pred[combo[1]] = self.saved_pred[combo[1]]
|
| 482 |
+
pred['_'.join(combo)] = self.saved_pred['_'.join(combo)]
|
| 483 |
+
except:
|
| 484 |
+
if self.config['uncertainty']:
|
| 485 |
+
pred = self.predict([[combo[0]], [combo[1]], combo])[0]
|
| 486 |
+
else:
|
| 487 |
+
pred = self.predict([[combo[0]], [combo[1]], combo])
|
| 488 |
+
|
| 489 |
+
# Calculate change relative to control group
|
| 490 |
+
mean_control = get_mean_control(self.adata).values
|
| 491 |
+
pred = {p:pred[p]-mean_control for p in pred}
|
| 492 |
+
|
| 493 |
+
if GI_genes_file is not None:
|
| 494 |
+
GI_genes_idx = get_GI_genes_idx(self.adata, GI_genes_file)
|
| 495 |
+
else:
|
| 496 |
+
GI_genes_idx = np.arange(len(self.adata.var.gene_name.values))
|
| 497 |
+
|
| 498 |
+
pred = {p:pred[p][GI_genes_idx] for p in pred}
|
| 499 |
+
return get_GI_params(pred, combo)
|
| 500 |
+
|
| 501 |
+
def plot_perturbation(self, query, save_file = None):
|
| 502 |
+
"""
|
| 503 |
+
Visualizing disturbance prediction results
|
| 504 |
+
|
| 505 |
+
[Function]
|
| 506 |
+
Draw a comparison chart between predicted values and actual values to show the prediction effect of top DE genes
|
| 507 |
+
|
| 508 |
+
[Chart description]
|
| 509 |
+
- Box plot: distribution of true values
|
| 510 |
+
- Red dot: predicted value
|
| 511 |
+
- Green dashed line: control group baseline
|
| 512 |
+
"""
|
| 513 |
+
import seaborn as sns
|
| 514 |
+
import numpy as np
|
| 515 |
+
import matplotlib.pyplot as plt
|
| 516 |
+
|
| 517 |
+
sns.set_theme(style="ticks", rc={"axes.facecolor": (0, 0, 0, 0)}, font_scale=1.5)
|
| 518 |
+
|
| 519 |
+
adata = self.adata
|
| 520 |
+
gene2idx = self.node_map
|
| 521 |
+
cond2name = dict(adata.obs[['condition', 'condition_name']].values)
|
| 522 |
+
gene_raw2id = dict(zip(adata.var.index.values, adata.var.gene_name.values))
|
| 523 |
+
|
| 524 |
+
de_idx = [gene2idx[gene_raw2id[i]] for i in adata.uns['top_non_dropout_de_20'][cond2name[query]]]
|
| 525 |
+
genes = [gene_raw2id[i] for i in adata.uns['top_non_dropout_de_20'][cond2name[query]]]
|
| 526 |
+
truth = adata[adata.obs.condition == query].X.toarray()[:, de_idx]
|
| 527 |
+
pred = self.predict([query.split('+')])['_'.join(query.split('+'))][de_idx]
|
| 528 |
+
ctrl_means = adata[adata.obs['condition'] == 'ctrl'].to_df().mean()[de_idx].values
|
| 529 |
+
|
| 530 |
+
pred = pred - ctrl_means
|
| 531 |
+
truth = truth - ctrl_means
|
| 532 |
+
|
| 533 |
+
plt.figure(figsize=[16.5,4.5])
|
| 534 |
+
plt.title(query)
|
| 535 |
+
plt.boxplot(truth, showfliers=False,
|
| 536 |
+
medianprops = dict(linewidth=0))
|
| 537 |
+
|
| 538 |
+
for i in range(pred.shape[0]):
|
| 539 |
+
_ = plt.scatter(i+1, pred[i], color='red')
|
| 540 |
+
|
| 541 |
+
plt.axhline(0, linestyle="dashed", color = 'green')
|
| 542 |
+
|
| 543 |
+
ax = plt.gca()
|
| 544 |
+
ax.xaxis.set_ticklabels(genes, rotation = 90)
|
| 545 |
+
|
| 546 |
+
plt.ylabel("Change in Gene Expression over Control",labelpad=10)
|
| 547 |
+
plt.tick_params(axis='x', which='major', pad=5)
|
| 548 |
+
plt.tick_params(axis='y', which='major', pad=5)
|
| 549 |
+
sns.despine()
|
| 550 |
+
|
| 551 |
+
if save_file:
|
| 552 |
+
plt.savefig(save_file, bbox_inches='tight')
|
| 553 |
+
plt.show()
|
| 554 |
+
|
| 555 |
+
|
| 556 |
+
def train(self,
|
| 557 |
+
epochs = 20,
|
| 558 |
+
result_dir='./results',
|
| 559 |
+
lr = 1e-3,
|
| 560 |
+
weight_decay = 5e-4,
|
| 561 |
+
valid_every = 1
|
| 562 |
+
):
|
| 563 |
+
# Simulate larger batches through gradient accumulation. This value affects optimization stability and equivalent learning rate.
|
| 564 |
+
GRADIENT_ACCUMULATION = self.config['accumulation_steps']
|
| 565 |
+
test = (epochs == 1)
|
| 566 |
+
train_loader = self.dataloader['train_loader']
|
| 567 |
+
val_loader = self.dataloader['val_loader']
|
| 568 |
+
|
| 569 |
+
self.model = self.model
|
| 570 |
+
|
| 571 |
+
# Key switch: Freezing the pre-trained encoder reduces overfitting, but reduces adaptation capabilities.
|
| 572 |
+
if self.config['finetune_method'] == 'frozen':
|
| 573 |
+
for name, p in self.model.named_parameters():
|
| 574 |
+
if "singlecell_model" in name:
|
| 575 |
+
p.requires_grad = False
|
| 576 |
+
else:
|
| 577 |
+
for name, p in self.model.named_parameters():
|
| 578 |
+
p.requires_grad = True
|
| 579 |
+
|
| 580 |
+
# ============================================================
|
| 581 |
+
# Optimization: DDP configuration optimization
|
| 582 |
+
# ============================================================
|
| 583 |
+
self.model = DDP(self.model,
|
| 584 |
+
device_ids=[self.local_rank],
|
| 585 |
+
output_device=self.local_rank,
|
| 586 |
+
find_unused_parameters=True, # True is required because a few parameters are not used (such as gene_emb when pretrained=True)
|
| 587 |
+
broadcast_buffers=False, # Optimization: skip buffer broadcast (such as BN running stats)
|
| 588 |
+
gradient_as_bucket_view=True) # Optimization: reduce memory copy
|
| 589 |
+
|
| 590 |
+
optimizer = optim.Adam(self.model.parameters(), lr=lr, weight_decay = weight_decay)
|
| 591 |
+
scheduler = StepLR(optimizer, step_size=1, gamma=0.5)
|
| 592 |
+
|
| 593 |
+
min_val = np.inf
|
| 594 |
+
dist.barrier()
|
| 595 |
+
if self.is_master:
|
| 596 |
+
print_sys('Start Training...')
|
| 597 |
+
for epoch in range(epochs):
|
| 598 |
+
train_loader.sampler.set_epoch(epoch)
|
| 599 |
+
self.model.train()
|
| 600 |
+
dist.barrier()
|
| 601 |
+
for step, batch in enumerate(train_loader):
|
| 602 |
+
if test and (step+1 > 2 * GRADIENT_ACCUMULATION):
|
| 603 |
+
break
|
| 604 |
+
batch.to(self.device)
|
| 605 |
+
y = batch.y
|
| 606 |
+
if (step+1) % GRADIENT_ACCUMULATION != 0:
|
| 607 |
+
# no_sync: Avoid doing all-reduce for each micro-batch to save communication.
|
| 608 |
+
with self.model.no_sync():
|
| 609 |
+
pred = self.model(batch)
|
| 610 |
+
loss = loss_fct(pred, y, batch.pert,
|
| 611 |
+
ctrl = self.ctrl_expression,
|
| 612 |
+
dict_filter = self.dict_filter,
|
| 613 |
+
direction_lambda = self.config['direction_lambda'])
|
| 614 |
+
|
| 615 |
+
loss.backward()
|
| 616 |
+
elif (step+1) % GRADIENT_ACCUMULATION == 0:
|
| 617 |
+
pred = self.model(batch)
|
| 618 |
+
loss = loss_fct(pred, y, batch.pert,
|
| 619 |
+
ctrl = self.ctrl_expression,
|
| 620 |
+
dict_filter = self.dict_filter,
|
| 621 |
+
direction_lambda = self.config['direction_lambda'])
|
| 622 |
+
|
| 623 |
+
loss.backward()
|
| 624 |
+
# Gradient clipping affects training stability; if it is too small, it will inhibit learning, and if it is too large, the gradient may explode.
|
| 625 |
+
nn.utils.clip_grad_value_(self.model.parameters(), clip_value=1.0)
|
| 626 |
+
optimizer.step()
|
| 627 |
+
optimizer.zero_grad()
|
| 628 |
+
|
| 629 |
+
if (step + 1) % 50 == 0:
|
| 630 |
+
if self.is_master:
|
| 631 |
+
if self.wandb:
|
| 632 |
+
self.wandb.log({'training_loss': loss.item()})
|
| 633 |
+
log = "Epoch {} Step {} Train Loss: {:.4f}"
|
| 634 |
+
print_sys(log.format(epoch + 1, step + 1, loss.item()))
|
| 635 |
+
dist.barrier()
|
| 636 |
+
scheduler.step()
|
| 637 |
+
if (epoch + 1) % valid_every == 0:
|
| 638 |
+
# Evaluate model performance on train and val set
|
| 639 |
+
with torch.no_grad():
|
| 640 |
+
val_res = evaluate(val_loader, self.model, self.config['uncertainty'], self.device)
|
| 641 |
+
val_metrics, _ = compute_metrics(val_res)
|
| 642 |
+
del val_res
|
| 643 |
+
val_metrics_reduced = {}
|
| 644 |
+
for m in ['mse', 'mse_de', 'pearson', 'pearson_de']:
|
| 645 |
+
val_metrics_reduced[m] = get_reduced(val_metrics[m], self.local_rank, 0, self.world_size)
|
| 646 |
+
val_metrics = val_metrics_reduced
|
| 647 |
+
# Print epoch performance
|
| 648 |
+
log = "Epoch {}: Validation Overall MSE: {:.4f}. Validation Top 20 DE MSE: {:.4f}."
|
| 649 |
+
|
| 650 |
+
if self.is_master:
|
| 651 |
+
print_sys(log.format(epoch + 1, val_metrics['mse'], val_metrics['mse_de']))
|
| 652 |
+
if self.wandb:
|
| 653 |
+
metrics = ['mse', 'pearson']
|
| 654 |
+
for m in metrics:
|
| 655 |
+
self.wandb.log({'val_'+m: val_metrics[m],'val_de_'+m: val_metrics[m + '_de']})
|
| 656 |
+
# Use `mse_de` as early stop/select optimal criterion: pay more attention to DE gene prediction quality.
|
| 657 |
+
if val_metrics['mse_de'] < min_val:
|
| 658 |
+
min_val = val_metrics['mse_de']
|
| 659 |
+
print_sys("Best epoch:{} mse_de:{}!".format(epoch + 1, min_val))
|
| 660 |
+
self.save_model(result_dir)
|
| 661 |
+
|
| 662 |
+
dist.barrier()
|
| 663 |
+
if self.is_master:
|
| 664 |
+
print_sys("Done!")
|
| 665 |
+
if 'test_loader' not in self.dataloader:
|
| 666 |
+
print_sys('Done! No test dataloader detected.')
|
| 667 |
+
return
|
| 668 |
+
# Force the best checkpoint to be loaded before testing and avoid using the parameters of the last epoch.
|
| 669 |
+
checkpoint = torch.load(os.path.join(result_dir, 'model.pt'), map_location=self.device)
|
| 670 |
+
self.model.module.load_state_dict(checkpoint)
|
| 671 |
+
self.model.eval()
|
| 672 |
+
|
| 673 |
+
if self.config.get('record_pred', False):
|
| 674 |
+
self.model.module.args['start_record'] = True
|
| 675 |
+
|
| 676 |
+
# Model testing
|
| 677 |
+
test_loader = self.dataloader['test_loader']
|
| 678 |
+
if self.is_master:
|
| 679 |
+
print_sys("Start Testing...")
|
| 680 |
+
with torch.no_grad():
|
| 681 |
+
test_res = evaluate(test_loader, self.model.module, self.config['uncertainty'], self.device)
|
| 682 |
+
'''
|
| 683 |
+
if not test:
|
| 684 |
+
test_res = evaluate(test_loader, self.model.module, self.config['uncertainty'], self.device)
|
| 685 |
+
else:
|
| 686 |
+
quick_model = debug_model()
|
| 687 |
+
test_res = evaluate(test_loader, quick_model, self.config['uncertainty'], self.device)'''
|
| 688 |
+
test_metrics, test_pert_res = compute_metrics(test_res)
|
| 689 |
+
|
| 690 |
+
'''
|
| 691 |
+
test_metrics_reduced = {}
|
| 692 |
+
for m in test_metrics.keys():
|
| 693 |
+
test_metrics_reduced[m] = get_reduced(test_metrics[m], self.local_rank, 0, self.world_size)
|
| 694 |
+
test_metrics = test_metrics_reduced'''
|
| 695 |
+
|
| 696 |
+
log = "Best performing model: Test Top 20 DE MSE: {:.4f}"
|
| 697 |
+
if self.is_master:
|
| 698 |
+
print_sys(log.format(test_metrics['mse_de']))
|
| 699 |
+
if self.wandb:
|
| 700 |
+
metrics = ['mse', 'pearson']
|
| 701 |
+
for m in metrics:
|
| 702 |
+
self.wandb.log({'test_' + m: test_metrics[m],
|
| 703 |
+
'test_de_'+m: test_metrics[m + '_de']
|
| 704 |
+
})
|
| 705 |
+
|
| 706 |
+
out = deeper_analysis(self.adata, test_res)
|
| 707 |
+
out_non_dropout = non_dropout_analysis(self.adata, test_res)
|
| 708 |
+
|
| 709 |
+
if self.split == 'simulation':
|
| 710 |
+
print_sys("Start doing subgroup analysis for simulation split...")
|
| 711 |
+
subgroup = self.subgroup
|
| 712 |
+
subgroup_analysis = {}
|
| 713 |
+
for name in subgroup['test_subgroup'].keys():
|
| 714 |
+
subgroup_analysis[name] = {}
|
| 715 |
+
for m in list(list(test_pert_res.values())[0].keys()):
|
| 716 |
+
subgroup_analysis[name][m] = []
|
| 717 |
+
|
| 718 |
+
for name, pert_list in subgroup['test_subgroup'].items():
|
| 719 |
+
for pert in pert_list:
|
| 720 |
+
for m, res in test_pert_res[pert].items():
|
| 721 |
+
subgroup_analysis[name][m].append(res)
|
| 722 |
+
|
| 723 |
+
for name, result in subgroup_analysis.items():
|
| 724 |
+
for m in result.keys():
|
| 725 |
+
subgroup_analysis[name][m] = np.mean(subgroup_analysis[name][m])
|
| 726 |
+
if self.wandb:
|
| 727 |
+
self.wandb.log({'test_' + name + '_' + m: subgroup_analysis[name][m]})
|
| 728 |
+
|
| 729 |
+
print_sys('test_' + name + '_' + m + ': ' + str(subgroup_analysis[name][m]))
|
| 730 |
+
|
| 731 |
+
## deeper analysis
|
| 732 |
+
subgroup_analysis = {}
|
| 733 |
+
for name in subgroup['test_subgroup'].keys():
|
| 734 |
+
subgroup_analysis[name] = {}
|
| 735 |
+
|
| 736 |
+
for name, pert_list in subgroup['test_subgroup'].items():
|
| 737 |
+
for pert in pert_list:
|
| 738 |
+
for m in out[pert].keys():
|
| 739 |
+
if m in subgroup_analysis[name].keys():
|
| 740 |
+
subgroup_analysis[name][m].append(out[pert][m])
|
| 741 |
+
else:
|
| 742 |
+
subgroup_analysis[name][m] = [out[pert][m]]
|
| 743 |
+
|
| 744 |
+
for m in out_non_dropout[pert].keys():
|
| 745 |
+
if m in subgroup_analysis[name].keys():
|
| 746 |
+
subgroup_analysis[name][m].append(out_non_dropout[pert][m])
|
| 747 |
+
else:
|
| 748 |
+
subgroup_analysis[name][m] = [out_non_dropout[pert][m]]
|
| 749 |
+
|
| 750 |
+
for name, result in subgroup_analysis.items():
|
| 751 |
+
for m in result.keys():
|
| 752 |
+
subgroup_analysis[name][m] = np.mean(subgroup_analysis[name][m])
|
| 753 |
+
if self.wandb:
|
| 754 |
+
self.wandb.log({'test_' + name + '_' + m: subgroup_analysis[name][m]})
|
| 755 |
+
|
| 756 |
+
print_sys('test_' + name + '_' + m + ': ' + str(subgroup_analysis[name][m]))
|
| 757 |
+
print_sys('Done!')
|
| 758 |
+
|
| 759 |
+
|
| 760 |
+
|
| 761 |
+
|
BioMod/gene_perturbation/inference_local.py
ADDED
|
@@ -0,0 +1,1034 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
================================================================================
|
| 3 |
+
Model evaluation module (inference_local.py)
|
| 4 |
+
================================================================================
|
| 5 |
+
|
| 6 |
+
[Module function]
|
| 7 |
+
Provides evaluation and analysis functions for genetic perturbation models, including:
|
| 8 |
+
|
| 9 |
+
1. evaluate(): main evaluation function, calculates MSE, Pearson correlation coefficient and other indicators
|
| 10 |
+
2. compute_metrics(): Calculate the complete dictionary of evaluation metrics
|
| 11 |
+
3. deeper_analysis(): In-depth analysis of prediction results
|
| 12 |
+
4. non_dropout_analysis(): analyze on non-dropout genes
|
| 13 |
+
|
| 14 |
+
[Description of key indicators]
|
| 15 |
+
- MSE: mean square error of all genes
|
| 16 |
+
- MSE_DE: MSE of Top 20 differentially expressed genes (paying more attention to biological significance)
|
| 17 |
+
- Pearson: Pearson correlation coefficient of all genes
|
| 18 |
+
- Pearson_DE: Pearson correlation coefficient of Top 20 DE genes
|
| 19 |
+
|
| 20 |
+
[Evaluation Notes]
|
| 21 |
+
- real_num_genes: only evaluate on real genes, excluding extended/completed genes
|
| 22 |
+
- de_idx: The differentially expressed gene index of each sample may be different
|
| 23 |
+
================================================================================
|
| 24 |
+
"""
|
| 25 |
+
|
| 26 |
+
import torch
|
| 27 |
+
import numpy as np
|
| 28 |
+
import scanpy as sc
|
| 29 |
+
|
| 30 |
+
from sklearn.metrics import r2_score
|
| 31 |
+
from scipy.stats import pearsonr, spearmanr
|
| 32 |
+
from sklearn.metrics import mean_squared_error as mse
|
| 33 |
+
from sklearn.metrics import mean_absolute_error as mae
|
| 34 |
+
|
| 35 |
+
def evaluate(loader, model, uncertainty, device, real_num_genes = None):
|
| 36 |
+
"""
|
| 37 |
+
main evaluation function
|
| 38 |
+
|
| 39 |
+
[Function]
|
| 40 |
+
Runs the model on the given data loader, collecting predicted and true values.
|
| 41 |
+
|
| 42 |
+
[Key steps]
|
| 43 |
+
1. Traverse all batches of the data loader
|
| 44 |
+
2. Run model inference (without calculating gradients)
|
| 45 |
+
3. Collect predicted values, true values, and DE gene predicted values
|
| 46 |
+
|
| 47 |
+
[Parameter description]
|
| 48 |
+
- real_num_genes: only evaluate the first N real genes to avoid expanding gene interference indicators
|
| 49 |
+
"""
|
| 50 |
+
|
| 51 |
+
model.eval()
|
| 52 |
+
model.to(device)
|
| 53 |
+
pert_cat = []
|
| 54 |
+
pred = []
|
| 55 |
+
truth = []
|
| 56 |
+
pred_de = []
|
| 57 |
+
truth_de = []
|
| 58 |
+
results = {}
|
| 59 |
+
logvar = []
|
| 60 |
+
|
| 61 |
+
for itr, batch in enumerate(loader):
|
| 62 |
+
|
| 63 |
+
batch.to(device)
|
| 64 |
+
pert_cat.extend(batch.pert)
|
| 65 |
+
|
| 66 |
+
with torch.no_grad():
|
| 67 |
+
if uncertainty:
|
| 68 |
+
p, unc = model(batch)
|
| 69 |
+
logvar.extend(unc.cpu())
|
| 70 |
+
else:
|
| 71 |
+
p = model(batch)
|
| 72 |
+
# Only evaluate in the real gene dimension to avoid the interference of completed/expanded genes on indicators.
|
| 73 |
+
p = p[:, :real_num_genes]
|
| 74 |
+
t = batch.y[:, :real_num_genes]
|
| 75 |
+
pred.extend(p.cpu())
|
| 76 |
+
truth.extend(t.cpu())
|
| 77 |
+
|
| 78 |
+
# The DE indicator is only counted on the de_idx corresponding to the sample and is one of the core calibers of downstream reporting.
|
| 79 |
+
for itr, de_idx in enumerate(batch.de_idx):
|
| 80 |
+
pred_de.append(p[itr, de_idx])
|
| 81 |
+
truth_de.append(t[itr, de_idx])
|
| 82 |
+
|
| 83 |
+
# all genes
|
| 84 |
+
results['pert_cat'] = np.array(pert_cat)
|
| 85 |
+
pred = torch.stack(pred)
|
| 86 |
+
truth = torch.stack(truth)
|
| 87 |
+
results['pred']= pred.detach().cpu().numpy()
|
| 88 |
+
results['truth']= truth.detach().cpu().numpy()
|
| 89 |
+
|
| 90 |
+
pred_de = torch.stack(pred_de)
|
| 91 |
+
truth_de = torch.stack(truth_de)
|
| 92 |
+
results['pred_de']= pred_de.detach().cpu().numpy()
|
| 93 |
+
results['truth_de']= truth_de.detach().cpu().numpy()
|
| 94 |
+
|
| 95 |
+
if uncertainty:
|
| 96 |
+
results['logvar'] = torch.stack(logvar).detach().cpu().numpy()
|
| 97 |
+
|
| 98 |
+
return results
|
| 99 |
+
|
| 100 |
+
#This evaluation is just as test. IT HAS INFORMATION LEAKAGE!
|
| 101 |
+
def evaluate_with_nonzero_mask(loader, model, uncertainty, device):
|
| 102 |
+
"""
|
| 103 |
+
Run model in inference mode using a given data loader
|
| 104 |
+
"""
|
| 105 |
+
|
| 106 |
+
model.eval()
|
| 107 |
+
model.to(device)
|
| 108 |
+
pert_cat = []
|
| 109 |
+
pred = []
|
| 110 |
+
truth = []
|
| 111 |
+
pred_de = []
|
| 112 |
+
truth_de = []
|
| 113 |
+
results = {}
|
| 114 |
+
logvar = []
|
| 115 |
+
|
| 116 |
+
for itr, batch in enumerate(loader):
|
| 117 |
+
|
| 118 |
+
batch.to(device)
|
| 119 |
+
pert_cat.extend(batch.pert)
|
| 120 |
+
|
| 121 |
+
with torch.no_grad():
|
| 122 |
+
if uncertainty:
|
| 123 |
+
p, unc = model(batch)
|
| 124 |
+
logvar.extend(unc.cpu())
|
| 125 |
+
else:
|
| 126 |
+
p = model(batch)
|
| 127 |
+
t = batch.y
|
| 128 |
+
p = p * (t != 0)
|
| 129 |
+
pred.extend(p.cpu())
|
| 130 |
+
truth.extend(t.cpu())
|
| 131 |
+
|
| 132 |
+
# Differentially expressed genes
|
| 133 |
+
for itr, de_idx in enumerate(batch.de_idx):
|
| 134 |
+
pred_de.append(p[itr, de_idx])
|
| 135 |
+
truth_de.append(t[itr, de_idx])
|
| 136 |
+
|
| 137 |
+
# all genes
|
| 138 |
+
results['pert_cat'] = np.array(pert_cat)
|
| 139 |
+
pred = torch.stack(pred)
|
| 140 |
+
truth = torch.stack(truth)
|
| 141 |
+
results['pred']= pred.detach().cpu().numpy()
|
| 142 |
+
results['truth']= truth.detach().cpu().numpy()
|
| 143 |
+
|
| 144 |
+
pred_de = torch.stack(pred_de)
|
| 145 |
+
truth_de = torch.stack(truth_de)
|
| 146 |
+
results['pred_de']= pred_de.detach().cpu().numpy()
|
| 147 |
+
results['truth_de']= truth_de.detach().cpu().numpy()
|
| 148 |
+
|
| 149 |
+
if uncertainty:
|
| 150 |
+
results['logvar'] = torch.stack(logvar).detach().cpu().numpy()
|
| 151 |
+
|
| 152 |
+
return results
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def non_zero_evaluate(loader, model, uncertainty, device,
|
| 156 |
+
delta_list, penalty = {
|
| 157 |
+
"false_neg": 1,
|
| 158 |
+
"false_pos": 1 }):
|
| 159 |
+
"""
|
| 160 |
+
Run model in inference mode using a given data loader
|
| 161 |
+
"""
|
| 162 |
+
|
| 163 |
+
model.eval()
|
| 164 |
+
model.to(device)
|
| 165 |
+
pert_cat = []
|
| 166 |
+
pred = []
|
| 167 |
+
truth = []
|
| 168 |
+
pred_de = []
|
| 169 |
+
truth_de = []
|
| 170 |
+
results = {}
|
| 171 |
+
logvar = []
|
| 172 |
+
|
| 173 |
+
for itr, batch in enumerate(loader):
|
| 174 |
+
|
| 175 |
+
batch.to(device)
|
| 176 |
+
pert_cat.extend(batch.pert)
|
| 177 |
+
|
| 178 |
+
with torch.no_grad():
|
| 179 |
+
if uncertainty:
|
| 180 |
+
p, unc = model(batch)
|
| 181 |
+
logvar.extend(unc.cpu())
|
| 182 |
+
else:
|
| 183 |
+
p = model(batch)
|
| 184 |
+
t = batch.y
|
| 185 |
+
pred.extend(p.cpu())
|
| 186 |
+
truth.extend(t.cpu())
|
| 187 |
+
|
| 188 |
+
# Differentially expressed genes
|
| 189 |
+
for itr, de_idx in enumerate(batch.de_idx):
|
| 190 |
+
pred_de.append(p[itr, de_idx])
|
| 191 |
+
truth_de.append(t[itr, de_idx])
|
| 192 |
+
|
| 193 |
+
# all genes
|
| 194 |
+
results['pert_cat'] = np.array(pert_cat)
|
| 195 |
+
pred = torch.stack(pred)
|
| 196 |
+
truth = torch.stack(truth)
|
| 197 |
+
results['pred']= pred.detach().cpu().numpy()
|
| 198 |
+
results['truth']= truth.detach().cpu().numpy()
|
| 199 |
+
|
| 200 |
+
pred_de = torch.stack(pred_de)
|
| 201 |
+
truth_de = torch.stack(truth_de)
|
| 202 |
+
results['pred_de']= pred_de.detach().cpu().numpy()
|
| 203 |
+
results['truth_de']= truth_de.detach().cpu().numpy()
|
| 204 |
+
|
| 205 |
+
if uncertainty:
|
| 206 |
+
results['logvar'] = torch.stack(logvar).detach().cpu().numpy()
|
| 207 |
+
|
| 208 |
+
return results
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
def compute_metrics(results, gene_idx=None):
|
| 212 |
+
"""
|
| 213 |
+
Given results from a model run and the ground truth, compute metrics
|
| 214 |
+
|
| 215 |
+
"""
|
| 216 |
+
metrics = {}
|
| 217 |
+
metrics_pert = {}
|
| 218 |
+
|
| 219 |
+
metric2fct = {
|
| 220 |
+
'mse': mse,
|
| 221 |
+
'pearson': pearsonr
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
for m in metric2fct.keys():
|
| 225 |
+
metrics[m] = []
|
| 226 |
+
metrics[m + '_de'] = []
|
| 227 |
+
|
| 228 |
+
for pert in np.unique(results['pert_cat']):
|
| 229 |
+
|
| 230 |
+
metrics_pert[pert] = {}
|
| 231 |
+
p_idx = np.where(results['pert_cat'] == pert)[0]
|
| 232 |
+
|
| 233 |
+
for m, fct in metric2fct.items():
|
| 234 |
+
# First average the cells of the same perturbation, and then calculate the indicator of the perturbation.
|
| 235 |
+
if m == 'pearson':
|
| 236 |
+
val = fct(results['pred'][p_idx].mean(0), results['truth'][p_idx].mean(0))[0]
|
| 237 |
+
if np.isnan(val):
|
| 238 |
+
val = 0
|
| 239 |
+
else:
|
| 240 |
+
val = fct(results['pred'][p_idx].mean(0), results['truth'][p_idx].mean(0))
|
| 241 |
+
|
| 242 |
+
metrics_pert[pert][m] = val
|
| 243 |
+
metrics[m].append(metrics_pert[pert][m])
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
if pert != 'ctrl':
|
| 247 |
+
|
| 248 |
+
for m, fct in metric2fct.items():
|
| 249 |
+
if m == 'pearson':
|
| 250 |
+
val = fct(results['pred_de'][p_idx].mean(0), results['truth_de'][p_idx].mean(0))[0]
|
| 251 |
+
if np.isnan(val):
|
| 252 |
+
val = 0
|
| 253 |
+
else:
|
| 254 |
+
val = fct(results['pred_de'][p_idx].mean(0), results['truth_de'][p_idx].mean(0))
|
| 255 |
+
|
| 256 |
+
metrics_pert[pert][m + '_de'] = val
|
| 257 |
+
metrics[m + '_de'].append(metrics_pert[pert][m + '_de'])
|
| 258 |
+
|
| 259 |
+
else:
|
| 260 |
+
for m, fct in metric2fct.items():
|
| 261 |
+
metrics_pert[pert][m + '_de'] = 0
|
| 262 |
+
|
| 263 |
+
for m in metric2fct.keys():
|
| 264 |
+
# The result is a perturbation-level macro average (each perturbation is equally weighted).
|
| 265 |
+
metrics[m] = np.mean(metrics[m])
|
| 266 |
+
metrics[m + '_de'] = np.mean(metrics[m + '_de'])
|
| 267 |
+
|
| 268 |
+
return metrics, metrics_pert
|
| 269 |
+
|
| 270 |
+
|
| 271 |
+
def compute_synergy_loss(results, mean_control, high_umi_idx, subtype = 'POTENTIATION'):
|
| 272 |
+
pred_res = get_test_set_results_seen2(results, subtype)
|
| 273 |
+
all_perts = np.unique(results['pert_cat'])
|
| 274 |
+
linear_params = get_linear_params(pred_res, high_umi_idx,
|
| 275 |
+
mean_control,all_perts)
|
| 276 |
+
synergy_loss = np.sum([np.abs(linear_params[k]['pred']['mag']
|
| 277 |
+
- linear_params[k]['truth']['mag']) for k in
|
| 278 |
+
linear_params])
|
| 279 |
+
|
| 280 |
+
mag = np.sum([np.abs(linear_params[k]['pred']['mag']) for k in
|
| 281 |
+
linear_params])
|
| 282 |
+
return synergy_loss, mag
|
| 283 |
+
|
| 284 |
+
|
| 285 |
+
def non_zero_analysis(adata, test_res):
|
| 286 |
+
metric2fct = {
|
| 287 |
+
'pearson': pearsonr,
|
| 288 |
+
'mse': mse
|
| 289 |
+
}
|
| 290 |
+
|
| 291 |
+
pert_metric = {}
|
| 292 |
+
|
| 293 |
+
## in silico modeling and upperbounding
|
| 294 |
+
pert2pert_full_id = dict(adata.obs[['condition', 'condition_name']].values)
|
| 295 |
+
geneid2name = dict(zip(adata.var.index.values, adata.var['gene_name']))
|
| 296 |
+
geneid2idx = dict(zip(adata.var.index.values, range(len(adata.var.index.values))))
|
| 297 |
+
|
| 298 |
+
# calculate mean expression for each condition
|
| 299 |
+
unique_conditions = adata.obs.condition.unique()
|
| 300 |
+
conditions2index = {}
|
| 301 |
+
for i in unique_conditions:
|
| 302 |
+
conditions2index[i] = np.where(adata.obs.condition == i)[0]
|
| 303 |
+
|
| 304 |
+
condition2mean_expression = {}
|
| 305 |
+
for i, j in conditions2index.items():
|
| 306 |
+
condition2mean_expression[i] = np.mean(adata.X[j], axis = 0)
|
| 307 |
+
pert_list = np.array(list(condition2mean_expression.keys()))
|
| 308 |
+
mean_expression = np.array(list(condition2mean_expression.values())).reshape(len(adata.obs.condition.unique()), adata.X.toarray().shape[1])
|
| 309 |
+
ctrl = mean_expression[np.where(pert_list == 'ctrl')[0]]
|
| 310 |
+
|
| 311 |
+
gene_list = adata.var['gene_name'].values
|
| 312 |
+
|
| 313 |
+
for pert in np.unique(test_res['pert_cat']):
|
| 314 |
+
pert_metric[pert] = {}
|
| 315 |
+
|
| 316 |
+
pert_idx = np.where(test_res['pert_cat'] == pert)[0]
|
| 317 |
+
de_idx = [geneid2idx[i] for i in adata.uns['top_non_zero_de_20'][pert2pert_full_id[pert]]]
|
| 318 |
+
|
| 319 |
+
direc_change = np.abs(np.sign(test_res['pred'][pert_idx].mean(0)[de_idx] - ctrl[0][de_idx]) - np.sign(test_res['truth'][pert_idx].mean(0)[de_idx] - ctrl[0][de_idx]))
|
| 320 |
+
frac_correct_direction = len(np.where(direc_change == 0)[0])/len(de_idx)
|
| 321 |
+
pert_metric[pert]['frac_correct_direction_top20_non_zero'] = frac_correct_direction
|
| 322 |
+
|
| 323 |
+
frac_direction_opposite = len(np.where(direc_change == 2)[0])/len(de_idx)
|
| 324 |
+
pert_metric[pert]['frac_opposite_direction_top20_non_zero'] = frac_direction_opposite
|
| 325 |
+
|
| 326 |
+
frac_direction_opposite = len(np.where(direc_change == 1)[0])/len(de_idx)
|
| 327 |
+
pert_metric[pert]['frac_0/1_direction_top20_non_zero'] = frac_direction_opposite
|
| 328 |
+
|
| 329 |
+
mean = np.mean(test_res['truth'][pert_idx][:, de_idx], axis = 0)
|
| 330 |
+
std = np.std(test_res['truth'][pert_idx][:, de_idx], axis = 0)
|
| 331 |
+
min_ = np.min(test_res['truth'][pert_idx][:, de_idx], axis = 0)
|
| 332 |
+
max_ = np.max(test_res['truth'][pert_idx][:, de_idx], axis = 0)
|
| 333 |
+
q25 = np.quantile(test_res['truth'][pert_idx][:, de_idx], 0.25, axis = 0)
|
| 334 |
+
q75 = np.quantile(test_res['truth'][pert_idx][:, de_idx], 0.75, axis = 0)
|
| 335 |
+
q55 = np.quantile(test_res['truth'][pert_idx][:, de_idx], 0.55, axis = 0)
|
| 336 |
+
q45 = np.quantile(test_res['truth'][pert_idx][:, de_idx], 0.45, axis = 0)
|
| 337 |
+
q40 = np.quantile(test_res['truth'][pert_idx][:, de_idx], 0.4, axis = 0)
|
| 338 |
+
q60 = np.quantile(test_res['truth'][pert_idx][:, de_idx], 0.6, axis = 0)
|
| 339 |
+
|
| 340 |
+
zero_des = np.intersect1d(np.where(min_ == 0)[0], np.where(max_ == 0)[0])
|
| 341 |
+
nonzero_des = np.setdiff1d(list(range(20)), zero_des)
|
| 342 |
+
|
| 343 |
+
if len(nonzero_des) == 0:
|
| 344 |
+
pass
|
| 345 |
+
# pert that all de genes are 0...
|
| 346 |
+
else:
|
| 347 |
+
pred_mean = np.mean(test_res['pred'][pert_idx][:, de_idx], axis = 0).reshape(-1,)
|
| 348 |
+
true_mean = np.mean(test_res['truth'][pert_idx][:, de_idx], axis = 0).reshape(-1,)
|
| 349 |
+
|
| 350 |
+
in_range = (pred_mean[nonzero_des] >= min_[nonzero_des]) & (pred_mean[nonzero_des] <= max_[nonzero_des])
|
| 351 |
+
frac_in_range = sum(in_range)/len(nonzero_des)
|
| 352 |
+
pert_metric[pert]['frac_in_range_non_zero'] = frac_in_range
|
| 353 |
+
|
| 354 |
+
in_range_5 = (pred_mean[nonzero_des] >= q45[nonzero_des]) & (pred_mean[nonzero_des] <= q55[nonzero_des])
|
| 355 |
+
frac_in_range_45_55 = sum(in_range_5)/len(nonzero_des)
|
| 356 |
+
pert_metric[pert]['frac_in_range_45_55_non_zero'] = frac_in_range_45_55
|
| 357 |
+
|
| 358 |
+
in_range_10 = (pred_mean[nonzero_des] >= q40[nonzero_des]) & (pred_mean[nonzero_des] <= q60[nonzero_des])
|
| 359 |
+
frac_in_range_40_60 = sum(in_range_10)/len(nonzero_des)
|
| 360 |
+
pert_metric[pert]['frac_in_range_40_60_non_zero'] = frac_in_range_40_60
|
| 361 |
+
|
| 362 |
+
in_range_25 = (pred_mean[nonzero_des] >= q25[nonzero_des]) & (pred_mean[nonzero_des] <= q75[nonzero_des])
|
| 363 |
+
frac_in_range_25_75 = sum(in_range_25)/len(nonzero_des)
|
| 364 |
+
pert_metric[pert]['frac_in_range_25_75_non_zero'] = frac_in_range_25_75
|
| 365 |
+
|
| 366 |
+
zero_idx = np.where(std > 0)[0]
|
| 367 |
+
sigma = (np.abs(pred_mean[zero_idx] - mean[zero_idx]))/(std[zero_idx])
|
| 368 |
+
pert_metric[pert]['mean_sigma_non_zero'] = np.mean(sigma)
|
| 369 |
+
pert_metric[pert]['std_sigma_non_zero'] = np.std(sigma)
|
| 370 |
+
pert_metric[pert]['frac_sigma_below_1_non_zero'] = 1 - len(np.where(sigma > 1)[0])/len(zero_idx)
|
| 371 |
+
pert_metric[pert]['frac_sigma_below_2_non_zero'] = 1 - len(np.where(sigma > 2)[0])/len(zero_idx)
|
| 372 |
+
|
| 373 |
+
p_idx = np.where(test_res['pert_cat'] == pert)[0]
|
| 374 |
+
for m, fct in metric2fct.items():
|
| 375 |
+
if m != 'mse':
|
| 376 |
+
val = fct(test_res['pred'][p_idx].mean(0)[de_idx] - ctrl[0][de_idx], test_res['truth'][p_idx].mean(0)[de_idx]-ctrl[0][de_idx])[0]
|
| 377 |
+
if np.isnan(val):
|
| 378 |
+
val = 0
|
| 379 |
+
pert_metric[pert][m + '_delta_top20_de_non_zero'] = val
|
| 380 |
+
|
| 381 |
+
|
| 382 |
+
val = fct(test_res['pred'][p_idx].mean(0)[de_idx], test_res['truth'][p_idx].mean(0)[de_idx])[0]
|
| 383 |
+
if np.isnan(val):
|
| 384 |
+
val = 0
|
| 385 |
+
pert_metric[pert][m + '_top20_de_non_zero'] = val
|
| 386 |
+
else:
|
| 387 |
+
val = fct(test_res['pred'][p_idx].mean(0)[de_idx] - ctrl[0][de_idx], test_res['truth'][p_idx].mean(0)[de_idx]-ctrl[0][de_idx])
|
| 388 |
+
pert_metric[pert][m + '_top20_de_non_zero'] = val
|
| 389 |
+
|
| 390 |
+
return pert_metric
|
| 391 |
+
|
| 392 |
+
def non_dropout_analysis(adata, test_res):
|
| 393 |
+
metric2fct = {
|
| 394 |
+
'pearson': pearsonr,
|
| 395 |
+
'mse': mse
|
| 396 |
+
}
|
| 397 |
+
|
| 398 |
+
pert_metric = {}
|
| 399 |
+
|
| 400 |
+
## in silico modeling and upperbounding
|
| 401 |
+
pert2pert_full_id = dict(adata.obs[['condition', 'condition_name']].values)
|
| 402 |
+
geneid2name = dict(zip(adata.var.index.values, adata.var['gene_name']))
|
| 403 |
+
geneid2idx = dict(zip(adata.var.index.values, range(len(adata.var.index.values))))
|
| 404 |
+
|
| 405 |
+
# calculate mean expression for each condition
|
| 406 |
+
unique_conditions = adata.obs.condition.unique()
|
| 407 |
+
conditions2index = {}
|
| 408 |
+
for i in unique_conditions:
|
| 409 |
+
conditions2index[i] = np.where(adata.obs.condition == i)[0]
|
| 410 |
+
|
| 411 |
+
condition2mean_expression = {}
|
| 412 |
+
for i, j in conditions2index.items():
|
| 413 |
+
condition2mean_expression[i] = np.mean(adata.X[j], axis = 0)
|
| 414 |
+
pert_list = np.array(list(condition2mean_expression.keys()))
|
| 415 |
+
mean_expression = np.array(list(condition2mean_expression.values())).reshape(len(adata.obs.condition.unique()), adata.X.toarray().shape[1])
|
| 416 |
+
ctrl = mean_expression[np.where(pert_list == 'ctrl')[0]]
|
| 417 |
+
|
| 418 |
+
gene_list = adata.var['gene_name'].values
|
| 419 |
+
|
| 420 |
+
for pert in np.unique(test_res['pert_cat']):
|
| 421 |
+
pert_metric[pert] = {}
|
| 422 |
+
|
| 423 |
+
pert_idx = np.where(test_res['pert_cat'] == pert)[0]
|
| 424 |
+
de_idx = [geneid2idx[i] for i in adata.uns['top_non_dropout_de_20'][pert2pert_full_id[pert]]]
|
| 425 |
+
non_zero_idx = adata.uns['non_zeros_gene_idx'][pert2pert_full_id[pert]]
|
| 426 |
+
non_dropout_gene_idx = adata.uns['non_dropout_gene_idx'][pert2pert_full_id[pert]]
|
| 427 |
+
|
| 428 |
+
direc_change = np.abs(np.sign(test_res['pred'][pert_idx].mean(0)[de_idx] - ctrl[0][de_idx]) - np.sign(test_res['truth'][pert_idx].mean(0)[de_idx] - ctrl[0][de_idx]))
|
| 429 |
+
frac_correct_direction = len(np.where(direc_change == 0)[0])/len(de_idx)
|
| 430 |
+
pert_metric[pert]['frac_correct_direction_top20_non_dropout'] = frac_correct_direction
|
| 431 |
+
|
| 432 |
+
frac_direction_opposite = len(np.where(direc_change == 2)[0])/len(de_idx)
|
| 433 |
+
pert_metric[pert]['frac_opposite_direction_top20_non_dropout'] = frac_direction_opposite
|
| 434 |
+
|
| 435 |
+
frac_direction_opposite = len(np.where(direc_change == 1)[0])/len(de_idx)
|
| 436 |
+
pert_metric[pert]['frac_0/1_direction_top20_non_dropout'] = frac_direction_opposite
|
| 437 |
+
|
| 438 |
+
direc_change = np.abs(np.sign(test_res['pred'][pert_idx].mean(0)[non_zero_idx] - ctrl[0][non_zero_idx]) - np.sign(test_res['truth'][pert_idx].mean(0)[non_zero_idx] - ctrl[0][non_zero_idx]))
|
| 439 |
+
frac_correct_direction = len(np.where(direc_change == 0)[0])/len(non_zero_idx)
|
| 440 |
+
pert_metric[pert]['frac_correct_direction_non_zero'] = frac_correct_direction
|
| 441 |
+
|
| 442 |
+
frac_direction_opposite = len(np.where(direc_change == 2)[0])/len(non_zero_idx)
|
| 443 |
+
pert_metric[pert]['frac_opposite_direction_non_zero'] = frac_direction_opposite
|
| 444 |
+
|
| 445 |
+
frac_direction_opposite = len(np.where(direc_change == 1)[0])/len(non_zero_idx)
|
| 446 |
+
pert_metric[pert]['frac_0/1_direction_non_zero'] = frac_direction_opposite
|
| 447 |
+
|
| 448 |
+
direc_change = np.abs(np.sign(test_res['pred'][pert_idx].mean(0)[non_dropout_gene_idx] - ctrl[0][non_dropout_gene_idx]) - np.sign(test_res['truth'][pert_idx].mean(0)[non_dropout_gene_idx] - ctrl[0][non_dropout_gene_idx]))
|
| 449 |
+
frac_correct_direction = len(np.where(direc_change == 0)[0])/len(non_dropout_gene_idx)
|
| 450 |
+
pert_metric[pert]['frac_correct_direction_non_dropout'] = frac_correct_direction
|
| 451 |
+
|
| 452 |
+
frac_direction_opposite = len(np.where(direc_change == 2)[0])/len(non_dropout_gene_idx)
|
| 453 |
+
pert_metric[pert]['frac_opposite_direction_non_dropout'] = frac_direction_opposite
|
| 454 |
+
|
| 455 |
+
frac_direction_opposite = len(np.where(direc_change == 1)[0])/len(non_dropout_gene_idx)
|
| 456 |
+
pert_metric[pert]['frac_0/1_direction_non_dropout'] = frac_direction_opposite
|
| 457 |
+
|
| 458 |
+
mean = np.mean(test_res['truth'][pert_idx][:, de_idx], axis = 0)
|
| 459 |
+
std = np.std(test_res['truth'][pert_idx][:, de_idx], axis = 0)
|
| 460 |
+
min_ = np.min(test_res['truth'][pert_idx][:, de_idx], axis = 0)
|
| 461 |
+
max_ = np.max(test_res['truth'][pert_idx][:, de_idx], axis = 0)
|
| 462 |
+
q25 = np.quantile(test_res['truth'][pert_idx][:, de_idx], 0.25, axis = 0)
|
| 463 |
+
q75 = np.quantile(test_res['truth'][pert_idx][:, de_idx], 0.75, axis = 0)
|
| 464 |
+
q55 = np.quantile(test_res['truth'][pert_idx][:, de_idx], 0.55, axis = 0)
|
| 465 |
+
q45 = np.quantile(test_res['truth'][pert_idx][:, de_idx], 0.45, axis = 0)
|
| 466 |
+
q40 = np.quantile(test_res['truth'][pert_idx][:, de_idx], 0.4, axis = 0)
|
| 467 |
+
q60 = np.quantile(test_res['truth'][pert_idx][:, de_idx], 0.6, axis = 0)
|
| 468 |
+
|
| 469 |
+
zero_des = np.intersect1d(np.where(min_ == 0)[0], np.where(max_ == 0)[0])
|
| 470 |
+
nonzero_des = np.setdiff1d(list(range(20)), zero_des)
|
| 471 |
+
|
| 472 |
+
if len(nonzero_des) == 0:
|
| 473 |
+
pass
|
| 474 |
+
# pert that all de genes are 0...
|
| 475 |
+
else:
|
| 476 |
+
pred_mean = np.mean(test_res['pred'][pert_idx][:, de_idx], axis = 0).reshape(-1,)
|
| 477 |
+
true_mean = np.mean(test_res['truth'][pert_idx][:, de_idx], axis = 0).reshape(-1,)
|
| 478 |
+
|
| 479 |
+
in_range = (pred_mean[nonzero_des] >= min_[nonzero_des]) & (pred_mean[nonzero_des] <= max_[nonzero_des])
|
| 480 |
+
frac_in_range = sum(in_range)/len(nonzero_des)
|
| 481 |
+
pert_metric[pert]['frac_in_range_non_dropout'] = frac_in_range
|
| 482 |
+
|
| 483 |
+
in_range_5 = (pred_mean[nonzero_des] >= q45[nonzero_des]) & (pred_mean[nonzero_des] <= q55[nonzero_des])
|
| 484 |
+
frac_in_range_45_55 = sum(in_range_5)/len(nonzero_des)
|
| 485 |
+
pert_metric[pert]['frac_in_range_45_55_non_dropout'] = frac_in_range_45_55
|
| 486 |
+
|
| 487 |
+
in_range_10 = (pred_mean[nonzero_des] >= q40[nonzero_des]) & (pred_mean[nonzero_des] <= q60[nonzero_des])
|
| 488 |
+
frac_in_range_40_60 = sum(in_range_10)/len(nonzero_des)
|
| 489 |
+
pert_metric[pert]['frac_in_range_40_60_non_dropout'] = frac_in_range_40_60
|
| 490 |
+
|
| 491 |
+
in_range_25 = (pred_mean[nonzero_des] >= q25[nonzero_des]) & (pred_mean[nonzero_des] <= q75[nonzero_des])
|
| 492 |
+
frac_in_range_25_75 = sum(in_range_25)/len(nonzero_des)
|
| 493 |
+
pert_metric[pert]['frac_in_range_25_75_non_dropout'] = frac_in_range_25_75
|
| 494 |
+
|
| 495 |
+
zero_idx = np.where(std > 0)[0]
|
| 496 |
+
sigma = (np.abs(pred_mean[zero_idx] - mean[zero_idx]))/(std[zero_idx])
|
| 497 |
+
pert_metric[pert]['mean_sigma_non_dropout'] = np.mean(sigma)
|
| 498 |
+
pert_metric[pert]['std_sigma_non_dropout'] = np.std(sigma)
|
| 499 |
+
pert_metric[pert]['frac_sigma_below_1_non_dropout'] = 1 - len(np.where(sigma > 1)[0])/len(zero_idx)
|
| 500 |
+
pert_metric[pert]['frac_sigma_below_2_non_dropout'] = 1 - len(np.where(sigma > 2)[0])/len(zero_idx)
|
| 501 |
+
|
| 502 |
+
p_idx = np.where(test_res['pert_cat'] == pert)[0]
|
| 503 |
+
for m, fct in metric2fct.items():
|
| 504 |
+
if m != 'mse':
|
| 505 |
+
val = fct(test_res['pred'][p_idx].mean(0)[de_idx] - ctrl[0][de_idx], test_res['truth'][p_idx].mean(0)[de_idx]-ctrl[0][de_idx])[0]
|
| 506 |
+
if np.isnan(val):
|
| 507 |
+
val = 0
|
| 508 |
+
pert_metric[pert][m + '_delta_top20_de_non_dropout'] = val
|
| 509 |
+
|
| 510 |
+
|
| 511 |
+
val = fct(test_res['pred'][p_idx].mean(0)[de_idx], test_res['truth'][p_idx].mean(0)[de_idx])[0]
|
| 512 |
+
if np.isnan(val):
|
| 513 |
+
val = 0
|
| 514 |
+
pert_metric[pert][m + '_top20_de_non_dropout'] = val
|
| 515 |
+
else:
|
| 516 |
+
val = fct(test_res['pred'][p_idx].mean(0)[de_idx] - ctrl[0][de_idx], test_res['truth'][p_idx].mean(0)[de_idx]-ctrl[0][de_idx])
|
| 517 |
+
pert_metric[pert][m + '_top20_de_non_dropout'] = val
|
| 518 |
+
|
| 519 |
+
return pert_metric
|
| 520 |
+
|
| 521 |
+
def deeper_analysis(adata, test_res, de_column_prefix = 'rank_genes_groups_cov', most_variable_genes = None):
|
| 522 |
+
|
| 523 |
+
metric2fct = {
|
| 524 |
+
'pearson': pearsonr,
|
| 525 |
+
'mse': mse
|
| 526 |
+
}
|
| 527 |
+
|
| 528 |
+
pert_metric = {}
|
| 529 |
+
|
| 530 |
+
## in silico modeling and upperbounding
|
| 531 |
+
pert2pert_full_id = dict(adata.obs[['condition', 'condition_name']].values)
|
| 532 |
+
geneid2name = dict(zip(adata.var.index.values, adata.var['gene_name']))
|
| 533 |
+
geneid2idx = dict(zip(adata.var.index.values, range(len(adata.var.index.values))))
|
| 534 |
+
|
| 535 |
+
# calculate mean expression for each condition
|
| 536 |
+
unique_conditions = adata.obs.condition.unique()
|
| 537 |
+
conditions2index = {}
|
| 538 |
+
for i in unique_conditions:
|
| 539 |
+
conditions2index[i] = np.where(adata.obs.condition == i)[0]
|
| 540 |
+
|
| 541 |
+
condition2mean_expression = {}
|
| 542 |
+
for i, j in conditions2index.items():
|
| 543 |
+
condition2mean_expression[i] = np.mean(adata.X[j], axis = 0)
|
| 544 |
+
pert_list = np.array(list(condition2mean_expression.keys()))
|
| 545 |
+
mean_expression = np.array(list(condition2mean_expression.values())).reshape(len(adata.obs.condition.unique()), adata.X.toarray().shape[1])
|
| 546 |
+
ctrl = mean_expression[np.where(pert_list == 'ctrl')[0]]
|
| 547 |
+
|
| 548 |
+
if most_variable_genes is None:
|
| 549 |
+
most_variable_genes = np.argsort(np.std(mean_expression, axis = 0))[-200:]
|
| 550 |
+
|
| 551 |
+
gene_list = adata.var['gene_name'].values
|
| 552 |
+
|
| 553 |
+
for pert in np.unique(test_res['pert_cat']):
|
| 554 |
+
pert_metric[pert] = {}
|
| 555 |
+
de_idx = [geneid2idx[i] for i in adata.uns['rank_genes_groups_cov_all'][pert2pert_full_id[pert]][:20]]
|
| 556 |
+
de_idx_200 = [geneid2idx[i] for i in adata.uns['rank_genes_groups_cov_all'][pert2pert_full_id[pert]][:200]]
|
| 557 |
+
de_idx_100 = [geneid2idx[i] for i in adata.uns['rank_genes_groups_cov_all'][pert2pert_full_id[pert]][:100]]
|
| 558 |
+
de_idx_50 = [geneid2idx[i] for i in adata.uns['rank_genes_groups_cov_all'][pert2pert_full_id[pert]][:50]]
|
| 559 |
+
|
| 560 |
+
pert_idx = np.where(test_res['pert_cat'] == pert)[0]
|
| 561 |
+
pred_mean = np.mean(test_res['pred_de'][pert_idx], axis = 0).reshape(-1,)
|
| 562 |
+
true_mean = np.mean(test_res['truth_de'][pert_idx], axis = 0).reshape(-1,)
|
| 563 |
+
|
| 564 |
+
direc_change = np.abs(np.sign(test_res['pred'][pert_idx].mean(0) - ctrl[0]) - np.sign(test_res['truth'][pert_idx].mean(0) - ctrl[0]))
|
| 565 |
+
frac_correct_direction = len(np.where(direc_change == 0)[0])/len(geneid2name)
|
| 566 |
+
pert_metric[pert]['frac_correct_direction_all'] = frac_correct_direction
|
| 567 |
+
|
| 568 |
+
de_idx_map = {20: de_idx,
|
| 569 |
+
50: de_idx_50,
|
| 570 |
+
100: de_idx_100,
|
| 571 |
+
200: de_idx_200
|
| 572 |
+
}
|
| 573 |
+
|
| 574 |
+
for val in [20, 50, 100, 200]:
|
| 575 |
+
|
| 576 |
+
direc_change = np.abs(np.sign(test_res['pred'][pert_idx].mean(0)[de_idx_map[val]] - ctrl[0][de_idx_map[val]]) - np.sign(test_res['truth'][pert_idx].mean(0)[de_idx_map[val]] - ctrl[0][de_idx_map[val]]))
|
| 577 |
+
frac_correct_direction = len(np.where(direc_change == 0)[0])/val
|
| 578 |
+
pert_metric[pert]['frac_correct_direction_' + str(val)] = frac_correct_direction
|
| 579 |
+
|
| 580 |
+
mean = np.mean(test_res['truth_de'][pert_idx], axis = 0)
|
| 581 |
+
std = np.std(test_res['truth_de'][pert_idx], axis = 0)
|
| 582 |
+
min_ = np.min(test_res['truth_de'][pert_idx], axis = 0)
|
| 583 |
+
max_ = np.max(test_res['truth_de'][pert_idx], axis = 0)
|
| 584 |
+
q25 = np.quantile(test_res['truth_de'][pert_idx], 0.25, axis = 0)
|
| 585 |
+
q75 = np.quantile(test_res['truth_de'][pert_idx], 0.75, axis = 0)
|
| 586 |
+
q55 = np.quantile(test_res['truth_de'][pert_idx], 0.55, axis = 0)
|
| 587 |
+
q45 = np.quantile(test_res['truth_de'][pert_idx], 0.45, axis = 0)
|
| 588 |
+
q40 = np.quantile(test_res['truth_de'][pert_idx], 0.4, axis = 0)
|
| 589 |
+
q60 = np.quantile(test_res['truth_de'][pert_idx], 0.6, axis = 0)
|
| 590 |
+
|
| 591 |
+
zero_des = np.intersect1d(np.where(min_ == 0)[0], np.where(max_ == 0)[0])
|
| 592 |
+
nonzero_des = np.setdiff1d(list(range(20)), zero_des)
|
| 593 |
+
if len(nonzero_des) == 0:
|
| 594 |
+
pass
|
| 595 |
+
# pert that all de genes are 0...
|
| 596 |
+
else:
|
| 597 |
+
|
| 598 |
+
direc_change = np.abs(np.sign(pred_mean[nonzero_des] - ctrl[0][de_idx][nonzero_des]) - np.sign(true_mean[nonzero_des] - ctrl[0][de_idx][nonzero_des]))
|
| 599 |
+
frac_correct_direction = len(np.where(direc_change == 0)[0])/len(nonzero_des)
|
| 600 |
+
pert_metric[pert]['frac_correct_direction_20_nonzero'] = frac_correct_direction
|
| 601 |
+
|
| 602 |
+
in_range = (pred_mean[nonzero_des] >= min_[nonzero_des]) & (pred_mean[nonzero_des] <= max_[nonzero_des])
|
| 603 |
+
frac_in_range = sum(in_range)/len(nonzero_des)
|
| 604 |
+
pert_metric[pert]['frac_in_range'] = frac_in_range
|
| 605 |
+
|
| 606 |
+
in_range_5 = (pred_mean[nonzero_des] >= q45[nonzero_des]) & (pred_mean[nonzero_des] <= q55[nonzero_des])
|
| 607 |
+
frac_in_range_45_55 = sum(in_range_5)/len(nonzero_des)
|
| 608 |
+
pert_metric[pert]['frac_in_range_45_55'] = frac_in_range_45_55
|
| 609 |
+
|
| 610 |
+
in_range_10 = (pred_mean[nonzero_des] >= q40[nonzero_des]) & (pred_mean[nonzero_des] <= q60[nonzero_des])
|
| 611 |
+
frac_in_range_40_60 = sum(in_range_10)/len(nonzero_des)
|
| 612 |
+
pert_metric[pert]['frac_in_range_40_60'] = frac_in_range_40_60
|
| 613 |
+
|
| 614 |
+
in_range_25 = (pred_mean[nonzero_des] >= q25[nonzero_des]) & (pred_mean[nonzero_des] <= q75[nonzero_des])
|
| 615 |
+
frac_in_range_25_75 = sum(in_range_25)/len(nonzero_des)
|
| 616 |
+
pert_metric[pert]['frac_in_range_25_75'] = frac_in_range_25_75
|
| 617 |
+
|
| 618 |
+
zero_idx = np.where(std > 0)[0]
|
| 619 |
+
sigma = (np.abs(pred_mean[zero_idx] - mean[zero_idx]))/(std[zero_idx])
|
| 620 |
+
pert_metric[pert]['mean_sigma'] = np.mean(sigma)
|
| 621 |
+
pert_metric[pert]['std_sigma'] = np.std(sigma)
|
| 622 |
+
pert_metric[pert]['frac_sigma_below_1'] = 1 - len(np.where(sigma > 1)[0])/len(zero_idx)
|
| 623 |
+
pert_metric[pert]['frac_sigma_below_2'] = 1 - len(np.where(sigma > 2)[0])/len(zero_idx)
|
| 624 |
+
|
| 625 |
+
## correlation on delta
|
| 626 |
+
p_idx = np.where(test_res['pert_cat'] == pert)[0]
|
| 627 |
+
|
| 628 |
+
for m, fct in metric2fct.items():
|
| 629 |
+
if m != 'mse':
|
| 630 |
+
val = fct(test_res['pred'][p_idx].mean(0)- ctrl[0], test_res['truth'][p_idx].mean(0)-ctrl[0])[0]
|
| 631 |
+
if np.isnan(val):
|
| 632 |
+
val = 0
|
| 633 |
+
|
| 634 |
+
pert_metric[pert][m + '_delta'] = val
|
| 635 |
+
|
| 636 |
+
val = fct(test_res['pred'][p_idx].mean(0)[de_idx] - ctrl[0][de_idx], test_res['truth'][p_idx].mean(0)[de_idx]-ctrl[0][de_idx])[0]
|
| 637 |
+
if np.isnan(val):
|
| 638 |
+
val = 0
|
| 639 |
+
|
| 640 |
+
pert_metric[pert][m + '_delta_de'] = val
|
| 641 |
+
|
| 642 |
+
## up fold changes > 10,
|
| 643 |
+
pert_mean = np.mean(test_res['truth'][p_idx], axis = 0).reshape(-1,)
|
| 644 |
+
|
| 645 |
+
fold_change = pert_mean/ctrl
|
| 646 |
+
fold_change[np.isnan(fold_change)] = 0
|
| 647 |
+
fold_change[np.isinf(fold_change)] = 0
|
| 648 |
+
## this is to remove the ones that are super low and the fold change becomes unmeaningful
|
| 649 |
+
fold_change[0][np.where(pert_mean < 0.5)[0]] = 0
|
| 650 |
+
|
| 651 |
+
o = np.where(fold_change[0] > 0)[0]
|
| 652 |
+
|
| 653 |
+
pred_fc = test_res['pred'][p_idx].mean(0)[o]
|
| 654 |
+
true_fc = test_res['truth'][p_idx].mean(0)[o]
|
| 655 |
+
ctrl_fc = ctrl[0][o]
|
| 656 |
+
|
| 657 |
+
if len(o) > 0:
|
| 658 |
+
pert_metric[pert]['fold_change_gap_all'] = np.mean(np.abs(pred_fc/ctrl_fc - true_fc/ctrl_fc))
|
| 659 |
+
|
| 660 |
+
|
| 661 |
+
o = np.intersect1d(np.where(fold_change[0] <0.333)[0], np.where(fold_change[0] > 0)[0])
|
| 662 |
+
|
| 663 |
+
pred_fc = test_res['pred'][p_idx].mean(0)[o]
|
| 664 |
+
true_fc = test_res['truth'][p_idx].mean(0)[o]
|
| 665 |
+
ctrl_fc = ctrl[0][o]
|
| 666 |
+
|
| 667 |
+
if len(o) > 0:
|
| 668 |
+
pert_metric[pert]['fold_change_gap_downreg_0.33'] = np.mean(np.abs(pred_fc/ctrl_fc - true_fc/ctrl_fc))
|
| 669 |
+
|
| 670 |
+
|
| 671 |
+
o = np.intersect1d(np.where(fold_change[0] <0.1)[0], np.where(fold_change[0] > 0)[0])
|
| 672 |
+
|
| 673 |
+
pred_fc = test_res['pred'][p_idx].mean(0)[o]
|
| 674 |
+
true_fc = test_res['truth'][p_idx].mean(0)[o]
|
| 675 |
+
ctrl_fc = ctrl[0][o]
|
| 676 |
+
|
| 677 |
+
if len(o) > 0:
|
| 678 |
+
pert_metric[pert]['fold_change_gap_downreg_0.1'] = np.mean(np.abs(pred_fc/ctrl_fc - true_fc/ctrl_fc))
|
| 679 |
+
|
| 680 |
+
o = np.where(fold_change[0] > 3)[0]
|
| 681 |
+
|
| 682 |
+
pred_fc = test_res['pred'][p_idx].mean(0)[o]
|
| 683 |
+
true_fc = test_res['truth'][p_idx].mean(0)[o]
|
| 684 |
+
ctrl_fc = ctrl[0][o]
|
| 685 |
+
|
| 686 |
+
if len(o) > 0:
|
| 687 |
+
pert_metric[pert]['fold_change_gap_upreg_3'] = np.mean(np.abs(pred_fc/ctrl_fc - true_fc/ctrl_fc))
|
| 688 |
+
|
| 689 |
+
o = np.where(fold_change[0] > 10)[0]
|
| 690 |
+
|
| 691 |
+
pred_fc = test_res['pred'][p_idx].mean(0)[o]
|
| 692 |
+
true_fc = test_res['truth'][p_idx].mean(0)[o]
|
| 693 |
+
ctrl_fc = ctrl[0][o]
|
| 694 |
+
|
| 695 |
+
if len(o) > 0:
|
| 696 |
+
pert_metric[pert]['fold_change_gap_upreg_10'] = np.mean(np.abs(pred_fc/ctrl_fc - true_fc/ctrl_fc))
|
| 697 |
+
|
| 698 |
+
## most variable genes
|
| 699 |
+
for m, fct in metric2fct.items():
|
| 700 |
+
if m != 'mse':
|
| 701 |
+
val = fct(test_res['pred'][p_idx].mean(0)[most_variable_genes] - ctrl[0][most_variable_genes], test_res['truth'][p_idx].mean(0)[most_variable_genes]-ctrl[0][most_variable_genes])[0]
|
| 702 |
+
if np.isnan(val):
|
| 703 |
+
val = 0
|
| 704 |
+
pert_metric[pert][m + '_delta_top200_hvg'] = val
|
| 705 |
+
|
| 706 |
+
|
| 707 |
+
val = fct(test_res['pred'][p_idx].mean(0)[most_variable_genes], test_res['truth'][p_idx].mean(0)[most_variable_genes])[0]
|
| 708 |
+
if np.isnan(val):
|
| 709 |
+
val = 0
|
| 710 |
+
pert_metric[pert][m + '_top200_hvg'] = val
|
| 711 |
+
else:
|
| 712 |
+
val = fct(test_res['pred'][p_idx].mean(0)[most_variable_genes], test_res['truth'][p_idx].mean(0)[most_variable_genes])
|
| 713 |
+
pert_metric[pert][m + '_top200_hvg'] = val
|
| 714 |
+
|
| 715 |
+
|
| 716 |
+
## top 20/50/100/200 DEs
|
| 717 |
+
for m, fct in metric2fct.items():
|
| 718 |
+
if m != 'mse':
|
| 719 |
+
val = fct(test_res['pred'][p_idx].mean(0)[de_idx] - ctrl[0][de_idx], test_res['truth'][p_idx].mean(0)[de_idx]-ctrl[0][de_idx])[0]
|
| 720 |
+
if np.isnan(val):
|
| 721 |
+
val = 0
|
| 722 |
+
pert_metric[pert][m + '_delta_top20_de'] = val
|
| 723 |
+
|
| 724 |
+
|
| 725 |
+
val = fct(test_res['pred'][p_idx].mean(0)[de_idx], test_res['truth'][p_idx].mean(0)[de_idx])[0]
|
| 726 |
+
if np.isnan(val):
|
| 727 |
+
val = 0
|
| 728 |
+
pert_metric[pert][m + '_top20_de'] = val
|
| 729 |
+
else:
|
| 730 |
+
val = fct(test_res['pred'][p_idx].mean(0)[de_idx] - ctrl[0][de_idx], test_res['truth'][p_idx].mean(0)[de_idx]-ctrl[0][de_idx])
|
| 731 |
+
pert_metric[pert][m + '_top20_de'] = val
|
| 732 |
+
|
| 733 |
+
|
| 734 |
+
for m, fct in metric2fct.items():
|
| 735 |
+
if m != 'mse':
|
| 736 |
+
val = fct(test_res['pred'][p_idx].mean(0)[de_idx_200] - ctrl[0][de_idx_200], test_res['truth'][p_idx].mean(0)[de_idx_200]-ctrl[0][de_idx_200])[0]
|
| 737 |
+
if np.isnan(val):
|
| 738 |
+
val = 0
|
| 739 |
+
pert_metric[pert][m + '_delta_top200_de'] = val
|
| 740 |
+
|
| 741 |
+
|
| 742 |
+
val = fct(test_res['pred'][p_idx].mean(0)[de_idx_200], test_res['truth'][p_idx].mean(0)[de_idx_200])[0]
|
| 743 |
+
if np.isnan(val):
|
| 744 |
+
val = 0
|
| 745 |
+
pert_metric[pert][m + '_top200_de'] = val
|
| 746 |
+
else:
|
| 747 |
+
val = fct(test_res['pred'][p_idx].mean(0)[de_idx_200] - ctrl[0][de_idx_200], test_res['truth'][p_idx].mean(0)[de_idx_200]-ctrl[0][de_idx_200])
|
| 748 |
+
pert_metric[pert][m + '_top200_de'] = val
|
| 749 |
+
|
| 750 |
+
for m, fct in metric2fct.items():
|
| 751 |
+
if m != 'mse':
|
| 752 |
+
|
| 753 |
+
val = fct(test_res['pred'][p_idx].mean(0)[de_idx_100] - ctrl[0][de_idx_100], test_res['truth'][p_idx].mean(0)[de_idx_100]-ctrl[0][de_idx_100])[0]
|
| 754 |
+
if np.isnan(val):
|
| 755 |
+
val = 0
|
| 756 |
+
pert_metric[pert][m + '_delta_top100_de'] = val
|
| 757 |
+
|
| 758 |
+
|
| 759 |
+
val = fct(test_res['pred'][p_idx].mean(0)[de_idx_100], test_res['truth'][p_idx].mean(0)[de_idx_100])[0]
|
| 760 |
+
if np.isnan(val):
|
| 761 |
+
val = 0
|
| 762 |
+
pert_metric[pert][m + '_top100_de'] = val
|
| 763 |
+
else:
|
| 764 |
+
val = fct(test_res['pred'][p_idx].mean(0)[de_idx_100] - ctrl[0][de_idx_100], test_res['truth'][p_idx].mean(0)[de_idx_100]-ctrl[0][de_idx_100])
|
| 765 |
+
pert_metric[pert][m + '_top100_de'] = val
|
| 766 |
+
|
| 767 |
+
for m, fct in metric2fct.items():
|
| 768 |
+
if m != 'mse':
|
| 769 |
+
|
| 770 |
+
val = fct(test_res['pred'][p_idx].mean(0)[de_idx_50] - ctrl[0][de_idx_50], test_res['truth'][p_idx].mean(0)[de_idx_50]-ctrl[0][de_idx_50])[0]
|
| 771 |
+
if np.isnan(val):
|
| 772 |
+
val = 0
|
| 773 |
+
pert_metric[pert][m + '_delta_top50_de'] = val
|
| 774 |
+
|
| 775 |
+
|
| 776 |
+
val = fct(test_res['pred'][p_idx].mean(0)[de_idx_50], test_res['truth'][p_idx].mean(0)[de_idx_50])[0]
|
| 777 |
+
if np.isnan(val):
|
| 778 |
+
val = 0
|
| 779 |
+
pert_metric[pert][m + '_top50_de'] = val
|
| 780 |
+
else:
|
| 781 |
+
val = fct(test_res['pred'][p_idx].mean(0)[de_idx_50] - ctrl[0][de_idx_50], test_res['truth'][p_idx].mean(0)[de_idx_50]-ctrl[0][de_idx_50])
|
| 782 |
+
pert_metric[pert][m + '_top50_de'] = val
|
| 783 |
+
|
| 784 |
+
|
| 785 |
+
|
| 786 |
+
return pert_metric
|
| 787 |
+
|
| 788 |
+
|
| 789 |
+
def GI_subgroup(pert_metric):
|
| 790 |
+
GI_type2Score = {}
|
| 791 |
+
test_pert_list = list(pert_metric.keys())
|
| 792 |
+
for GI_type, gi_list in GIs.items():
|
| 793 |
+
intersect = np.intersect1d(gi_list, test_pert_list)
|
| 794 |
+
if len(intersect) != 0:
|
| 795 |
+
GI_type2Score[GI_type] = {}
|
| 796 |
+
|
| 797 |
+
for m in list(list(pert_metric.values())[0].keys()):
|
| 798 |
+
GI_type2Score[GI_type][m] = np.mean([pert_metric[i][m] for i in intersect if m in pert_metric[i]])
|
| 799 |
+
|
| 800 |
+
return GI_type2Score
|
| 801 |
+
|
| 802 |
+
def node_specific_batch_out(models, batch):
|
| 803 |
+
# Returns output for all node specific models as a matrix of dimension batch_size x nodes
|
| 804 |
+
outs = []
|
| 805 |
+
for idx in range(len(models)):
|
| 806 |
+
outs.append(models[idx](batch).detach().cpu().numpy()[:,idx])
|
| 807 |
+
return np.vstack(outs).T
|
| 808 |
+
|
| 809 |
+
# Run prediction over all batches
|
| 810 |
+
def batch_predict(loader, loaded_models, args):
|
| 811 |
+
# Prediction for node specific GNNs
|
| 812 |
+
preds = []
|
| 813 |
+
print("Loader size: ", len(loader))
|
| 814 |
+
for itr, batch in enumerate(loader):
|
| 815 |
+
print(itr)
|
| 816 |
+
batch = batch.to(args['device'])
|
| 817 |
+
preds.append(node_specific_batch_out(loaded_models, batch))
|
| 818 |
+
|
| 819 |
+
preds = np.vstack(preds)
|
| 820 |
+
return preds
|
| 821 |
+
|
| 822 |
+
def get_high_umi_idx(gene_list):
|
| 823 |
+
# Genes used for linear model fitting
|
| 824 |
+
try:
|
| 825 |
+
high_umi = np.load('../genes_with_hi_mean.npy', allow_pickle=True)
|
| 826 |
+
except:
|
| 827 |
+
high_umi = np.load('./genes_with_hi_mean.npy', allow_pickle=True)
|
| 828 |
+
high_umi_idx = np.where([g in high_umi for g in gene_list])[0]
|
| 829 |
+
return high_umi_idx
|
| 830 |
+
|
| 831 |
+
def get_mean_ctrl(adata):
|
| 832 |
+
return adata[adata.obs['condition'] == 'ctrl'].to_df().mean().reset_index(
|
| 833 |
+
drop=True)
|
| 834 |
+
|
| 835 |
+
def get_single_name(g, all_perts):
|
| 836 |
+
name = g+'+ctrl'
|
| 837 |
+
if name in all_perts:
|
| 838 |
+
return name
|
| 839 |
+
else:
|
| 840 |
+
return 'ctrl+'+g
|
| 841 |
+
|
| 842 |
+
|
| 843 |
+
def get_test_set_results_seen2(res, sel_GI_type):
|
| 844 |
+
# Get relevant test set results
|
| 845 |
+
test_pert_cats = [p for p in np.unique(res['pert_cat']) if
|
| 846 |
+
p in GIs[sel_GI_type] or 'ctrl' in p]
|
| 847 |
+
pred_idx = np.where([t in test_pert_cats for t in res['pert_cat']])
|
| 848 |
+
out = {}
|
| 849 |
+
for key in res:
|
| 850 |
+
out[key] = res[key][pred_idx]
|
| 851 |
+
return out
|
| 852 |
+
|
| 853 |
+
## Synergy loss calculation functions
|
| 854 |
+
def get_all_vectors(all_res, mean_control, double,
|
| 855 |
+
single1, single2, high_umi_idx):
|
| 856 |
+
# Pred
|
| 857 |
+
pred_df = pd.DataFrame(all_res['pred'])
|
| 858 |
+
pred_df['condition'] = all_res['pert_cat']
|
| 859 |
+
subset_df = pred_df[pred_df['condition'] == double].iloc[:, :-1]
|
| 860 |
+
delta_double_pred = subset_df.mean(0) - mean_control
|
| 861 |
+
single_df_1_pred = pred_df[pred_df['condition'] == single1].iloc[:, :-1]
|
| 862 |
+
single_df_2_pred = pred_df[pred_df['condition'] == single2].iloc[:, :-1]
|
| 863 |
+
|
| 864 |
+
# True
|
| 865 |
+
truth_df = pd.DataFrame(all_res['truth'])
|
| 866 |
+
truth_df['condition'] = all_res['pert_cat']
|
| 867 |
+
subset_df = truth_df[truth_df['condition'] == double].iloc[:, :-1]
|
| 868 |
+
delta_double_truth = subset_df.mean(0) - mean_control
|
| 869 |
+
single_df_1_truth = truth_df[truth_df['condition'] == single1].iloc[:, :-1]
|
| 870 |
+
single_df_2_truth = truth_df[truth_df['condition'] == single2].iloc[:, :-1]
|
| 871 |
+
|
| 872 |
+
delta_single_truth_1 = single_df_1_truth.mean(0) - mean_control
|
| 873 |
+
delta_single_truth_2 = single_df_2_truth.mean(0) - mean_control
|
| 874 |
+
delta_single_pred_1 = single_df_1_pred.mean(0) - mean_control
|
| 875 |
+
delta_single_pred_2 = single_df_2_pred.mean(0) - mean_control
|
| 876 |
+
|
| 877 |
+
return {'single_pred_1': delta_single_pred_1.values[high_umi_idx],
|
| 878 |
+
'single_pred_2': delta_single_pred_2.values[high_umi_idx],
|
| 879 |
+
'double_pred': delta_double_pred.values[high_umi_idx],
|
| 880 |
+
'single_truth_1': delta_single_truth_1.values[high_umi_idx],
|
| 881 |
+
'single_truth_2': delta_single_truth_2.values[high_umi_idx],
|
| 882 |
+
'double_truth': delta_double_truth.values[high_umi_idx]}
|
| 883 |
+
|
| 884 |
+
|
| 885 |
+
def get_coeffs_synergy(singles_expr, double_expr):
|
| 886 |
+
results = {}
|
| 887 |
+
results['ts'] = TheilSenRegressor(fit_intercept=False,
|
| 888 |
+
max_subpopulation=1e5,
|
| 889 |
+
max_iter=1000,
|
| 890 |
+
random_state=1000)
|
| 891 |
+
X = singles_expr
|
| 892 |
+
y = double_expr
|
| 893 |
+
try:
|
| 894 |
+
results['ts'].fit(X, y.ravel())
|
| 895 |
+
except:
|
| 896 |
+
print(X)
|
| 897 |
+
print(y)
|
| 898 |
+
results['c1'] = results['ts'].coef_[0]
|
| 899 |
+
results['c2'] = results['ts'].coef_[1]
|
| 900 |
+
results['mag'] = np.sqrt((results['c1'] ** 2 + results['c2'] ** 2))
|
| 901 |
+
return results
|
| 902 |
+
|
| 903 |
+
|
| 904 |
+
def Fit(all_vectors, type_='pertnet'):
|
| 905 |
+
if type_ == 'pertnet':
|
| 906 |
+
singles_expr = np.array(
|
| 907 |
+
[all_vectors['single_pred_1'], all_vectors['single_pred_2']]).T
|
| 908 |
+
first_expr = np.array([all_vectors['single_pred_1']]).T
|
| 909 |
+
second_expr = np.array([all_vectors['single_pred_2']]).T
|
| 910 |
+
double_expr = np.array(all_vectors['double_pred']).T
|
| 911 |
+
|
| 912 |
+
elif type_ == 'truth':
|
| 913 |
+
singles_expr = np.array(
|
| 914 |
+
[all_vectors['single_truth_1'], all_vectors['single_truth_2']]).T
|
| 915 |
+
first_expr = np.array([all_vectors['single_truth_1']]).T
|
| 916 |
+
second_expr = np.array([all_vectors['single_truth_2']]).T
|
| 917 |
+
double_expr = np.array(all_vectors['double_truth']).T
|
| 918 |
+
|
| 919 |
+
return get_coeffs_synergy(singles_expr, double_expr)
|
| 920 |
+
|
| 921 |
+
|
| 922 |
+
def get_linear_params(pred_res, high_umi_idx, mean_control, all_perts):
|
| 923 |
+
results = {}
|
| 924 |
+
for c in set(pred_res['pert_cat']):
|
| 925 |
+
if 'ctrl' in c:
|
| 926 |
+
continue
|
| 927 |
+
double = c
|
| 928 |
+
single1 = get_single_name(double.split('+')[0], all_perts)
|
| 929 |
+
single2 = get_single_name(double.split('+')[1], all_perts)
|
| 930 |
+
all_vectors = get_all_vectors(pred_res, mean_control, double,
|
| 931 |
+
single1, single2, high_umi_idx)
|
| 932 |
+
|
| 933 |
+
pertnet_results = Fit(all_vectors, type_='pertnet')
|
| 934 |
+
truth_results = Fit(all_vectors, type_='truth')
|
| 935 |
+
|
| 936 |
+
results[c] = {
|
| 937 |
+
'truth': truth_results,
|
| 938 |
+
'pred': pertnet_results}
|
| 939 |
+
|
| 940 |
+
return results
|
| 941 |
+
|
| 942 |
+
|
| 943 |
+
# Read in model for each gene
|
| 944 |
+
|
| 945 |
+
GIs = {
|
| 946 |
+
'NEOMORPHIC': ['CBL+TGFBR2',
|
| 947 |
+
'KLF1+TGFBR2',
|
| 948 |
+
'MAP2K6+SPI1',
|
| 949 |
+
'SAMD1+TGFBR2',
|
| 950 |
+
'TGFBR2+C19orf26',
|
| 951 |
+
'TGFBR2+ETS2',
|
| 952 |
+
'CBL+UBASH3A',
|
| 953 |
+
'CEBPE+KLF1',
|
| 954 |
+
'DUSP9+MAPK1',
|
| 955 |
+
'FOSB+PTPN12',
|
| 956 |
+
'PLK4+STIL',
|
| 957 |
+
'PTPN12+OSR2',
|
| 958 |
+
'ZC3HAV1+CEBPE'],
|
| 959 |
+
'ADDITIVE': ['BPGM+SAMD1',
|
| 960 |
+
'CEBPB+MAPK1',
|
| 961 |
+
'CEBPB+OSR2',
|
| 962 |
+
'DUSP9+PRTG',
|
| 963 |
+
'FOSB+OSR2',
|
| 964 |
+
'IRF1+SET',
|
| 965 |
+
'MAP2K3+ELMSAN1',
|
| 966 |
+
'MAP2K6+ELMSAN1',
|
| 967 |
+
'POU3F2+FOXL2',
|
| 968 |
+
'RHOXF2BB+SET',
|
| 969 |
+
'SAMD1+PTPN12',
|
| 970 |
+
'SAMD1+UBASH3B',
|
| 971 |
+
'SAMD1+ZBTB1',
|
| 972 |
+
'SGK1+TBX2',
|
| 973 |
+
'TBX3+TBX2',
|
| 974 |
+
'ZBTB10+SNAI1'],
|
| 975 |
+
'EPISTASIS': ['AHR+KLF1',
|
| 976 |
+
'MAPK1+TGFBR2',
|
| 977 |
+
'TGFBR2+IGDCC3',
|
| 978 |
+
'TGFBR2+PRTG',
|
| 979 |
+
'UBASH3B+OSR2',
|
| 980 |
+
'DUSP9+ETS2',
|
| 981 |
+
'KLF1+CEBPA',
|
| 982 |
+
'MAP2K6+IKZF3',
|
| 983 |
+
'ZC3HAV1+CEBPA'],
|
| 984 |
+
'REDUNDANT': ['CDKN1C+CDKN1A',
|
| 985 |
+
'MAP2K3+MAP2K6',
|
| 986 |
+
'CEBPB+CEBPA',
|
| 987 |
+
'CEBPE+CEBPA',
|
| 988 |
+
'CEBPE+SPI1',
|
| 989 |
+
'ETS2+MAPK1',
|
| 990 |
+
'FOSB+CEBPE',
|
| 991 |
+
'FOXA3+FOXA1'],
|
| 992 |
+
'POTENTIATION': ['CNN1+UBASH3A',
|
| 993 |
+
'ETS2+MAP7D1',
|
| 994 |
+
'FEV+CBFA2T3',
|
| 995 |
+
'FEV+ISL2',
|
| 996 |
+
'FEV+MAP7D1',
|
| 997 |
+
'PTPN12+UBASH3A'],
|
| 998 |
+
'SYNERGY_SIMILAR_PHENO':['CBL+CNN1',
|
| 999 |
+
'CBL+PTPN12',
|
| 1000 |
+
'CBL+PTPN9',
|
| 1001 |
+
'CBL+UBASH3B',
|
| 1002 |
+
'FOXA3+FOXL2',
|
| 1003 |
+
'FOXA3+HOXB9',
|
| 1004 |
+
'FOXL2+HOXB9',
|
| 1005 |
+
'UBASH3B+CNN1',
|
| 1006 |
+
'UBASH3B+PTPN12',
|
| 1007 |
+
'UBASH3B+PTPN9',
|
| 1008 |
+
'UBASH3B+ZBTB25'],
|
| 1009 |
+
'SYNERGY_DISSIMILAR_PHENO': ['AHR+FEV',
|
| 1010 |
+
'DUSP9+SNAI1',
|
| 1011 |
+
'FOXA1+FOXF1',
|
| 1012 |
+
'FOXA1+FOXL2',
|
| 1013 |
+
'FOXA1+HOXB9',
|
| 1014 |
+
'FOXF1+FOXL2',
|
| 1015 |
+
'FOXF1+HOXB9',
|
| 1016 |
+
'FOXL2+MEIS1',
|
| 1017 |
+
'IGDCC3+ZBTB25',
|
| 1018 |
+
'POU3F2+CBFA2T3',
|
| 1019 |
+
'PTPN12+ZBTB25',
|
| 1020 |
+
'SNAI1+DLX2',
|
| 1021 |
+
'SNAI1+UBASH3B'],
|
| 1022 |
+
'SUPPRESSOR': ['CEBPB+PTPN12',
|
| 1023 |
+
'CEBPE+CNN1',
|
| 1024 |
+
'CEBPE+PTPN12',
|
| 1025 |
+
'CNN1+MAPK1',
|
| 1026 |
+
'ETS2+CNN1',
|
| 1027 |
+
'ETS2+IGDCC3',
|
| 1028 |
+
'ETS2+PRTG',
|
| 1029 |
+
'FOSB+UBASH3B',
|
| 1030 |
+
'IGDCC3+MAPK1',
|
| 1031 |
+
'LYL1+CEBPB',
|
| 1032 |
+
'MAPK1+PRTG',
|
| 1033 |
+
'PTPN12+SNAI1']
|
| 1034 |
+
}
|
BioMod/gene_perturbation/model_module.py
ADDED
|
@@ -0,0 +1,760 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
================================================================================
|
| 3 |
+
Genetic perturbation task model definition (model_module.py)
|
| 4 |
+
================================================================================
|
| 5 |
+
|
| 6 |
+
[Model Architecture Overview]
|
| 7 |
+
This document implements a deep learning model for genetic perturbation prediction. The core components include:
|
| 8 |
+
|
| 9 |
+
1. ModuleEmbedding: Convert gene-level expression values into module-level tokens
|
| 10 |
+
- Supports multiple integration methods (multiply/add/gated_residual)
|
| 11 |
+
- Use attention pooling to aggregate genetic information within the module
|
| 12 |
+
|
| 13 |
+
2. PerformerEncoderOnly_Module: Encoder-only Transformer model
|
| 14 |
+
- Use Performer (Linear Complexity Attention) to process large numbers of module tokens
|
| 15 |
+
- Support Masked Autoencoder (MAE) pre-training paradigm
|
| 16 |
+
|
| 17 |
+
3. SCFM_Module: wrapper for pre-trained models
|
| 18 |
+
- Process the mapping of input genes to pre-trained model genes
|
| 19 |
+
- Recover gene-level embeddings from module encoding
|
| 20 |
+
|
| 21 |
+
4. GEARS_Model_Module: Genetic perturbation prediction model
|
| 22 |
+
- Integrate pre-trained encoder + GNN + decoder
|
| 23 |
+
- Predict expression changes after gene perturbation
|
| 24 |
+
|
| 25 |
+
[Key technical points]
|
| 26 |
+
- Module-based gene representation: Group thousands of genes into hundreds of modules to reduce computational complexity
|
| 27 |
+
- Gene embedding recovery: Recover gene-level features from module encoding through average pooling
|
| 28 |
+
- GO graph neural network: using gene ontology knowledge to enhance perturbation representation
|
| 29 |
+
|
| 30 |
+
================================================================================
|
| 31 |
+
"""
|
| 32 |
+
|
| 33 |
+
import torch
|
| 34 |
+
import torch.nn as nn
|
| 35 |
+
import torch.nn.functional as F
|
| 36 |
+
from torch.nn import Sequential, Linear, ReLU
|
| 37 |
+
import numpy as np
|
| 38 |
+
import pickle
|
| 39 |
+
from torch_geometric.nn import SGConv
|
| 40 |
+
from typing import List, Tuple, Optional
|
| 41 |
+
import sys
|
| 42 |
+
import os
|
| 43 |
+
from copy import deepcopy
|
| 44 |
+
import scipy
|
| 45 |
+
|
| 46 |
+
# ============================================================
|
| 47 |
+
# Import Performer related components (does not depend on gears_reduce)
|
| 48 |
+
# Performer is a linear complexity Transformer variant suitable for processing long sequences
|
| 49 |
+
# ============================================================
|
| 50 |
+
try:
|
| 51 |
+
from BioMod.models_performer_module import (
|
| 52 |
+
ModuleEmbedding,
|
| 53 |
+
PerformerEncoderOnly_Module,
|
| 54 |
+
sanitize_performer_module_config,
|
| 55 |
+
)
|
| 56 |
+
except ModuleNotFoundError:
|
| 57 |
+
project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 58 |
+
if project_root not in sys.path:
|
| 59 |
+
sys.path.insert(0, project_root)
|
| 60 |
+
from BioMod.models_performer_module import (
|
| 61 |
+
ModuleEmbedding,
|
| 62 |
+
PerformerEncoderOnly_Module,
|
| 63 |
+
sanitize_performer_module_config,
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
# ============================================================
|
| 67 |
+
# Original GEARS related classes
|
| 68 |
+
# ============================================================
|
| 69 |
+
|
| 70 |
+
class MLP(torch.nn.Module):
|
| 71 |
+
"""MLP network"""
|
| 72 |
+
def __init__(self, sizes, batch_norm=True, last_layer_act="linear"):
|
| 73 |
+
super(MLP, self).__init__()
|
| 74 |
+
layers = []
|
| 75 |
+
for s in range(len(sizes) - 1):
|
| 76 |
+
layers = layers + [
|
| 77 |
+
torch.nn.Linear(sizes[s], sizes[s + 1]),
|
| 78 |
+
torch.nn.BatchNorm1d(sizes[s + 1])
|
| 79 |
+
if batch_norm and s < len(sizes) - 1 else None,
|
| 80 |
+
torch.nn.ReLU()
|
| 81 |
+
]
|
| 82 |
+
|
| 83 |
+
layers = [l for l in layers if l is not None][:-1]
|
| 84 |
+
self.activation = last_layer_act
|
| 85 |
+
self.network = torch.nn.Sequential(*layers)
|
| 86 |
+
self.relu = torch.nn.ReLU()
|
| 87 |
+
|
| 88 |
+
def forward(self, x):
|
| 89 |
+
return self.network(x)
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def reindex_tensor_universal(tensor, index_positions, dim, filler='0', device='cpu'):
|
| 93 |
+
"""
|
| 94 |
+
Reindex tensor along specified dimensions
|
| 95 |
+
"""
|
| 96 |
+
index_tensor = torch.tensor(index_positions, device=device)
|
| 97 |
+
tensor_shape = list(tensor.shape)
|
| 98 |
+
tensor_shape[dim] = len(index_positions)
|
| 99 |
+
index_shape = [1] * len(tensor_shape)
|
| 100 |
+
index_shape[dim] = len(index_positions)
|
| 101 |
+
index_tensor = index_tensor.view(*index_shape).expand(*tensor_shape)
|
| 102 |
+
|
| 103 |
+
if filler == '0':
|
| 104 |
+
padder_shape = deepcopy(tensor_shape)
|
| 105 |
+
padder_shape[dim] = 1
|
| 106 |
+
padder = torch.zeros(padder_shape, dtype=tensor.dtype, device=device)
|
| 107 |
+
elif filler == 'mean':
|
| 108 |
+
padder = tensor.mean(dim=dim, keepdim=True)
|
| 109 |
+
else:
|
| 110 |
+
raise ValueError("filler should be 0 or mean!")
|
| 111 |
+
expanded_tensor = torch.cat((tensor, padder), dim=dim)
|
| 112 |
+
reindexed = torch.gather(expanded_tensor, dim, index_tensor)
|
| 113 |
+
return reindexed
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
class SCFM_Module(torch.nn.Module):
|
| 117 |
+
"""
|
| 118 |
+
================================================================================
|
| 119 |
+
SCFM_Module: wrapper for pre-trained single cell base models
|
| 120 |
+
|
| 121 |
+
[Core functions]
|
| 122 |
+
Adapt the PerformerEncoderOnly_Module model to the genetic perturbation task to address the following key issues:
|
| 123 |
+
|
| 124 |
+
1. Gene mapping: The gene order of the input data may be different from that of the pre-trained model, and re-indexing is required.
|
| 125 |
+
2. Module-to-gene recovery: Recovering gene-level embeddings from module encodings
|
| 126 |
+
3. Dimension adjustment: adjust the encoder output to the dimensions required by downstream tasks
|
| 127 |
+
|
| 128 |
+
[Key Design Decisions]
|
| 129 |
+
- use_module_level=False (recommended): restore gene level embedding from module encoding
|
| 130 |
+
Advantages: Compatible with existing GEARS architecture, no need to modify downstream tasks
|
| 131 |
+
- use_module_level=True (experimental): use module level encoding directly
|
| 132 |
+
Downstream architecture needs to be redesigned
|
| 133 |
+
|
| 134 |
+
[Gene Embedding Recovery Strategy]
|
| 135 |
+
For each gene:
|
| 136 |
+
1. Find all modules containing the gene
|
| 137 |
+
2. Perform average pooling on the encoding of these modules
|
| 138 |
+
3. If the gene is not in any module, use the global average as fallback
|
| 139 |
+
|
| 140 |
+
[Performance Optimization]
|
| 141 |
+
- Precompute scatter index to avoid Python loops
|
| 142 |
+
- Vectorized aggregation using scatter_add
|
| 143 |
+
================================================================================
|
| 144 |
+
"""
|
| 145 |
+
def __init__(self, input_genes, scfm_genes,
|
| 146 |
+
scfm_hyper_params, scfm_ckpt,
|
| 147 |
+
module_sets=None,
|
| 148 |
+
requires_grad=False,
|
| 149 |
+
device='cpu',
|
| 150 |
+
output_dim=200,
|
| 151 |
+
use_module_level=False):
|
| 152 |
+
super(SCFM_Module, self).__init__()
|
| 153 |
+
self.device = device
|
| 154 |
+
self.use_module_level = use_module_level
|
| 155 |
+
|
| 156 |
+
# ============================================================
|
| 157 |
+
# [Gene Mapping] Establish index mapping between input genes and pre-trained model genes
|
| 158 |
+
# This is a critical step in dealing with inconsistencies in gene order across different datasets
|
| 159 |
+
# ============================================================
|
| 160 |
+
input_mapping = {idx: i for i, idx in enumerate(input_genes)}
|
| 161 |
+
self.input_gene_num = len(input_genes)
|
| 162 |
+
|
| 163 |
+
# ============================================================
|
| 164 |
+
# [Key] fusion_method processing
|
| 165 |
+
# Read from pkl to ensure consistency with pre-training
|
| 166 |
+
# ============================================================
|
| 167 |
+
fusion_method = scfm_hyper_params.get('fusion_method', 'multiply')
|
| 168 |
+
print(f"[SCFM_Module] Using fusion_method from hyper_params: {fusion_method}")
|
| 169 |
+
|
| 170 |
+
# Convert new indices to a list of index positions from the original tensor
|
| 171 |
+
scfm_genes_pad = scfm_genes + ['PAD']
|
| 172 |
+
self.scfm_seq_len = len(scfm_genes_pad)
|
| 173 |
+
scfm_mapping = {idx: i for i, idx in enumerate(scfm_genes_pad)}
|
| 174 |
+
|
| 175 |
+
# Create bidirectional index mapping
|
| 176 |
+
self.scfm_index_positions = [input_mapping.get(idx, self.input_gene_num) for idx in scfm_genes_pad]
|
| 177 |
+
self.input_index_positions = [scfm_mapping.get(idx, self.scfm_seq_len) for idx in input_genes]
|
| 178 |
+
|
| 179 |
+
# Load module data (if not provided in hyper_params)
|
| 180 |
+
if module_sets is None:
|
| 181 |
+
if 'module_sets' in scfm_hyper_params and scfm_hyper_params['module_sets'] is not None:
|
| 182 |
+
module_sets = scfm_hyper_params['module_sets']
|
| 183 |
+
else:
|
| 184 |
+
raise ValueError("module_sets must be provided (via parameters or hyper_params)")
|
| 185 |
+
|
| 186 |
+
self.module_sets = module_sets
|
| 187 |
+
self.num_modules = len(module_sets)
|
| 188 |
+
self.scfm_seq_len = len(scfm_genes)
|
| 189 |
+
|
| 190 |
+
# ============================================================
|
| 191 |
+
# [Key] Create a gene-to-module mapping
|
| 192 |
+
# Each gene may appear in multiple modules for subsequent average pooling.
|
| 193 |
+
# This is the core data structure of module representation.
|
| 194 |
+
# ============================================================
|
| 195 |
+
self.gene_to_modules = {} # {gene_idx: [module_idx1, module_idx2, ...]}
|
| 196 |
+
for module_idx, module_genes in enumerate(module_sets):
|
| 197 |
+
for gene_idx in module_genes:
|
| 198 |
+
if gene_idx < len(scfm_genes): # Only consider genes in scfm_genes
|
| 199 |
+
if gene_idx not in self.gene_to_modules:
|
| 200 |
+
self.gene_to_modules[gene_idx] = []
|
| 201 |
+
self.gene_to_modules[gene_idx].append(module_idx)
|
| 202 |
+
|
| 203 |
+
# ============================================================
|
| 204 |
+
# [Performance Optimization] Pre-built sparse index for efficient module->gene aggregation
|
| 205 |
+
# Use scatter_add to replace Python loop, greatly improving forward efficiency
|
| 206 |
+
# This is the standard optimization method in PyTorch for handling irregular aggregations
|
| 207 |
+
# ============================================================
|
| 208 |
+
import torch.sparse
|
| 209 |
+
max_modules_per_gene = max(len(v) for v in self.gene_to_modules.values()) if self.gene_to_modules else 1
|
| 210 |
+
|
| 211 |
+
# Construct a sparse index matrix: shape (num_genes, max_modules_per_gene)
|
| 212 |
+
gene_to_module_matrix = []
|
| 213 |
+
for gene_idx in range(self.scfm_seq_len):
|
| 214 |
+
if gene_idx in self.gene_to_modules:
|
| 215 |
+
modules = self.gene_to_modules[gene_idx]
|
| 216 |
+
padded = modules + [-1] * (max_modules_per_gene - len(modules))
|
| 217 |
+
gene_to_module_matrix.append(padded)
|
| 218 |
+
else:
|
| 219 |
+
gene_to_module_matrix.append([-1] * max_modules_per_gene)
|
| 220 |
+
|
| 221 |
+
# Registered as a buffer (will not be updated by the optimizer, but will be moved to the correct device with the model)
|
| 222 |
+
self.register_buffer('gene_to_module_matrix',
|
| 223 |
+
torch.tensor(gene_to_module_matrix, dtype=torch.long))
|
| 224 |
+
gene_module_counts = torch.tensor([
|
| 225 |
+
len(self.gene_to_modules.get(i, [])) for i in range(self.scfm_seq_len)
|
| 226 |
+
], dtype=torch.long)
|
| 227 |
+
self.register_buffer('gene_module_counts', gene_module_counts)
|
| 228 |
+
|
| 229 |
+
# ============================================================
|
| 230 |
+
# [Performance Optimization] Precompute scatter_add index
|
| 231 |
+
# Module->gene aggregation for fully vectorized, avoiding Python loops
|
| 232 |
+
# ============================================================
|
| 233 |
+
module_indices = []
|
| 234 |
+
gene_indices = []
|
| 235 |
+
for module_idx, module_genes in enumerate(module_sets):
|
| 236 |
+
for gene_idx in module_genes:
|
| 237 |
+
if gene_idx < self.scfm_seq_len:
|
| 238 |
+
module_indices.append(module_idx)
|
| 239 |
+
gene_indices.append(gene_idx)
|
| 240 |
+
|
| 241 |
+
self.register_buffer('scatter_module_idx', torch.tensor(module_indices, dtype=torch.long))
|
| 242 |
+
self.register_buffer('scatter_gene_idx', torch.tensor(gene_indices, dtype=torch.long))
|
| 243 |
+
|
| 244 |
+
# Create PerformerEncoderOnly_Module model
|
| 245 |
+
scfm_hyper_params['module_sets'] = module_sets
|
| 246 |
+
scfm_hyper_params['num_genes'] = len(scfm_genes)
|
| 247 |
+
scfm_hyper_params['max_seq_len'] = len(module_sets)
|
| 248 |
+
|
| 249 |
+
self.scfm = PerformerEncoderOnly_Module(**scfm_hyper_params)
|
| 250 |
+
|
| 251 |
+
# ============================================================
|
| 252 |
+
# [Key] Load pre-trained weights
|
| 253 |
+
# Filter out incompatible parameters (GO related, module_heads, etc.)
|
| 254 |
+
# strict=False allows partial parameter mismatch (used for fine-tuning mode)
|
| 255 |
+
# ============================================================
|
| 256 |
+
if scfm_ckpt != 'None':
|
| 257 |
+
ckpt = torch.load(scfm_ckpt, map_location=device)
|
| 258 |
+
model_state_dict = ckpt.get('model_state_dict', ckpt)
|
| 259 |
+
current_state_dict = self.scfm.state_dict()
|
| 260 |
+
filtered_state_dict = {}
|
| 261 |
+
for k, v in model_state_dict.items():
|
| 262 |
+
# Filter out GO related parameters (may not be available in different data sets)
|
| 263 |
+
if 'go_conv' in k or 'G_go' in k.lower():
|
| 264 |
+
continue
|
| 265 |
+
# Filter out module_heads (not needed for fine-tuning)
|
| 266 |
+
if 'module_heads' in k:
|
| 267 |
+
continue
|
| 268 |
+
# Filter out parameters that do not exist or have mismatched shapes in the current model
|
| 269 |
+
if k not in current_state_dict:
|
| 270 |
+
continue
|
| 271 |
+
if current_state_dict[k].shape != v.shape:
|
| 272 |
+
print(
|
| 273 |
+
f"[SCFM_Module] skip shape-mismatched key: {k}, "
|
| 274 |
+
f"ckpt={tuple(v.shape)} current={tuple(current_state_dict[k].shape)}"
|
| 275 |
+
)
|
| 276 |
+
continue
|
| 277 |
+
filtered_state_dict[k] = v
|
| 278 |
+
# Use strict=False because module_heads is intentionally removed
|
| 279 |
+
missing, unexpected = self.scfm.load_state_dict(filtered_state_dict, strict=False)
|
| 280 |
+
for param in self.scfm.parameters():
|
| 281 |
+
param.requires_grad = requires_grad
|
| 282 |
+
else:
|
| 283 |
+
for param in self.scfm.parameters():
|
| 284 |
+
param.requires_grad = True
|
| 285 |
+
|
| 286 |
+
# Output dimension adjustment (if needed)
|
| 287 |
+
self.output_dim = output_dim
|
| 288 |
+
if self.output_dim != 200:
|
| 289 |
+
try:
|
| 290 |
+
self.feedforward = nn.Linear(self.scfm.base_dim, self.output_dim)
|
| 291 |
+
except AttributeError:
|
| 292 |
+
self.feedforward = nn.Linear(scfm_hyper_params.get('base_dim', 200), self.output_dim)
|
| 293 |
+
|
| 294 |
+
def forward(self, x):
|
| 295 |
+
"""
|
| 296 |
+
Forward propagation: from gene expression values to gene-level embeddings
|
| 297 |
+
|
| 298 |
+
[Processing Process]
|
| 299 |
+
1. Re-index the input gene expression into the gene order of the pre-trained model
|
| 300 |
+
2. Get the module encoding through PerformerEncoderOnly_Module
|
| 301 |
+
3. Restore gene-level embedding from module encoding (or return module encoding directly)
|
| 302 |
+
4. Reindex gene embeddings back to input gene order
|
| 303 |
+
|
| 304 |
+
[Input and output]
|
| 305 |
+
Input: x (B, input_gene_num) - gene expression value
|
| 306 |
+
Output:
|
| 307 |
+
- use_module_level=False: output_reindex (B, input_gene_num, D) - gene level embedding
|
| 308 |
+
- use_module_level=True: module_encodings (B, num_modules, D) - module level encoding
|
| 309 |
+
"""
|
| 310 |
+
x = x.view((-1, self.input_gene_num)) # B, M
|
| 311 |
+
|
| 312 |
+
# ============================================================
|
| 313 |
+
# [Step 1] Re-index: Map the input gene expression to the gene order of the pre-trained model
|
| 314 |
+
# Genes that are not present are filled with 0 (PAD position)
|
| 315 |
+
# ============================================================
|
| 316 |
+
x_scfm = reindex_tensor_universal(x, self.scfm_index_positions, dim=1, filler='0', device=self.device) # B, N
|
| 317 |
+
|
| 318 |
+
# ============================================================
|
| 319 |
+
# [Step 2] Obtain the module code through the pre-trained model
|
| 320 |
+
# mask_ratio=0 means not to mask any modules (fine-tuning mode)
|
| 321 |
+
# Use mask_ratio>0 for MAE pre-training during pre-training.
|
| 322 |
+
# ============================================================
|
| 323 |
+
out = self.scfm(x_scfm, mask_ratio=0.0, return_encodings=True)
|
| 324 |
+
|
| 325 |
+
if 'encodings' in out:
|
| 326 |
+
module_encodings = out['encodings'] # Local legacy interface
|
| 327 |
+
elif 'module_encodings' in out:
|
| 328 |
+
module_encodings = out['module_encodings'] # Reuse pre-trained shared implementation interfaces
|
| 329 |
+
else:
|
| 330 |
+
raise ValueError(f"Expected one of ['encodings', 'module_encodings'] in output, got: {list(out.keys())}")
|
| 331 |
+
|
| 332 |
+
# ============================================================
|
| 333 |
+
# [Option] Directly return to module level encoding
|
| 334 |
+
# For experimental module-level downstream tasks
|
| 335 |
+
# ============================================================
|
| 336 |
+
if self.use_module_level:
|
| 337 |
+
if self.output_dim != 200:
|
| 338 |
+
B, num_modules, D = module_encodings.shape
|
| 339 |
+
module_encodings_flat = module_encodings.reshape(B * num_modules, D)
|
| 340 |
+
module_encodings_flat = self.feedforward(module_encodings_flat)
|
| 341 |
+
module_encodings = module_encodings_flat.reshape(B, num_modules, self.output_dim)
|
| 342 |
+
return module_encodings # (B, num_modules, D)
|
| 343 |
+
|
| 344 |
+
# ============================================================
|
| 345 |
+
# [Step 3] Restore gene-level embedding from module encoding
|
| 346 |
+
# Core idea: The embedding of each gene = the average encoding of all modules containing the gene
|
| 347 |
+
# Vectorized aggregation using scatter_add to avoid Python loops
|
| 348 |
+
# ============================================================
|
| 349 |
+
B, num_modules, D = module_encodings.shape
|
| 350 |
+
G = self.scfm_seq_len
|
| 351 |
+
|
| 352 |
+
# Calculate global average (for genes without any modules)
|
| 353 |
+
global_mean = module_encodings.mean(dim=1) # (B, D)
|
| 354 |
+
|
| 355 |
+
# Extract the corresponding module embeddings: (B, num_pairs, D)
|
| 356 |
+
module_embs = module_encodings[:, self.scatter_module_idx, :] # (B, num_pairs, D)
|
| 357 |
+
|
| 358 |
+
# Aggregate to gene dimension using scatter_add
|
| 359 |
+
scfm_gene_embeddings = torch.zeros(B, G, D, device=module_encodings.device)
|
| 360 |
+
gene_idx_expanded = self.scatter_gene_idx.unsqueeze(0).unsqueeze(-1).expand(B, -1, D)
|
| 361 |
+
scfm_gene_embeddings.scatter_add_(1, gene_idx_expanded, module_embs)
|
| 362 |
+
|
| 363 |
+
# ============================================================
|
| 364 |
+
# [Key] Divide by the number of modules for each gene to get the average
|
| 365 |
+
# This is the core operation from module to gene aggregation
|
| 366 |
+
# clamp(min=1) avoids division by zero errors
|
| 367 |
+
# ============================================================
|
| 368 |
+
gene_counts = self.gene_module_counts.float().view(1, G, 1).clamp(min=1)
|
| 369 |
+
scfm_gene_embeddings = scfm_gene_embeddings / gene_counts
|
| 370 |
+
|
| 371 |
+
# For genes without any modules, use global average as fallback
|
| 372 |
+
no_module_mask = (self.gene_module_counts == 0).view(1, G, 1).float()
|
| 373 |
+
scfm_gene_embeddings = scfm_gene_embeddings * (1 - no_module_mask) + global_mean.unsqueeze(1) * no_module_mask
|
| 374 |
+
|
| 375 |
+
# Add embedding of PAD positions (for handling non-existent genes)
|
| 376 |
+
pad_emb = torch.zeros(B, 1, D, device=module_encodings.device)
|
| 377 |
+
output_encodings = torch.cat([scfm_gene_embeddings, pad_emb], dim=1) # (B, G+1, D)
|
| 378 |
+
|
| 379 |
+
# ============================================================
|
| 380 |
+
# [Step 4] Reindex back to the input gene sequence
|
| 381 |
+
# Use mean as fallback to fill in non-existent genes
|
| 382 |
+
# ============================================================
|
| 383 |
+
output_reindex = reindex_tensor_universal(
|
| 384 |
+
output_encodings,
|
| 385 |
+
self.input_index_positions,
|
| 386 |
+
dim=1,
|
| 387 |
+
filler='mean',
|
| 388 |
+
device=self.device
|
| 389 |
+
) # B, M, D
|
| 390 |
+
|
| 391 |
+
if self.output_dim != 200:
|
| 392 |
+
output_reindex = self.feedforward(output_reindex)
|
| 393 |
+
|
| 394 |
+
return output_reindex
|
| 395 |
+
|
| 396 |
+
|
| 397 |
+
class GEARS_Model_Module(torch.nn.Module):
|
| 398 |
+
"""
|
| 399 |
+
================================================================================
|
| 400 |
+
GEARS_Model_Module: Genetic perturbation prediction model
|
| 401 |
+
|
| 402 |
+
[Core functions]
|
| 403 |
+
Predicting transcriptomic changes in cells following genetic perturbation. Enter a perturbation combination (such as knockout gene A),
|
| 404 |
+
Output the expression value prediction of each gene after perturbation.
|
| 405 |
+
|
| 406 |
+
[Model Architecture]
|
| 407 |
+
1. Pretrained encoder (SCFM_Module): Encode gene expression into embedding vectors
|
| 408 |
+
2. Perturbation embedding layer: generate perturbation features for perturbed genes
|
| 409 |
+
3. GO graph neural network (GNN): using gene ontology knowledge to enhance perturbation representation
|
| 410 |
+
4. Decoder: predict gene expression values after perturbation
|
| 411 |
+
|
| 412 |
+
[Key Design Decisions]
|
| 413 |
+
- Use GO graph neural network: gene ontology contains gene functional relationships, which can guide model understanding
|
| 414 |
+
How perturbation effects spread between genes
|
| 415 |
+
- Gene specific decoder: Each gene has independent decoding parameters to capture gene specificity
|
| 416 |
+
- Cross-gene MLP: Modeling interdependencies between genes
|
| 417 |
+
|
| 418 |
+
[Input and output]
|
| 419 |
+
Input: data (PyG Data object)
|
| 420 |
+
- x: (B*N, 2) feature matrix, [:, 0] is gene expression, [:, 1] is perturbation mark
|
| 421 |
+
- y: true expression value (used for training)
|
| 422 |
+
Output: out (B, N) - predicted gene expression value after perturbation
|
| 423 |
+
================================================================================
|
| 424 |
+
"""
|
| 425 |
+
def __init__(self, args):
|
| 426 |
+
super(GEARS_Model_Module, self).__init__()
|
| 427 |
+
self.args = args
|
| 428 |
+
|
| 429 |
+
# Forecast record related settings
|
| 430 |
+
if args.get('record_pred', False):
|
| 431 |
+
self.pred_dir = f"./result_process/preds/{self.args['exp_name']}"
|
| 432 |
+
if not os.path.exists(self.pred_dir):
|
| 433 |
+
os.makedirs(self.pred_dir)
|
| 434 |
+
self.pred_batch_idx = 0
|
| 435 |
+
|
| 436 |
+
# ============================================================
|
| 437 |
+
# [Basic parameters]
|
| 438 |
+
# hidden_size: embedding dimension, affecting model capacity
|
| 439 |
+
# num_genes: number of genes, determines the output dimension
|
| 440 |
+
# ============================================================
|
| 441 |
+
self.num_genes = args['num_genes']
|
| 442 |
+
hidden_size = args['hidden_size']
|
| 443 |
+
self.uncertainty = args['uncertainty']
|
| 444 |
+
self.num_layers = args['num_go_gnn_layers'] # GO GNN layers
|
| 445 |
+
self.indv_out_hidden_size = args['decoder_hidden_size']
|
| 446 |
+
self.num_layers_gene_pos = args['num_gene_gnn_layers']
|
| 447 |
+
self.pert_emb_lambda = 0.2
|
| 448 |
+
|
| 449 |
+
self.input_genes_list = args['input_genes_ens_ids']
|
| 450 |
+
self.scfm_genes_list = args['scfm_genes_ens_ids']
|
| 451 |
+
|
| 452 |
+
# ============================================================
|
| 453 |
+
# [Load pre-trained model configuration]
|
| 454 |
+
# hyper_params contains model architecture parameters, which must be consistent with pre-training
|
| 455 |
+
# ============================================================
|
| 456 |
+
try:
|
| 457 |
+
with open(args["scfm_hyper_params_path"], 'rb') as handle:
|
| 458 |
+
self.scfm_hyper_params = pickle.load(handle)
|
| 459 |
+
except ValueError:
|
| 460 |
+
import pickle5
|
| 461 |
+
with open(args["scfm_hyper_params_path"], 'rb') as handle:
|
| 462 |
+
self.scfm_hyper_params = pickle5.load(handle)
|
| 463 |
+
self.scfm_hyper_params.pop('module_embed_chunk_padded_gene_budget', None)
|
| 464 |
+
self.scfm_hyper_params = sanitize_performer_module_config(
|
| 465 |
+
self.scfm_hyper_params,
|
| 466 |
+
source=args["scfm_hyper_params_path"],
|
| 467 |
+
log_fn=print,
|
| 468 |
+
)
|
| 469 |
+
|
| 470 |
+
self.scfm_hyper_params['gene2vec_file'] = args['scfm_gene2vec_file']
|
| 471 |
+
self.scfm_ckpt = args["scfm_ckpt"]
|
| 472 |
+
|
| 473 |
+
# ============================================================
|
| 474 |
+
# [Module data loading]
|
| 475 |
+
# module_sets defines how genes are grouped into modules
|
| 476 |
+
# Can be loaded from hyper_params or external files
|
| 477 |
+
# ============================================================
|
| 478 |
+
module_sets = None
|
| 479 |
+
if 'module_sets' in self.scfm_hyper_params and self.scfm_hyper_params['module_sets'] is not None:
|
| 480 |
+
module_sets = self.scfm_hyper_params['module_sets']
|
| 481 |
+
elif args.get('module_data_path') or args.get('module_data_dir'):
|
| 482 |
+
from BioMod.module_dataset import load_module_data
|
| 483 |
+
module_sets = load_module_data(
|
| 484 |
+
module_data_path=args.get('module_data_path'),
|
| 485 |
+
module_data_dir=args.get('module_data_dir'),
|
| 486 |
+
gene_list_path=args.get('scfm_genes_list_path'),
|
| 487 |
+
gene_mapping_path=args.get('gene_mapping_path', '../gene2ens.csv'),
|
| 488 |
+
verbose=True
|
| 489 |
+
)
|
| 490 |
+
else:
|
| 491 |
+
raise ValueError("module_sets must be provided (via hyper_params or module_data_path/module_data_dir)")
|
| 492 |
+
|
| 493 |
+
# ============================================================
|
| 494 |
+
# [Ablation Experiment]shuffle_module_genes
|
| 495 |
+
# Disrupt the gene assignment within the module and verify the importance of the module structure (gene grouping)
|
| 496 |
+
# If performance decreases after scrambling, it means that the module structure contains useful biological information.
|
| 497 |
+
# ============================================================
|
| 498 |
+
if args.get('shuffle_module_genes', False):
|
| 499 |
+
shuffle_seed = args.get('shuffle_seed', 2021)
|
| 500 |
+
print(
|
| 501 |
+
f"[GEARS_Model_Module] Applying downstream-only extra module reshuffle "
|
| 502 |
+
f"(seed={shuffle_seed}). Pretraining-time module_sets from PKL will be reshuffled again."
|
| 503 |
+
)
|
| 504 |
+
all_gene_indices = []
|
| 505 |
+
module_sizes = []
|
| 506 |
+
for module in module_sets:
|
| 507 |
+
all_gene_indices.extend(module)
|
| 508 |
+
module_sizes.append(len(module))
|
| 509 |
+
|
| 510 |
+
import random
|
| 511 |
+
rng = random.Random(shuffle_seed)
|
| 512 |
+
rng.shuffle(all_gene_indices)
|
| 513 |
+
|
| 514 |
+
shuffled_module_sets = []
|
| 515 |
+
start_idx = 0
|
| 516 |
+
for size in module_sizes:
|
| 517 |
+
shuffled_module_sets.append(all_gene_indices[start_idx:start_idx + size])
|
| 518 |
+
start_idx += size
|
| 519 |
+
module_sets = shuffled_module_sets
|
| 520 |
+
print(f"[GEARS_Model_Module] Shuffled {len(all_gene_indices)} genes across {len(module_sets)} modules")
|
| 521 |
+
|
| 522 |
+
# ============================================================
|
| 523 |
+
# [Perturbation Embedding Layer]
|
| 524 |
+
# pert_w: Convert perturbation markers (0/1) to embedding vectors
|
| 525 |
+
# gene_emb: Basic embedding of genes (used when pre-trained models are not available)
|
| 526 |
+
# pert_emb: dedicated embedding of perturbed genes
|
| 527 |
+
# ============================================================
|
| 528 |
+
self.pert_w = nn.Linear(1, hidden_size)
|
| 529 |
+
|
| 530 |
+
# gene/global perturbation embedding dictionary lookup
|
| 531 |
+
self.gene_emb = nn.Embedding(self.num_genes, hidden_size, max_norm=True)
|
| 532 |
+
self.pert_emb = nn.Embedding(self.num_genes, hidden_size, max_norm=True)
|
| 533 |
+
|
| 534 |
+
# transformation layer
|
| 535 |
+
self.emb_trans = nn.ReLU()
|
| 536 |
+
self.pert_base_trans = nn.ReLU()
|
| 537 |
+
self.transform = nn.ReLU()
|
| 538 |
+
self.pert_fuse = MLP([hidden_size, hidden_size, hidden_size], last_layer_act='ReLU')
|
| 539 |
+
|
| 540 |
+
# ============================================================
|
| 541 |
+
# [GO graph neural network]
|
| 542 |
+
# G_sim: edge index of GO similarity graph
|
| 543 |
+
# G_sim_weight: edge weight
|
| 544 |
+
# Using SGConv (Simplified Graph Convolution) for message passing
|
| 545 |
+
# GO diagram helps the model understand gene functional relationships, and perturbation effects propagate along the diagram.
|
| 546 |
+
# ============================================================
|
| 547 |
+
self.G_sim = args['G_go'].to(args['device'])
|
| 548 |
+
self.G_sim_weight = args['G_go_weight'].to(args['device'])
|
| 549 |
+
|
| 550 |
+
self.sim_layers = torch.nn.ModuleList()
|
| 551 |
+
for i in range(1, self.num_layers + 1):
|
| 552 |
+
self.sim_layers.append(SGConv(hidden_size, hidden_size, 1))
|
| 553 |
+
|
| 554 |
+
# ============================================================
|
| 555 |
+
# [Decoder]
|
| 556 |
+
# recovery_w: shared MLP, projects embeddings into decoding space
|
| 557 |
+
# indv_w1/b1: Gene specific parameters, each gene has independent decoding weight
|
| 558 |
+
# cross_gene_state: cross-gene MLP, modeling inter-gene dependencies
|
| 559 |
+
# indv_w2/b2: final decoding layer
|
| 560 |
+
# ============================================================
|
| 561 |
+
self.recovery_w = MLP([hidden_size, hidden_size*2, hidden_size], last_layer_act='linear')
|
| 562 |
+
|
| 563 |
+
# gene specific decoder
|
| 564 |
+
self.indv_w1 = nn.Parameter(torch.rand(self.num_genes, hidden_size, 1))
|
| 565 |
+
self.indv_b1 = nn.Parameter(torch.rand(self.num_genes, 1))
|
| 566 |
+
self.act = nn.ReLU()
|
| 567 |
+
nn.init.xavier_normal_(self.indv_w1)
|
| 568 |
+
nn.init.xavier_normal_(self.indv_b1)
|
| 569 |
+
|
| 570 |
+
# Cross gene MLP
|
| 571 |
+
self.cross_gene_state = MLP([self.num_genes, hidden_size, hidden_size])
|
| 572 |
+
self.indv_w2 = nn.Parameter(torch.rand(1, self.num_genes, hidden_size+1))
|
| 573 |
+
self.indv_b2 = nn.Parameter(torch.rand(1, self.num_genes))
|
| 574 |
+
nn.init.xavier_normal_(self.indv_w2)
|
| 575 |
+
nn.init.xavier_normal_(self.indv_b2)
|
| 576 |
+
|
| 577 |
+
# BatchNorm layers
|
| 578 |
+
self.bn_emb = nn.BatchNorm1d(hidden_size)
|
| 579 |
+
self.bn_pert_base = nn.BatchNorm1d(hidden_size)
|
| 580 |
+
self.bn_pert_base_trans = nn.BatchNorm1d(hidden_size)
|
| 581 |
+
|
| 582 |
+
# uncertainty mode (optional)
|
| 583 |
+
if self.uncertainty:
|
| 584 |
+
self.uncertainty_w = MLP([hidden_size, hidden_size*2, hidden_size, 1], last_layer_act='linear')
|
| 585 |
+
|
| 586 |
+
# ============================================================
|
| 587 |
+
# [Core] Initialize pre-trained encoder (SCFM_Module)
|
| 588 |
+
# requires_grad: whether to fine-tune pre-training parameters
|
| 589 |
+
# - finetune_method='frozen': freeze pre-training parameters
|
| 590 |
+
# - Others: Full parameter fine-tuning
|
| 591 |
+
# ============================================================
|
| 592 |
+
self.use_module_level = args.get('use_module_level', False)
|
| 593 |
+
self.singlecell_model = SCFM_Module(
|
| 594 |
+
self.input_genes_list,
|
| 595 |
+
self.scfm_genes_list,
|
| 596 |
+
self.scfm_hyper_params,
|
| 597 |
+
self.scfm_ckpt,
|
| 598 |
+
module_sets=module_sets,
|
| 599 |
+
requires_grad=args.get('finetune_method') != 'frozen',
|
| 600 |
+
device=args['device'],
|
| 601 |
+
output_dim=hidden_size,
|
| 602 |
+
use_module_level=self.use_module_level
|
| 603 |
+
)
|
| 604 |
+
self.pretrained = True
|
| 605 |
+
|
| 606 |
+
# ============================================================
|
| 607 |
+
# [Experimental feature] module level features
|
| 608 |
+
# In the current implementation, module-level downstream tasks are not fully implemented.
|
| 609 |
+
# GNN and decoder need to be redesigned to handle module dimensions
|
| 610 |
+
# ============================================================
|
| 611 |
+
if self.use_module_level:
|
| 612 |
+
self.num_modules = len(module_sets)
|
| 613 |
+
import warnings
|
| 614 |
+
warnings.warn(
|
| 615 |
+
f"Using module-level features. This requires compatible downstream architecture. "
|
| 616 |
+
f"Number of modules: {self.num_modules}, Number of genes: {self.num_genes}. "
|
| 617 |
+
f"Currently module-level downstream task is not fully implemented.",
|
| 618 |
+
UserWarning
|
| 619 |
+
)
|
| 620 |
+
|
| 621 |
+
def forward(self, data):
|
| 622 |
+
"""
|
| 623 |
+
Forward propagation: predicting expression values after gene perturbation
|
| 624 |
+
|
| 625 |
+
[enter]
|
| 626 |
+
data: PyG Data object
|
| 627 |
+
- x: (B*N, 2) characteristic matrix
|
| 628 |
+
[:, 0]: gene expression value (control group)
|
| 629 |
+
[:, 1]: perturbation flag (1 indicates that the gene is perturbed)
|
| 630 |
+
- y: real expression value (used during training)
|
| 631 |
+
- pert: disturbance type identifier
|
| 632 |
+
|
| 633 |
+
[Output]
|
| 634 |
+
out: (B, N) predicted gene expression value after perturbation
|
| 635 |
+
|
| 636 |
+
[Processing Process]
|
| 637 |
+
1. Obtain base gene embeddings (from pretrained encoder or random embeddings)
|
| 638 |
+
2. Generate perturbation embedding (enhanced by GO-GNN)
|
| 639 |
+
3. Merge base embedding and perturbation embedding
|
| 640 |
+
4. Predict the expression value through the decoder
|
| 641 |
+
"""
|
| 642 |
+
if str(type(data)) == "<class 'torch.Tensor'>":
|
| 643 |
+
x = data.reshape((-1, 2))
|
| 644 |
+
else:
|
| 645 |
+
x, batch = data.x, data.batch
|
| 646 |
+
x_pert = x[:, 1].reshape((-1, self.num_genes)) # Disturbance mark
|
| 647 |
+
num_graphs = len(data.batch.unique())
|
| 648 |
+
|
| 649 |
+
# ============================================================
|
| 650 |
+
# [Step 1] Obtain basic gene embedding
|
| 651 |
+
# If using a pretrained model, get embeddings from gene expression encoding
|
| 652 |
+
# Otherwise use randomly initialized gene embeddings
|
| 653 |
+
# ============================================================
|
| 654 |
+
if self.pretrained:
|
| 655 |
+
exp = x[:, 0].reshape((-1, self.num_genes)) # B, N - gene expression value
|
| 656 |
+
emb = self.singlecell_model(exp) # B, N, D - by pre-trained encoder
|
| 657 |
+
emb = emb.reshape((num_graphs * self.num_genes, -1))
|
| 658 |
+
else:
|
| 659 |
+
emb = self.gene_emb(torch.LongTensor(list(range(self.num_genes))).repeat(num_graphs, ).to(self.args['device']))
|
| 660 |
+
emb = self.bn_emb(emb)
|
| 661 |
+
base_emb = self.emb_trans(emb)
|
| 662 |
+
|
| 663 |
+
base_emb = base_emb.reshape(num_graphs, self.num_genes, -1)
|
| 664 |
+
|
| 665 |
+
pert = x_pert.reshape(-1, 1) # B * N, 1
|
| 666 |
+
|
| 667 |
+
# ============================================================
|
| 668 |
+
# [Step 2] Generate perturbation embedding (GO-GNN enhancement)
|
| 669 |
+
# Perturbation embeddings are propagated through GO similarity graphs to capture gene functional relationships
|
| 670 |
+
# This enables the model to exploit a priori knowledge of gene function
|
| 671 |
+
# ============================================================
|
| 672 |
+
pert_embeddings = self.pert_emb(torch.LongTensor(list(range(self.num_genes))).to(self.args['device'])) # N, D
|
| 673 |
+
|
| 674 |
+
# Propagating perturbation embeddings via GO-GNN
|
| 675 |
+
for idx, layer in enumerate(self.sim_layers):
|
| 676 |
+
pert_embeddings = layer(pert_embeddings, self.G_sim, self.G_sim_weight)
|
| 677 |
+
if idx < self.num_layers - 1:
|
| 678 |
+
pert_embeddings = pert_embeddings.relu()
|
| 679 |
+
|
| 680 |
+
# After GNN: N, D
|
| 681 |
+
pert_embeddings = torch.stack(list((pert_embeddings,)) * num_graphs) # B, N, D
|
| 682 |
+
pert_embeddings = pert_embeddings * x_pert.reshape((num_graphs, self.num_genes, 1)) # Only perturbed genes are retained
|
| 683 |
+
pert_embeddings = pert_embeddings.sum(1) # B, D - aggregate all perturbed genes
|
| 684 |
+
pert_mask = (x_pert.sum(1) != 0).reshape((num_graphs, 1)) # B, 1
|
| 685 |
+
pert_embeddings = self.pert_fuse(pert_embeddings) * pert_mask # B, D
|
| 686 |
+
pert_embeddings = pert_embeddings.reshape((num_graphs, 1, pert_embeddings.shape[1])) # B, 1, D
|
| 687 |
+
|
| 688 |
+
# ============================================================
|
| 689 |
+
# [Step 3] Merge base embedding and perturbation embedding
|
| 690 |
+
# Perturbation information is added to the embedding of each gene
|
| 691 |
+
# ============================================================
|
| 692 |
+
base_emb = base_emb + pert_embeddings
|
| 693 |
+
|
| 694 |
+
base_emb = base_emb.reshape(num_graphs * self.num_genes, -1)
|
| 695 |
+
|
| 696 |
+
# Add perturbed position embedding
|
| 697 |
+
pert_emb = self.pert_w(pert)
|
| 698 |
+
combined = pert_emb + base_emb
|
| 699 |
+
combined = self.bn_pert_base_trans(combined)
|
| 700 |
+
base_emb = self.pert_base_trans(combined)
|
| 701 |
+
base_emb = self.bn_pert_base(base_emb)
|
| 702 |
+
|
| 703 |
+
# ============================================================
|
| 704 |
+
# [Step 4] Decoder: predict the expression value after perturbation
|
| 705 |
+
# Using gene-specific decoders + cross-gene MLP
|
| 706 |
+
# ============================================================
|
| 707 |
+
base_emb = self.transform(base_emb)
|
| 708 |
+
out = self.recovery_w(base_emb) # B * N, D
|
| 709 |
+
out = out.reshape(num_graphs, self.num_genes, -1) # B, N, D
|
| 710 |
+
out = out.unsqueeze(-1) * self.indv_w1 # B, N, D, 1 - Gene specific transformation
|
| 711 |
+
w = torch.sum(out, axis=2) # B, N, 1
|
| 712 |
+
out = w + self.indv_b1 # B, N, 1
|
| 713 |
+
|
| 714 |
+
# Cross gene MLP: Modeling inter-gene dependencies
|
| 715 |
+
cross_gene_embed = self.cross_gene_state(out.reshape(num_graphs, self.num_genes, -1).squeeze(2)) # B, D
|
| 716 |
+
cross_gene_embed = cross_gene_embed.repeat(1, self.num_genes) # B, N*D
|
| 717 |
+
|
| 718 |
+
cross_gene_embed = cross_gene_embed.reshape([num_graphs, self.num_genes, -1]) # B, N, D
|
| 719 |
+
cross_gene_out = torch.cat([out, cross_gene_embed], 2) # B, N, D+1
|
| 720 |
+
|
| 721 |
+
cross_gene_out = cross_gene_out * self.indv_w2 # B, N, D+1
|
| 722 |
+
cross_gene_out = torch.sum(cross_gene_out, axis=2) # B, N
|
| 723 |
+
out = cross_gene_out + self.indv_b2 # B, N
|
| 724 |
+
|
| 725 |
+
# ============================================================
|
| 726 |
+
# [Key] Residual connection: predicted value = change + original value
|
| 727 |
+
# This is the core design of GEARS. The model predicts expression changes rather than absolute values.
|
| 728 |
+
# ============================================================
|
| 729 |
+
out = out.reshape(num_graphs * self.num_genes, -1) + x[:, 0].reshape(-1, 1) # B * N, 1
|
| 730 |
+
|
| 731 |
+
# ============================================================
|
| 732 |
+
# [Prediction record] for subsequent analysis
|
| 733 |
+
# ============================================================
|
| 734 |
+
if self.args.get('start_record', False):
|
| 735 |
+
y_flat = data.y.reshape(-1, 1) # B * N, 1
|
| 736 |
+
pred = torch.cat((pert, out, y_flat), dim=1) # B * N, 3
|
| 737 |
+
pred = pred.cpu().to_sparse()
|
| 738 |
+
|
| 739 |
+
values = pred.values().numpy()
|
| 740 |
+
indices = pred.indices().numpy()
|
| 741 |
+
sparse_matrix = scipy.sparse.coo_matrix((values, indices), shape=pred.shape)
|
| 742 |
+
|
| 743 |
+
scipy.sparse.save_npz(f'{self.pred_dir}/b{self.pred_batch_idx}_{num_graphs}.npz', sparse_matrix)
|
| 744 |
+
self.pred_batch_idx += 1
|
| 745 |
+
|
| 746 |
+
# ============================================================
|
| 747 |
+
# [Key] reshape output is (B, N) format
|
| 748 |
+
# In this way, the loss function can correctly index the predicted value and the true value.
|
| 749 |
+
# ============================================================
|
| 750 |
+
out = torch.split(torch.flatten(out), self.num_genes)
|
| 751 |
+
out = torch.stack(out) # (B, N)
|
| 752 |
+
|
| 753 |
+
# uncertainty mode (optional)
|
| 754 |
+
if self.uncertainty:
|
| 755 |
+
logvar = self.uncertainty_w(base_emb)
|
| 756 |
+
logvar = torch.split(torch.flatten(logvar), self.num_genes)
|
| 757 |
+
return out, torch.stack(logvar)
|
| 758 |
+
|
| 759 |
+
return out # (B, N)
|
| 760 |
+
|
BioMod/gene_perturbation/pertdata_local.py
ADDED
|
@@ -0,0 +1,524 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
================================================================================
|
| 3 |
+
Perturbation data processing module (pertdata_local.py)
|
| 4 |
+
================================================================================
|
| 5 |
+
|
| 6 |
+
[Module function]
|
| 7 |
+
Load and preprocess the genetic perturbation dataset, converting the raw data into a model-usable PyG graph format.
|
| 8 |
+
|
| 9 |
+
[Supported data sets]
|
| 10 |
+
- norman: Norman et al. 2019, Large-scale CRISPR perturbation data
|
| 11 |
+
- adamson: Adamson et al. 2016, single gene perturbation data
|
| 12 |
+
- dixit: Dixit et al. 2016, single gene perturbation data
|
| 13 |
+
- replogle_k562_essential/replogle_rpe1_essential: Replogle et al. 2022
|
| 14 |
+
|
| 15 |
+
[Data format]
|
| 16 |
+
Each cell is represented as a graph data object:
|
| 17 |
+
- x: (num_genes, 2) feature matrix, [:, 0] is the expression value, [:, 1] is the perturbation mark
|
| 18 |
+
- y: target expression value
|
| 19 |
+
- de_idx: differentially expressed gene index
|
| 20 |
+
- pert: disturbance type identifier
|
| 21 |
+
|
| 22 |
+
[Key steps]
|
| 23 |
+
1. Load the original h5ad file
|
| 24 |
+
2. Calculate differentially expressed genes
|
| 25 |
+
3. Divide training/validation/test sets
|
| 26 |
+
4. Create PyG graph data objects
|
| 27 |
+
================================================================================
|
| 28 |
+
"""
|
| 29 |
+
|
| 30 |
+
from torch_geometric.data import Data
|
| 31 |
+
import torch
|
| 32 |
+
import numpy as np
|
| 33 |
+
import pickle
|
| 34 |
+
from torch_geometric.data import DataLoader
|
| 35 |
+
import os
|
| 36 |
+
import scanpy as sc
|
| 37 |
+
import networkx as nx
|
| 38 |
+
from tqdm import tqdm
|
| 39 |
+
import scipy.sparse as sp
|
| 40 |
+
|
| 41 |
+
import warnings
|
| 42 |
+
warnings.filterwarnings("ignore")
|
| 43 |
+
sc.settings.verbosity = 0
|
| 44 |
+
|
| 45 |
+
# ============================================================
|
| 46 |
+
# Use local modules and avoid external gears_reduce imports
|
| 47 |
+
# ============================================================
|
| 48 |
+
from utils import print_sys, zip_data_download_wrapper, filter_pert_cond_in_go
|
| 49 |
+
from data_utils_local import get_DE_genes, get_dropout_non_zero_genes, DataSplitter
|
| 50 |
+
|
| 51 |
+
class PertData:
|
| 52 |
+
|
| 53 |
+
def __init__(self, data_path):
|
| 54 |
+
"""
|
| 55 |
+
Initialize the perturbation data processing class
|
| 56 |
+
|
| 57 |
+
Args:
|
| 58 |
+
data_path: data storage directory path
|
| 59 |
+
"""
|
| 60 |
+
self.data_path = data_path
|
| 61 |
+
self.gene_names = None
|
| 62 |
+
if not os.path.exists(self.data_path):
|
| 63 |
+
os.mkdir(self.data_path)
|
| 64 |
+
|
| 65 |
+
def load(self, data_name = None,
|
| 66 |
+
data_path = None, exclude_pert = set()):
|
| 67 |
+
"""
|
| 68 |
+
Load perturbation dataset
|
| 69 |
+
|
| 70 |
+
[Data source]
|
| 71 |
+
- Download public data sets such as norman/adamson/dixit from Harvard Dataverse
|
| 72 |
+
- You can also provide the local h5ad file path
|
| 73 |
+
|
| 74 |
+
[Processing Process]
|
| 75 |
+
1. Download or load data
|
| 76 |
+
2. Filter disturbances that are not in the GO graph
|
| 77 |
+
3. Create PyG graph data objects
|
| 78 |
+
"""
|
| 79 |
+
if data_name in ['norman', 'adamson', 'dixit',
|
| 80 |
+
'replogle_k562_essential',
|
| 81 |
+
'replogle_rpe1_essential']:
|
| 82 |
+
# Download public datasets from Harvard Dataverse
|
| 83 |
+
if data_name == 'norman':
|
| 84 |
+
url = 'https://dataverse.harvard.edu/api/access/datafile/6154020'
|
| 85 |
+
elif data_name == 'adamson':
|
| 86 |
+
url = 'https://dataverse.harvard.edu/api/access/datafile/6154417'
|
| 87 |
+
elif data_name == 'dixit':
|
| 88 |
+
url = 'https://dataverse.harvard.edu/api/access/datafile/6154416'
|
| 89 |
+
elif data_name == 'replogle_k562_essential':
|
| 90 |
+
url = 'https://dataverse.harvard.edu/api/access/datafile/7458695'
|
| 91 |
+
elif data_name == 'replogle_rpe1_essential':
|
| 92 |
+
url = 'https://dataverse.harvard.edu/api/access/datafile/7458694'
|
| 93 |
+
data_path = os.path.join(self.data_path, data_name)
|
| 94 |
+
zip_data_download_wrapper(url, data_path, self.data_path)
|
| 95 |
+
self.dataset_name = data_path.split('/')[-1]
|
| 96 |
+
self.dataset_path = data_path
|
| 97 |
+
adata_path = os.path.join(data_path, 'perturb_processed.h5ad')
|
| 98 |
+
self.adata = sc.read_h5ad(adata_path)
|
| 99 |
+
|
| 100 |
+
elif os.path.exists(data_path):
|
| 101 |
+
# Load local data
|
| 102 |
+
adata_path = os.path.join(data_path, 'perturb_processed.h5ad')
|
| 103 |
+
self.adata = sc.read_h5ad(adata_path)
|
| 104 |
+
self.dataset_name = data_path.split('/')[-1]
|
| 105 |
+
self.dataset_path = data_path
|
| 106 |
+
else:
|
| 107 |
+
raise ValueError("data is either Norman/Adamson/Dixit or a path to an h5ad file")
|
| 108 |
+
|
| 109 |
+
# Filter perturbed genes that are not in the GO graph
|
| 110 |
+
print_sys('These perturbations are not in the GO graph and their '
|
| 111 |
+
'perturbation can thus not be predicted')
|
| 112 |
+
print_sys(exclude_pert)
|
| 113 |
+
if len(exclude_pert) > 0:
|
| 114 |
+
filter_go = self.adata.obs[self.adata.obs.condition.apply(
|
| 115 |
+
lambda x: filter_pert_cond_in_go(x, exclude_pert))]
|
| 116 |
+
self.adata = self.adata[filter_go.index.values, :]
|
| 117 |
+
|
| 118 |
+
# ============================================================
|
| 119 |
+
# [Key] Create or load PyG graph data objects
|
| 120 |
+
# This is the core step of data preprocessing, converting each cell into graph format
|
| 121 |
+
# ============================================================
|
| 122 |
+
pyg_path = os.path.join(data_path, 'data_pyg')
|
| 123 |
+
if not os.path.exists(pyg_path):
|
| 124 |
+
os.mkdir(pyg_path)
|
| 125 |
+
dataset_fname = os.path.join(pyg_path, 'cell_graphs.pkl')
|
| 126 |
+
|
| 127 |
+
if os.path.isfile(dataset_fname):
|
| 128 |
+
# Already processed data can be loaded directly.
|
| 129 |
+
print_sys("Local copy of pyg dataset is detected. Loading...")
|
| 130 |
+
self.dataset_processed = pickle.load(open(dataset_fname, "rb"))
|
| 131 |
+
print_sys("Done!")
|
| 132 |
+
else:
|
| 133 |
+
# Data needs to be reprocessed
|
| 134 |
+
self.ctrl_adata = self.adata[self.adata.obs['condition'] == 'ctrl']
|
| 135 |
+
self.gene_names = list(self.adata.var.gene_name)
|
| 136 |
+
conditions = list(self.adata.obs.condition.unique())
|
| 137 |
+
pert_genes = []
|
| 138 |
+
for condition in conditions:
|
| 139 |
+
if condition == 'ctrl':
|
| 140 |
+
continue
|
| 141 |
+
# Analyze perturbation combinations (format: gene1+gene2)
|
| 142 |
+
cond1 = condition.split('+')[0]
|
| 143 |
+
cond2 = condition.split('+')[1]
|
| 144 |
+
if cond1 != 'ctrl':
|
| 145 |
+
if cond1 not in pert_genes:
|
| 146 |
+
pert_genes.append(cond1)
|
| 147 |
+
if cond1 not in self.gene_names:
|
| 148 |
+
self.gene_names.append(cond1)
|
| 149 |
+
if cond2 != 'ctrl':
|
| 150 |
+
if cond2 not in pert_genes:
|
| 151 |
+
pert_genes.append(cond2)
|
| 152 |
+
if cond2 not in self.gene_names:
|
| 153 |
+
self.gene_names.append(cond2)
|
| 154 |
+
self.gene_names = np.array(self.gene_names)
|
| 155 |
+
self.real_gene_names = np.array(list(self.adata.var.gene_name))
|
| 156 |
+
print_sys(f"len(self.gene_names): {len(self.gene_names)}")
|
| 157 |
+
print_sys(f"len(self.real_gene_names): {len(self.real_gene_names)}")
|
| 158 |
+
print_sys("Creating pyg object for each cell in the data...")
|
| 159 |
+
self.dataset_processed = self.create_dataset_file()
|
| 160 |
+
print_sys("Saving new dataset pyg object at " + dataset_fname)
|
| 161 |
+
pickle.dump(self.dataset_processed, open(dataset_fname, "wb"))
|
| 162 |
+
print_sys("Done!")
|
| 163 |
+
|
| 164 |
+
def new_data_process(self, dataset_name,
|
| 165 |
+
adata = None):
|
| 166 |
+
|
| 167 |
+
if 'condition' not in adata.obs.columns.values:
|
| 168 |
+
raise ValueError("Please specify condition")
|
| 169 |
+
if 'gene_name' not in adata.var.columns.values:
|
| 170 |
+
raise ValueError("Please specify gene name")
|
| 171 |
+
if 'cell_type' not in adata.obs.columns.values:
|
| 172 |
+
raise ValueError("Please specify cell type")
|
| 173 |
+
|
| 174 |
+
dataset_name = dataset_name.lower()
|
| 175 |
+
self.dataset_name = dataset_name
|
| 176 |
+
save_data_folder = os.path.join(self.data_path, dataset_name)
|
| 177 |
+
|
| 178 |
+
if not os.path.exists(save_data_folder):
|
| 179 |
+
os.mkdir(save_data_folder)
|
| 180 |
+
self.dataset_path = save_data_folder
|
| 181 |
+
self.adata = get_DE_genes(adata)
|
| 182 |
+
self.adata = get_dropout_non_zero_genes(self.adata)
|
| 183 |
+
self.adata.write_h5ad(os.path.join(save_data_folder, 'perturb_processed.h5ad'))
|
| 184 |
+
|
| 185 |
+
self.ctrl_adata = self.adata[self.adata.obs['condition'] == 'ctrl']
|
| 186 |
+
|
| 187 |
+
self.gene_names = list(self.adata.var.gene_name)
|
| 188 |
+
conditions = list(self.adata.obs.condition.unique())
|
| 189 |
+
pert_genes = []
|
| 190 |
+
for condition in conditions:
|
| 191 |
+
if condition == 'ctrl':
|
| 192 |
+
continue
|
| 193 |
+
cond1 = condition.split('+')[0]
|
| 194 |
+
cond2 = condition.split('+')[1]
|
| 195 |
+
if cond1 != 'ctrl':
|
| 196 |
+
if cond1 not in pert_genes:
|
| 197 |
+
pert_genes.append(cond1)
|
| 198 |
+
if cond1 not in self.gene_names:
|
| 199 |
+
self.gene_names.append(cond1)
|
| 200 |
+
if cond2 != 'ctrl':
|
| 201 |
+
if cond2 not in pert_genes:
|
| 202 |
+
pert_genes.append(cond2)
|
| 203 |
+
if cond2 not in self.gene_names:
|
| 204 |
+
self.gene_names.append(cond2)
|
| 205 |
+
self.gene_names = np.array(self.gene_names)
|
| 206 |
+
self.real_gene_names = np.array(list(self.adata.var.gene_name))
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
pyg_path = os.path.join(save_data_folder, 'data_pyg')
|
| 210 |
+
if not os.path.exists(pyg_path):
|
| 211 |
+
os.mkdir(pyg_path)
|
| 212 |
+
dataset_fname = os.path.join(pyg_path, 'cell_graphs.pkl')
|
| 213 |
+
print_sys("Creating pyg object for each cell in the data...")
|
| 214 |
+
self.dataset_processed = self.create_dataset_file()
|
| 215 |
+
print_sys("Saving new dataset pyg object at " + dataset_fname)
|
| 216 |
+
pickle.dump(self.dataset_processed, open(dataset_fname, "wb"))
|
| 217 |
+
print_sys("Done!")
|
| 218 |
+
|
| 219 |
+
def prepare_split(self, split = 'simulation',
|
| 220 |
+
seed = 1,
|
| 221 |
+
train_gene_set_size = 0.75,
|
| 222 |
+
combo_seen2_train_frac = 0.75,
|
| 223 |
+
combo_single_split_test_set_fraction = 0.1,
|
| 224 |
+
test_perts = None,
|
| 225 |
+
only_test_set_perts = False,
|
| 226 |
+
test_pert_genes = None):
|
| 227 |
+
"""
|
| 228 |
+
Prepare data partitioning
|
| 229 |
+
|
| 230 |
+
[Description of partition strategy]
|
| 231 |
+
- 'simulation': simulate the division of real application scenarios
|
| 232 |
+
The test set contains: unseen_single (unseen single gene perturbation),
|
| 233 |
+
combo_seen0/1/2 (combination perturbations with different familiarity levels)
|
| 234 |
+
This is the most commonly used division method to evaluate the generalization ability of the model.
|
| 235 |
+
- 'combo_seen0/1/2': divided according to the number of genes seen in the combination
|
| 236 |
+
- 'single': only evaluate single gene perturbations
|
| 237 |
+
- 'no_test': Do not divide the test set
|
| 238 |
+
|
| 239 |
+
[Key parameters]
|
| 240 |
+
- train_gene_set_size: training set gene proportion (0.75 means 75% of genes are used for training)
|
| 241 |
+
- seed: random seed to ensure the experiment is reproducible
|
| 242 |
+
"""
|
| 243 |
+
available_splits = ['simulation', 'simulation_single', 'combo_seen0', 'combo_seen1',
|
| 244 |
+
'combo_seen2', 'single', 'no_test', 'no_split']
|
| 245 |
+
if split not in available_splits:
|
| 246 |
+
raise ValueError('currently, we only support ' + ','.join(available_splits))
|
| 247 |
+
self.split = split
|
| 248 |
+
self.seed = seed
|
| 249 |
+
self.subgroup = None
|
| 250 |
+
self.train_gene_set_size = train_gene_set_size
|
| 251 |
+
|
| 252 |
+
# Divide file path
|
| 253 |
+
split_folder = os.path.join(self.dataset_path, 'splits')
|
| 254 |
+
if not os.path.exists(split_folder):
|
| 255 |
+
os.mkdir(split_folder)
|
| 256 |
+
split_file = self.dataset_name + '_' + split + '_' + str(seed) + '_' + str(train_gene_set_size) + '.pkl'
|
| 257 |
+
split_path = os.path.join(split_folder, split_file)
|
| 258 |
+
|
| 259 |
+
if test_perts:
|
| 260 |
+
split_path = split_path[:-4] + '_' + test_perts + '.pkl'
|
| 261 |
+
|
| 262 |
+
if os.path.exists(split_path):
|
| 263 |
+
# Already divided, load directly
|
| 264 |
+
print_sys("Local copy of split is detected. Loading...")
|
| 265 |
+
set2conditions = pickle.load(open(split_path, "rb"))
|
| 266 |
+
if split == 'simulation':
|
| 267 |
+
subgroup_path = split_path[:-4] + '_subgroup.pkl'
|
| 268 |
+
subgroup = pickle.load(open(subgroup_path, "rb"))
|
| 269 |
+
self.subgroup = subgroup
|
| 270 |
+
else:
|
| 271 |
+
# Create new partition
|
| 272 |
+
print_sys("Creating new splits....")
|
| 273 |
+
if test_perts:
|
| 274 |
+
test_perts = test_perts.split('_')
|
| 275 |
+
|
| 276 |
+
if split in ['simulation', 'simulation_single']:
|
| 277 |
+
DS = DataSplitter(self.adata, split_type=split)
|
| 278 |
+
|
| 279 |
+
adata, subgroup = DS.split_data(train_gene_set_size = train_gene_set_size,
|
| 280 |
+
combo_seen2_train_frac = combo_seen2_train_frac,
|
| 281 |
+
seed=seed,
|
| 282 |
+
test_perts = test_perts,
|
| 283 |
+
only_test_set_perts = only_test_set_perts
|
| 284 |
+
)
|
| 285 |
+
subgroup_path = split_path[:-4] + '_subgroup.pkl'
|
| 286 |
+
pickle.dump(subgroup, open(subgroup_path, "wb"))
|
| 287 |
+
self.subgroup = subgroup
|
| 288 |
+
|
| 289 |
+
elif split[:5] == 'combo':
|
| 290 |
+
split_type = 'combo'
|
| 291 |
+
seen = int(split[-1])
|
| 292 |
+
|
| 293 |
+
if test_pert_genes:
|
| 294 |
+
test_pert_genes = test_pert_genes.split('_')
|
| 295 |
+
|
| 296 |
+
DS = DataSplitter(self.adata, split_type=split_type, seen=int(seen))
|
| 297 |
+
adata = DS.split_data(test_size=combo_single_split_test_set_fraction,
|
| 298 |
+
test_perts=test_perts,
|
| 299 |
+
test_pert_genes=test_pert_genes,
|
| 300 |
+
seed=seed)
|
| 301 |
+
|
| 302 |
+
elif split == 'single':
|
| 303 |
+
DS = DataSplitter(self.adata, split_type=split)
|
| 304 |
+
adata = DS.split_data(test_size=combo_single_split_test_set_fraction, seed=seed)
|
| 305 |
+
|
| 306 |
+
elif split == 'no_test':
|
| 307 |
+
DS = DataSplitter(self.adata, split_type=split)
|
| 308 |
+
adata = DS.split_data(test_size=combo_single_split_test_set_fraction, seed=seed)
|
| 309 |
+
|
| 310 |
+
elif split == 'no_split':
|
| 311 |
+
adata = self.adata
|
| 312 |
+
adata.obs['split'] = 'test'
|
| 313 |
+
|
| 314 |
+
# Save partition results
|
| 315 |
+
set2conditions = dict(adata.obs.groupby('split').agg({'condition': lambda x: x}).condition)
|
| 316 |
+
set2conditions = {i: j.unique().tolist() for i,j in set2conditions.items()}
|
| 317 |
+
pickle.dump(set2conditions, open(split_path, "wb"))
|
| 318 |
+
print_sys("Saving new splits at " + split_path)
|
| 319 |
+
|
| 320 |
+
self.set2conditions = set2conditions
|
| 321 |
+
|
| 322 |
+
if split == 'simulation':
|
| 323 |
+
print_sys('Simulation split test composition:')
|
| 324 |
+
for i,j in subgroup['test_subgroup'].items():
|
| 325 |
+
print_sys(i + ':' + str(len(j)))
|
| 326 |
+
print_sys("Done!")
|
| 327 |
+
|
| 328 |
+
def get_dataloader(self, batch_size, test_batch_size = None):
|
| 329 |
+
"""
|
| 330 |
+
Create data loader
|
| 331 |
+
|
| 332 |
+
[Data loader]
|
| 333 |
+
Encapsulate PyG graph data objects as DataLoader, supporting batch processing and shuffling.
|
| 334 |
+
|
| 335 |
+
[Key settings]
|
| 336 |
+
- Training set: shuffle=True, drop_last=True (make sure each batch size is consistent)
|
| 337 |
+
- Validation/test set: shuffle=False (maintain order for easy analysis)
|
| 338 |
+
"""
|
| 339 |
+
if test_batch_size is None:
|
| 340 |
+
test_batch_size = batch_size
|
| 341 |
+
if self.gene_names is None:
|
| 342 |
+
self.gene_names = list(self.adata.var.gene_name)
|
| 343 |
+
conditions = list(self.adata.obs.condition.unique())
|
| 344 |
+
pert_genes = []
|
| 345 |
+
for condition in conditions:
|
| 346 |
+
if condition == 'ctrl':
|
| 347 |
+
continue
|
| 348 |
+
cond1 = condition.split('+')[0]
|
| 349 |
+
cond2 = condition.split('+')[1]
|
| 350 |
+
if cond1 != 'ctrl':
|
| 351 |
+
if cond1 not in pert_genes:
|
| 352 |
+
pert_genes.append(cond1)
|
| 353 |
+
if cond1 not in self.gene_names:
|
| 354 |
+
self.gene_names.append(cond1)
|
| 355 |
+
if cond2 != 'ctrl':
|
| 356 |
+
if cond2 not in pert_genes:
|
| 357 |
+
pert_genes.append(cond2)
|
| 358 |
+
if cond2 not in self.gene_names:
|
| 359 |
+
self.gene_names.append(cond2)
|
| 360 |
+
self.gene_names = np.array(self.gene_names)
|
| 361 |
+
self.real_gene_names = np.array(list(self.adata.var.gene_name))
|
| 362 |
+
|
| 363 |
+
# Gene name to index mapping
|
| 364 |
+
self.node_map = {x: it for it, x in enumerate(self.gene_names)}
|
| 365 |
+
if (self.gene_names[: len(self.real_gene_names)] != self.real_gene_names).any():
|
| 366 |
+
raise ValueError("self.real_gene_names must be in the start of self.gene_names")
|
| 367 |
+
|
| 368 |
+
# Create cytogram data
|
| 369 |
+
cell_graphs = {}
|
| 370 |
+
if self.split == 'no_split':
|
| 371 |
+
i = 'test'
|
| 372 |
+
cell_graphs[i] = []
|
| 373 |
+
for p in self.set2conditions[i]:
|
| 374 |
+
if p != 'ctrl':
|
| 375 |
+
cell_graphs[i].extend(self.dataset_processed[p])
|
| 376 |
+
|
| 377 |
+
print_sys("Creating dataloaders....")
|
| 378 |
+
test_loader = DataLoader(cell_graphs['test'],
|
| 379 |
+
batch_size=batch_size, shuffle=False)
|
| 380 |
+
|
| 381 |
+
print_sys("Dataloaders created...")
|
| 382 |
+
return {'test_loader': test_loader}
|
| 383 |
+
else:
|
| 384 |
+
if self.split =='no_test':
|
| 385 |
+
splits = ['train','val']
|
| 386 |
+
else:
|
| 387 |
+
splits = ['train','val','test']
|
| 388 |
+
for i in splits:
|
| 389 |
+
cell_graphs[i] = []
|
| 390 |
+
for p in self.set2conditions[i]:
|
| 391 |
+
cell_graphs[i].extend(self.dataset_processed[p])
|
| 392 |
+
|
| 393 |
+
print_sys("Creating dataloaders....")
|
| 394 |
+
|
| 395 |
+
# Set up data loader
|
| 396 |
+
train_loader = DataLoader(cell_graphs['train'],
|
| 397 |
+
batch_size=batch_size, shuffle=True, drop_last = True)
|
| 398 |
+
val_loader = DataLoader(cell_graphs['val'],
|
| 399 |
+
batch_size=batch_size, shuffle=True)
|
| 400 |
+
|
| 401 |
+
if self.split !='no_test':
|
| 402 |
+
test_loader = DataLoader(cell_graphs['test'],
|
| 403 |
+
batch_size=batch_size, shuffle=False)
|
| 404 |
+
self.dataloader = {'train_loader': train_loader,
|
| 405 |
+
'val_loader': val_loader,
|
| 406 |
+
'test_loader': test_loader}
|
| 407 |
+
|
| 408 |
+
else:
|
| 409 |
+
self.dataloader = {'train_loader': train_loader,
|
| 410 |
+
'val_loader': val_loader}
|
| 411 |
+
print_sys("Done!")
|
| 412 |
+
del self.dataset_processed # Clean memory
|
| 413 |
+
|
| 414 |
+
|
| 415 |
+
def create_dataset_file(self):
|
| 416 |
+
dl = {}
|
| 417 |
+
for p in tqdm(self.adata.obs['condition'].unique()):
|
| 418 |
+
cell_graph_dataset = self.create_cell_graph_dataset(self.adata, p, num_samples=1)
|
| 419 |
+
dl[p] = cell_graph_dataset
|
| 420 |
+
return dl
|
| 421 |
+
|
| 422 |
+
def get_pert_idx(self, pert_category, adata_):
|
| 423 |
+
pert_idx = [np.where(p == self.gene_names)[0][0]
|
| 424 |
+
for p in pert_category.split('+')
|
| 425 |
+
if p != 'ctrl']
|
| 426 |
+
return pert_idx
|
| 427 |
+
|
| 428 |
+
# Set up feature matrix and output
|
| 429 |
+
def create_cell_graph(self, X, y, de_idx, pert, pert_idx=None):
|
| 430 |
+
"""
|
| 431 |
+
Create a graph data object of a single cell
|
| 432 |
+
|
| 433 |
+
[enter]
|
| 434 |
+
- X: Control group gene expression value (expression before perturbation)
|
| 435 |
+
- y: gene expression value after perturbation (target value)
|
| 436 |
+
- de_idx: index of differentially expressed genes
|
| 437 |
+
- pert: disturbance type identifier
|
| 438 |
+
- pert_idx: index list of perturbed genes
|
| 439 |
+
|
| 440 |
+
[Output]
|
| 441 |
+
PyG Data object:
|
| 442 |
+
- x: (num_genes, 2) feature matrix
|
| 443 |
+
[:, 0]: Control group expression value
|
| 444 |
+
[:, 1]: perturbation flag (1=the gene is perturbed)
|
| 445 |
+
- y: target expression value
|
| 446 |
+
- de_idx: differentially expressed gene index
|
| 447 |
+
- pert: perturbation type
|
| 448 |
+
"""
|
| 449 |
+
# Create perturbation feature vectors (mark which genes are perturbed)
|
| 450 |
+
pert_feats = np.zeros(len(X[0]))
|
| 451 |
+
if pert_idx is not None:
|
| 452 |
+
for p in pert_idx:
|
| 453 |
+
pert_feats[int(np.abs(p))] = 1
|
| 454 |
+
pert_feats = np.expand_dims(pert_feats, 0)
|
| 455 |
+
# Spliced expression values and perturbation markers
|
| 456 |
+
feature_mat = torch.Tensor(np.concatenate([X, pert_feats])).T
|
| 457 |
+
|
| 458 |
+
return Data(x=feature_mat, edge_index=None, edge_attr=None,
|
| 459 |
+
y=torch.Tensor(y), de_idx=de_idx, pert=pert)
|
| 460 |
+
|
| 461 |
+
def create_cell_graph_dataset(self, split_adata, pert_category,
|
| 462 |
+
num_samples=1):
|
| 463 |
+
"""
|
| 464 |
+
Create a graph dataset of perturbation categories
|
| 465 |
+
|
| 466 |
+
[Processing logic]
|
| 467 |
+
1. For non-control group disturbances:
|
| 468 |
+
- Get perturbed gene index
|
| 469 |
+
- Expression before perturbation = randomly sample control cells
|
| 470 |
+
- Target = expression of cells after perturbation
|
| 471 |
+
2. For the control group:
|
| 472 |
+
- Expression before perturbation = target = expression in control cells
|
| 473 |
+
|
| 474 |
+
[num_samples parameter]
|
| 475 |
+
Each perturbed cell can be paired with multiple control group cells,
|
| 476 |
+
Increase the diversity of training data
|
| 477 |
+
"""
|
| 478 |
+
num_de_genes = 20 # Only focus on the top 20 differentially expressed genes for evaluation
|
| 479 |
+
adata_ = split_adata[split_adata.obs['condition'] == pert_category]
|
| 480 |
+
de_genes = adata_.uns['rank_genes_groups_cov_all']
|
| 481 |
+
Xs = []
|
| 482 |
+
ys = []
|
| 483 |
+
|
| 484 |
+
# Handling non-control group disturbances
|
| 485 |
+
if pert_category != 'ctrl':
|
| 486 |
+
# Get the index of the perturbed gene
|
| 487 |
+
pert_idx = self.get_pert_idx(pert_category, adata_)
|
| 488 |
+
|
| 489 |
+
# Get differentially expressed gene index
|
| 490 |
+
pert_de_category = adata_.obs['condition_name'][0]
|
| 491 |
+
de_idx = np.where(adata_.var_names.isin(
|
| 492 |
+
np.array(de_genes[pert_de_category][:num_de_genes])))[0]
|
| 493 |
+
|
| 494 |
+
# Expand feature matrix to include perturbed genes
|
| 495 |
+
padded_X = sp.lil_matrix((adata_.X.shape[0], len(self.gene_names)), dtype=adata_.X.dtype)
|
| 496 |
+
padded_X[:, :len(self.real_gene_names)] = adata_.X
|
| 497 |
+
|
| 498 |
+
for cell_z in padded_X:
|
| 499 |
+
# Use control group samples as expression before perturbation
|
| 500 |
+
ctrl_samples = self.ctrl_adata[np.random.randint(0,
|
| 501 |
+
len(self.ctrl_adata), num_samples), :]
|
| 502 |
+
padded_ctrl_X = sp.lil_matrix((ctrl_samples.X.shape[0], len(self.gene_names)), dtype=ctrl_samples.X.dtype)
|
| 503 |
+
padded_ctrl_X[:, :len(self.real_gene_names)] = ctrl_samples.X
|
| 504 |
+
for c in padded_ctrl_X:
|
| 505 |
+
Xs.append(c) # control group expression
|
| 506 |
+
ys.append(cell_z) # expression after perturbation
|
| 507 |
+
|
| 508 |
+
# Process control group
|
| 509 |
+
else:
|
| 510 |
+
pert_idx = None
|
| 511 |
+
de_idx = [-1] * num_de_genes
|
| 512 |
+
padded_X = sp.lil_matrix((adata_.X.shape[0], len(self.gene_names)), dtype=adata_.X.dtype)
|
| 513 |
+
padded_X[:, :len(self.real_gene_names)] = adata_.X
|
| 514 |
+
for cell_z in padded_X:
|
| 515 |
+
Xs.append(cell_z)
|
| 516 |
+
ys.append(cell_z)
|
| 517 |
+
|
| 518 |
+
# Create a cell diagram data object
|
| 519 |
+
cell_graphs = []
|
| 520 |
+
for X, y in zip(Xs, ys):
|
| 521 |
+
cell_graphs.append(self.create_cell_graph(X.toarray(),
|
| 522 |
+
y.toarray(), de_idx, pert_category, pert_idx))
|
| 523 |
+
|
| 524 |
+
return cell_graphs
|
BioMod/gene_perturbation/scfm_utils_local.py
ADDED
|
@@ -0,0 +1,777 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
================================================================================
|
| 3 |
+
Single cell basic model tool module (scfm_utils_local.py)
|
| 4 |
+
================================================================================
|
| 5 |
+
|
| 6 |
+
[Module function]
|
| 7 |
+
Provides basic tool functions for distributed training and model management, including:
|
| 8 |
+
|
| 9 |
+
1. Distributed training tools:
|
| 10 |
+
- MetricLogger: training process indicator record
|
| 11 |
+
- SequentialDistributedSampler: Distributed sequential sampler
|
| 12 |
+
- get_reduced/distributed_concat: cross-process data aggregation
|
| 13 |
+
|
| 14 |
+
2. Learning rate scheduler:
|
| 15 |
+
- CosineAnnealingWarmupRestarts: Cosine Annealing with warm-up and restart
|
| 16 |
+
|
| 17 |
+
3. Model saving/loading:
|
| 18 |
+
- save_ckpt/save_scaler_ckpt: save checkpoint
|
| 19 |
+
- load_model: Load pre-trained model
|
| 20 |
+
|
| 21 |
+
[Notes on distributed training]
|
| 22 |
+
- RANK, WORLD_SIZE, LOCAL_RANK environment variables need to be set correctly
|
| 23 |
+
- The main process (rank=0) is responsible for printing logs and saving models
|
| 24 |
+
- Data needs to be aggregated across processes to get global indicators
|
| 25 |
+
================================================================================
|
| 26 |
+
"""
|
| 27 |
+
|
| 28 |
+
# -*- coding: utf-8 -*-
|
| 29 |
+
|
| 30 |
+
from __future__ import print_function
|
| 31 |
+
import json
|
| 32 |
+
import os
|
| 33 |
+
import struct
|
| 34 |
+
import sys
|
| 35 |
+
import platform
|
| 36 |
+
import re
|
| 37 |
+
import time
|
| 38 |
+
import traceback
|
| 39 |
+
import requests
|
| 40 |
+
import socket
|
| 41 |
+
import random
|
| 42 |
+
import math
|
| 43 |
+
import numpy as np
|
| 44 |
+
import torch
|
| 45 |
+
import logging
|
| 46 |
+
import datetime
|
| 47 |
+
from torch.optim.lr_scheduler import _LRScheduler
|
| 48 |
+
from torch import nn
|
| 49 |
+
import torch.nn.functional as F
|
| 50 |
+
from torch.nn.modules.loss import _WeightedLoss
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
# Copyright (c) Meta Platforms, Inc. and affiliates.
|
| 54 |
+
# All rights reserved.
|
| 55 |
+
|
| 56 |
+
# This source code is licensed under the license found in the
|
| 57 |
+
# LICENSE file in the root directory of this source tree.
|
| 58 |
+
# --------------------------------------------------------
|
| 59 |
+
# References:
|
| 60 |
+
# DeiT: https://github.com/facebookresearch/deit
|
| 61 |
+
# BEiT: https://github.com/microsoft/unilm/tree/master/beit
|
| 62 |
+
# --------------------------------------------------------
|
| 63 |
+
|
| 64 |
+
import builtins
|
| 65 |
+
import datetime
|
| 66 |
+
import os
|
| 67 |
+
import time
|
| 68 |
+
from collections import defaultdict, deque
|
| 69 |
+
from pathlib import Path
|
| 70 |
+
|
| 71 |
+
import torch
|
| 72 |
+
import torch.distributed as dist
|
| 73 |
+
from torch import inf
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
class SmoothedValue(object):
|
| 77 |
+
"""Track a series of values and provide access to smoothed values over a
|
| 78 |
+
window or the global series average.
|
| 79 |
+
"""
|
| 80 |
+
|
| 81 |
+
def __init__(self, window_size=20, fmt=None):
|
| 82 |
+
if fmt is None:
|
| 83 |
+
fmt = "{median:.4f} ({global_avg:.4f})"
|
| 84 |
+
self.deque = deque(maxlen=window_size)
|
| 85 |
+
self.total = 0.0
|
| 86 |
+
self.count = 0
|
| 87 |
+
self.fmt = fmt
|
| 88 |
+
|
| 89 |
+
def update(self, value, n=1):
|
| 90 |
+
self.deque.append(value)
|
| 91 |
+
self.count += n
|
| 92 |
+
self.total += value * n
|
| 93 |
+
|
| 94 |
+
def synchronize_between_processes(self):
|
| 95 |
+
"""
|
| 96 |
+
Warning: does not synchronize the deque!
|
| 97 |
+
"""
|
| 98 |
+
if not is_dist_avail_and_initialized():
|
| 99 |
+
return
|
| 100 |
+
t = torch.tensor([self.count, self.total], dtype=torch.float64, device='cuda')
|
| 101 |
+
dist.barrier()
|
| 102 |
+
dist.all_reduce(t)
|
| 103 |
+
t = t.tolist()
|
| 104 |
+
self.count = int(t[0])
|
| 105 |
+
self.total = t[1]
|
| 106 |
+
|
| 107 |
+
@property
|
| 108 |
+
def median(self):
|
| 109 |
+
d = torch.tensor(list(self.deque))
|
| 110 |
+
return d.median().item()
|
| 111 |
+
|
| 112 |
+
@property
|
| 113 |
+
def avg(self):
|
| 114 |
+
d = torch.tensor(list(self.deque), dtype=torch.float32)
|
| 115 |
+
return d.mean().item()
|
| 116 |
+
|
| 117 |
+
@property
|
| 118 |
+
def global_avg(self):
|
| 119 |
+
return self.total / self.count
|
| 120 |
+
|
| 121 |
+
@property
|
| 122 |
+
def max(self):
|
| 123 |
+
return max(self.deque)
|
| 124 |
+
|
| 125 |
+
@property
|
| 126 |
+
def value(self):
|
| 127 |
+
return self.deque[-1]
|
| 128 |
+
|
| 129 |
+
def __str__(self):
|
| 130 |
+
return self.fmt.format(
|
| 131 |
+
median=self.median,
|
| 132 |
+
avg=self.avg,
|
| 133 |
+
global_avg=self.global_avg,
|
| 134 |
+
max=self.max,
|
| 135 |
+
value=self.value)
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
class MetricLogger(object):
|
| 139 |
+
def __init__(self, delimiter="\t"):
|
| 140 |
+
self.meters = defaultdict(SmoothedValue)
|
| 141 |
+
self.delimiter = delimiter
|
| 142 |
+
|
| 143 |
+
def update(self, **kwargs):
|
| 144 |
+
for k, v in kwargs.items():
|
| 145 |
+
if v is None:
|
| 146 |
+
continue
|
| 147 |
+
if isinstance(v, torch.Tensor):
|
| 148 |
+
v = v.item()
|
| 149 |
+
assert isinstance(v, (float, int))
|
| 150 |
+
self.meters[k].update(v)
|
| 151 |
+
|
| 152 |
+
def __getattr__(self, attr):
|
| 153 |
+
if attr in self.meters:
|
| 154 |
+
return self.meters[attr]
|
| 155 |
+
if attr in self.__dict__:
|
| 156 |
+
return self.__dict__[attr]
|
| 157 |
+
raise AttributeError("'{}' object has no attribute '{}'".format(
|
| 158 |
+
type(self).__name__, attr))
|
| 159 |
+
|
| 160 |
+
def __str__(self):
|
| 161 |
+
loss_str = []
|
| 162 |
+
for name, meter in self.meters.items():
|
| 163 |
+
loss_str.append(
|
| 164 |
+
"{}: {}".format(name, str(meter))
|
| 165 |
+
)
|
| 166 |
+
return self.delimiter.join(loss_str)
|
| 167 |
+
|
| 168 |
+
def synchronize_between_processes(self):
|
| 169 |
+
for meter in self.meters.values():
|
| 170 |
+
meter.synchronize_between_processes()
|
| 171 |
+
|
| 172 |
+
def add_meter(self, name, meter):
|
| 173 |
+
self.meters[name] = meter
|
| 174 |
+
|
| 175 |
+
def log_every(self, iterable, print_freq, header=None):
|
| 176 |
+
i = 0
|
| 177 |
+
if not header:
|
| 178 |
+
header = ''
|
| 179 |
+
start_time = time.time()
|
| 180 |
+
end = time.time()
|
| 181 |
+
iter_time = SmoothedValue(fmt='{avg:.4f}')
|
| 182 |
+
data_time = SmoothedValue(fmt='{avg:.4f}')
|
| 183 |
+
space_fmt = ':' + str(len(str(len(iterable)))) + 'd'
|
| 184 |
+
log_msg = [
|
| 185 |
+
header,
|
| 186 |
+
'[{0' + space_fmt + '}/{1}]',
|
| 187 |
+
'eta: {eta}',
|
| 188 |
+
'{meters}',
|
| 189 |
+
'time: {time}',
|
| 190 |
+
'data: {data}'
|
| 191 |
+
]
|
| 192 |
+
if torch.cuda.is_available():
|
| 193 |
+
log_msg.append('max mem: {memory:.0f}')
|
| 194 |
+
log_msg = self.delimiter.join(log_msg)
|
| 195 |
+
MB = 1024.0 * 1024.0
|
| 196 |
+
for obj in iterable:
|
| 197 |
+
data_time.update(time.time() - end)
|
| 198 |
+
yield obj
|
| 199 |
+
iter_time.update(time.time() - end)
|
| 200 |
+
if i % print_freq == 0 or i == len(iterable) - 1:
|
| 201 |
+
eta_seconds = iter_time.global_avg * (len(iterable) - i)
|
| 202 |
+
eta_string = str(datetime.timedelta(seconds=int(eta_seconds)))
|
| 203 |
+
if torch.cuda.is_available():
|
| 204 |
+
print(log_msg.format(
|
| 205 |
+
i, len(iterable), eta=eta_string,
|
| 206 |
+
meters=str(self),
|
| 207 |
+
time=str(iter_time), data=str(data_time),
|
| 208 |
+
memory=torch.cuda.max_memory_allocated() / MB))
|
| 209 |
+
else:
|
| 210 |
+
print(log_msg.format(
|
| 211 |
+
i, len(iterable), eta=eta_string,
|
| 212 |
+
meters=str(self),
|
| 213 |
+
time=str(iter_time), data=str(data_time)))
|
| 214 |
+
i += 1
|
| 215 |
+
end = time.time()
|
| 216 |
+
total_time = time.time() - start_time
|
| 217 |
+
total_time_str = str(datetime.timedelta(seconds=int(total_time)))
|
| 218 |
+
print('{} Total time: {} ({:.4f} s / it)'.format(
|
| 219 |
+
header, total_time_str, total_time / len(iterable)))
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
def setup_for_distributed(is_master):
|
| 223 |
+
"""
|
| 224 |
+
This function disables printing when not in master process
|
| 225 |
+
"""
|
| 226 |
+
builtin_print = builtins.print
|
| 227 |
+
|
| 228 |
+
def print(*args, **kwargs):
|
| 229 |
+
force = kwargs.pop('force', False)
|
| 230 |
+
force = force or (get_world_size() > 8)
|
| 231 |
+
if is_master or force:
|
| 232 |
+
now = datetime.datetime.now().time()
|
| 233 |
+
builtin_print('[{}] '.format(now), end='') # print with time stamp
|
| 234 |
+
builtin_print(*args, **kwargs)
|
| 235 |
+
|
| 236 |
+
builtins.print = print
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
def is_dist_avail_and_initialized():
|
| 240 |
+
if not dist.is_available():
|
| 241 |
+
return False
|
| 242 |
+
if not dist.is_initialized():
|
| 243 |
+
return False
|
| 244 |
+
return True
|
| 245 |
+
|
| 246 |
+
|
| 247 |
+
def get_world_size():
|
| 248 |
+
if not is_dist_avail_and_initialized():
|
| 249 |
+
return 1
|
| 250 |
+
return dist.get_world_size()
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
def get_rank():
|
| 254 |
+
if not is_dist_avail_and_initialized():
|
| 255 |
+
return 0
|
| 256 |
+
return dist.get_rank()
|
| 257 |
+
|
| 258 |
+
|
| 259 |
+
def is_main_process():
|
| 260 |
+
return get_rank() == 0
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
def save_on_master(*args, **kwargs):
|
| 264 |
+
if is_main_process():
|
| 265 |
+
torch.save(*args, **kwargs)
|
| 266 |
+
|
| 267 |
+
|
| 268 |
+
def init_distributed_mode(args):
|
| 269 |
+
if args.dist_on_itp:
|
| 270 |
+
args.rank = int(os.environ['OMPI_COMM_WORLD_RANK'])
|
| 271 |
+
args.world_size = int(os.environ['OMPI_COMM_WORLD_SIZE'])
|
| 272 |
+
args.gpu = int(os.environ['OMPI_COMM_WORLD_LOCAL_RANK'])
|
| 273 |
+
args.dist_url = "tcp://%s:%s" % (os.environ['MASTER_ADDR'], os.environ['MASTER_PORT'])
|
| 274 |
+
os.environ['LOCAL_RANK'] = str(args.gpu)
|
| 275 |
+
os.environ['RANK'] = str(args.rank)
|
| 276 |
+
os.environ['WORLD_SIZE'] = str(args.world_size)
|
| 277 |
+
# ["RANK", "WORLD_SIZE", "MASTER_ADDR", "MASTER_PORT", "LOCAL_RANK"]
|
| 278 |
+
elif 'RANK' in os.environ and 'WORLD_SIZE' in os.environ:
|
| 279 |
+
args.rank = int(os.environ["RANK"])
|
| 280 |
+
args.world_size = int(os.environ['WORLD_SIZE'])
|
| 281 |
+
args.gpu = int(os.environ['LOCAL_RANK'])
|
| 282 |
+
elif 'SLURM_PROCID' in os.environ:
|
| 283 |
+
args.rank = int(os.environ['SLURM_PROCID'])
|
| 284 |
+
args.gpu = args.rank % torch.cuda.device_count()
|
| 285 |
+
else:
|
| 286 |
+
print('Not using distributed mode')
|
| 287 |
+
setup_for_distributed(is_master=True) # hack
|
| 288 |
+
args.distributed = False
|
| 289 |
+
return
|
| 290 |
+
|
| 291 |
+
args.distributed = True
|
| 292 |
+
|
| 293 |
+
torch.cuda.set_device(args.gpu)
|
| 294 |
+
args.dist_backend = 'nccl'
|
| 295 |
+
print('| distributed init (rank {}): {}, gpu {}'.format(
|
| 296 |
+
args.rank, args.dist_url, args.gpu), flush=True)
|
| 297 |
+
torch.distributed.init_process_group(backend=args.dist_backend, init_method=args.dist_url,
|
| 298 |
+
world_size=args.world_size, rank=args.rank)
|
| 299 |
+
torch.distributed.barrier()
|
| 300 |
+
setup_for_distributed(args.rank == 0)
|
| 301 |
+
|
| 302 |
+
|
| 303 |
+
class NativeScalerWithGradNormCount:
|
| 304 |
+
state_dict_key = "amp_scaler"
|
| 305 |
+
|
| 306 |
+
def __init__(self):
|
| 307 |
+
self._scaler = torch.cuda.amp.GradScaler()
|
| 308 |
+
|
| 309 |
+
def __call__(self, loss, optimizer, clip_grad=None, parameters=None, create_graph=False, update_grad=True):
|
| 310 |
+
self._scaler.scale(loss).backward(create_graph=create_graph)
|
| 311 |
+
if update_grad:
|
| 312 |
+
if clip_grad is not None:
|
| 313 |
+
assert parameters is not None
|
| 314 |
+
self._scaler.unscale_(optimizer) # unscale the gradients of optimizer's assigned params in-place
|
| 315 |
+
norm = torch.nn.utils.clip_grad_norm_(parameters, clip_grad)
|
| 316 |
+
else:
|
| 317 |
+
self._scaler.unscale_(optimizer)
|
| 318 |
+
norm = get_grad_norm_(parameters)
|
| 319 |
+
self._scaler.step(optimizer)
|
| 320 |
+
self._scaler.update()
|
| 321 |
+
else:
|
| 322 |
+
norm = None
|
| 323 |
+
return norm
|
| 324 |
+
|
| 325 |
+
def state_dict(self):
|
| 326 |
+
return self._scaler.state_dict()
|
| 327 |
+
|
| 328 |
+
def load_state_dict(self, state_dict):
|
| 329 |
+
self._scaler.load_state_dict(state_dict)
|
| 330 |
+
|
| 331 |
+
|
| 332 |
+
def get_grad_norm_(parameters, norm_type: float = 2.0) -> torch.Tensor:
|
| 333 |
+
if isinstance(parameters, torch.Tensor):
|
| 334 |
+
parameters = [parameters]
|
| 335 |
+
parameters = [p for p in parameters if p.grad is not None]
|
| 336 |
+
norm_type = float(norm_type)
|
| 337 |
+
if len(parameters) == 0:
|
| 338 |
+
return torch.tensor(0.)
|
| 339 |
+
device = parameters[0].grad.device
|
| 340 |
+
if norm_type == inf:
|
| 341 |
+
total_norm = max(p.grad.detach().abs().max().to(device) for p in parameters)
|
| 342 |
+
else:
|
| 343 |
+
total_norm = torch.norm(torch.stack([torch.norm(p.grad.detach(), norm_type).to(device) for p in parameters]), norm_type)
|
| 344 |
+
return total_norm
|
| 345 |
+
|
| 346 |
+
|
| 347 |
+
def save_model(args, epoch, model, model_without_ddp, optimizer, loss_scaler):
|
| 348 |
+
output_dir = Path(args.output_dir)
|
| 349 |
+
epoch_name = str(epoch)
|
| 350 |
+
if loss_scaler is not None:
|
| 351 |
+
checkpoint_paths = [output_dir / ('checkpoint-%s.pth' % epoch_name)]
|
| 352 |
+
for checkpoint_path in checkpoint_paths:
|
| 353 |
+
to_save = {
|
| 354 |
+
'model': model_without_ddp.state_dict(),
|
| 355 |
+
'optimizer': optimizer.state_dict(),
|
| 356 |
+
'epoch': epoch,
|
| 357 |
+
'scaler': loss_scaler.state_dict(),
|
| 358 |
+
'args': args,
|
| 359 |
+
}
|
| 360 |
+
|
| 361 |
+
save_on_master(to_save, checkpoint_path)
|
| 362 |
+
else:
|
| 363 |
+
client_state = {'epoch': epoch}
|
| 364 |
+
model.save_checkpoint(save_dir=args.output_dir, tag="checkpoint-%s" % epoch_name, client_state=client_state)
|
| 365 |
+
|
| 366 |
+
|
| 367 |
+
def load_model(args, model_without_ddp, optimizer, loss_scaler):
|
| 368 |
+
if args.resume:
|
| 369 |
+
if args.resume.startswith('https'):
|
| 370 |
+
checkpoint = torch.hub.load_state_dict_from_url(
|
| 371 |
+
args.resume, map_location='cpu', check_hash=True)
|
| 372 |
+
else:
|
| 373 |
+
checkpoint = torch.load(args.resume, map_location='cpu')
|
| 374 |
+
model_without_ddp.load_state_dict(checkpoint['model'])
|
| 375 |
+
print("Resume checkpoint %s" % args.resume)
|
| 376 |
+
if 'optimizer' in checkpoint and 'epoch' in checkpoint and not (hasattr(args, 'eval') and args.eval):
|
| 377 |
+
optimizer.load_state_dict(checkpoint['optimizer'])
|
| 378 |
+
args.start_epoch = checkpoint['epoch'] + 1
|
| 379 |
+
if 'scaler' in checkpoint:
|
| 380 |
+
loss_scaler.load_state_dict(checkpoint['scaler'])
|
| 381 |
+
print("With optim & sched!")
|
| 382 |
+
|
| 383 |
+
|
| 384 |
+
def all_reduce_mean(x):
|
| 385 |
+
world_size = get_world_size()
|
| 386 |
+
if world_size > 1:
|
| 387 |
+
x_reduce = torch.tensor(x).cuda()
|
| 388 |
+
dist.all_reduce(x_reduce)
|
| 389 |
+
x_reduce /= world_size
|
| 390 |
+
return x_reduce.item()
|
| 391 |
+
else:
|
| 392 |
+
return x
|
| 393 |
+
|
| 394 |
+
|
| 395 |
+
|
| 396 |
+
def seed_all(seed_value, cuda_deterministic=False):
|
| 397 |
+
"""
|
| 398 |
+
Set all random seeds
|
| 399 |
+
"""
|
| 400 |
+
random.seed(seed_value)
|
| 401 |
+
os.environ['PYTHONHASHSEED'] = str(seed_value)
|
| 402 |
+
np.random.seed(seed_value)
|
| 403 |
+
torch.manual_seed(seed_value)
|
| 404 |
+
if torch.cuda.is_available():
|
| 405 |
+
torch.cuda.manual_seed(seed_value)
|
| 406 |
+
torch.cuda.manual_seed_all(seed_value)
|
| 407 |
+
# Speed-reproducibility tradeoff https://pytorch.org/docs/stable/notes/randomness.html
|
| 408 |
+
if cuda_deterministic: # slower, more reproducible
|
| 409 |
+
torch.backends.cudnn.deterministic = True
|
| 410 |
+
torch.backends.cudnn.benchmark = False
|
| 411 |
+
else: # faster, less reproducible
|
| 412 |
+
torch.backends.cudnn.deterministic = False
|
| 413 |
+
torch.backends.cudnn.benchmark = True
|
| 414 |
+
|
| 415 |
+
|
| 416 |
+
def set_log(logfileName, rank=-1):
|
| 417 |
+
"""
|
| 418 |
+
The master node saves all logs, and other nodes only save warnings and errors.
|
| 419 |
+
"""
|
| 420 |
+
log_file_folder = os.path.dirname(logfileName)
|
| 421 |
+
time_now = datetime.datetime.now()
|
| 422 |
+
logfileName = f'{logfileName}_{time_now.year}_{time_now.month}_{time_now.day}_{time_now.hour}_{time_now.minute}.log'
|
| 423 |
+
if not os.path.exists(log_file_folder):
|
| 424 |
+
os.makedirs(log_file_folder)
|
| 425 |
+
else:
|
| 426 |
+
pass
|
| 427 |
+
|
| 428 |
+
logging.basicConfig(level=logging.INFO if rank in [-1, 0] else logging.WARN,
|
| 429 |
+
format='[%(asctime)s %(levelname)s %(filename)s line %(lineno)d %(process)d] %(message)s',
|
| 430 |
+
datefmt='[%X]',
|
| 431 |
+
handlers=[logging.FileHandler(logfileName), logging.StreamHandler()]
|
| 432 |
+
)
|
| 433 |
+
logger = logging.getLogger()
|
| 434 |
+
return logger
|
| 435 |
+
|
| 436 |
+
|
| 437 |
+
def save_ckpt(epoch, model, optimizer, scheduler, losses, model_name, ckpt_folder):
|
| 438 |
+
"""
|
| 439 |
+
Save model checkpoint
|
| 440 |
+
"""
|
| 441 |
+
if not os.path.exists(ckpt_folder):
|
| 442 |
+
os.makedirs(ckpt_folder)
|
| 443 |
+
torch.save(
|
| 444 |
+
{
|
| 445 |
+
'epoch': epoch,
|
| 446 |
+
'model_state_dict': model.module.state_dict(),
|
| 447 |
+
'optimizer_state_dict': optimizer.state_dict(),
|
| 448 |
+
'scheduler_state_dict': scheduler.state_dict(),
|
| 449 |
+
'losses': losses,
|
| 450 |
+
},
|
| 451 |
+
f'{ckpt_folder}{model_name}_{epoch}.pth'
|
| 452 |
+
)
|
| 453 |
+
|
| 454 |
+
|
| 455 |
+
def save_scaler_ckpt(epoch, model, optimizer, scheduler, scaler, losses, model_name, ckpt_folder, torch_compile = False):
|
| 456 |
+
"""
|
| 457 |
+
Save model checkpoint
|
| 458 |
+
"""
|
| 459 |
+
if not os.path.exists(ckpt_folder):
|
| 460 |
+
os.makedirs(ckpt_folder)
|
| 461 |
+
if torch_compile:
|
| 462 |
+
model = model._orig_mod
|
| 463 |
+
torch.save(
|
| 464 |
+
{
|
| 465 |
+
'epoch': epoch,
|
| 466 |
+
'model_state_dict': model.module.state_dict(),
|
| 467 |
+
'optimizer_state_dict': optimizer.state_dict(),
|
| 468 |
+
'scheduler_state_dict': scheduler.state_dict(),
|
| 469 |
+
'scaler_state_dict': scaler.state_dict(),
|
| 470 |
+
'losses': losses,
|
| 471 |
+
},
|
| 472 |
+
f'{ckpt_folder}{model_name}_{epoch}.pth'
|
| 473 |
+
)
|
| 474 |
+
|
| 475 |
+
def save_simple_ckpt(model, model_name, ckpt_folder):
|
| 476 |
+
"""
|
| 477 |
+
Save model checkpoint
|
| 478 |
+
"""
|
| 479 |
+
if not os.path.exists(ckpt_folder):
|
| 480 |
+
os.makedirs(ckpt_folder)
|
| 481 |
+
torch.save(
|
| 482 |
+
{
|
| 483 |
+
'model_state_dict': model.module.state_dict()
|
| 484 |
+
},
|
| 485 |
+
f'{ckpt_folder}{model_name}.pth'
|
| 486 |
+
)
|
| 487 |
+
|
| 488 |
+
def save_best_ckpt(epoch, model, optimizer, scheduler, losses, model_name, ckpt_folder):
|
| 489 |
+
"""
|
| 490 |
+
Save model checkpoint
|
| 491 |
+
"""
|
| 492 |
+
if not os.path.exists(ckpt_folder):
|
| 493 |
+
os.makedirs(ckpt_folder)
|
| 494 |
+
torch.save(
|
| 495 |
+
{
|
| 496 |
+
'epoch': epoch,
|
| 497 |
+
'model_state_dict': model.module.state_dict(),
|
| 498 |
+
'optimizer_state_dict': optimizer.state_dict(),
|
| 499 |
+
'scheduler_state_dict': scheduler.state_dict(),
|
| 500 |
+
'losses': losses,
|
| 501 |
+
},
|
| 502 |
+
f'{ckpt_folder}{model_name}_best.pth'
|
| 503 |
+
)
|
| 504 |
+
|
| 505 |
+
def get_reduced(tensor, current_device, dest_device, world_size):
|
| 506 |
+
"""
|
| 507 |
+
Concentrate variables or tensors on different GPUs on the main GPU and get the mean
|
| 508 |
+
"""
|
| 509 |
+
tensor = tensor.clone().detach() if torch.is_tensor(tensor) else torch.tensor(tensor)
|
| 510 |
+
tensor = tensor.to(current_device)
|
| 511 |
+
torch.distributed.reduce(tensor, dst=dest_device)
|
| 512 |
+
tensor_mean = tensor.item() / world_size
|
| 513 |
+
return tensor_mean
|
| 514 |
+
|
| 515 |
+
def get_ndtensor_reduced(tensor, current_device, dest_device, world_size):
|
| 516 |
+
"""
|
| 517 |
+
Concentrate variables or tensors on different GPUs on the main GPU and get the mean, which needs to be a 2-dimensional tensor.
|
| 518 |
+
"""
|
| 519 |
+
tensor = tensor.clone().detach() if torch.is_tensor(tensor) else torch.tensor(tensor)
|
| 520 |
+
tensor = tensor.to(current_device)
|
| 521 |
+
torch.distributed.reduce(tensor, dst=dest_device)
|
| 522 |
+
tensor_mean = torch.zeros(tensor.shape)
|
| 523 |
+
if len(tensor.shape) == 2:
|
| 524 |
+
for i in range(tensor.shape[0]):
|
| 525 |
+
for j in range(tensor.shape[1]):
|
| 526 |
+
tensor_mean[i,j] = tensor[i,j].item() / world_size
|
| 527 |
+
elif len(tensor.shape) == 1:
|
| 528 |
+
for i in range(tensor.shape[0]):
|
| 529 |
+
tensor_mean[i] = tensor[i].item() / world_size
|
| 530 |
+
return tensor_mean
|
| 531 |
+
|
| 532 |
+
def numel(m: torch.nn.Module, only_trainable: bool = False):
|
| 533 |
+
"""
|
| 534 |
+
returns the total number of parameters used by `m` (only counting
|
| 535 |
+
shared parameters once); if `only_trainable` is True, then only
|
| 536 |
+
includes parameters with `requires_grad = True`
|
| 537 |
+
"""
|
| 538 |
+
parameters = m.parameters()
|
| 539 |
+
if only_trainable:
|
| 540 |
+
parameters = list(p for p in parameters if p.requires_grad)
|
| 541 |
+
unique = dict((p.data_ptr(), p) for p in parameters).values()
|
| 542 |
+
return sum(p.numel() for p in unique)
|
| 543 |
+
|
| 544 |
+
|
| 545 |
+
def label_smooth(y, K, epsilon=0.1):
|
| 546 |
+
"""
|
| 547 |
+
Label smoothing for multiclass labels
|
| 548 |
+
One hot encode labels `y` over `K` classes. `y` should be of the form [1, 6, 3, etc.]
|
| 549 |
+
"""
|
| 550 |
+
m = len(y)
|
| 551 |
+
out = np.ones((m, K)) * epsilon / K
|
| 552 |
+
for index in range(m):
|
| 553 |
+
out[index][y[index] - 1] += 1 - epsilon
|
| 554 |
+
return torch.tensor(out)
|
| 555 |
+
|
| 556 |
+
|
| 557 |
+
class SequentialDistributedSampler(torch.utils.data.sampler.Sampler):
|
| 558 |
+
"""
|
| 559 |
+
sequential distributed sampler
|
| 560 |
+
|
| 561 |
+
[Function]
|
| 562 |
+
Sequentially subsample data in a distributed environment to facilitate post-training
|
| 563 |
+
Collect the results of all processes.
|
| 564 |
+
|
| 565 |
+
[Why is this needed?]
|
| 566 |
+
- Ordinary DistributedSampler will disrupt the order of data
|
| 567 |
+
- The order needs to be maintained during verification/testing to facilitate subsequent aggregation of results.
|
| 568 |
+
- Ensure equal number of samples per process by adding padding samples
|
| 569 |
+
"""
|
| 570 |
+
|
| 571 |
+
def __init__(self, dataset, batch_size, world_size, rank=None, num_replicas=None):
|
| 572 |
+
if num_replicas is None:
|
| 573 |
+
if not torch.distributed.is_available():
|
| 574 |
+
raise RuntimeError("Requires distributed package to be available")
|
| 575 |
+
num_replicas = world_size
|
| 576 |
+
if rank is None:
|
| 577 |
+
if not torch.distributed.is_available():
|
| 578 |
+
raise RuntimeError("Requires distributed package to be available")
|
| 579 |
+
rank = torch.distributed.get_rank()
|
| 580 |
+
self.dataset = dataset
|
| 581 |
+
self.num_replicas = num_replicas
|
| 582 |
+
self.rank = rank
|
| 583 |
+
self.batch_size = batch_size
|
| 584 |
+
self.num_samples = int(math.ceil(len(self.dataset) * 1.0 / self.batch_size / self.num_replicas)) * self.batch_size
|
| 585 |
+
self.total_size = self.num_samples * self.num_replicas
|
| 586 |
+
|
| 587 |
+
def __iter__(self):
|
| 588 |
+
indices = list(range(len(self.dataset)))
|
| 589 |
+
# Add padding samples to make the data size divisible
|
| 590 |
+
indices += [indices[-1]] * (self.total_size - len(indices))
|
| 591 |
+
# Subsampling: Each process only takes the part it is responsible for
|
| 592 |
+
indices = indices[self.rank * self.num_samples : (self.rank + 1) * self.num_samples]
|
| 593 |
+
return iter(indices)
|
| 594 |
+
|
| 595 |
+
def __len__(self):
|
| 596 |
+
return self.num_samples
|
| 597 |
+
|
| 598 |
+
|
| 599 |
+
def distributed_concat(tensor, num_total_examples, world_size):
|
| 600 |
+
"""
|
| 601 |
+
Merging inference results from different processes
|
| 602 |
+
|
| 603 |
+
[Function]
|
| 604 |
+
Use all_gather to collect tensors from all processes, and then splice and truncate the filled samples.
|
| 605 |
+
|
| 606 |
+
[parameter]
|
| 607 |
+
- tensor: the result tensor of the current process
|
| 608 |
+
- num_total_examples: real total number of samples
|
| 609 |
+
- world_size: number of processes
|
| 610 |
+
"""
|
| 611 |
+
output_tensors = [tensor.clone() for _ in range(world_size)]
|
| 612 |
+
torch.distributed.all_gather(output_tensors, tensor)
|
| 613 |
+
concat = torch.cat(output_tensors, dim=0)
|
| 614 |
+
# Truncate filler samples
|
| 615 |
+
return concat[:num_total_examples]
|
| 616 |
+
|
| 617 |
+
|
| 618 |
+
class CosineAnnealingWarmupRestarts(_LRScheduler):
|
| 619 |
+
"""
|
| 620 |
+
optimizer (Optimizer): Wrapped optimizer.
|
| 621 |
+
first_cycle_steps (int): First cycle step size.
|
| 622 |
+
cycle_mult(float): Cycle steps magnification. Default: -1.
|
| 623 |
+
max_lr(float): First cycle's max learning rate. Default: 0.1.
|
| 624 |
+
min_lr(float): Min learning rate. Default: 0.001.
|
| 625 |
+
warmup_steps(int): Linear warmup step size. Default: 0.
|
| 626 |
+
gamma(float): Decrease rate of max learning rate by cycle. Default: 1.
|
| 627 |
+
last_epoch (int): The index of last epoch. Default: -1.
|
| 628 |
+
"""
|
| 629 |
+
|
| 630 |
+
def __init__(self,
|
| 631 |
+
optimizer : torch.optim.Optimizer,
|
| 632 |
+
first_cycle_steps : int,
|
| 633 |
+
cycle_mult : float = 1.,
|
| 634 |
+
max_lr : float = 0.1,
|
| 635 |
+
min_lr : float = 0.001,
|
| 636 |
+
warmup_steps : int = 0,
|
| 637 |
+
gamma : float = 1.,
|
| 638 |
+
last_epoch : int = -1
|
| 639 |
+
):
|
| 640 |
+
assert warmup_steps < first_cycle_steps
|
| 641 |
+
|
| 642 |
+
self.first_cycle_steps = first_cycle_steps # first cycle step size
|
| 643 |
+
self.cycle_mult = cycle_mult # cycle steps magnification
|
| 644 |
+
self.base_max_lr = max_lr # first max learning rate
|
| 645 |
+
self.max_lr = max_lr # max learning rate in the current cycle
|
| 646 |
+
self.min_lr = min_lr # min learning rate
|
| 647 |
+
self.warmup_steps = warmup_steps # warmup step size
|
| 648 |
+
self.gamma = gamma # decrease rate of max learning rate by cycle
|
| 649 |
+
|
| 650 |
+
self.cur_cycle_steps = first_cycle_steps # first cycle step size
|
| 651 |
+
self.cycle = 0 # cycle count
|
| 652 |
+
self.step_in_cycle = last_epoch # step size of the current cycle
|
| 653 |
+
|
| 654 |
+
super(CosineAnnealingWarmupRestarts, self).__init__(optimizer, last_epoch)
|
| 655 |
+
|
| 656 |
+
# set learning rate min_lr
|
| 657 |
+
self.init_lr()
|
| 658 |
+
|
| 659 |
+
def init_lr(self):
|
| 660 |
+
self.base_lrs = []
|
| 661 |
+
for param_group in self.optimizer.param_groups:
|
| 662 |
+
param_group['lr'] = self.min_lr
|
| 663 |
+
self.base_lrs.append(self.min_lr)
|
| 664 |
+
|
| 665 |
+
def get_lr(self):
|
| 666 |
+
if self.step_in_cycle == -1:
|
| 667 |
+
return self.base_lrs
|
| 668 |
+
elif self.step_in_cycle < self.warmup_steps:
|
| 669 |
+
return [(self.max_lr - base_lr)*self.step_in_cycle / self.warmup_steps + base_lr for base_lr in self.base_lrs]
|
| 670 |
+
else:
|
| 671 |
+
return [base_lr + (self.max_lr - base_lr) \
|
| 672 |
+
* (1 + math.cos(math.pi * (self.step_in_cycle-self.warmup_steps) \
|
| 673 |
+
/ (self.cur_cycle_steps - self.warmup_steps))) / 2
|
| 674 |
+
for base_lr in self.base_lrs]
|
| 675 |
+
|
| 676 |
+
def step(self, epoch=None):
|
| 677 |
+
if epoch is None:
|
| 678 |
+
epoch = self.last_epoch + 1
|
| 679 |
+
self.step_in_cycle = self.step_in_cycle + 1
|
| 680 |
+
if self.step_in_cycle >= self.cur_cycle_steps:
|
| 681 |
+
self.cycle += 1
|
| 682 |
+
self.step_in_cycle = self.step_in_cycle - self.cur_cycle_steps
|
| 683 |
+
self.cur_cycle_steps = int((self.cur_cycle_steps - self.warmup_steps) * self.cycle_mult) + self.warmup_steps
|
| 684 |
+
else:
|
| 685 |
+
if epoch >= self.first_cycle_steps:
|
| 686 |
+
if self.cycle_mult == 1.:
|
| 687 |
+
self.step_in_cycle = epoch % self.first_cycle_steps
|
| 688 |
+
self.cycle = epoch // self.first_cycle_steps
|
| 689 |
+
else:
|
| 690 |
+
n = int(math.log((epoch / self.first_cycle_steps * (self.cycle_mult - 1) + 1), self.cycle_mult))
|
| 691 |
+
self.cycle = n
|
| 692 |
+
self.step_in_cycle = epoch - int(self.first_cycle_steps * (self.cycle_mult ** n - 1) / (self.cycle_mult - 1))
|
| 693 |
+
self.cur_cycle_steps = self.first_cycle_steps * self.cycle_mult ** (n)
|
| 694 |
+
else:
|
| 695 |
+
self.cur_cycle_steps = self.first_cycle_steps
|
| 696 |
+
self.step_in_cycle = epoch
|
| 697 |
+
|
| 698 |
+
self.max_lr = self.base_max_lr * (self.gamma**self.cycle)
|
| 699 |
+
self.last_epoch = math.floor(epoch)
|
| 700 |
+
for param_group, lr in zip(self.optimizer.param_groups, self.get_lr()):
|
| 701 |
+
param_group['lr'] = lr
|
| 702 |
+
|
| 703 |
+
|
| 704 |
+
class DistanceLoss(_WeightedLoss):
|
| 705 |
+
"""
|
| 706 |
+
CrossEntropyLoss with Distance Weighted
|
| 707 |
+
"""
|
| 708 |
+
def __init__(self, weight=None, reduction='mean', ignore_index = None):
|
| 709 |
+
super().__init__(weight=weight, reduction=reduction)
|
| 710 |
+
self.weight = weight
|
| 711 |
+
self.reduction = reduction
|
| 712 |
+
self.ignore_index = ignore_index
|
| 713 |
+
def forward(self, inputs, targets):
|
| 714 |
+
if len(inputs.shape) > 2:
|
| 715 |
+
inputs = inputs.reshape(-1, inputs.size(-1))
|
| 716 |
+
if len(targets.shape) > 1:
|
| 717 |
+
targets = targets.reshape(-1)
|
| 718 |
+
if self.ignore_index is not None:
|
| 719 |
+
keep_index = (targets != self.ignore_index).nonzero(as_tuple=True)[0]
|
| 720 |
+
targets = torch.index_select(targets, 0, keep_index) #targets[targets != self.ignore_index]
|
| 721 |
+
inputs = torch.index_select(inputs, 0, keep_index)
|
| 722 |
+
lsm = F.log_softmax(inputs, -1)
|
| 723 |
+
targets = torch.empty(size=(targets.size(0), inputs.size(-1)), device=targets.device).fill_(0).scatter_(1, targets.data.unsqueeze(1), 1)
|
| 724 |
+
if self.weight is not None:
|
| 725 |
+
lsm = lsm * self.weight.unsqueeze(0)
|
| 726 |
+
loss = -(targets * lsm).sum(-1)
|
| 727 |
+
inputs = nn.Softmax(dim=-1)(inputs)[..., 1:-1].argmax(dim=-1) + 1
|
| 728 |
+
# print('inputs', inputs.device, inputs.shape)
|
| 729 |
+
targets = nn.Softmax(dim=-1)(targets)[..., 1:-1].argmax(dim=-1) + 1
|
| 730 |
+
# print('targets', targets.device, targets.shape)
|
| 731 |
+
distance = abs(inputs - targets) + 1e-2
|
| 732 |
+
# print('loss.shape', loss.shape)
|
| 733 |
+
# print('distance.shape', distance.shape)
|
| 734 |
+
loss = loss * distance
|
| 735 |
+
if self.reduction == 'sum':
|
| 736 |
+
loss = loss.sum()
|
| 737 |
+
elif self.reduction == 'mean':
|
| 738 |
+
loss = loss.mean()
|
| 739 |
+
return loss
|
| 740 |
+
|
| 741 |
+
|
| 742 |
+
class LabelSmoothCrossEntropyLoss(_WeightedLoss):
|
| 743 |
+
"""
|
| 744 |
+
CrossEntropyLoss with Label Somoothing
|
| 745 |
+
"""
|
| 746 |
+
def __init__(self, weight=None, reduction='mean', smoothing=0.0):
|
| 747 |
+
super().__init__(weight=weight, reduction=reduction)
|
| 748 |
+
self.smoothing = smoothing
|
| 749 |
+
self.weight = weight
|
| 750 |
+
self.reduction = reduction
|
| 751 |
+
|
| 752 |
+
@staticmethod
|
| 753 |
+
def _smooth_one_hot(targets: torch.Tensor, n_classes: int, smoothing=0.0):
|
| 754 |
+
assert 0 <= smoothing < 1
|
| 755 |
+
with torch.no_grad():
|
| 756 |
+
targets = torch.empty(size=(targets.size(0), n_classes),
|
| 757 |
+
device=targets.device) \
|
| 758 |
+
.fill_(smoothing / (n_classes - 1)) \
|
| 759 |
+
.scatter_(1, targets.data.unsqueeze(1), 1. - smoothing)
|
| 760 |
+
return targets
|
| 761 |
+
|
| 762 |
+
def forward(self, inputs, targets):
|
| 763 |
+
targets = LabelSmoothCrossEntropyLoss._smooth_one_hot(targets, inputs.size(-1),
|
| 764 |
+
self.smoothing)
|
| 765 |
+
lsm = F.log_softmax(inputs, -1)
|
| 766 |
+
|
| 767 |
+
if self.weight is not None:
|
| 768 |
+
lsm = lsm * self.weight.unsqueeze(0)
|
| 769 |
+
|
| 770 |
+
loss = -(targets * lsm).sum(-1)
|
| 771 |
+
|
| 772 |
+
if self.reduction == 'sum':
|
| 773 |
+
loss = loss.sum()
|
| 774 |
+
elif self.reduction == 'mean':
|
| 775 |
+
loss = loss.mean()
|
| 776 |
+
|
| 777 |
+
return loss
|
BioMod/gene_perturbation/train_module.py
ADDED
|
@@ -0,0 +1,325 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
================================================================================
|
| 3 |
+
Genetic perturbation task training script (train_module.py)
|
| 4 |
+
================================================================================
|
| 5 |
+
|
| 6 |
+
[Task Overview]
|
| 7 |
+
This script is used to train a model that predicts the effects of genetic perturbation. Gene perturbation refers to knockout or
|
| 8 |
+
Overexpress a gene and observe its effect on the cell transcriptome. The goal of the model is: given a combination of genetic perturbations,
|
| 9 |
+
Predict the gene expression profile of cells after perturbation.
|
| 10 |
+
|
| 11 |
+
[Core process]
|
| 12 |
+
1. Load perturbation data sets (such as Norman, Adamson and other public data sets)
|
| 13 |
+
2. Initialize the pre-trained single-cell basic model (PerformerEncoderOnly_Module)
|
| 14 |
+
3. Fine-tune the model on perturbed data and learn to predict the effect of perturbation
|
| 15 |
+
4. Evaluate model performance (MSE, Pearson correlation coefficient and other indicators)
|
| 16 |
+
|
| 17 |
+
[Key technical points]
|
| 18 |
+
- Use DDP (DistributedDataParallel) for multi-GPU distributed training
|
| 19 |
+
- Use module-based gene representation to group genes and then encode them
|
| 20 |
+
- Supports multiple gene expression and gene2vec fusion methods
|
| 21 |
+
|
| 22 |
+
[Adaptation instructions]
|
| 23 |
+
This script is based on GEARS/train_dual_ddp_reduce_dec.py and adapted to the module-based model.
|
| 24 |
+
Use a completely separate native module, avoiding any external gears_reduce imports.
|
| 25 |
+
================================================================================
|
| 26 |
+
"""
|
| 27 |
+
|
| 28 |
+
import os
|
| 29 |
+
# [Key] Disable Numba JIT compilation to avoid compilation conflicts in multi-process environments
|
| 30 |
+
# Numba may cause problems during distributed training. Use pure Python to implement it after disabling it.
|
| 31 |
+
os.environ['NUMBA_DISABLE_JIT'] = '1'
|
| 32 |
+
import sys
|
| 33 |
+
import time
|
| 34 |
+
import argparse
|
| 35 |
+
import pandas as pd
|
| 36 |
+
import numpy as np
|
| 37 |
+
import scanpy as sc
|
| 38 |
+
from os.path import join as pjoin
|
| 39 |
+
|
| 40 |
+
import torch
|
| 41 |
+
from torch.utils.data.distributed import DistributedSampler
|
| 42 |
+
from torch.nn.parallel import DistributedDataParallel as DDP
|
| 43 |
+
import torch.distributed as dist
|
| 44 |
+
import tempfile
|
| 45 |
+
|
| 46 |
+
# ============================================================
|
| 47 |
+
# [Key] Correctly set the order of sys.path
|
| 48 |
+
# Local modules must take precedence and avoid importing into external utils.py
|
| 49 |
+
# This is a common Python module import trap: if a module with the same name exists in the project root directory,
|
| 50 |
+
# can be imported incorrectly, causing problems that are difficult to debug
|
| 51 |
+
# ============================================================
|
| 52 |
+
current_dir = os.path.dirname(os.path.abspath(__file__))
|
| 53 |
+
project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 54 |
+
|
| 55 |
+
# First add the project root directory (for performer_pytorch_cont)
|
| 56 |
+
if project_root not in sys.path:
|
| 57 |
+
sys.path.append(project_root)
|
| 58 |
+
|
| 59 |
+
# Finally add the current directory (make sure local modules take precedence)
|
| 60 |
+
# Use append instead of insert(0), but since the current directory is empty there will be no conflict
|
| 61 |
+
if current_dir not in sys.path:
|
| 62 |
+
sys.path.insert(0, current_dir)
|
| 63 |
+
|
| 64 |
+
# ============================================================
|
| 65 |
+
# [Key] Create an independent Numba cache directory for each process
|
| 66 |
+
# In distributed training, multiple processes sharing the same cache directory can cause file conflicts.
|
| 67 |
+
# This is a common problem with Numba in a DDP environment. An independent cache must be allocated for each rank.
|
| 68 |
+
# ============================================================
|
| 69 |
+
if "RANK" in os.environ: # Distributed environment
|
| 70 |
+
cache_dir = f"/tmp/numba_cache_{os.environ['RANK']}"
|
| 71 |
+
else:
|
| 72 |
+
cache_dir = tempfile.mkdtemp(prefix="numba_cache_")
|
| 73 |
+
os.environ["NUMBA_CACHE_DIR"] = cache_dir
|
| 74 |
+
|
| 75 |
+
# ============================================================
|
| 76 |
+
# [Module Import] Use completely independent local modules
|
| 77 |
+
# These local modules are to avoid conflicts with modules of the same name in other parts of the project
|
| 78 |
+
# ============================================================
|
| 79 |
+
from gears_reduce_local import GEARS
|
| 80 |
+
from utils import print_sys
|
| 81 |
+
from pertdata_local import PertData # Local version, does not rely on external gears_reduce
|
| 82 |
+
|
| 83 |
+
# Import module model
|
| 84 |
+
from model_module import GEARS_Model_Module
|
| 85 |
+
|
| 86 |
+
def main(parser):
|
| 87 |
+
"""
|
| 88 |
+
main training function
|
| 89 |
+
|
| 90 |
+
[Training process]
|
| 91 |
+
1. Initialize the distributed training environment (DDP)
|
| 92 |
+
2. Loading and preprocessing perturbation data
|
| 93 |
+
3. Prepare data partitioning (training/validation/testing)
|
| 94 |
+
4. Initialize model and optimizer
|
| 95 |
+
5. Training loop + validation + testing
|
| 96 |
+
"""
|
| 97 |
+
# Turn on anomaly detection to help debug gradient calculation problems
|
| 98 |
+
torch.autograd.set_detect_anomaly(True)
|
| 99 |
+
|
| 100 |
+
args = parser.parse_args()
|
| 101 |
+
|
| 102 |
+
# ============================================================
|
| 103 |
+
# [Distributed training initialization]
|
| 104 |
+
# DDP uses NCCL backend for inter-GPU communication
|
| 105 |
+
# local_rank: the GPU number of the current process on this machine
|
| 106 |
+
# RANK: global process number (different machines have different ranks during multi-machine training)
|
| 107 |
+
# ============================================================
|
| 108 |
+
local_rank = args.local_rank
|
| 109 |
+
rank = int(os.environ["RANK"])
|
| 110 |
+
is_master = (rank == 0) # Rank 0 is the main process, responsible for printing logs and saving models
|
| 111 |
+
|
| 112 |
+
dist.init_process_group(backend='nccl', init_method='env://')
|
| 113 |
+
print('DDP init!')
|
| 114 |
+
local_rank = int(os.environ['LOCAL_RANK'])
|
| 115 |
+
device = f'cuda:{local_rank}'
|
| 116 |
+
torch.cuda.set_device(device)
|
| 117 |
+
world_size = torch.distributed.get_world_size() # Total number of processes
|
| 118 |
+
|
| 119 |
+
print("local_rank: ", local_rank)
|
| 120 |
+
|
| 121 |
+
valid_every = args.valid_every # Verify every epoch
|
| 122 |
+
|
| 123 |
+
# ============================================================
|
| 124 |
+
# [Data loading]
|
| 125 |
+
# The PertData class is responsible for loading and processing perturbation data
|
| 126 |
+
# Supported data sets: norman, adamson, dixit and other public data sets
|
| 127 |
+
# ============================================================
|
| 128 |
+
pert_data = PertData(args.data_dir)
|
| 129 |
+
# load dataset in paper: norman, adamson, dixit.
|
| 130 |
+
try:
|
| 131 |
+
if args.data_name in ['norman', 'adamson', 'dixit']:
|
| 132 |
+
pert_data.load(data_name=args.data_name)
|
| 133 |
+
else:
|
| 134 |
+
print('load data')
|
| 135 |
+
pert_data.load(data_path=pjoin(args.data_dir, args.data_name))
|
| 136 |
+
except:
|
| 137 |
+
# If the data format is not standard, try loading from h5ad file
|
| 138 |
+
adata = sc.read_h5ad(pjoin(args.data_dir, args.data_name+'.h5ad'))
|
| 139 |
+
adata.uns['log1p'] = {}
|
| 140 |
+
adata.uns['log1p']['base'] = None
|
| 141 |
+
pert_data.new_data_process(dataset_name=args.data_name, adata=adata)
|
| 142 |
+
|
| 143 |
+
# ============================================================
|
| 144 |
+
# [Gene Mapping]
|
| 145 |
+
# Check the overlap between the genes of the input data and the genes of the pre-trained model
|
| 146 |
+
# An overlap ratio that is too low may result in poor model performance.
|
| 147 |
+
# ============================================================
|
| 148 |
+
input_genes_ens_ids = pert_data.adata.var.index.tolist()
|
| 149 |
+
with open(args.scfm_genes_list_path, 'r') as f:
|
| 150 |
+
scfm_genes_ens_ids = [line.rstrip('\n') for line in f.readlines()]
|
| 151 |
+
if is_master:
|
| 152 |
+
print("target and scfm genes intersect: ", len(np.intersect1d(input_genes_ens_ids, scfm_genes_ens_ids)))
|
| 153 |
+
|
| 154 |
+
# ============================================================
|
| 155 |
+
# [Data division]
|
| 156 |
+
# The split parameter determines the data partitioning strategy:
|
| 157 |
+
# - 'simulation': simulates the division of real application scenarios, and the test set contains unseen gene combinations
|
| 158 |
+
# - 'combo_seen0/1/2': divided according to the number of genes seen in the combination
|
| 159 |
+
# train_gene_set_size: training set gene proportion
|
| 160 |
+
# ============================================================
|
| 161 |
+
pert_data.prepare_split(split=args.split, seed=args.seed, train_gene_set_size=args.train_gene_set_size)
|
| 162 |
+
# get dataloader with batch size
|
| 163 |
+
pert_data.get_dataloader(batch_size=args.batch_size, test_batch_size=args.test_batch_size)
|
| 164 |
+
|
| 165 |
+
# set up and train a model
|
| 166 |
+
if is_master:
|
| 167 |
+
print_sys("EXPERIMENT: " + args.run_name)
|
| 168 |
+
|
| 169 |
+
# ============================================================
|
| 170 |
+
# [Model initialization]
|
| 171 |
+
# The GEARS class encapsulates the complete process of training, verification, and testing
|
| 172 |
+
# ============================================================
|
| 173 |
+
gears_model = GEARS(pert_data,
|
| 174 |
+
local_rank=local_rank,
|
| 175 |
+
is_master=is_master,
|
| 176 |
+
world_size=world_size,
|
| 177 |
+
weight_bias_track=args.wandb,
|
| 178 |
+
train_bs=args.batch_size,
|
| 179 |
+
test_bs=args.test_batch_size,
|
| 180 |
+
device=device,
|
| 181 |
+
exp_name=args.run_name)
|
| 182 |
+
|
| 183 |
+
# ============================================================
|
| 184 |
+
# [Model parameter configuration]
|
| 185 |
+
# Use GEARS_Model_Module instead of GEARS_Model_Acc
|
| 186 |
+
# Key parameter description:
|
| 187 |
+
# - hidden_size: hidden layer dimension, affecting model capacity
|
| 188 |
+
# - finetune_method: fine-tuning method ('frozen' freezes pre-training parameters, None means fine-tuning all parameters)
|
| 189 |
+
# - accumulation_steps: the number of gradient accumulation steps, used to simulate larger batch size
|
| 190 |
+
# ============================================================
|
| 191 |
+
gears_model.model_initialize(
|
| 192 |
+
hidden_size=args.hidden_size,
|
| 193 |
+
model_type=args.model_type,
|
| 194 |
+
bin_set=args.bin_set,
|
| 195 |
+
load_path=args.singlecell_model_path,
|
| 196 |
+
finetune_method=args.finetune_method,
|
| 197 |
+
accumulation_steps=args.accumulation_steps,
|
| 198 |
+
mode=args.mode,
|
| 199 |
+
input_genes_ens_ids=input_genes_ens_ids,
|
| 200 |
+
scfm_genes_ens_ids=scfm_genes_ens_ids,
|
| 201 |
+
scfm_hyper_params_path=args.scfm_hyper_params_path,
|
| 202 |
+
scfm_ckpt_path=args.scfm_ckpt_path,
|
| 203 |
+
scfm_class=None, # Do not use scfm_class, use module model directly
|
| 204 |
+
key_enc=None,
|
| 205 |
+
scfm_gene2vec_file=args.scfm_gene2vec_file,
|
| 206 |
+
record_pred=args.record_pred,
|
| 207 |
+
model_class=GEARS_Model_Module, # Use module model class
|
| 208 |
+
# ============================================================
|
| 209 |
+
# [Module related parameters] The following parameters affect how the model processes module-based data
|
| 210 |
+
# module_data_path/module_data_dir: module divides the data path
|
| 211 |
+
# gene_mapping_path: mapping file from gene symbol to Ensembl ID
|
| 212 |
+
# use_module_level: whether to use module level features directly (default False, reverts to gene level)
|
| 213 |
+
# fusion_method: Read from pkl to ensure consistency with pre-training
|
| 214 |
+
# shuffle_module_genes: [Ablation experiment] Disrupt the gene distribution within the module
|
| 215 |
+
# ============================================================
|
| 216 |
+
module_data_path=args.module_data_path if hasattr(args, 'module_data_path') else None,
|
| 217 |
+
module_data_dir=args.module_data_dir if hasattr(args, 'module_data_dir') else None,
|
| 218 |
+
gene_mapping_path=args.gene_mapping_path if hasattr(args, 'gene_mapping_path') else '../gene2ens.csv',
|
| 219 |
+
scfm_genes_list_path=args.scfm_genes_list_path,
|
| 220 |
+
use_module_level=args.use_module_level if hasattr(args, 'use_module_level') else False,
|
| 221 |
+
shuffle_module_genes=args.shuffle_module_genes if hasattr(args, 'shuffle_module_genes') else False,
|
| 222 |
+
shuffle_seed=args.shuffle_seed if hasattr(args, 'shuffle_seed') else 2021,
|
| 223 |
+
)
|
| 224 |
+
|
| 225 |
+
gears_model.train(
|
| 226 |
+
epochs=args.epochs,
|
| 227 |
+
result_dir=args.result_dir,
|
| 228 |
+
lr=args.lr,
|
| 229 |
+
valid_every=valid_every
|
| 230 |
+
)
|
| 231 |
+
|
| 232 |
+
# save params
|
| 233 |
+
if is_master:
|
| 234 |
+
param_pd = pd.DataFrame(vars(args), index=['params']).T
|
| 235 |
+
param_pd.to_csv(f'{args.result_dir}/params.csv')
|
| 236 |
+
|
| 237 |
+
if __name__ == '__main__':
|
| 238 |
+
parser = argparse.ArgumentParser(description='GEARS with PerformerEncoderOnly_Module')
|
| 239 |
+
|
| 240 |
+
# ============================================================
|
| 241 |
+
# [Distributed training parameters]
|
| 242 |
+
# local-rank: automatically set by torch.distributed.launch, no need to specify manually
|
| 243 |
+
# ============================================================
|
| 244 |
+
parser.add_argument("--local-rank", type=int, default=-1, help='Local process rank.')
|
| 245 |
+
|
| 246 |
+
# ============================================================
|
| 247 |
+
# [Data related parameters]
|
| 248 |
+
# data_dir: data storage directory
|
| 249 |
+
# data_name: Data set name (norman/adamson/dixit is a public data set)
|
| 250 |
+
# split: data partitioning strategy, simulation is the most commonly used
|
| 251 |
+
# train_gene_set_size: The proportion of genes in the training set, which affects the evaluation of model generalization ability
|
| 252 |
+
# ============================================================
|
| 253 |
+
parser.add_argument('--data_dir', type=str, default='../../GEARS/data')
|
| 254 |
+
parser.add_argument('--data_name', type=str, default='norman')
|
| 255 |
+
parser.add_argument('--split', type=str, default='simulation')
|
| 256 |
+
parser.add_argument('--result_dir', type=str, default='./results')
|
| 257 |
+
parser.add_argument('--seed', type=int, default=1)
|
| 258 |
+
|
| 259 |
+
# ============================================================
|
| 260 |
+
# [Training hyperparameters]
|
| 261 |
+
# epochs: number of training rounds, usually 15-20 rounds is enough
|
| 262 |
+
# batch_size: Batch size, adjusted according to GPU memory
|
| 263 |
+
# lr: learning rate, usually a smaller learning rate (1e-3 or less) is used during fine-tuning
|
| 264 |
+
# accumulation_steps: Number of gradient accumulation steps, used to simulate larger batch size
|
| 265 |
+
# ============================================================
|
| 266 |
+
parser.add_argument('--epochs', type=int, default=20)
|
| 267 |
+
parser.add_argument('--batch_size', type=int, default=32)
|
| 268 |
+
parser.add_argument('--valid_every', type=int, default=1)
|
| 269 |
+
parser.add_argument('--test_batch_size', type=int, default=128)
|
| 270 |
+
parser.add_argument('--train_gene_set_size', type=float, default=0.75)
|
| 271 |
+
parser.add_argument('--hidden_size', type=int, default=64)
|
| 272 |
+
parser.add_argument('--device', type=str, default='cuda')
|
| 273 |
+
parser.add_argument('--model_type', type=str, default=None)
|
| 274 |
+
parser.add_argument('--bin_set', type=str, default=None)
|
| 275 |
+
parser.add_argument('--singlecell_model_path', type=str, default=None)
|
| 276 |
+
parser.add_argument('--finetune_method', type=str, default=None)
|
| 277 |
+
parser.add_argument('--mode', type=str, default='v1')
|
| 278 |
+
parser.add_argument('--accumulation_steps', type=int, default=1)
|
| 279 |
+
parser.add_argument('-wandb', action='store_true', help='Enable Weights & Biases logging')
|
| 280 |
+
parser.add_argument('-record_pred', action='store_true', help='Record predictions for analysis')
|
| 281 |
+
|
| 282 |
+
# ============================================================
|
| 283 |
+
# [Related parameters of pre-training model]
|
| 284 |
+
# scfm_genes_list_path: list of genes used by the pre-trained model
|
| 285 |
+
# scfm_hyper_params_path: Hyperparameter configuration file of the pre-trained model
|
| 286 |
+
# scfm_ckpt_path: checkpoint file of the pre-trained model
|
| 287 |
+
# scfm_gene2vec_file: gene2vec pre-trained embedding file
|
| 288 |
+
# ============================================================
|
| 289 |
+
parser.add_argument('--scfm_genes_list_path', type=str,
|
| 290 |
+
default="../../selected_genes_27k.txt")
|
| 291 |
+
parser.add_argument('--scfm_hyper_params_path', type=str,
|
| 292 |
+
default="../ckpts_performer_module_test/performer_module_test.pkl")
|
| 293 |
+
parser.add_argument('--scfm_ckpt_path', type=str,
|
| 294 |
+
default="../ckpts_performer_module_test/performer_module_test_epoch6.pth")
|
| 295 |
+
parser.add_argument('--run_name', type=str,
|
| 296 |
+
default="default_run_module")
|
| 297 |
+
parser.add_argument('--scfm_gene2vec_file', type=str,
|
| 298 |
+
default='../../selected_gene2vec_27k.npy')
|
| 299 |
+
|
| 300 |
+
# ============================================================
|
| 301 |
+
# [Module related parameters] These parameters affect how the model handles module-based gene representation.
|
| 302 |
+
# module_data_path: single module JSON file path
|
| 303 |
+
# module_data_dir: module JSON file directory
|
| 304 |
+
# gene_mapping_path: mapping file from gene symbol to Ensembl ID
|
| 305 |
+
# use_module_level: whether to use module level features directly (experimental feature)
|
| 306 |
+
# fusion_method: Read from pkl to ensure consistency with pre-training
|
| 307 |
+
# shuffle_module_genes: [Ablation experiment] Disrupt the genes in the module to verify the importance of the module structure
|
| 308 |
+
# ============================================================
|
| 309 |
+
parser.add_argument('--module_data_path', type=str, default=None,
|
| 310 |
+
help='Path to module JSON file (single file)')
|
| 311 |
+
parser.add_argument('--module_data_dir', type=str, default='../module_data',
|
| 312 |
+
help='Path to directory containing module JSON files')
|
| 313 |
+
parser.add_argument('--gene_mapping_path', type=str, default='../../gene2ens.csv',
|
| 314 |
+
help='Path to gene symbol to Ensembl ID mapping CSV')
|
| 315 |
+
parser.add_argument('--use_module_level', action='store_true',
|
| 316 |
+
help='Use module-level features directly instead of recovering to gene level. '
|
| 317 |
+
'Note: This requires compatible downstream architecture (not fully implemented).')
|
| 318 |
+
parser.add_argument('--shuffle_module_genes', action='store_true',
|
| 319 |
+
help='[Ablation experiment] Additionally shuffle module_sets after restoring them from the pretrained PKL. '
|
| 320 |
+
'Do not pass this parameter when reproducing the experiment normally.')
|
| 321 |
+
parser.add_argument('--shuffle_seed', type=int, default=2021,
|
| 322 |
+
help='Only takes effect when --shuffle_module_genes is turned on and is used for additional downstream shuffling.')
|
| 323 |
+
parser.add_argument('--lr', type=float, default=1e-3)
|
| 324 |
+
|
| 325 |
+
main(parser)
|
BioMod/gene_perturbation/utils.py
ADDED
|
@@ -0,0 +1,489 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
================================================================================
|
| 3 |
+
Utility function module (utils.py)
|
| 4 |
+
================================================================================
|
| 5 |
+
|
| 6 |
+
[Module function]
|
| 7 |
+
Provides various tool functions required for genetic perturbation tasks, including:
|
| 8 |
+
|
| 9 |
+
1. Perturbation analysis function: analyze the gene name of single gene/combination perturbation
|
| 10 |
+
2. Gene relationship graph construction: co-expression graph and GO similarity graph
|
| 11 |
+
3. Loss function: training loss and uncertainty loss
|
| 12 |
+
4. Genetic interaction (GI) analysis: Calculate genetic interaction coefficients
|
| 13 |
+
|
| 14 |
+
[Key concepts]
|
| 15 |
+
- Perturbation format: "gene1+gene2", such as "ctrl+MAPK1" or "MAPK1+KRAS"
|
| 16 |
+
- Co-expression map: constructed based on gene expression correlations in the data
|
| 17 |
+
- GO graph: Jaccard similarity construction based on Gene Ontology annotation
|
| 18 |
+
|
| 19 |
+
[Key parameters that may affect the results]
|
| 20 |
+
- coexpress_threshold: Threshold for co-expression graph construction
|
| 21 |
+
- dict_filter: training loss is only calculated on non-zero genes
|
| 22 |
+
- direction_lambda: direction consistency loss weight
|
| 23 |
+
================================================================================
|
| 24 |
+
"""
|
| 25 |
+
|
| 26 |
+
import torch
|
| 27 |
+
import numpy as np
|
| 28 |
+
import pandas as pd
|
| 29 |
+
from sklearn.linear_model import TheilSenRegressor
|
| 30 |
+
import torch.nn as nn
|
| 31 |
+
|
| 32 |
+
import networkx as nx
|
| 33 |
+
from tqdm import tqdm
|
| 34 |
+
import pickle
|
| 35 |
+
import sys, os
|
| 36 |
+
import requests
|
| 37 |
+
from torch_geometric.data import Data
|
| 38 |
+
|
| 39 |
+
from zipfile import ZipFile
|
| 40 |
+
|
| 41 |
+
import statsmodels.api as sm
|
| 42 |
+
from sklearn.linear_model import LinearRegression, TheilSenRegressor
|
| 43 |
+
# ============================================================
|
| 44 |
+
# Lazy loading of dcor to avoid numba compatibility issues in DDP multi-process environments
|
| 45 |
+
# dcor will be imported when needed (in the get_coeffs function)
|
| 46 |
+
# ============================================================
|
| 47 |
+
from sklearn.metrics import r2_score
|
| 48 |
+
|
| 49 |
+
def parse_single_pert(i):
|
| 50 |
+
"""
|
| 51 |
+
Parsing single gene perturbation strings
|
| 52 |
+
|
| 53 |
+
[Format]
|
| 54 |
+
- "ctrl+GENE": The perturbed gene is GENE
|
| 55 |
+
- "GENE+ctrl": The perturbed gene is GENE
|
| 56 |
+
|
| 57 |
+
[return]
|
| 58 |
+
Perturbed gene name
|
| 59 |
+
"""
|
| 60 |
+
a = i.split('+')[0]
|
| 61 |
+
b = i.split('+')[1]
|
| 62 |
+
if a == 'ctrl':
|
| 63 |
+
pert = b
|
| 64 |
+
else:
|
| 65 |
+
pert = a
|
| 66 |
+
return pert
|
| 67 |
+
|
| 68 |
+
def filter_pert_cond_in_go(condition, exclude_pert):
|
| 69 |
+
"""
|
| 70 |
+
Filter perturbations not in the GO graph
|
| 71 |
+
|
| 72 |
+
[use]
|
| 73 |
+
Genes without annotations in the GO diagram cannot predict their perturbation effects.
|
| 74 |
+
These disturbing conditions need to be eliminated during data loading.
|
| 75 |
+
"""
|
| 76 |
+
if condition == 'ctrl':
|
| 77 |
+
return True
|
| 78 |
+
else:
|
| 79 |
+
if condition in exclude_pert:
|
| 80 |
+
return False
|
| 81 |
+
else:
|
| 82 |
+
return True
|
| 83 |
+
|
| 84 |
+
def parse_combo_pert(i):
|
| 85 |
+
"""
|
| 86 |
+
Parsing combined perturbation strings
|
| 87 |
+
|
| 88 |
+
[Format]
|
| 89 |
+
- "GENE1+GENE2": perturb two genes simultaneously
|
| 90 |
+
|
| 91 |
+
[return]
|
| 92 |
+
(gene1, gene2) tuple
|
| 93 |
+
"""
|
| 94 |
+
return i.split('+')[0], i.split('+')[1]
|
| 95 |
+
|
| 96 |
+
def combine_res(res_1, res_2):
|
| 97 |
+
"""Merge two evaluation result dictionaries"""
|
| 98 |
+
res_out = {}
|
| 99 |
+
for key in res_1:
|
| 100 |
+
res_out[key] = np.concatenate([res_1[key], res_2[key]])
|
| 101 |
+
return res_out
|
| 102 |
+
|
| 103 |
+
def parse_any_pert(p):
|
| 104 |
+
"""
|
| 105 |
+
Parse arbitrary perturbation strings (single genes or combinations)
|
| 106 |
+
|
| 107 |
+
[return]
|
| 108 |
+
Gene list:
|
| 109 |
+
- Single gene perturbation: [gene]
|
| 110 |
+
- Combinatorial perturbation: [gene1, gene2]
|
| 111 |
+
"""
|
| 112 |
+
if ('ctrl' in p) and (p != 'ctrl'):
|
| 113 |
+
return [parse_single_pert(p)]
|
| 114 |
+
elif 'ctrl' not in p:
|
| 115 |
+
out = parse_combo_pert(p)
|
| 116 |
+
return [out[0], out[1]]
|
| 117 |
+
|
| 118 |
+
def np_pearson_cor(x, y):
|
| 119 |
+
"""
|
| 120 |
+
Calculate Pearson correlation coefficient matrix
|
| 121 |
+
|
| 122 |
+
[use]
|
| 123 |
+
Used to construct co-expressed gene relationship diagrams
|
| 124 |
+
"""
|
| 125 |
+
xv = x - x.mean(axis=0)
|
| 126 |
+
yv = y - y.mean(axis=0)
|
| 127 |
+
xvss = (xv * xv).sum(axis=0)
|
| 128 |
+
yvss = (yv * yv).sum(axis=0)
|
| 129 |
+
result = np.matmul(xv.transpose(), yv) / np.sqrt(np.outer(xvss, yvss))
|
| 130 |
+
return np.maximum(np.minimum(result, 1.0), -1.0)
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def dataverse_download(url, save_path):
|
| 134 |
+
"""dataverse download helper with progress bar
|
| 135 |
+
|
| 136 |
+
Args:
|
| 137 |
+
url (str): the url of the dataset
|
| 138 |
+
path (str): the path to save the dataset
|
| 139 |
+
"""
|
| 140 |
+
|
| 141 |
+
if os.path.exists(save_path):
|
| 142 |
+
print_sys('Found local copy...')
|
| 143 |
+
else:
|
| 144 |
+
print_sys("Downloading...")
|
| 145 |
+
response = requests.get(url, stream=True)
|
| 146 |
+
total_size_in_bytes= int(response.headers.get('content-length', 0))
|
| 147 |
+
block_size = 1024
|
| 148 |
+
progress_bar = tqdm(total=total_size_in_bytes, unit='iB', unit_scale=True)
|
| 149 |
+
with open(save_path, 'wb') as file:
|
| 150 |
+
for data in response.iter_content(block_size):
|
| 151 |
+
progress_bar.update(len(data))
|
| 152 |
+
file.write(data)
|
| 153 |
+
progress_bar.close()
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def zip_data_download_wrapper(url, save_path, data_path):
|
| 157 |
+
|
| 158 |
+
if os.path.exists(save_path):
|
| 159 |
+
print_sys('Found local copy...')
|
| 160 |
+
else:
|
| 161 |
+
dataverse_download(url, save_path + '.zip')
|
| 162 |
+
print_sys('Extracting zip file...')
|
| 163 |
+
with ZipFile((save_path + '.zip'), 'r') as zip:
|
| 164 |
+
zip.extractall(path = data_path)
|
| 165 |
+
print_sys("Done!")
|
| 166 |
+
|
| 167 |
+
def get_go_auto(gene_list, data_path, data_name):
|
| 168 |
+
go_path = os.path.join(data_path, data_name, 'go.csv')
|
| 169 |
+
|
| 170 |
+
if os.path.exists(go_path):
|
| 171 |
+
return pd.read_csv(go_path)
|
| 172 |
+
else:
|
| 173 |
+
with open(os.path.join('/home/ding.bai/pert_new/GEARS/data_all/gene2go_all.pkl'), 'rb') as f:
|
| 174 |
+
gene2go = pickle.load(f)
|
| 175 |
+
|
| 176 |
+
gene2go = {i: list(gene2go[i]) for i in gene_list if i in gene2go}
|
| 177 |
+
edge_list = []
|
| 178 |
+
for g1 in tqdm(gene2go.keys()):
|
| 179 |
+
for g2 in gene2go.keys():
|
| 180 |
+
edge_list.append((g1, g2, len(np.intersect1d(gene2go[g1], gene2go[g2]))/len(np.union1d(gene2go[g1], gene2go[g2]))))
|
| 181 |
+
|
| 182 |
+
edge_list_filter = [i for i in edge_list if i[2] > 0]
|
| 183 |
+
further_filter = [i for i in edge_list if i[2] > 0.1]
|
| 184 |
+
df_edge_list = pd.DataFrame(further_filter).rename(columns = {0: 'gene1', 1: 'gene2', 2: 'score'})
|
| 185 |
+
|
| 186 |
+
df_edge_list = df_edge_list.rename(columns = {'gene1': 'source', 'gene2': 'target', 'score': 'importance'})
|
| 187 |
+
|
| 188 |
+
df_edge_list.to_csv(go_path, index = False)
|
| 189 |
+
return df_edge_list
|
| 190 |
+
|
| 191 |
+
def get_go(df_gene2go):
|
| 192 |
+
df_gene2go['Entry name'] = df_gene2go['Entry name'].apply(lambda x: x.split('_')[0])
|
| 193 |
+
df_gene2go = df_gene2go[df_gene2go['Gene ontology IDs'].notnull()]
|
| 194 |
+
df_gene2go = df_gene2go.rename(columns = {[i for i in df_gene2go.columns.values if 'yourlist' in i][0]: 'gene_id'})
|
| 195 |
+
|
| 196 |
+
geneid2go = dict(df_gene2go[['gene_id', 'Gene ontology IDs']].values)
|
| 197 |
+
|
| 198 |
+
gene2go = {}
|
| 199 |
+
for i,j in geneid2go.items():
|
| 200 |
+
j = [k.strip() for k in j.split(';')]
|
| 201 |
+
for k in i.split(','):
|
| 202 |
+
gene2go[ensembl2genename[k]] = j
|
| 203 |
+
|
| 204 |
+
from tqdm import tqdm
|
| 205 |
+
edge_list = []
|
| 206 |
+
for g1 in tqdm(gene2go.keys()):
|
| 207 |
+
for g2 in gene2go.keys():
|
| 208 |
+
|
| 209 |
+
edge_list.append((g1, g2, len(np.intersect1d(gene2go[g1], gene2go[g2]))/len(np.union1d(gene2go[g1], gene2go[g2]))))
|
| 210 |
+
|
| 211 |
+
edge_list_filter = [i for i in edge_list if i[2] > 0]
|
| 212 |
+
further_filter = [i for i in edge_list if i[2] > 0.1]
|
| 213 |
+
df_edge_list = pd.DataFrame(further_filter).rename(columns = {0: 'gene1', 1: 'gene2', 2: 'score'})
|
| 214 |
+
|
| 215 |
+
df_edge_list = df_edge_list.rename(columns = {'gene1': 'source', 'gene2': 'target', 'score': 'importance'})
|
| 216 |
+
|
| 217 |
+
return df_edge_list
|
| 218 |
+
|
| 219 |
+
class GeneSimNetwork():
|
| 220 |
+
'''
|
| 221 |
+
Represents a gene similarity network. Has the following attributes:
|
| 222 |
+
- self.edge_list
|
| 223 |
+
- self.gene_list
|
| 224 |
+
- self.G
|
| 225 |
+
- self.edge_index (Tensor, can be used to set G_go in model_initialize)
|
| 226 |
+
- self.edge_weight (Tensor, can be used to set G_go_weight in model_initialize)
|
| 227 |
+
'''
|
| 228 |
+
def __init__(self, edge_list, gene_list, node_map):
|
| 229 |
+
# Keeps edges if both source and target exist in node_map
|
| 230 |
+
keep_edges = np.zeros(len(edge_list)).astype(bool)
|
| 231 |
+
for i, (source, target, importance) in edge_list.iterrows():
|
| 232 |
+
keep_edges[i] = source in node_map and target in node_map
|
| 233 |
+
self.edge_list = edge_list[keep_edges]
|
| 234 |
+
|
| 235 |
+
# Constructs NetworkX graph
|
| 236 |
+
self.G = nx.from_pandas_edgelist(self.edge_list, source='source',
|
| 237 |
+
target='target', edge_attr=['importance'],
|
| 238 |
+
create_using=nx.DiGraph())
|
| 239 |
+
self.gene_list = gene_list
|
| 240 |
+
|
| 241 |
+
# Add any isolated nodes (represented in the tensor with self edges)
|
| 242 |
+
for n in self.gene_list:
|
| 243 |
+
added_nodes = []
|
| 244 |
+
if n not in self.G.nodes():
|
| 245 |
+
self.G.add_node(n)
|
| 246 |
+
added_nodes.append(n)
|
| 247 |
+
if len(added_nodes) > 0:
|
| 248 |
+
print('The following nodes were not present in the edge list and have been added to the graph as self-edges:', added_nodes)
|
| 249 |
+
|
| 250 |
+
# Convert graph edges to tensor
|
| 251 |
+
edge_index_ = [(node_map[e[0]], node_map[e[1]]) for e in self.G.edges]
|
| 252 |
+
self.edge_index = torch.tensor(edge_index_, dtype=torch.long).T
|
| 253 |
+
|
| 254 |
+
# Convert edge weights to tensor
|
| 255 |
+
edge_attr = nx.get_edge_attributes(self.G, 'importance')
|
| 256 |
+
importance = np.array([edge_attr[e] for e in self.G.edges])
|
| 257 |
+
self.edge_weight = torch.Tensor(importance)
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
def get_similarity_network(network_type, adata, threshold, k, gene_list, data_path, data_name, split, seed, train_gene_set_size, set2conditions):
|
| 261 |
+
|
| 262 |
+
if network_type == 'co-express':
|
| 263 |
+
df_out = get_coexpression_network_from_train(adata, threshold, k, data_path, data_name, split, seed, train_gene_set_size, set2conditions)
|
| 264 |
+
elif network_type == 'go':
|
| 265 |
+
df_jaccard = get_go_auto(gene_list, data_path, data_name)
|
| 266 |
+
df_out = df_jaccard.groupby('target').apply(lambda x: x.nlargest(k + 1,['importance'])).reset_index(drop = True)
|
| 267 |
+
|
| 268 |
+
return df_out
|
| 269 |
+
|
| 270 |
+
def get_coexpression_network_from_train(adata, threshold, k, data_path, data_name, split, seed, train_gene_set_size, set2conditions):
|
| 271 |
+
|
| 272 |
+
fname = os.path.join(os.path.join(data_path, data_name), split + '_' + str(seed) + '_' + str(train_gene_set_size) + '_' + str(threshold) + '_' + str(k) + '_co_expression_network.csv')
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
if os.path.exists(fname):
|
| 276 |
+
return pd.read_csv(fname)
|
| 277 |
+
else:
|
| 278 |
+
gene_list = [f for f in adata.var.gene_name.values]
|
| 279 |
+
idx2gene = dict(zip(range(len(gene_list)), gene_list))
|
| 280 |
+
X = adata.X
|
| 281 |
+
train_perts = set2conditions['train']
|
| 282 |
+
X_tr = X[np.isin(adata.obs.condition, [i for i in train_perts if 'ctrl' in i])]
|
| 283 |
+
gene_list = adata.var['gene_name'].values
|
| 284 |
+
|
| 285 |
+
X_tr = X_tr.toarray()
|
| 286 |
+
out = np_pearson_cor(X_tr, X_tr)
|
| 287 |
+
out[np.isnan(out)] = 0
|
| 288 |
+
out = np.abs(out)
|
| 289 |
+
|
| 290 |
+
out_sort_idx = np.argsort(out)[:, -(k + 1):]
|
| 291 |
+
out_sort_val = np.sort(out)[:, -(k + 1):]
|
| 292 |
+
|
| 293 |
+
df_g = []
|
| 294 |
+
for i in range(out_sort_idx.shape[0]):
|
| 295 |
+
target = idx2gene[i]
|
| 296 |
+
for j in range(out_sort_idx.shape[1]):
|
| 297 |
+
df_g.append((idx2gene[out_sort_idx[i, j]], target, out_sort_val[i, j]))
|
| 298 |
+
|
| 299 |
+
df_g = [i for i in df_g if i[2] > threshold]
|
| 300 |
+
|
| 301 |
+
df_co_expression = pd.DataFrame(df_g).rename(columns = {0: 'source', 1: 'target', 2: 'importance'})
|
| 302 |
+
df_co_expression.to_csv(fname, index = False)
|
| 303 |
+
return df_co_expression
|
| 304 |
+
|
| 305 |
+
|
| 306 |
+
def uncertainty_loss_fct(pred, logvar, y, perts, reg = 0.1, ctrl = None, direction_lambda = 1e-3, dict_filter = None):
|
| 307 |
+
"""
|
| 308 |
+
Uncertain version loss:
|
| 309 |
+
1) The error term is weighted by logvar (heteroscedastic modeling)
|
| 310 |
+
2) Optional direction penalty (relative to the up/down direction of control)
|
| 311 |
+
3) `dict_filter` is only calculated on a non-zero gene subset, and the caliber is consistent with the evaluation
|
| 312 |
+
"""
|
| 313 |
+
gamma = 2
|
| 314 |
+
perts = np.array(perts)
|
| 315 |
+
losses = torch.tensor(0.0, requires_grad=True).to(pred.device)
|
| 316 |
+
for p in set(perts):
|
| 317 |
+
if p!= 'ctrl':
|
| 318 |
+
# Training only on non-zero genes of this perturbation can reduce the impact of dropout noise.
|
| 319 |
+
retain_idx = dict_filter[p]
|
| 320 |
+
pred_p = pred[np.where(perts==p)[0]][:, retain_idx]
|
| 321 |
+
y_p = y[np.where(perts==p)[0]][:, retain_idx]
|
| 322 |
+
logvar_p = logvar[np.where(perts==p)[0]][:, retain_idx]
|
| 323 |
+
else:
|
| 324 |
+
pred_p = pred[np.where(perts==p)[0]]
|
| 325 |
+
y_p = y[np.where(perts==p)[0]]
|
| 326 |
+
logvar_p = logvar[np.where(perts==p)[0]]
|
| 327 |
+
|
| 328 |
+
# uncertainty based loss
|
| 329 |
+
|
| 330 |
+
losses += torch.sum((pred_p - y_p)**(2 + gamma) + reg * torch.exp(-logvar_p) * (pred_p - y_p)**(2 + gamma))/pred_p.shape[0]/pred_p.shape[1]
|
| 331 |
+
|
| 332 |
+
# direction loss
|
| 333 |
+
if p!= 'ctrl':
|
| 334 |
+
# Directional consistency term: The constraint prediction is consistent with the true direction of change relative to control.
|
| 335 |
+
losses += torch.sum(direction_lambda * (torch.sign(y_p - ctrl[retain_idx]) - torch.sign(pred_p - ctrl[retain_idx]))**2)/pred_p.shape[0]/pred_p.shape[1]
|
| 336 |
+
else:
|
| 337 |
+
losses += torch.sum(direction_lambda * (torch.sign(y_p - ctrl) - torch.sign(pred_p - ctrl))**2)/pred_p.shape[0]/pred_p.shape[1]
|
| 338 |
+
|
| 339 |
+
return losses/(len(set(perts)))
|
| 340 |
+
|
| 341 |
+
def loss_fct(pred, y, perts, ctrl = None, direction_lambda = 1e-3, dict_filter = None):
|
| 342 |
+
"""
|
| 343 |
+
Main training loss (without uncertainty):
|
| 344 |
+
- Basic item: MSE
|
| 345 |
+
- Optional direction item: Penalize upward/downward direction errors
|
| 346 |
+
- `dict_filter` will change the training gene set and is an important switch that affects the results.
|
| 347 |
+
"""
|
| 348 |
+
gamma = 2
|
| 349 |
+
mse_p = torch.nn.MSELoss()
|
| 350 |
+
perts = np.array(perts)
|
| 351 |
+
losses = torch.tensor(0.0).to(pred.device)
|
| 352 |
+
|
| 353 |
+
for p in set(perts):
|
| 354 |
+
pert_idx = np.where(perts == p)[0]
|
| 355 |
+
|
| 356 |
+
|
| 357 |
+
# during training, we remove the all zero genes into calculation of loss. this gives a cleaner direction loss. empirically, the performance stays the same.
|
| 358 |
+
|
| 359 |
+
# during training, we remove the all zero genes into calculation of loss.
|
| 360 |
+
# this gives a cleaner direction loss. empirically, the performance stays the same.
|
| 361 |
+
|
| 362 |
+
if p!= 'ctrl':
|
| 363 |
+
# Generally more stable when consistent with the evaluated non-zero / non-dropout settings.
|
| 364 |
+
retain_idx = dict_filter[p]
|
| 365 |
+
pred_p = pred[pert_idx][:, retain_idx]
|
| 366 |
+
y_p = y[pert_idx][:, retain_idx]
|
| 367 |
+
else:
|
| 368 |
+
pred_p = pred[pert_idx]
|
| 369 |
+
y_p = y[pert_idx]
|
| 370 |
+
|
| 371 |
+
|
| 372 |
+
losses += torch.sum((pred_p - y_p)**(2 + gamma))/pred_p.shape[0]/pred_p.shape[1]
|
| 373 |
+
|
| 374 |
+
## direction loss
|
| 375 |
+
if (p!= 'ctrl'):
|
| 376 |
+
losses += torch.sum(direction_lambda * (torch.sign(y_p - ctrl[retain_idx]) - torch.sign(pred_p - ctrl[retain_idx]))**2)/pred_p.shape[0]/pred_p.shape[1]
|
| 377 |
+
else:
|
| 378 |
+
losses += torch.sum(direction_lambda * (torch.sign(y_p - ctrl) - torch.sign(pred_p - ctrl))**2)/pred_p.shape[0]/pred_p.shape[1]
|
| 379 |
+
|
| 380 |
+
return losses/(len(set(perts)))
|
| 381 |
+
|
| 382 |
+
|
| 383 |
+
def print_sys(s):
|
| 384 |
+
"""system print
|
| 385 |
+
|
| 386 |
+
Args:
|
| 387 |
+
s (str): the string to print
|
| 388 |
+
"""
|
| 389 |
+
print(s, flush = True, file = sys.stderr)
|
| 390 |
+
|
| 391 |
+
def create_cell_graph_for_prediction(X, pert_idx, pert_gene):
|
| 392 |
+
|
| 393 |
+
|
| 394 |
+
# If perturbations will be represented as node features
|
| 395 |
+
pert_feats = np.zeros(len(X))
|
| 396 |
+
for p in pert_idx:
|
| 397 |
+
pert_feats[int(np.abs(p))] = np.sign(p)
|
| 398 |
+
feature_mat = torch.Tensor(np.vstack([X, pert_feats])).T
|
| 399 |
+
|
| 400 |
+
return Data(x=feature_mat, pert=pert_gene)
|
| 401 |
+
|
| 402 |
+
|
| 403 |
+
def create_cell_graph_dataset_for_prediction(pert_gene, ctrl_adata, gene_names, device, num_samples = 300):
|
| 404 |
+
Xs = []
|
| 405 |
+
|
| 406 |
+
# Get the indices (and signs) of applied perturbation
|
| 407 |
+
pert_idx = [np.where(p == np.array(gene_names))[0][0] for p in pert_gene]
|
| 408 |
+
|
| 409 |
+
Xs = ctrl_adata[np.random.randint(0, len(ctrl_adata), num_samples), :].X.toarray()
|
| 410 |
+
# Create cell graphs
|
| 411 |
+
cell_graphs = [create_cell_graph_for_prediction(X, pert_idx, pert_gene).to(device) for X in Xs]
|
| 412 |
+
return cell_graphs
|
| 413 |
+
|
| 414 |
+
##
|
| 415 |
+
##GI related utils
|
| 416 |
+
##
|
| 417 |
+
|
| 418 |
+
def get_coeffs(singles_expr, first_expr, second_expr, double_expr):
|
| 419 |
+
# ============================================================
|
| 420 |
+
# Lazy loading of dcor to avoid numba conflicts when importing into multiple processes
|
| 421 |
+
# Import dcor only when the function is called
|
| 422 |
+
# ============================================================
|
| 423 |
+
try:
|
| 424 |
+
from dcor import distance_correlation
|
| 425 |
+
except Exception as e:
|
| 426 |
+
# Use alternative if dcor import fails (numba compatibility issue)
|
| 427 |
+
import warnings
|
| 428 |
+
warnings.warn(f"dcor import failed: {e}. Using fallback distance correlation implementation.")
|
| 429 |
+
# Use a simplified scipy/numpy based implementation as an alternative
|
| 430 |
+
def distance_correlation(x, y):
|
| 431 |
+
"""Simplified version of distance correlation coefficient, does not rely on numba"""
|
| 432 |
+
from scipy.spatial.distance import pdist, squareform
|
| 433 |
+
x = np.asarray(x)
|
| 434 |
+
y = np.asarray(y)
|
| 435 |
+
a = squareform(pdist(x))
|
| 436 |
+
b = squareform(pdist(y))
|
| 437 |
+
A = a - a.mean(axis=0)[None, :] - a.mean(axis=1)[:, None] + a.mean()
|
| 438 |
+
B = b - b.mean(axis=0)[None, :] - b.mean(axis=1)[:, None] + b.mean()
|
| 439 |
+
dcov2 = (A * B).sum() / (A.size * A.size)
|
| 440 |
+
dvar_x = (A * A).sum() / (A.size * A.size)
|
| 441 |
+
dvar_y = (B * B).sum() / (B.size * B.size)
|
| 442 |
+
if dvar_x == 0 or dvar_y == 0:
|
| 443 |
+
return 0.0
|
| 444 |
+
return np.sqrt(dcov2 / np.sqrt(dvar_x * dvar_y))
|
| 445 |
+
|
| 446 |
+
results = {}
|
| 447 |
+
results['ts'] = TheilSenRegressor(fit_intercept=False,
|
| 448 |
+
max_subpopulation=1e5,
|
| 449 |
+
max_iter=1000,
|
| 450 |
+
random_state=1000)
|
| 451 |
+
X = singles_expr
|
| 452 |
+
y = double_expr
|
| 453 |
+
results['ts'].fit(X, y.ravel())
|
| 454 |
+
Zts = results['ts'].predict(X)
|
| 455 |
+
results['c1'] = results['ts'].coef_[0]
|
| 456 |
+
results['c2'] = results['ts'].coef_[1]
|
| 457 |
+
results['mag'] = np.sqrt((results['c1']**2 + results['c2']**2))
|
| 458 |
+
|
| 459 |
+
results['dcor'] = distance_correlation(singles_expr, double_expr)
|
| 460 |
+
results['dcor_singles'] = distance_correlation(first_expr, second_expr)
|
| 461 |
+
results['dcor_first'] = distance_correlation(first_expr, double_expr)
|
| 462 |
+
results['dcor_second'] = distance_correlation(second_expr, double_expr)
|
| 463 |
+
results['corr_fit'] = np.corrcoef(Zts.flatten(), double_expr.flatten())[0,1]
|
| 464 |
+
results['dominance'] = np.abs(np.log10(results['c1']/results['c2']))
|
| 465 |
+
results['eq_contr'] = np.min([results['dcor_first'], results['dcor_second']])/\
|
| 466 |
+
np.max([results['dcor_first'], results['dcor_second']])
|
| 467 |
+
|
| 468 |
+
return results
|
| 469 |
+
|
| 470 |
+
def get_GI_params(preds, combo):
|
| 471 |
+
|
| 472 |
+
singles_expr = np.array([preds[combo[0]], preds[combo[1]]]).T
|
| 473 |
+
first_expr = np.array(preds[combo[0]]).T
|
| 474 |
+
second_expr = np.array(preds[combo[1]]).T
|
| 475 |
+
double_expr = np.array(preds[combo[0]+'_'+combo[1]]).T
|
| 476 |
+
|
| 477 |
+
return get_coeffs(singles_expr, first_expr, second_expr, double_expr)
|
| 478 |
+
|
| 479 |
+
def get_GI_genes_idx(adata, GI_gene_file):
|
| 480 |
+
# Genes used for linear model fitting
|
| 481 |
+
GI_genes = np.load(GI_gene_file, allow_pickle=True)
|
| 482 |
+
GI_genes_idx = np.where([g in GI_genes for g in adata.var.gene_name.values])[0]
|
| 483 |
+
|
| 484 |
+
return GI_genes_idx
|
| 485 |
+
|
| 486 |
+
def get_mean_control(adata):
|
| 487 |
+
cols = adata.var.gene_name.values.astype('str')
|
| 488 |
+
mean_ctrl_exp = adata[adata.obs['condition'] == 'ctrl'].to_df().mean()
|
| 489 |
+
return mean_ctrl_exp
|
BioMod/interpretability/README.md
ADDED
|
@@ -0,0 +1,412 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Module Interpretability Experiments for scLong (performer_module)
|
| 2 |
+
|
| 3 |
+
This module contains interpretability experiments designed specifically for the
|
| 4 |
+
**module level** of scLong. The goal is to demonstrate, using structural,
|
| 5 |
+
semantic, and functional evidence, that module design has a paradigm-level
|
| 6 |
+
advantage over gene-level foundation models such as GenePT, scGPT, and
|
| 7 |
+
scFoundation.
|
| 8 |
+
|
| 9 |
+
## Experiment Roadmap, Ordered by Priority
|
| 10 |
+
|
| 11 |
+
| # | Script | Core question | Status |
|
| 12 |
+
| --- | --- | --- | --- |
|
| 13 |
+
| 1 | `module_geometry.py` | Does the module latent space self-organize by pathway family, | Completed |
|
| 14 |
+
| 2 | `module_text_alignment/module_text_alignment.py` | Do module embeddings align with natural-language pathway descriptions, | Implemented |
|
| 15 |
+
| 3 | `module_importance/module_importance.py` | Does task-driven module importance match known pathway-cell-type or pathway-disease associations, | Implemented |
|
| 16 |
+
| 4 | `module_vs_gene.py` | Is scLong better than GenePT/scGPT for pathway-level interpretability, | TODO |
|
| 17 |
+
| 6 | `attention/module_crosstalk.py` | Do module interactions match Reactome/KEGG pathway relationships, | Implemented |
|
| 18 |
+
|
| 19 |
+
Experiments 1 and 2 are the core differentiators because gene-level baselines
|
| 20 |
+
cannot structurally reproduce them. Experiment 3 provides task-level functional
|
| 21 |
+
validation. Experiment 4 provides direct comparison to baselines. Additional
|
| 22 |
+
experiments, such as module crosstalk vs Reactome, module dynamics along
|
| 23 |
+
developmental trajectories, and retraining-based module-granularity ablation, can
|
| 24 |
+
be pursued after the core experiments are complete.
|
| 25 |
+
|
| 26 |
+
## Experiment 1: Intrinsic Geometry of Module Embeddings (`module_geometry.py`)
|
| 27 |
+
|
| 28 |
+
**Hypothesis:** the scLong encoder is trained only with a masked-module
|
| 29 |
+
reconstruction task, but it should still encode semantically related modules,
|
| 30 |
+
such as modules from the same pathway family or MSigDB hallmark biological
|
| 31 |
+
process, into nearby regions of latent space, even though family labels are
|
| 32 |
+
never seen during training.
|
| 33 |
+
|
| 34 |
+
**Workflow:**
|
| 35 |
+
|
| 36 |
+
1. Load the pretrained backbone and run inference with `mask_ratio=0.0` and `return_encodings=True` to obtain module encodings shaped `[B, num_modules, base_dim]`.
|
| 37 |
+
2. Average over the cell population to obtain `[num_modules, base_dim]`, using streaming accumulation rather than storing the full table in memory.
|
| 38 |
+
3. Parse family assignments from the keys in `merged_modules.json`: `Hallmarks` (50), `Reactome` (491), `Hotspot` (73), and `Processed` (46). Hallmark modules are also annotated with one of eight MSigDB biological-process categories: immune, metabolic, signaling, proliferation, development, dna_damage, pathway, and cellular_component.
|
| 39 |
+
4. Quantify family separation:
|
| 40 |
+
- silhouette coefficient, using both cosine and Euclidean metrics.
|
| 41 |
+
- kNN purity at `k=10`, using both cosine and Euclidean metrics.
|
| 42 |
+
- ratio of mean inter-family to intra-family distance.
|
| 43 |
+
5. Compare to a permutation null distribution by shuffling family labels `K=200` times, recomputing metrics, and reporting the observed value, null mean, null standard deviation, and right-tail empirical p-value.
|
| 44 |
+
6. Visualize with UMAP and PCA in 2D, colored by family or hallmark biological process.
|
| 45 |
+
|
| 46 |
+
**Differentiation from gene-level baselines:** gene-level foundation models embed
|
| 47 |
+
single genes rather than pathway groups. To reproduce this experiment, they would
|
| 48 |
+
first have to manually aggregate gene embeddings by pathway, which compresses
|
| 49 |
+
gene-level signals and introduces a hand-designed step. scLong learns this
|
| 50 |
+
aggregation end to end during pretraining.
|
| 51 |
+
|
| 52 |
+
## File Structure
|
| 53 |
+
|
| 54 |
+
```text
|
| 55 |
+
BioMod/interpretability/ # Module representations and biological interpretability
|
| 56 |
+
├── attention/ # Module crosstalk and within-module gene attention
|
| 57 |
+
│ ├── __init__.py
|
| 58 |
+
│ ├── module_crosstalk.py
|
| 59 |
+
│ ├── module_gene_attention.py
|
| 60 |
+
│ ├── README.md
|
| 61 |
+
│ └── README_gene_attention.md
|
| 62 |
+
├── module_importance/ # Module importance, ablation studies and marker-gene validation
|
| 63 |
+
│ ├── __init__.py
|
| 64 |
+
│ ├── cross_class_similarity.py
|
| 65 |
+
│ ├── family_ablation.py
|
| 66 |
+
│ ├── family_importance_analysis.py
|
| 67 |
+
│ ├── marker_gene_verification.py
|
| 68 |
+
│ ├── masking_ablation.py
|
| 69 |
+
│ ├── module_importance.py
|
| 70 |
+
│ ├── module_importance_publication.py
|
| 71 |
+
│ ├── pub_utils.py
|
| 72 |
+
│ ├── random_baseline.py
|
| 73 |
+
│ ├── README.md
|
| 74 |
+
│ ├── RESULTS.md
|
| 75 |
+
│ ├── tcell_reactome_analysis.py
|
| 76 |
+
│ └── TODO.md
|
| 77 |
+
├── module_text_alignment/ # Semantic alignment of module representations and pathway text
|
| 78 |
+
│ ├── __init__.py
|
| 79 |
+
│ ├── DESIGN.md
|
| 80 |
+
│ ├── fetch_reactome_descriptions.py
|
| 81 |
+
│ ├── MANUSCRIPT_SECTION.md
|
| 82 |
+
│ ├── module_text_alignment.py
|
| 83 |
+
│ ├── module_text_alignment_extended.py
|
| 84 |
+
│ ├── module_text_alignment_publication.py
|
| 85 |
+
│ └── reactome_descriptions.json
|
| 86 |
+
├── __init__.py
|
| 87 |
+
├── module_geometry.py
|
| 88 |
+
├── README.md
|
| 89 |
+
└── utils.py
|
| 90 |
+
```
|
| 91 |
+
|
| 92 |
+
## Command-Line Usage
|
| 93 |
+
|
| 94 |
+
Run scripts from `BioMod/interpretability/` within the project root; relative paths below are
|
| 95 |
+
based on that directory.
|
| 96 |
+
|
| 97 |
+
```bash
|
| 98 |
+
# General h5ad input: cell annotation, zero-shot, or any annotated dataset.
|
| 99 |
+
python module_geometry.py \
|
| 100 |
+
--performer_checkpoint ../mae_tost_48M_cross_attn_only_pure_memory/mae_tost_48M_cross_attn_only_pure_memory_epoch12.pth \
|
| 101 |
+
--config_pkl ../mae_tost_48M_cross_attn_only_pure_memory/mae_tost_48M_cross_attn_only_pure_memory.pkl \
|
| 102 |
+
--module_data_dir ../module_data_all \
|
| 103 |
+
--gene_list_path ../../selected_genes_27k.txt \
|
| 104 |
+
--gene_mapping_path ../../gene2ens.csv \
|
| 105 |
+
--gene2vec_data_path ../../selected_gene2vec_27k.npy \
|
| 106 |
+
--gene2ens_file ../../gene2ens.csv \
|
| 107 |
+
--data_path ../../zero-shot-batch/pancreas_scib.h5ad \
|
| 108 |
+
--data_mode h5ad \
|
| 109 |
+
--gene_name_column auto \
|
| 110 |
+
--batch_size 640 \
|
| 111 |
+
--output_dir ./results/geometry/pancreas_scib
|
| 112 |
+
```
|
| 113 |
+
|
| 114 |
+
For pretraining data, where each cell is an `.npz` file already in the 27,874
|
| 115 |
+
model-gene space:
|
| 116 |
+
|
| 117 |
+
```bash
|
| 118 |
+
python module_geometry.py \
|
| 119 |
+
--performer_checkpoint ../mae_tost_48M_cross_attn_only_pure_memory/mae_tost_48M_cross_attn_only_pure_memory_epoch12.pth \
|
| 120 |
+
--config_pkl ../mae_tost_48M_cross_attn_only_pure_memory/mae_tost_48M_cross_attn_only_pure_memory.pkl \
|
| 121 |
+
--module_data_dir ../module_data_all \
|
| 122 |
+
--gene_list_path ../../selected_genes_27k.txt \
|
| 123 |
+
--gene_mapping_path ../../gene2ens.csv \
|
| 124 |
+
--gene2vec_data_path ../../selected_gene2vec_27k.npy \
|
| 125 |
+
--data_path ../../../../share/cell_files \
|
| 126 |
+
--data_mode pretrain_npz \
|
| 127 |
+
--cell_num 48024242 \
|
| 128 |
+
--max_cells 50000 \
|
| 129 |
+
--batch_size 512 \
|
| 130 |
+
--output_dir ./results/geometry/pretrain_subset
|
| 131 |
+
```
|
| 132 |
+
|
| 133 |
+
### Multi-GPU DDP Acceleration
|
| 134 |
+
|
| 135 |
+
`torchrun` multi-GPU inference is supported. When `WORLD_SIZE > 1`, each GPU
|
| 136 |
+
processes a subset of cells, embeddings are aggregated with `all_reduce`, and
|
| 137 |
+
rank 0 computes metrics and figures. Single-GPU and CPU behavior is unchanged.
|
| 138 |
+
|
| 139 |
+
```bash
|
| 140 |
+
torchrun --nproc_per_node=2 module_geometry.py \
|
| 141 |
+
--performer_checkpoint ../mae_tost_48M_cross_attn_only_pure_memory/mae_tost_48M_cross_attn_only_pure_memory_epoch2.pth \
|
| 142 |
+
--config_pkl ../mae_tost_48M_cross_attn_only_pure_memory/mae_tost_48M_cross_attn_only_pure_memory.pkl \
|
| 143 |
+
--module_data_dir ../module_data_all \
|
| 144 |
+
--gene_list_path ../../selected_genes_27k.txt \
|
| 145 |
+
--gene_mapping_path ../../gene2ens.csv \
|
| 146 |
+
--gene2vec_data_path ../../selected_gene2vec_27k.npy \
|
| 147 |
+
--data_path ../../../share/cell_files \
|
| 148 |
+
--data_mode pretrain_npz \
|
| 149 |
+
--cell_num 49738024 \
|
| 150 |
+
--max_cells 100000 \
|
| 151 |
+
--batch_size 16 \
|
| 152 |
+
--output_dir ./results/geometry/pretrain_ddp
|
| 153 |
+
```
|
| 154 |
+
|
| 155 |
+
### Common Arguments
|
| 156 |
+
|
| 157 |
+
| Argument | Default | Description |
|
| 158 |
+
| --- | --- | --- |
|
| 159 |
+
| `--max_cells` | None | Number of cells to subsample; the mean is stable from a few thousand cells onward. |
|
| 160 |
+
| `--cell_num` | None | Total cell count in `pretrain_npz` mode, used for sequential names `cell0.npz` to `cellN.npz`. |
|
| 161 |
+
| `--knn_k` | 10 | `k` for kNN purity. |
|
| 162 |
+
| `--null_permutations` | 200 | Number of label permutations for the empirical null distribution. |
|
| 163 |
+
| `--include_other_in_metrics` | Off | By default, modules from the `Other` family are excluded. |
|
| 164 |
+
| `--shuffle_module_genes` | Off | Randomly shuffle gene-to-module assignments as a negative control or ablation. Uses the same flag as pretraining and downstream finetuning. |
|
| 165 |
+
| `--umap_n_neighbors` | 15 | UMAP graph-construction parameter. |
|
| 166 |
+
| `--umap_min_dist` | 0.3 | UMAP layout parameter. |
|
| 167 |
+
|
| 168 |
+
## Output Files
|
| 169 |
+
|
| 170 |
+
Files are written to `<output_dir>/`:
|
| 171 |
+
|
| 172 |
+
| File | Content |
|
| 173 |
+
| --- | --- |
|
| 174 |
+
| `module_embeddings.npy` | Cell-averaged module embeddings with shape `[num_modules, base_dim]` |
|
| 175 |
+
| `module_metadata.csv` | Metadata for each module: `module_name`, `family`, and `hallmark_process` |
|
| 176 |
+
| `metrics.json` | Observed metrics, null mean/std, and right-tail p-values for family-level and hallmark-process-level analyses |
|
| 177 |
+
| `pca_family.png` | 2D PCA colored by family |
|
| 178 |
+
| `umap_family.png` | 2D UMAP colored by family; requires `umap-learn` |
|
| 179 |
+
| `pca_hallmark_process.png` | PCA for Hallmark modules only, colored by MSigDB biological process |
|
| 180 |
+
| `umap_hallmark_process.png` | UMAP for Hallmark modules only, colored by MSigDB biological process |
|
| 181 |
+
| `run.log` | Full log with formatted core metrics |
|
| 182 |
+
|
| 183 |
+
## Recommended Negative Controls
|
| 184 |
+
|
| 185 |
+
Two control experiments make Experiment 1 more rigorous:
|
| 186 |
+
|
| 187 |
+
1. **Random module assignment**: rerun with `--shuffle_module_genes`. This flag preserves module sizes but randomly assigns genes to modules, matching the same ablation flag used for pretraining and finetuning. If geometry is pathway-driven rather than a generic property of the encoder, family separation should drop close to the null distribution.
|
| 188 |
+
|
| 189 |
+
2. **Untrained backbone**: set `--performer_checkpoint` to the `*_init.pth` checkpoint generated during pretraining with `--save_init_ckpt_and_exit`. Family separation should be close to the null distribution. The gap between trained and untrained models quantifies the contribution of pretraining.
|
| 190 |
+
|
| 191 |
+
Together, these controls support the claim that the family structure of the module
|
| 192 |
+
latent space is produced by pathway-based module design plus pretraining, rather
|
| 193 |
+
than by module design alone or by a generic encoder architecture.
|
| 194 |
+
|
| 195 |
+
### Control 1: Random Module Assignment (`shuffle_module_genes`)
|
| 196 |
+
|
| 197 |
+
Add `--shuffle_module_genes` to the original command and write to a control output
|
| 198 |
+
directory.
|
| 199 |
+
|
| 200 |
+
For h5ad data, using `pancreas_scib` as an example:
|
| 201 |
+
|
| 202 |
+
```bash
|
| 203 |
+
python module_geometry.py \
|
| 204 |
+
--performer_checkpoint ../mae_tost_48M_cross_attn_only_pure_memory/mae_tost_48M_cross_attn_only_pure_memory_epoch12.pth \
|
| 205 |
+
--config_pkl ../mae_tost_48M_cross_attn_only_pure_memory/mae_tost_48M_cross_attn_only_pure_memory.pkl \
|
| 206 |
+
--module_data_dir ../module_data_all \
|
| 207 |
+
--gene_list_path ../../selected_genes_27k.txt \
|
| 208 |
+
--gene_mapping_path ../../gene2ens.csv \
|
| 209 |
+
--gene2vec_data_path ../../selected_gene2vec_27k.npy \
|
| 210 |
+
--gene2ens_file ../../gene2ens.csv \
|
| 211 |
+
--data_path ../../zero-shot-batch/pancreas_scib.h5ad \
|
| 212 |
+
--data_mode h5ad \
|
| 213 |
+
--gene_name_column auto \
|
| 214 |
+
--batch_size 640 \
|
| 215 |
+
--shuffle_module_genes \
|
| 216 |
+
--output_dir ./results/geometry/pancreas_scib_shuffle
|
| 217 |
+
```
|
| 218 |
+
|
| 219 |
+
For pretraining npz data:
|
| 220 |
+
|
| 221 |
+
```bash
|
| 222 |
+
python module_geometry.py \
|
| 223 |
+
--performer_checkpoint ../mae_tost_48M_cross_attn_only_pure_memory/mae_tost_48M_cross_attn_only_pure_memory_epoch12.pth \
|
| 224 |
+
--config_pkl ../mae_tost_48M_cross_attn_only_pure_memory/mae_tost_48M_cross_attn_only_pure_memory.pkl \
|
| 225 |
+
--module_data_dir ../module_data_all \
|
| 226 |
+
--gene_list_path ../../selected_genes_27k.txt \
|
| 227 |
+
--gene_mapping_path ../../gene2ens.csv \
|
| 228 |
+
--gene2vec_data_path ../../selected_gene2vec_27k.npy \
|
| 229 |
+
--gene2ens_file ../../gene2ens.csv \
|
| 230 |
+
--data_path ../../../../share/cell_files \
|
| 231 |
+
--data_mode pretrain_npz \
|
| 232 |
+
--cell_num 48024242 \
|
| 233 |
+
--max_cells 50000 \
|
| 234 |
+
--batch_size 640 \
|
| 235 |
+
--shuffle_module_genes \
|
| 236 |
+
--output_dir ./results/geometry/pretrain_subset_shuffle
|
| 237 |
+
```
|
| 238 |
+
|
| 239 |
+
How `--shuffle_module_genes` works: inside `load_backbone()`, module sizes are
|
| 240 |
+
kept unchanged, all genes are collected into one list, the list is randomly
|
| 241 |
+
shuffled, and genes are reassigned to modules. The seed is controlled by
|
| 242 |
+
`--shuffle_seed`, defaulting to 2021. Model weights are unchanged; only the
|
| 243 |
+
gene-to-module mapping is shuffled.
|
| 244 |
+
|
| 245 |
+
**Expected result:** kNN purity should drop to about 0.576, the null level, and
|
| 246 |
+
the inter-/intra-family distance ratio should drop to about 1.0. `metrics.json`
|
| 247 |
+
records `"shuffle_module_genes": true`.
|
| 248 |
+
|
| 249 |
+
### Control 2: Untrained Backbone with Random Initialization
|
| 250 |
+
|
| 251 |
+
First generate a randomly initialized checkpoint, then run `module_geometry.py`
|
| 252 |
+
with it.
|
| 253 |
+
|
| 254 |
+
**Step 1: Generate `_init.pth`**
|
| 255 |
+
|
| 256 |
+
Use the pretraining script with `--save_init_ckpt_and_exit`. The command
|
| 257 |
+
initializes the model, saves a random-weight checkpoint and the matching config
|
| 258 |
+
PKL, then exits immediately without entering training.
|
| 259 |
+
|
| 260 |
+
```bash
|
| 261 |
+
cd /mnt/c/Users/250010166/Desktop/single_cell/scLong/performer_module
|
| 262 |
+
|
| 263 |
+
torchrun --nproc_per_node=1 performer_module.py \
|
| 264 |
+
--model_name mae_tost_48M_cross_attn_only_pure_memory \
|
| 265 |
+
--ckpt_dir ./mae_tost_48M_cross_attn_only_pure_memory \
|
| 266 |
+
--gene_list_path ../selected_genes_27k.txt \
|
| 267 |
+
--gene2vec_data_path ../selected_gene2vec_27k.npy \
|
| 268 |
+
--module_data_dir module_data_all \
|
| 269 |
+
--gene_mapping_path ../gene2ens.csv \
|
| 270 |
+
--data_path ../../../share/cell_files \
|
| 271 |
+
--cell_num 48024242 \
|
| 272 |
+
--enc_depth 32 \
|
| 273 |
+
--enc_heads 16 \
|
| 274 |
+
--base_dim 1024 \
|
| 275 |
+
--pretrain_arch mae \
|
| 276 |
+
--decoder_type cross_attn \
|
| 277 |
+
--save_init_ckpt_and_exit
|
| 278 |
+
```
|
| 279 |
+
|
| 280 |
+
The following files are generated under
|
| 281 |
+
`mae_tost_48M_cross_attn_only_pure_memory/`:
|
| 282 |
+
|
| 283 |
+
- `mae_tost_48M_cross_attn_only_pure_memory_init.pth`, randomly initialized weights.
|
| 284 |
+
- `mae_tost_48M_cross_attn_only_pure_memory_init.pkl`, model config.
|
| 285 |
+
|
| 286 |
+
**Step 2: Run `module_geometry.py` with `_init.pth`**
|
| 287 |
+
|
| 288 |
+
For h5ad data:
|
| 289 |
+
|
| 290 |
+
```bash
|
| 291 |
+
cd /mnt/c/Users/250010166/Desktop/single_cell/scLong/performer_module/interpretability
|
| 292 |
+
|
| 293 |
+
python module_geometry.py \
|
| 294 |
+
--performer_checkpoint ../mae_tost_48M_cross_attn_only_pure_memory_init/mae_tost_48M_cross_attn_only_pure_memory_init.pth \
|
| 295 |
+
--config_pkl ../mae_tost_48M_cross_attn_only_pure_memory_init/mae_tost_48M_cross_attn_only_pure_memory_init.pkl \
|
| 296 |
+
--module_data_dir ../module_data_all \
|
| 297 |
+
--gene_list_path ../../selected_genes_27k.txt \
|
| 298 |
+
--gene_mapping_path ../../gene2ens.csv \
|
| 299 |
+
--gene2vec_data_path ../../selected_gene2vec_27k.npy \
|
| 300 |
+
--gene2ens_file ../../gene2ens.csv \
|
| 301 |
+
--data_path ../../zero-shot-batch/pancreas_scib.h5ad \
|
| 302 |
+
--data_mode h5ad \
|
| 303 |
+
--gene_name_column auto \
|
| 304 |
+
--batch_size 640 \
|
| 305 |
+
--output_dir ./results/geometry/pancreas_scib_init
|
| 306 |
+
```
|
| 307 |
+
|
| 308 |
+
For pretraining npz data:
|
| 309 |
+
|
| 310 |
+
```bash
|
| 311 |
+
python module_geometry.py \
|
| 312 |
+
--performer_checkpoint ../mae_tost_48M_cross_attn_only_pure_memory_init/mae_tost_48M_cross_attn_only_pure_memory_init.pth \
|
| 313 |
+
--config_pkl ../mae_tost_48M_cross_attn_only_pure_memory_init/mae_tost_48M_cross_attn_only_pure_memory_init.pkl \
|
| 314 |
+
--module_data_dir ../module_data_all \
|
| 315 |
+
--gene_list_path ../../selected_genes_27k.txt \
|
| 316 |
+
--gene_mapping_path ../../gene2ens.csv \
|
| 317 |
+
--gene2vec_data_path ../../selected_gene2vec_27k.npy \
|
| 318 |
+
--gene2ens_file ../../gene2ens.csv \
|
| 319 |
+
--data_path ../../../../share/cell_files \
|
| 320 |
+
--data_mode pretrain_npz \
|
| 321 |
+
--cell_num 48024242 \
|
| 322 |
+
--max_cells 50000 \
|
| 323 |
+
--batch_size 640 \
|
| 324 |
+
--output_dir ./results/geometry/pretrain_subset_init
|
| 325 |
+
```
|
| 326 |
+
|
| 327 |
+
**Expected result:** all family-level metrics should be close to the null
|
| 328 |
+
distribution: kNN purity about 0.576, distance ratio about 1.0, and p-value
|
| 329 |
+
above 0.05. This indicates that a randomly initialized encoder cannot separate
|
| 330 |
+
pathway families.
|
| 331 |
+
|
| 332 |
+
### Result Comparison Matrix
|
| 333 |
+
|
| 334 |
+
After running all controls, construct the following table for the paper:
|
| 335 |
+
|
| 336 |
+
| Condition | kNN purity, cosine | Inter-/intra-family ratio, cosine | Conclusion |
|
| 337 |
+
| --- | --- | --- | --- |
|
| 338 |
+
| **Normal**, trained + pathway module | about 0.72, p<0.005 | 3.8-6.2, p<0.005 | Significant family structure |
|
| 339 |
+
| **Shuffle**, trained + random module | about 0.576 expected | about 1.0 expected | Structure is driven by pathway design |
|
| 340 |
+
| **Init**, untrained + pathway module | about 0.576 expected | about 1.0 expected | Structure is learned during pretraining |
|
| 341 |
+
|
| 342 |
+
The three experiments jointly show that the family structure of the module latent
|
| 343 |
+
space is generated by both pathway-based module design and pretraining; both are
|
| 344 |
+
necessary.
|
| 345 |
+
|
| 346 |
+
## Experiment 2: Module Embedding to Pathway Text Alignment (`module_text_alignment/`)
|
| 347 |
+
|
| 348 |
+
**Hypothesis:** the structure of the scLong module embedding space significantly
|
| 349 |
+
aligns with natural-language pathway semantics. Semantically similar pathways
|
| 350 |
+
are also encoded nearby by the model. Gene-level models cannot reproduce this
|
| 351 |
+
experiment because individual gene names do not carry phrase-level semantics.
|
| 352 |
+
|
| 353 |
+
**Method:**
|
| 354 |
+
|
| 355 |
+
1. Parse 541 semantically named modules from `merged_modules.json`: 50 Hallmark and 491 Reactome.
|
| 356 |
+
2. Encode pathway names with S-PubMedBert-MS-MARCO, a sentence-level fine-tuned PubMedBERT model.
|
| 357 |
+
3. Load `module_embeddings.npy` from Experiment 1, containing cell-averaged module embeddings.
|
| 358 |
+
4. Measure alignment:
|
| 359 |
+
- **RSA / Mantel test**, the main metric: Spearman correlation between paired distance matrices from the two spaces, with a permutation test.
|
| 360 |
+
- **CKA**: global representation-structure alignment.
|
| 361 |
+
- **LOO Ridge retrieval**: cross-modal retrieval recall@k.
|
| 362 |
+
|
| 363 |
+
**Data requirement:** only `module_embeddings.npy` from Experiment 1 is needed;
|
| 364 |
+
raw expression data are not required.
|
| 365 |
+
|
| 366 |
+
### Commands
|
| 367 |
+
|
| 368 |
+
```bash
|
| 369 |
+
cd performer_module/interpretability/module_text_alignment
|
| 370 |
+
|
| 371 |
+
# Download the sentence encoder.
|
| 372 |
+
wget https://hf-mirror.com/hfd/hfd.sh && chmod a+x hfd.sh && export HF_ENDPOINT=https://hf-mirror.com
|
| 373 |
+
./hfd.sh pritamdeka/S-PubMedBert-MS-MARCO --local-dir ./S-PubMedBert-MS-MARCO
|
| 374 |
+
|
| 375 |
+
# Main experiment: RSA + CKA + LOO retrieval.
|
| 376 |
+
python module_text_alignment.py \
|
| 377 |
+
--module_embedding_path ../results/geometry/pretrain_subset/module_embeddings.npy \
|
| 378 |
+
--module_json ../../module_data_all/merged_modules.json \
|
| 379 |
+
--sentence_model ./S-PubMedBert-MS-MARCO \
|
| 380 |
+
--n_permutations 1000 \
|
| 381 |
+
--output_dir ../results/text_alignment/pretrain_subset_v2
|
| 382 |
+
|
| 383 |
+
# Extended analysis: broad Reactome categories and Hallmark deep dive.
|
| 384 |
+
python module_text_alignment_extended.py \
|
| 385 |
+
--module_embedding_path ../results/geometry/pretrain_subset/module_embeddings.npy \
|
| 386 |
+
--module_json ../../module_data_all/merged_modules.json \
|
| 387 |
+
--sentence_model ./S-PubMedBert-MS-MARCO \
|
| 388 |
+
--descriptions_file ./reactome_descriptions.json \
|
| 389 |
+
--n_permutations 1000 \
|
| 390 |
+
--output_dir ../results/text_alignment/pretrain_subset_v2_extended
|
| 391 |
+
```
|
| 392 |
+
|
| 393 |
+
### Output Files
|
| 394 |
+
|
| 395 |
+
| File | Content |
|
| 396 |
+
| --- | --- |
|
| 397 |
+
| `alignment_metrics.json` | RSA, CKA, retrieval recall@k, and p-values |
|
| 398 |
+
| `rsa_scatter.png` | Paired-distance scatter plot, module space vs text space |
|
| 399 |
+
| `rsa_null_distribution.png` | Mantel test: observed value vs null distribution |
|
| 400 |
+
| `retrieval_ranks.png` | Histogram of retrieval ranks |
|
| 401 |
+
| `retrieval_examples.csv` | Top-5 retrieval results for representative pathways |
|
| 402 |
+
| `extended_metrics.json` | Extended-analysis results: broad-category RSA and Hallmark deep dive |
|
| 403 |
+
| `reactome_category_heatmaps.png` | Distance-matrix comparison at the broad Reactome category level |
|
| 404 |
+
| `hallmarks_deep_dive.png` | Hallmark per-module alignment scores and similarity matrix |
|
| 405 |
+
| `hallmarks_per_module_alignment.csv` | Per-module alignment ranking for 50 Hallmarks |
|
| 406 |
+
|
| 407 |
+
## Dependencies
|
| 408 |
+
|
| 409 |
+
`torch`, `numpy`, `pandas`, `scikit-learn`, `matplotlib`, `anndata`, and `scipy`.
|
| 410 |
+
UMAP plots additionally require `umap-learn`; if it is not installed, the script
|
| 411 |
+
warns and skips only UMAP plots. Experiment 2 also requires
|
| 412 |
+
`sentence-transformers`, installable with `pip install sentence-transformers`.
|
BioMod/interpretability/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Module interpretability experiments for scLong (performer_module).
|
| 2 |
+
|
| 3 |
+
Roadmap (priority order):
|
| 4 |
+
1. module_geometry.py — intrinsic module-embedding geometry (current).
|
| 5 |
+
2. module_text_alignment.py — module ↔ pathway natural-language alignment.
|
| 6 |
+
3. module_importance.py — task-driven ablation + literature enrichment.
|
| 7 |
+
4. module_vs_gene.py — comparison with gene-level baselines (GenePT, scGPT, ...).
|
| 8 |
+
"""
|
BioMod/interpretability/attention/README.md
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Experiment 6: Module Crosstalk Graph vs Pathway Interaction Database
|
| 2 |
+
|
| 3 |
+
This experiment tests whether module-module interactions learned by the scLong
|
| 4 |
+
encoder are consistent with known pathway-pathway relationships.
|
| 5 |
+
|
| 6 |
+
## Methods
|
| 7 |
+
|
| 8 |
+
| Method | Principle | Compute cost |
|
| 9 |
+
| --- | --- | --- |
|
| 10 |
+
| A, cosine | Cosine similarity between cell-averaged module embeddings | Low |
|
| 11 |
+
| C, jacobian | Cell-averaged norm of `d encoder_out[j] / d module_emb[i]` | High |
|
| 12 |
+
|
| 13 |
+
Ground truth: Jaccard index and overlap coefficient of shared genes between
|
| 14 |
+
modules.
|
| 15 |
+
|
| 16 |
+
## Evaluation Metrics
|
| 17 |
+
|
| 18 |
+
- Spearman correlation between the model interaction matrix and database overlap matrix.
|
| 19 |
+
- AUROC, using database edges as positives and model scores as predictions.
|
| 20 |
+
- Precision@k, the fraction of top-k model interactions that exist in the database.
|
| 21 |
+
- p-value from a permutation null distribution.
|
| 22 |
+
|
| 23 |
+
## Run
|
| 24 |
+
|
| 25 |
+
```bash
|
| 26 |
+
cd /path/to/performer_module/interpretability/attention
|
| 27 |
+
|
| 28 |
+
# Full run, methods A + C.
|
| 29 |
+
python module_crosstalk.py \
|
| 30 |
+
--performer_checkpoint ../../mae_tost_48M_cross_attn_only_pure_memory/mae_tost_48M_cross_attn_only_pure_memory_epoch12.pth \
|
| 31 |
+
--config_pkl ../../mae_tost_48M_cross_attn_only_pure_memory/mae_tost_48M_cross_attn_only_pure_memory.pkl \
|
| 32 |
+
--module_data_dir ../../module_data_all \
|
| 33 |
+
--gene_list_path ../../../selected_genes_27k.txt \
|
| 34 |
+
--gene_mapping_path ../../../gene2ens.csv \
|
| 35 |
+
--gene2vec_data_path ../../../selected_gene2vec_27k.npy \
|
| 36 |
+
--gene2ens_file ../../../gene2ens.csv \
|
| 37 |
+
--data_path ../../../zero-shot-batch/pancreas_scib.h5ad \
|
| 38 |
+
--data_mode h5ad \
|
| 39 |
+
--batch_size 640 \
|
| 40 |
+
--jacobian_cells 200 \
|
| 41 |
+
--output_dir ../results/crosstalk/pancreas_max \
|
| 42 |
+
--pool_mode max
|
| 43 |
+
|
| 44 |
+
# Method A only, for quick validation.
|
| 45 |
+
python module_crosstalk.py \
|
| 46 |
+
--performer_checkpoint ../../mae_tost_48M_cross_attn_only_pure_memory/mae_tost_48M_cross_attn_only_pure_memory_epoch12.pth \
|
| 47 |
+
--config_pkl ../../mae_tost_48M_cross_attn_only_pure_memory/mae_tost_48M_cross_attn_only_pure_memory.pkl \
|
| 48 |
+
--module_data_dir ../../module_data_all \
|
| 49 |
+
--gene_list_path ../../../selected_genes_27k.txt \
|
| 50 |
+
--gene_mapping_path ../../../gene2ens.csv \
|
| 51 |
+
--gene2vec_data_path ../../../selected_gene2vec_27k.npy \
|
| 52 |
+
--gene2ens_file ../../../gene2ens.csv \
|
| 53 |
+
--data_path ../../../zero-shot-batch/pancreas_scib.h5ad \
|
| 54 |
+
--data_mode h5ad \
|
| 55 |
+
--batch_size 64 \
|
| 56 |
+
--skip_jacobian \
|
| 57 |
+
--output_dir ../results/crosstalk/pancreas_cosine_only
|
| 58 |
+
|
| 59 |
+
# Pretraining npz data.
|
| 60 |
+
python module_crosstalk.py \
|
| 61 |
+
--performer_checkpoint ../../mae_tost_48M_cross_attn_only_pure_memory/mae_tost_48M_cross_attn_only_pure_memory_epoch12.pth \
|
| 62 |
+
--config_pkl ../../mae_tost_48M_cross_attn_only_pure_memory/mae_tost_48M_cross_attn_only_pure_memory.pkl \
|
| 63 |
+
--module_data_dir ../../module_data_all \
|
| 64 |
+
--gene_list_path ../../../selected_genes_27k.txt \
|
| 65 |
+
--gene_mapping_path ../../../gene2ens.csv \
|
| 66 |
+
--gene2vec_data_path ../../../selected_gene2vec_27k.npy \
|
| 67 |
+
--gene2ens_file ../../../gene2ens.csv \
|
| 68 |
+
--data_path ../../../../share/cell_files \
|
| 69 |
+
--data_mode pretrain_npz \
|
| 70 |
+
--cell_num 49738024 \
|
| 71 |
+
--max_cells 5000 \
|
| 72 |
+
--batch_size 16 \
|
| 73 |
+
--jacobian_cells 100 \
|
| 74 |
+
--output_dir ../results/crosstalk/pretrain
|
| 75 |
+
```
|
| 76 |
+
|
| 77 |
+
## Key Arguments
|
| 78 |
+
|
| 79 |
+
| Argument | Default | Description |
|
| 80 |
+
| --- | --- | --- |
|
| 81 |
+
| `--jacobian_cells` | 200 | Number of cells sampled for Jacobian computation; more is stabler but slower |
|
| 82 |
+
| `--skip_jacobian` | off | Run method A only |
|
| 83 |
+
| `--max_cells` | 5000 | Maximum cell count used by method A |
|
| 84 |
+
| `--null_permutations` | 200 | Number of permutations |
|
| 85 |
+
| `--top_k_fractions` | 0.01,0.05,0.1 | Fractions used for Precision@k |
|
| 86 |
+
|
| 87 |
+
## Outputs
|
| 88 |
+
|
| 89 |
+
```text
|
| 90 |
+
output_dir/
|
| 91 |
+
|-- cosine_interaction.npy # (660, 660) method A interaction matrix
|
| 92 |
+
|-- jacobian_interaction.npy # (660, 660) method C interaction matrix
|
| 93 |
+
|-- mean_module_embeddings.npy # (660, 1024) cell-averaged module embeddings
|
| 94 |
+
|-- gt_jaccard.npy # (660, 660) gene Jaccard overlap
|
| 95 |
+
|-- gt_overlap_coef.npy # (660, 660) overlap coefficient
|
| 96 |
+
|-- metrics.json # All evaluation metrics and p-values
|
| 97 |
+
|-- heatmap_cosine.png # Method A heatmap, sorted by family
|
| 98 |
+
|-- heatmap_jacobian.png # Method C heatmap
|
| 99 |
+
|-- heatmap_gt_jaccard.png # Ground-truth heatmap
|
| 100 |
+
|-- scatter_cosine_vs_jaccard.png
|
| 101 |
+
`-- scatter_jacobian_vs_jaccard.png
|
| 102 |
+
```
|
| 103 |
+
|
| 104 |
+
## Notes
|
| 105 |
+
|
| 106 |
+
- Jacobian computation requires 660 backward passes per cell. `--jacobian_cells 200` takes about 10-30 minutes on one A100.
|
| 107 |
+
- Method A has almost no extra overhead, so use `--skip_jacobian` for quick validation first.
|
| 108 |
+
- The current model uses TSSA attention rather than standard softmax attention, so attention weights cannot be read directly. Jacobian is used as a substitute.
|
BioMod/interpretability/attention/README_gene_attention.md
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Gene Attention Interpretability: ModuleEmbedding
|
| 2 |
+
|
| 3 |
+
Analyze gene contributions within modules using the content-based attention
|
| 4 |
+
pooling in ModuleEmbedding.
|
| 5 |
+
|
| 6 |
+
## Analyses
|
| 7 |
+
|
| 8 |
+
1. Global gene-importance ranking, averaged attention over all cells.
|
| 9 |
+
2. Cell-type specificity with Mann-Whitney U test and fold change.
|
| 10 |
+
3. Marker gene validation with precision@k and recall@k.
|
| 11 |
+
4. Pathway enrichment analysis with GO BP, requiring `gseapy`.
|
| 12 |
+
5. Comparison with DEG results using Spearman correlation.
|
| 13 |
+
|
| 14 |
+
## Dependencies
|
| 15 |
+
|
| 16 |
+
```bash
|
| 17 |
+
pip install torch numpy pandas scipy matplotlib seaborn anndata
|
| 18 |
+
# Optional
|
| 19 |
+
pip install gseapy # Pathway enrichment analysis
|
| 20 |
+
```
|
| 21 |
+
|
| 22 |
+
## Run Commands
|
| 23 |
+
|
| 24 |
+
### Basic Usage on the Pancreas Dataset with Built-In Markers
|
| 25 |
+
|
| 26 |
+
```bash
|
| 27 |
+
cd ../data/250010166/scLong/performer_module/interpretability/attention
|
| 28 |
+
|
| 29 |
+
python module_gene_attention.py \
|
| 30 |
+
--performer_checkpoint ../../mae_tost_48M_cross_attn_only_pure_memory/mae_tost_48M_cross_attn_only_pure_memory_epoch12.pth \
|
| 31 |
+
--config_pkl ../../mae_tost_48M_cross_attn_only_pure_memory/mae_tost_48M_cross_attn_only_pure_memory.pkl \
|
| 32 |
+
--module_data_dir ../../module_data_all \
|
| 33 |
+
--gene_list_path ../../../selected_genes_27k.txt \
|
| 34 |
+
--gene_mapping_path ../../../gene2ens.csv \
|
| 35 |
+
--gene2vec_data_path ../../../selected_gene2vec_27k.npy \
|
| 36 |
+
--gene2ens_file ../../../gene2ens.csv \
|
| 37 |
+
--data_path ../../../zero-shot-batch/pancreas_scib.h5ad \
|
| 38 |
+
--celltype_column celltype \
|
| 39 |
+
--batch_size 640 \
|
| 40 |
+
--top_k_genes 20 \
|
| 41 |
+
--output_dir ../results/gene_attention/pancreas
|
| 42 |
+
```
|
| 43 |
+
|
| 44 |
+
### With DEG Comparison
|
| 45 |
+
|
| 46 |
+
```bash
|
| 47 |
+
python module_gene_attention.py \
|
| 48 |
+
--performer_checkpoint ../../mae_tost_48M_cross_attn_only_pure_memory/mae_tost_48M_cross_attn_only_pure_memory_epoch12.pth \
|
| 49 |
+
--config_pkl ../../mae_tost_48M_cross_attn_only_pure_memory/mae_tost_48M_cross_attn_only_pure_memory.pkl \
|
| 50 |
+
--module_data_dir ../../module_data_all \
|
| 51 |
+
--gene_list_path ../../../selected_genes_27k.txt \
|
| 52 |
+
--gene_mapping_path ../../../gene2ens.csv \
|
| 53 |
+
--gene2vec_data_path ../../../selected_gene2vec_27k.npy \
|
| 54 |
+
--gene2ens_file ../../../gene2ens.csv \
|
| 55 |
+
--data_path ../../../zero-shot-batch/pancreas_scib.h5ad \
|
| 56 |
+
--celltype_column celltype \
|
| 57 |
+
--batch_size 64 \
|
| 58 |
+
--deg_csv /path/to/deg_results.csv \
|
| 59 |
+
--output_dir ../results/gene_attention/pancreas_with_deg
|
| 60 |
+
```
|
| 61 |
+
|
| 62 |
+
DEG CSV columns must include `gene`, `cell_type`, `log2fc`, and `pval`.
|
| 63 |
+
|
| 64 |
+
### Custom Marker Database
|
| 65 |
+
|
| 66 |
+
```bash
|
| 67 |
+
python module_gene_attention.py \
|
| 68 |
+
... \
|
| 69 |
+
--marker_db /path/to/markers.csv \
|
| 70 |
+
--output_dir ../results/gene_attention/custom_markers
|
| 71 |
+
```
|
| 72 |
+
|
| 73 |
+
Marker CSV columns must include `cell_type` and `gene_symbol`.
|
| 74 |
+
|
| 75 |
+
### Limit Cell Count for Faster Testing
|
| 76 |
+
|
| 77 |
+
```bash
|
| 78 |
+
python module_gene_attention.py \
|
| 79 |
+
... \
|
| 80 |
+
--max_cells 5000 \
|
| 81 |
+
--output_dir ../results/gene_attention/pancreas_5k
|
| 82 |
+
```
|
| 83 |
+
|
| 84 |
+
## Key Arguments
|
| 85 |
+
|
| 86 |
+
| Argument | Default | Description |
|
| 87 |
+
| --- | --- | --- |
|
| 88 |
+
| `--batch_size` | 64 | Inference batch size |
|
| 89 |
+
| `--top_k_genes` | 20 | Number of top genes shown for each cell type in visualizations |
|
| 90 |
+
| `--max_cells` | None | Number of cells to subsample |
|
| 91 |
+
| `--marker_db` | None | Custom marker CSV; built-in pancreas markers are used when omitted |
|
| 92 |
+
| `--deg_csv` | None | DEG result CSV; DEG correlation analysis is skipped when omitted |
|
| 93 |
+
| `--celltype_column` | celltype | Cell-type column name in h5ad |
|
| 94 |
+
| `--shuffle_module_genes` | off | Randomly shuffle gene-to-module assignment as a negative control |
|
| 95 |
+
|
| 96 |
+
## Output Files
|
| 97 |
+
|
| 98 |
+
```text
|
| 99 |
+
output_dir/
|
| 100 |
+
|-- global_gene_importance.csv # Global gene importance across all modules and genes
|
| 101 |
+
|-- global_top_genes.png # Top-N gene bar chart
|
| 102 |
+
|-- celltype_<ct>_attention.csv # Gene attention for each cell type
|
| 103 |
+
|-- celltype_attention_heatmap.png # Cell type x gene heatmap
|
| 104 |
+
|-- gene_specificity.csv # Gene specificity with fold change and p-value
|
| 105 |
+
|-- specificity_volcano.png # Volcano plot
|
| 106 |
+
|-- marker_validation.json # Marker validation results
|
| 107 |
+
|-- marker_recall.png # Recall@50/100 bar chart
|
| 108 |
+
|-- deg_correlation.json # DEG correlation, if --deg_csv is provided
|
| 109 |
+
|-- deg_correlation.png # Spearman rho bar chart
|
| 110 |
+
|-- enrichment/ # Pathway enrichment, if gseapy is installed
|
| 111 |
+
| |-- <ct>_enrichment.csv
|
| 112 |
+
| `-- enrichment_summary.json
|
| 113 |
+
`-- summary.json # Run summary
|
| 114 |
+
```
|
BioMod/interpretability/attention/__init__.py
ADDED
|
File without changes
|
BioMod/interpretability/attention/module_crosstalk.py
ADDED
|
@@ -0,0 +1,605 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Experiment 6 — Module crosstalk graph vs Reactome/KEGG pathway interactions.
|
| 2 |
+
|
| 3 |
+
Two complementary methods to quantify module-to-module interaction strength:
|
| 4 |
+
|
| 5 |
+
Method A (cosine similarity): Compute cosine similarity between cell-averaged
|
| 6 |
+
module encoder outputs. Fast, no gradient needed.
|
| 7 |
+
|
| 8 |
+
Method C (Jacobian): Compute ||d encoder_out[j] / d module_input[i]|| averaged
|
| 9 |
+
over cells. Measures causal influence of module i on module j's representation.
|
| 10 |
+
Requires gradient; more expensive but more principled.
|
| 11 |
+
|
| 12 |
+
Both interaction matrices are compared against known pathway-pathway relationships
|
| 13 |
+
from Reactome (shared genes / hierarchical overlap) and optionally KEGG.
|
| 14 |
+
|
| 15 |
+
Metrics:
|
| 16 |
+
- Spearman correlation between model interaction matrix and DB overlap matrix
|
| 17 |
+
- Precision@k: among top-k model interactions, fraction present in DB
|
| 18 |
+
- AUROC: treating DB edges as positives, model scores as predictions
|
| 19 |
+
- Permutation null p-values for all metrics
|
| 20 |
+
|
| 21 |
+
Usage:
|
| 22 |
+
python module_crosstalk.py \
|
| 23 |
+
--performer_checkpoint ../mae_tost_48M_cross_attn_only_pure_memory/mae_tost_48M_cross_attn_only_pure_memory_epoch12.pth \
|
| 24 |
+
--config_pkl ../mae_tost_48M_cross_attn_only_pure_memory/mae_tost_48M_cross_attn_only_pure_memory.pkl \
|
| 25 |
+
--module_data_dir ../module_data_all \
|
| 26 |
+
--gene_list_path ../../selected_genes_27k.txt \
|
| 27 |
+
--gene_mapping_path ../../gene2ens.csv \
|
| 28 |
+
--gene2vec_data_path ../../selected_gene2vec_27k.npy \
|
| 29 |
+
--data_path ../../zero-shot-batch/pancreas_scib.h5ad \
|
| 30 |
+
--data_mode h5ad \
|
| 31 |
+
--batch_size 16 \
|
| 32 |
+
--jacobian_cells 200 \
|
| 33 |
+
--output_dir ./results/crosstalk/pancreas
|
| 34 |
+
"""
|
| 35 |
+
from __future__ import annotations
|
| 36 |
+
|
| 37 |
+
import argparse
|
| 38 |
+
import json
|
| 39 |
+
import logging
|
| 40 |
+
import sys
|
| 41 |
+
from pathlib import Path
|
| 42 |
+
from typing import Dict, List, Optional, Tuple
|
| 43 |
+
|
| 44 |
+
import numpy as np
|
| 45 |
+
import torch
|
| 46 |
+
from torch.utils.data import DataLoader, Subset
|
| 47 |
+
|
| 48 |
+
import matplotlib
|
| 49 |
+
matplotlib.use("Agg")
|
| 50 |
+
import matplotlib.pyplot as plt
|
| 51 |
+
|
| 52 |
+
from scipy.stats import spearmanr
|
| 53 |
+
from sklearn.metrics import roc_auc_score
|
| 54 |
+
|
| 55 |
+
HERE = Path(__file__).resolve()
|
| 56 |
+
PERFORMER_MODULE = HERE.parents[2]
|
| 57 |
+
REPO_ROOT = HERE.parents[3]
|
| 58 |
+
for p in (str(PERFORMER_MODULE), str(REPO_ROOT)):
|
| 59 |
+
if p not in sys.path:
|
| 60 |
+
sys.path.insert(0, p)
|
| 61 |
+
|
| 62 |
+
from cell_annotation.performer_module_finetune import (
|
| 63 |
+
DataPreprocessor,
|
| 64 |
+
load_backbone,
|
| 65 |
+
seed_all,
|
| 66 |
+
)
|
| 67 |
+
|
| 68 |
+
from interpretability.utils import (
|
| 69 |
+
MatrixRowDataset,
|
| 70 |
+
NpzCellDataset,
|
| 71 |
+
build_module_metadata,
|
| 72 |
+
ensure_dir,
|
| 73 |
+
load_module_names,
|
| 74 |
+
)
|
| 75 |
+
|
| 76 |
+
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
| 77 |
+
logger = logging.getLogger(__name__)
|
| 78 |
+
|
| 79 |
+
# ---------------------------------------------------------------------------
|
| 80 |
+
# Argparse
|
| 81 |
+
# ---------------------------------------------------------------------------
|
| 82 |
+
|
| 83 |
+
def parse_args() -> argparse.Namespace:
|
| 84 |
+
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
| 85 |
+
|
| 86 |
+
p.add_argument("--performer_checkpoint", type=str, required=True)
|
| 87 |
+
p.add_argument("--config_pkl", type=str, required=True)
|
| 88 |
+
p.add_argument("--module_data_dir", type=str, default="../module_data_all")
|
| 89 |
+
p.add_argument("--module_json", type=str, default=None)
|
| 90 |
+
p.add_argument("--gene_list_path", type=str, default="../../selected_genes_27k.txt")
|
| 91 |
+
p.add_argument("--gene_mapping_path", type=str, default="../../gene2ens.csv")
|
| 92 |
+
p.add_argument("--gene2vec_data_path", type=str, default="../../selected_gene2vec_27k.npy")
|
| 93 |
+
p.add_argument("--gene2ens_file", type=str, default="../../gene2ens.csv")
|
| 94 |
+
p.add_argument("--gene2ens_column", type=str, default="Ensembl gene ID")
|
| 95 |
+
p.add_argument("--shuffle_module_genes", action="store_true")
|
| 96 |
+
p.add_argument("--shuffle_seed", type=int, default=2021)
|
| 97 |
+
|
| 98 |
+
p.add_argument("--data_path", type=str, required=True)
|
| 99 |
+
p.add_argument("--data_mode", type=str, default="h5ad",
|
| 100 |
+
choices=["h5ad", "pretrain_h5ad", "pretrain_npz"])
|
| 101 |
+
p.add_argument("--cell_num", type=int, default=None)
|
| 102 |
+
p.add_argument("--gene_name_column", type=str, default="auto")
|
| 103 |
+
p.add_argument("--dataset_name", type=str, default="crosstalk")
|
| 104 |
+
p.add_argument("--max_cells", type=int, default=5000,
|
| 105 |
+
help="Max cells for method A (cosine). Method C uses --jacobian_cells.")
|
| 106 |
+
|
| 107 |
+
p.add_argument("--batch_size", type=int, default=16)
|
| 108 |
+
p.add_argument("--num_workers", type=int, default=0)
|
| 109 |
+
p.add_argument("--device", type=str, default="cuda")
|
| 110 |
+
p.add_argument("--seed", type=int, default=2021)
|
| 111 |
+
p.add_argument("--pool_mode", type=str, default="mean",
|
| 112 |
+
choices=["mean", "max"],
|
| 113 |
+
help="Pooling over cells: 'mean' or 'max' (element-wise max).")
|
| 114 |
+
|
| 115 |
+
p.add_argument("--jacobian_cells", type=int, default=200,
|
| 116 |
+
help="Number of cells for Jacobian computation (expensive).")
|
| 117 |
+
p.add_argument("--jacobian_batch", type=int, default=4,
|
| 118 |
+
help="Cells per Jacobian forward-backward pass.")
|
| 119 |
+
p.add_argument("--skip_jacobian", action="store_true",
|
| 120 |
+
help="Only run method A (cosine), skip method C (Jacobian).")
|
| 121 |
+
|
| 122 |
+
p.add_argument("--null_permutations", type=int, default=200)
|
| 123 |
+
p.add_argument("--top_k_fractions", type=str, default="0.01,0.05,0.1",
|
| 124 |
+
help="Comma-separated fractions for precision@k.")
|
| 125 |
+
|
| 126 |
+
p.add_argument("--output_dir", type=str, required=True)
|
| 127 |
+
return p.parse_args()
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
# ---------------------------------------------------------------------------
|
| 131 |
+
# Data loading (reuses patterns from module_geometry.py)
|
| 132 |
+
# ---------------------------------------------------------------------------
|
| 133 |
+
|
| 134 |
+
def build_dataset(args, preprocessor):
|
| 135 |
+
"""Build dataset and optional reindex_state."""
|
| 136 |
+
if args.data_mode == "h5ad":
|
| 137 |
+
import anndata as ad
|
| 138 |
+
adata, reindex_state = preprocessor.preprocess_adata(
|
| 139 |
+
adata_path=args.data_path,
|
| 140 |
+
min_genes=0, min_cells=0,
|
| 141 |
+
max_cells=args.max_cells,
|
| 142 |
+
dataset_name=args.dataset_name,
|
| 143 |
+
gene_name_column=args.gene_name_column,
|
| 144 |
+
)
|
| 145 |
+
dataset = MatrixRowDataset(adata.X)
|
| 146 |
+
n_cells = adata.n_obs
|
| 147 |
+
elif args.data_mode == "pretrain_h5ad":
|
| 148 |
+
import anndata as ad
|
| 149 |
+
adata = ad.read_h5ad(args.data_path)
|
| 150 |
+
if args.max_cells and adata.n_obs > args.max_cells:
|
| 151 |
+
rng = np.random.default_rng(args.seed)
|
| 152 |
+
chosen = np.sort(rng.choice(adata.n_obs, size=args.max_cells, replace=False))
|
| 153 |
+
adata = adata[chosen].copy()
|
| 154 |
+
dataset = MatrixRowDataset(adata.X)
|
| 155 |
+
n_cells = adata.n_obs
|
| 156 |
+
reindex_state = None
|
| 157 |
+
elif args.data_mode == "pretrain_npz":
|
| 158 |
+
dataset = NpzCellDataset(
|
| 159 |
+
data_dir=args.data_path,
|
| 160 |
+
cell_num=args.cell_num,
|
| 161 |
+
max_cells=args.max_cells,
|
| 162 |
+
seed=args.seed,
|
| 163 |
+
)
|
| 164 |
+
n_cells = len(dataset)
|
| 165 |
+
reindex_state = None
|
| 166 |
+
else:
|
| 167 |
+
raise ValueError(f"Unsupported data_mode: {args.data_mode}")
|
| 168 |
+
return dataset, reindex_state, n_cells
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
# ---------------------------------------------------------------------------
|
| 172 |
+
# Method A: Cosine similarity of cell-averaged module embeddings
|
| 173 |
+
# ---------------------------------------------------------------------------
|
| 174 |
+
|
| 175 |
+
@torch.no_grad()
|
| 176 |
+
def compute_cosine_interaction(backbone, loader, preprocessor, reindex_state,
|
| 177 |
+
device, num_modules, base_dim, expected_genes,
|
| 178 |
+
pool_mode="mean"):
|
| 179 |
+
"""Compute pooled module embeddings, return cosine similarity matrix."""
|
| 180 |
+
backbone.eval()
|
| 181 |
+
if pool_mode == "mean":
|
| 182 |
+
agg_emb = torch.zeros(num_modules, base_dim, device=device, dtype=torch.float64)
|
| 183 |
+
else:
|
| 184 |
+
agg_emb = torch.full((num_modules, base_dim), -float("inf"), device=device, dtype=torch.float64)
|
| 185 |
+
n_cells = 0
|
| 186 |
+
|
| 187 |
+
for batch_idx, x in enumerate(loader):
|
| 188 |
+
x = x.to(device)
|
| 189 |
+
if reindex_state is not None:
|
| 190 |
+
x = preprocessor.reindex_tensor_to_model_space(x, reindex_state=reindex_state)
|
| 191 |
+
out = backbone(x, mask_ratio=0.0, return_encodings=True)
|
| 192 |
+
enc = out["module_encodings"].to(torch.float64) # (B, num_modules, D)
|
| 193 |
+
if pool_mode == "mean":
|
| 194 |
+
agg_emb += enc.sum(dim=0)
|
| 195 |
+
else:
|
| 196 |
+
batch_max = enc.max(dim=0).values # (num_modules, D)
|
| 197 |
+
agg_emb = torch.maximum(agg_emb, batch_max)
|
| 198 |
+
n_cells += enc.shape[0]
|
| 199 |
+
if (batch_idx + 1) % 50 == 0:
|
| 200 |
+
logger.info(" [cosine/%s] batch %d, %d cells", pool_mode, batch_idx + 1, n_cells)
|
| 201 |
+
|
| 202 |
+
if pool_mode == "mean":
|
| 203 |
+
pooled_emb = (agg_emb / max(n_cells, 1)).float().cpu().numpy()
|
| 204 |
+
else:
|
| 205 |
+
pooled_emb = agg_emb.float().cpu().numpy()
|
| 206 |
+
|
| 207 |
+
norms = np.linalg.norm(pooled_emb, axis=1, keepdims=True)
|
| 208 |
+
norms = np.clip(norms, 1e-8, None)
|
| 209 |
+
normed = pooled_emb / norms
|
| 210 |
+
cos_sim = normed @ normed.T
|
| 211 |
+
np.fill_diagonal(cos_sim, 0.0)
|
| 212 |
+
logger.info("Method A (%s) done: %d cells, interaction matrix shape %s", pool_mode, n_cells, cos_sim.shape)
|
| 213 |
+
return cos_sim, pooled_emb
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
# ---------------------------------------------------------------------------
|
| 217 |
+
# Method C: Jacobian-based module interaction
|
| 218 |
+
# ---------------------------------------------------------------------------
|
| 219 |
+
|
| 220 |
+
def compute_jacobian_interaction(backbone, loader, preprocessor, reindex_state,
|
| 221 |
+
device, num_modules, base_dim, expected_genes,
|
| 222 |
+
max_cells, batch_size):
|
| 223 |
+
"""Compute average ||d encoder_out[j] / d module_emb[i]|| interaction matrix."""
|
| 224 |
+
backbone.eval()
|
| 225 |
+
for param in backbone.parameters():
|
| 226 |
+
param.requires_grad_(False)
|
| 227 |
+
|
| 228 |
+
interaction = torch.zeros(num_modules, num_modules, device=device, dtype=torch.float64)
|
| 229 |
+
n_cells = 0
|
| 230 |
+
|
| 231 |
+
for x in loader:
|
| 232 |
+
if n_cells >= max_cells:
|
| 233 |
+
break
|
| 234 |
+
x = x.to(device)
|
| 235 |
+
if reindex_state is not None:
|
| 236 |
+
x = preprocessor.reindex_tensor_to_model_space(x, reindex_state=reindex_state)
|
| 237 |
+
|
| 238 |
+
actual_batch = min(x.shape[0], max_cells - n_cells)
|
| 239 |
+
x = x[:actual_batch]
|
| 240 |
+
|
| 241 |
+
for cell_idx in range(x.shape[0]):
|
| 242 |
+
cell = x[cell_idx:cell_idx+1]
|
| 243 |
+
J = _compute_single_cell_jacobian(backbone, cell, num_modules, base_dim, device)
|
| 244 |
+
interaction += J
|
| 245 |
+
n_cells += 1
|
| 246 |
+
if n_cells % 20 == 0:
|
| 247 |
+
logger.info(" [jacobian] %d / %d cells", n_cells, max_cells)
|
| 248 |
+
if n_cells >= max_cells:
|
| 249 |
+
break
|
| 250 |
+
|
| 251 |
+
interaction /= max(n_cells, 1)
|
| 252 |
+
result = interaction.cpu().numpy()
|
| 253 |
+
np.fill_diagonal(result, 0.0)
|
| 254 |
+
logger.info("Method C done: %d cells, interaction matrix shape %s", n_cells, result.shape)
|
| 255 |
+
return result
|
| 256 |
+
|
| 257 |
+
|
| 258 |
+
def _compute_single_cell_jacobian(backbone, cell, num_modules, base_dim, device):
|
| 259 |
+
"""For one cell, compute (num_modules, num_modules) Jacobian norm matrix.
|
| 260 |
+
|
| 261 |
+
J[i, j] = ||d encoder_out[j] / d module_emb[i]||_F
|
| 262 |
+
|
| 263 |
+
Strategy: run module_embed to get intermediate, detach, enable grad,
|
| 264 |
+
then replicate the encoder forward path and backprop from each output module.
|
| 265 |
+
"""
|
| 266 |
+
J = torch.zeros(num_modules, num_modules, device=device, dtype=torch.float32)
|
| 267 |
+
|
| 268 |
+
with torch.no_grad():
|
| 269 |
+
x_raw, _ = backbone.module_embed(cell) # (1, num_modules, D)
|
| 270 |
+
|
| 271 |
+
x_modules = x_raw.detach().clone().requires_grad_(True)
|
| 272 |
+
|
| 273 |
+
with torch.enable_grad():
|
| 274 |
+
if backbone.mask_style == "replace":
|
| 275 |
+
x = backbone._add_module_position_embedding(x_modules)
|
| 276 |
+
else:
|
| 277 |
+
x = backbone._add_module_position_embedding(x_modules)
|
| 278 |
+
|
| 279 |
+
if backbone.use_cls and backbone.cls_token is not None:
|
| 280 |
+
cls_token = backbone.cls_token + backbone.pos_emb[:, :1, :]
|
| 281 |
+
cls_tokens = cls_token.expand(x.shape[0], -1, -1)
|
| 282 |
+
x = torch.cat((cls_tokens, x), dim=1)
|
| 283 |
+
|
| 284 |
+
x_emb = backbone.dropout(x)
|
| 285 |
+
layer_pos_emb = backbone.layer_pos_emb(x_emb)
|
| 286 |
+
enc_out = backbone.encoder(x_emb, pos_emb=layer_pos_emb)
|
| 287 |
+
enc_out = backbone.encoder_norm(enc_out) # (1, L, D)
|
| 288 |
+
|
| 289 |
+
if backbone.use_cls and backbone.cls_token is not None:
|
| 290 |
+
enc_out = enc_out[:, 1:, :] # skip CLS
|
| 291 |
+
|
| 292 |
+
for j in range(num_modules):
|
| 293 |
+
if x_modules.grad is not None:
|
| 294 |
+
x_modules.grad.zero_()
|
| 295 |
+
scalar = enc_out[0, j].pow(2).sum()
|
| 296 |
+
scalar.backward(retain_graph=(j < num_modules - 1))
|
| 297 |
+
grad = x_modules.grad[0] # (num_modules, D)
|
| 298 |
+
J[:, j] = grad.norm(dim=1)
|
| 299 |
+
|
| 300 |
+
return J.to(torch.float64)
|
| 301 |
+
|
| 302 |
+
|
| 303 |
+
# ---------------------------------------------------------------------------
|
| 304 |
+
# Ground truth: pathway overlap from module gene sets
|
| 305 |
+
# ---------------------------------------------------------------------------
|
| 306 |
+
|
| 307 |
+
def compute_pathway_overlap(module_names: List[str], module_gene_dict: dict,
|
| 308 |
+
metric: str = "jaccard") -> np.ndarray:
|
| 309 |
+
"""Compute pairwise pathway overlap matrix from shared genes.
|
| 310 |
+
|
| 311 |
+
metric: 'jaccard' (|A∩B| / |A∪B|) or 'overlap_coef' (|A∩B| / min(|A|,|B|))
|
| 312 |
+
"""
|
| 313 |
+
n = len(module_names)
|
| 314 |
+
gene_sets = []
|
| 315 |
+
for name in module_names:
|
| 316 |
+
genes = module_gene_dict.get(name, [])
|
| 317 |
+
gene_sets.append(set(genes))
|
| 318 |
+
|
| 319 |
+
overlap = np.zeros((n, n), dtype=np.float32)
|
| 320 |
+
for i in range(n):
|
| 321 |
+
for j in range(i + 1, n):
|
| 322 |
+
inter = len(gene_sets[i] & gene_sets[j])
|
| 323 |
+
if inter == 0:
|
| 324 |
+
continue
|
| 325 |
+
if metric == "jaccard":
|
| 326 |
+
union = len(gene_sets[i] | gene_sets[j])
|
| 327 |
+
score = inter / union if union > 0 else 0.0
|
| 328 |
+
else:
|
| 329 |
+
min_size = min(len(gene_sets[i]), len(gene_sets[j]))
|
| 330 |
+
score = inter / min_size if min_size > 0 else 0.0
|
| 331 |
+
overlap[i, j] = score
|
| 332 |
+
overlap[j, i] = score
|
| 333 |
+
return overlap
|
| 334 |
+
|
| 335 |
+
|
| 336 |
+
# ---------------------------------------------------------------------------
|
| 337 |
+
# Evaluation metrics
|
| 338 |
+
# ---------------------------------------------------------------------------
|
| 339 |
+
|
| 340 |
+
def evaluate_interaction(model_matrix: np.ndarray, gt_matrix: np.ndarray,
|
| 341 |
+
top_k_fracs: List[float], n_permutations: int = 200):
|
| 342 |
+
"""Compare model interaction matrix against ground truth overlap.
|
| 343 |
+
|
| 344 |
+
Returns dict with spearman_r, auroc, precision@k, and permutation p-values.
|
| 345 |
+
"""
|
| 346 |
+
n = model_matrix.shape[0]
|
| 347 |
+
triu_idx = np.triu_indices(n, k=1)
|
| 348 |
+
model_flat = model_matrix[triu_idx]
|
| 349 |
+
gt_flat = gt_matrix[triu_idx]
|
| 350 |
+
|
| 351 |
+
has_positive = gt_flat.max() > 0
|
| 352 |
+
has_variation = model_flat.std() > 1e-10
|
| 353 |
+
|
| 354 |
+
rho, _ = spearmanr(model_flat, gt_flat) if has_variation else (0.0, 1.0)
|
| 355 |
+
|
| 356 |
+
gt_binary = (gt_flat > 0).astype(np.float32)
|
| 357 |
+
n_pos = gt_binary.sum()
|
| 358 |
+
if n_pos > 0 and n_pos < len(gt_binary) and has_variation:
|
| 359 |
+
auroc = roc_auc_score(gt_binary, model_flat)
|
| 360 |
+
else:
|
| 361 |
+
auroc = 0.5
|
| 362 |
+
|
| 363 |
+
precisions = {}
|
| 364 |
+
n_pairs = len(model_flat)
|
| 365 |
+
for frac in top_k_fracs:
|
| 366 |
+
k = max(1, int(n_pairs * frac))
|
| 367 |
+
top_k_idx = np.argsort(model_flat)[-k:]
|
| 368 |
+
precisions[f"precision@{frac}"] = float(gt_binary[top_k_idx].mean())
|
| 369 |
+
|
| 370 |
+
null_rhos = []
|
| 371 |
+
null_aurocs = []
|
| 372 |
+
rng = np.random.default_rng(42)
|
| 373 |
+
for _ in range(n_permutations):
|
| 374 |
+
perm = rng.permutation(n)
|
| 375 |
+
perm_matrix = model_matrix[perm][:, perm]
|
| 376 |
+
perm_flat = perm_matrix[triu_idx]
|
| 377 |
+
r, _ = spearmanr(perm_flat, gt_flat) if has_variation else (0.0, 1.0)
|
| 378 |
+
null_rhos.append(r)
|
| 379 |
+
if n_pos > 0 and n_pos < len(gt_binary):
|
| 380 |
+
null_aurocs.append(roc_auc_score(gt_binary, perm_flat))
|
| 381 |
+
else:
|
| 382 |
+
null_aurocs.append(0.5)
|
| 383 |
+
|
| 384 |
+
p_rho = (np.sum(np.array(null_rhos) >= rho) + 1) / (n_permutations + 1)
|
| 385 |
+
p_auroc = (np.sum(np.array(null_aurocs) >= auroc) + 1) / (n_permutations + 1)
|
| 386 |
+
|
| 387 |
+
return {
|
| 388 |
+
"spearman_r": float(rho),
|
| 389 |
+
"spearman_p_perm": float(p_rho),
|
| 390 |
+
"auroc": float(auroc),
|
| 391 |
+
"auroc_p_perm": float(p_auroc),
|
| 392 |
+
"null_rho_mean": float(np.mean(null_rhos)),
|
| 393 |
+
"null_rho_std": float(np.std(null_rhos)),
|
| 394 |
+
**precisions,
|
| 395 |
+
}
|
| 396 |
+
|
| 397 |
+
|
| 398 |
+
# ---------------------------------------------------------------------------
|
| 399 |
+
# Visualization
|
| 400 |
+
# ---------------------------------------------------------------------------
|
| 401 |
+
|
| 402 |
+
def plot_interaction_matrix(matrix: np.ndarray, title: str, path: Path,
|
| 403 |
+
module_families: Optional[List[str]] = None):
|
| 404 |
+
"""Plot heatmap of interaction matrix, optionally sorted by family."""
|
| 405 |
+
if module_families is not None:
|
| 406 |
+
order = np.argsort(module_families)
|
| 407 |
+
matrix = matrix[order][:, order]
|
| 408 |
+
sorted_families = [module_families[i] for i in order]
|
| 409 |
+
else:
|
| 410 |
+
sorted_families = None
|
| 411 |
+
|
| 412 |
+
fig, ax = plt.subplots(figsize=(10, 9))
|
| 413 |
+
vmax = np.percentile(np.abs(matrix), 99)
|
| 414 |
+
im = ax.imshow(matrix, cmap="RdBu_r", vmin=-vmax, vmax=vmax, aspect="equal")
|
| 415 |
+
ax.set_title(title, fontsize=12)
|
| 416 |
+
ax.set_xlabel("Module j")
|
| 417 |
+
ax.set_ylabel("Module i")
|
| 418 |
+
plt.colorbar(im, ax=ax, fraction=0.046)
|
| 419 |
+
|
| 420 |
+
if sorted_families is not None:
|
| 421 |
+
unique_fams = []
|
| 422 |
+
boundaries = [0]
|
| 423 |
+
prev = sorted_families[0]
|
| 424 |
+
for idx, f in enumerate(sorted_families):
|
| 425 |
+
if f != prev:
|
| 426 |
+
unique_fams.append(prev)
|
| 427 |
+
boundaries.append(idx)
|
| 428 |
+
prev = f
|
| 429 |
+
unique_fams.append(prev)
|
| 430 |
+
boundaries.append(len(sorted_families))
|
| 431 |
+
for b in boundaries[1:-1]:
|
| 432 |
+
ax.axhline(b - 0.5, color="black", linewidth=0.3, alpha=0.5)
|
| 433 |
+
ax.axvline(b - 0.5, color="black", linewidth=0.3, alpha=0.5)
|
| 434 |
+
|
| 435 |
+
plt.tight_layout()
|
| 436 |
+
fig.savefig(path, dpi=150, bbox_inches="tight")
|
| 437 |
+
plt.close(fig)
|
| 438 |
+
|
| 439 |
+
|
| 440 |
+
def plot_scatter(model_flat, gt_flat, title, path):
|
| 441 |
+
"""Scatter plot of model interaction vs ground truth overlap."""
|
| 442 |
+
fig, ax = plt.subplots(figsize=(6, 5))
|
| 443 |
+
subsample = min(len(model_flat), 50000)
|
| 444 |
+
if subsample < len(model_flat):
|
| 445 |
+
rng = np.random.default_rng(0)
|
| 446 |
+
idx = rng.choice(len(model_flat), size=subsample, replace=False)
|
| 447 |
+
model_flat = model_flat[idx]
|
| 448 |
+
gt_flat = gt_flat[idx]
|
| 449 |
+
ax.scatter(gt_flat, model_flat, alpha=0.1, s=2, rasterized=True)
|
| 450 |
+
ax.set_xlabel("Ground truth overlap (Jaccard)")
|
| 451 |
+
ax.set_ylabel("Model interaction score")
|
| 452 |
+
ax.set_title(title)
|
| 453 |
+
plt.tight_layout()
|
| 454 |
+
fig.savefig(path, dpi=150, bbox_inches="tight")
|
| 455 |
+
plt.close(fig)
|
| 456 |
+
|
| 457 |
+
|
| 458 |
+
# ---------------------------------------------------------------------------
|
| 459 |
+
# Main
|
| 460 |
+
# ---------------------------------------------------------------------------
|
| 461 |
+
|
| 462 |
+
def main():
|
| 463 |
+
args = parse_args()
|
| 464 |
+
seed_all(args.seed)
|
| 465 |
+
device = torch.device(args.device if torch.cuda.is_available() else "cpu")
|
| 466 |
+
output_dir = Path(args.output_dir)
|
| 467 |
+
ensure_dir(output_dir)
|
| 468 |
+
|
| 469 |
+
# Resolve all paths to absolute (relative to CWD) so that load_backbone's
|
| 470 |
+
# internal resolve_path (which resolves relative to cell_annotation/) is bypassed.
|
| 471 |
+
for attr in ("performer_checkpoint", "config_pkl", "module_data_dir",
|
| 472 |
+
"gene_list_path", "gene_mapping_path", "gene2vec_data_path",
|
| 473 |
+
"gene2ens_file", "data_path"):
|
| 474 |
+
val = getattr(args, attr, None)
|
| 475 |
+
if val is not None and not Path(val).is_absolute():
|
| 476 |
+
setattr(args, attr, str(Path(val).resolve()))
|
| 477 |
+
|
| 478 |
+
logger.info("Loading backbone...")
|
| 479 |
+
backbone, module_data, model_config = load_backbone(args, device)
|
| 480 |
+
backbone.eval()
|
| 481 |
+
|
| 482 |
+
num_modules = len(module_data)
|
| 483 |
+
base_dim = int(model_config["base_dim"])
|
| 484 |
+
expected_genes = int(model_config.get("num_genes", 27874))
|
| 485 |
+
logger.info("Model: %d modules, dim=%d, genes=%d", num_modules, base_dim, expected_genes)
|
| 486 |
+
|
| 487 |
+
module_json_path = args.module_json or str(Path(args.module_data_dir) / "merged_modules.json")
|
| 488 |
+
module_names = load_module_names(module_json_path)
|
| 489 |
+
with open(module_json_path, "r", encoding="utf-8") as f:
|
| 490 |
+
module_gene_dict = json.load(f)
|
| 491 |
+
|
| 492 |
+
preprocessor = None
|
| 493 |
+
if args.data_mode == "h5ad":
|
| 494 |
+
preprocessor = DataPreprocessor(
|
| 495 |
+
gene2ens_file=args.gene2ens_file,
|
| 496 |
+
gene2ens_column=args.gene2ens_column,
|
| 497 |
+
model_genes_file=args.gene_list_path,
|
| 498 |
+
)
|
| 499 |
+
preprocessor.load_gene_mapping()
|
| 500 |
+
preprocessor.load_model_genes()
|
| 501 |
+
|
| 502 |
+
dataset, reindex_state, n_cells = build_dataset(args, preprocessor)
|
| 503 |
+
logger.info("Dataset: %d cells", n_cells)
|
| 504 |
+
|
| 505 |
+
# --- Method A: Cosine ---
|
| 506 |
+
logger.info("=" * 60)
|
| 507 |
+
logger.info("Method A: Cosine similarity of module embeddings")
|
| 508 |
+
logger.info("=" * 60)
|
| 509 |
+
loader_a = DataLoader(dataset, batch_size=args.batch_size, shuffle=False,
|
| 510 |
+
num_workers=args.num_workers, pin_memory=True)
|
| 511 |
+
cos_matrix, mean_emb = compute_cosine_interaction(
|
| 512 |
+
backbone, loader_a, preprocessor, reindex_state,
|
| 513 |
+
device, num_modules, base_dim, expected_genes,
|
| 514 |
+
pool_mode=args.pool_mode,
|
| 515 |
+
)
|
| 516 |
+
|
| 517 |
+
# --- Method C: Jacobian ---
|
| 518 |
+
jac_matrix = None
|
| 519 |
+
if not args.skip_jacobian:
|
| 520 |
+
logger.info("=" * 60)
|
| 521 |
+
logger.info("Method C: Jacobian-based module interaction")
|
| 522 |
+
logger.info("=" * 60)
|
| 523 |
+
jac_n = min(args.jacobian_cells, n_cells)
|
| 524 |
+
if jac_n < n_cells:
|
| 525 |
+
rng = np.random.default_rng(args.seed)
|
| 526 |
+
jac_indices = np.sort(rng.choice(n_cells, size=jac_n, replace=False))
|
| 527 |
+
jac_dataset = Subset(dataset, jac_indices.tolist())
|
| 528 |
+
else:
|
| 529 |
+
jac_dataset = dataset
|
| 530 |
+
loader_c = DataLoader(jac_dataset, batch_size=args.jacobian_batch, shuffle=False,
|
| 531 |
+
num_workers=0, pin_memory=True)
|
| 532 |
+
jac_matrix = compute_jacobian_interaction(
|
| 533 |
+
backbone, loader_c, preprocessor, reindex_state,
|
| 534 |
+
device, num_modules, base_dim, expected_genes,
|
| 535 |
+
max_cells=jac_n, batch_size=args.jacobian_batch,
|
| 536 |
+
)
|
| 537 |
+
|
| 538 |
+
# --- Ground truth overlap ---
|
| 539 |
+
logger.info("Computing pathway gene overlap (Jaccard)...")
|
| 540 |
+
gt_jaccard = compute_pathway_overlap(module_names, module_gene_dict, metric="jaccard")
|
| 541 |
+
gt_overlap = compute_pathway_overlap(module_names, module_gene_dict, metric="overlap_coef")
|
| 542 |
+
|
| 543 |
+
# --- Evaluate ---
|
| 544 |
+
top_k_fracs = [float(f) for f in args.top_k_fractions.split(",")]
|
| 545 |
+
results = {}
|
| 546 |
+
|
| 547 |
+
logger.info("Evaluating Method A vs Jaccard overlap...")
|
| 548 |
+
results["cosine_vs_jaccard"] = evaluate_interaction(
|
| 549 |
+
cos_matrix, gt_jaccard, top_k_fracs, args.null_permutations)
|
| 550 |
+
results["cosine_vs_overlap_coef"] = evaluate_interaction(
|
| 551 |
+
cos_matrix, gt_overlap, top_k_fracs, args.null_permutations)
|
| 552 |
+
|
| 553 |
+
if jac_matrix is not None:
|
| 554 |
+
logger.info("Evaluating Method C vs Jaccard overlap...")
|
| 555 |
+
jac_sym = (jac_matrix + jac_matrix.T) / 2
|
| 556 |
+
results["jacobian_vs_jaccard"] = evaluate_interaction(
|
| 557 |
+
jac_sym, gt_jaccard, top_k_fracs, args.null_permutations)
|
| 558 |
+
results["jacobian_vs_overlap_coef"] = evaluate_interaction(
|
| 559 |
+
jac_sym, gt_overlap, top_k_fracs, args.null_permutations)
|
| 560 |
+
|
| 561 |
+
# --- Save ---
|
| 562 |
+
np.save(output_dir / "cosine_interaction.npy", cos_matrix)
|
| 563 |
+
np.save(output_dir / "mean_module_embeddings.npy", mean_emb)
|
| 564 |
+
np.save(output_dir / "gt_jaccard.npy", gt_jaccard)
|
| 565 |
+
np.save(output_dir / "gt_overlap_coef.npy", gt_overlap)
|
| 566 |
+
if jac_matrix is not None:
|
| 567 |
+
np.save(output_dir / "jacobian_interaction.npy", jac_matrix)
|
| 568 |
+
|
| 569 |
+
with open(output_dir / "metrics.json", "w", encoding="utf-8") as f:
|
| 570 |
+
json.dump(results, f, indent=2, ensure_ascii=False)
|
| 571 |
+
|
| 572 |
+
# --- Plots ---
|
| 573 |
+
meta = build_module_metadata(module_names)
|
| 574 |
+
families = [row["family"] for row in meta]
|
| 575 |
+
|
| 576 |
+
plot_interaction_matrix(cos_matrix, "Method A: Cosine Similarity",
|
| 577 |
+
output_dir / "heatmap_cosine.png", families)
|
| 578 |
+
if jac_matrix is not None:
|
| 579 |
+
plot_interaction_matrix(jac_sym, "Method C: Jacobian Interaction",
|
| 580 |
+
output_dir / "heatmap_jacobian.png", families)
|
| 581 |
+
plot_interaction_matrix(gt_jaccard, "Ground Truth: Jaccard Gene Overlap",
|
| 582 |
+
output_dir / "heatmap_gt_jaccard.png", families)
|
| 583 |
+
|
| 584 |
+
triu_idx = np.triu_indices(num_modules, k=1)
|
| 585 |
+
plot_scatter(cos_matrix[triu_idx], gt_jaccard[triu_idx],
|
| 586 |
+
"Cosine vs Jaccard", output_dir / "scatter_cosine_vs_jaccard.png")
|
| 587 |
+
if jac_matrix is not None:
|
| 588 |
+
plot_scatter(jac_sym[triu_idx], gt_jaccard[triu_idx],
|
| 589 |
+
"Jacobian vs Jaccard", output_dir / "scatter_jacobian_vs_jaccard.png")
|
| 590 |
+
|
| 591 |
+
# --- Print summary ---
|
| 592 |
+
logger.info("=" * 60)
|
| 593 |
+
logger.info("Results Summary")
|
| 594 |
+
logger.info("=" * 60)
|
| 595 |
+
for method_key, metrics in results.items():
|
| 596 |
+
logger.info(" %s:", method_key)
|
| 597 |
+
logger.info(" Spearman r = %.4f (p_perm = %.4f)", metrics["spearman_r"], metrics["spearman_p_perm"])
|
| 598 |
+
logger.info(" AUROC = %.4f (p_perm = %.4f)", metrics["auroc"], metrics["auroc_p_perm"])
|
| 599 |
+
for k in top_k_fracs:
|
| 600 |
+
logger.info(" Precision@%.2f = %.4f", k, metrics[f"precision@{k}"])
|
| 601 |
+
logger.info("Output saved to: %s", output_dir)
|
| 602 |
+
|
| 603 |
+
|
| 604 |
+
if __name__ == "__main__":
|
| 605 |
+
main()
|
BioMod/interpretability/attention/module_gene_attention.py
ADDED
|
@@ -0,0 +1,1090 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""ModuleEmbedding Attention-based Gene Interpretability Analysis (Streaming).
|
| 2 |
+
|
| 3 |
+
Interpretability analysis using single-head content-based attention pooling in ModuleEmbedding.
|
| 4 |
+
Streaming computing mode: memory usage O(n_celltypes × n_genes).
|
| 5 |
+
|
| 6 |
+
Analysis content:
|
| 7 |
+
1. Global gene importance ranking
|
| 8 |
+
2. Cell type specificity (z-score, Cohen's d, Welch's t)
|
| 9 |
+
3. Marker gene verification
|
| 10 |
+
4. Attention-Expression correlation
|
| 11 |
+
5. Random baseline comparison (KL divergence)
|
| 12 |
+
6. Attention concentration (Gini coefficient)
|
| 13 |
+
7. Pathway Enrichment Analysis
|
| 14 |
+
8. DEG comparison
|
| 15 |
+
|
| 16 |
+
Usage:
|
| 17 |
+
python module_gene_attention.py \
|
| 18 |
+
--performer_checkpoint ../../mae_tost_48M_cross_attn_only_pure_memory/mae_tost_48M_cross_attn_only_pure_memory_epoch12.pth \
|
| 19 |
+
--config_pkl ../../mae_tost_48M_cross_attn_only_pure_memory/mae_tost_48M_cross_attn_only_pure_memory.pkl \
|
| 20 |
+
--module_data_dir ../../module_data_all \
|
| 21 |
+
--gene_list_path ../../../selected_genes_27k.txt \
|
| 22 |
+
--gene_mapping_path ../../../gene2ens.csv \
|
| 23 |
+
--gene2vec_data_path ../../../selected_gene2vec_27k.npy \
|
| 24 |
+
--gene2ens_file ../../../gene2ens.csv \
|
| 25 |
+
--data_path ../../../zero-shot-batch/pancreas_scib.h5ad \
|
| 26 |
+
--celltype_column celltype \
|
| 27 |
+
--batch_size 64 \
|
| 28 |
+
--output_dir ../results/gene_attention/pancreas
|
| 29 |
+
"""
|
| 30 |
+
from __future__ import annotations
|
| 31 |
+
|
| 32 |
+
import argparse
|
| 33 |
+
import json
|
| 34 |
+
import logging
|
| 35 |
+
import sys
|
| 36 |
+
from pathlib import Path
|
| 37 |
+
from typing import Dict, List, Optional, Tuple
|
| 38 |
+
|
| 39 |
+
import numpy as np
|
| 40 |
+
import pandas as pd
|
| 41 |
+
import torch
|
| 42 |
+
from torch.utils.data import DataLoader
|
| 43 |
+
|
| 44 |
+
import matplotlib
|
| 45 |
+
matplotlib.use("Agg")
|
| 46 |
+
import matplotlib.pyplot as plt
|
| 47 |
+
import seaborn as sns
|
| 48 |
+
|
| 49 |
+
from scipy.stats import spearmanr, t as t_dist
|
| 50 |
+
|
| 51 |
+
HERE = Path(__file__).resolve()
|
| 52 |
+
PERFORMER_MODULE = HERE.parents[2]
|
| 53 |
+
REPO_ROOT = HERE.parents[3]
|
| 54 |
+
for p in (str(PERFORMER_MODULE), str(REPO_ROOT)):
|
| 55 |
+
if p not in sys.path:
|
| 56 |
+
sys.path.insert(0, p)
|
| 57 |
+
|
| 58 |
+
# Publication utilities
|
| 59 |
+
MODULE_IMPORTANCE_DIR = PERFORMER_MODULE / "interpretability" / "module_importance"
|
| 60 |
+
if str(MODULE_IMPORTANCE_DIR) not in sys.path:
|
| 61 |
+
sys.path.insert(0, str(MODULE_IMPORTANCE_DIR))
|
| 62 |
+
|
| 63 |
+
from pub_utils import setup_publication_style, savefig as pub_savefig
|
| 64 |
+
|
| 65 |
+
from cell_annotation.performer_module_finetune import (
|
| 66 |
+
DataPreprocessor,
|
| 67 |
+
load_backbone,
|
| 68 |
+
seed_all,
|
| 69 |
+
)
|
| 70 |
+
from interpretability.utils import (
|
| 71 |
+
MatrixRowDataset,
|
| 72 |
+
build_module_metadata,
|
| 73 |
+
ensure_dir,
|
| 74 |
+
load_module_names,
|
| 75 |
+
)
|
| 76 |
+
|
| 77 |
+
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
| 78 |
+
logger = logging.getLogger(__name__)
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
# ---------------------------------------------------------------------------
|
| 82 |
+
# Argparse
|
| 83 |
+
# ---------------------------------------------------------------------------
|
| 84 |
+
|
| 85 |
+
def parse_args() -> argparse.Namespace:
|
| 86 |
+
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
| 87 |
+
|
| 88 |
+
p.add_argument("--performer_checkpoint", type=str, required=True)
|
| 89 |
+
p.add_argument("--config_pkl", type=str, required=True)
|
| 90 |
+
p.add_argument("--module_data_dir", type=str, default="../../module_data_all")
|
| 91 |
+
p.add_argument("--module_json", type=str, default=None)
|
| 92 |
+
p.add_argument("--gene_list_path", type=str, default="../../../selected_genes_27k.txt")
|
| 93 |
+
p.add_argument("--gene_mapping_path", type=str, default="../../../gene2ens.csv")
|
| 94 |
+
p.add_argument("--gene2vec_data_path", type=str, default="../../../selected_gene2vec_27k.npy")
|
| 95 |
+
p.add_argument("--gene2ens_file", type=str, default="../../../gene2ens.csv")
|
| 96 |
+
p.add_argument("--gene2ens_column", type=str, default="Ensembl gene ID")
|
| 97 |
+
p.add_argument("--shuffle_module_genes", action="store_true")
|
| 98 |
+
p.add_argument("--shuffle_seed", type=int, default=2021)
|
| 99 |
+
|
| 100 |
+
p.add_argument("--data_path", type=str, required=True)
|
| 101 |
+
p.add_argument("--celltype_column", type=str, default="celltype")
|
| 102 |
+
p.add_argument("--gene_name_column", type=str, default="auto")
|
| 103 |
+
p.add_argument("--dataset_name", type=str, default="gene_attention")
|
| 104 |
+
p.add_argument("--max_cells", type=int, default=None)
|
| 105 |
+
|
| 106 |
+
p.add_argument("--batch_size", type=int, default=64)
|
| 107 |
+
p.add_argument("--num_workers", type=int, default=0)
|
| 108 |
+
p.add_argument("--device", type=str, default="cuda")
|
| 109 |
+
p.add_argument("--seed", type=int, default=2021)
|
| 110 |
+
|
| 111 |
+
p.add_argument("--top_k_genes", type=int, default=20)
|
| 112 |
+
p.add_argument("--marker_db", type=str, default=None)
|
| 113 |
+
p.add_argument("--deg_csv", type=str, default=None)
|
| 114 |
+
p.add_argument("--min_cells", type=int, default=30)
|
| 115 |
+
|
| 116 |
+
p.add_argument("--output_dir", type=str, required=True)
|
| 117 |
+
return p.parse_args()
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
# ---------------------------------------------------------------------------
|
| 121 |
+
# Built-in pancreas marker genes (from PanglaoDB + CellMarker)
|
| 122 |
+
# ---------------------------------------------------------------------------
|
| 123 |
+
|
| 124 |
+
PANCREAS_MARKERS: Dict[str, List[str]] = {
|
| 125 |
+
"alpha": ["GCG", "ARX", "IRX2", "TTR", "LOXL4", "GC", "CRYBA2", "DPP4", "IRX1", "PCSK2"],
|
| 126 |
+
"beta": ["INS", "IAPP", "MAFA", "NKX6-1", "PDX1", "HADH", "SLC30A8", "NPTX2", "DLK1", "PCSK1"],
|
| 127 |
+
"delta": ["SST", "HHEX", "RBP4", "LEPR", "PCSK1", "SEC11C"],
|
| 128 |
+
"gamma": ["PPY", "SERTM1", "CARTPT", "MEIS2", "ETV1", "ABCC9"],
|
| 129 |
+
"ductal": ["KRT19", "KRT7", "SPP1", "CFTR", "MUC1", "AMBP", "ANXA4", "BICC1", "SOX9"],
|
| 130 |
+
"acinar": ["PRSS1", "CELA3A", "CELA2A", "CPA1", "CPA2", "PNLIP", "CEL", "CLPS", "REG1A", "CTRB1"],
|
| 131 |
+
"activated_stellate": ["COL1A1", "COL1A2", "COL3A1", "ACTA2", "TAGLN", "RGS5", "PDGFRB"],
|
| 132 |
+
"endothelial": ["PECAM1", "VWF", "CDH5", "KDR", "ENG", "PLVAP", "ESAM"],
|
| 133 |
+
"macrophage": ["CD68", "CD163", "CSF1R", "MARCO", "MSR1", "C1QA", "C1QB"],
|
| 134 |
+
"epsilon": ["GHRL", "PCSK1", "ARX"],
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
PANCREAS_COLORS: Dict[str, str] = {
|
| 138 |
+
"alpha": "#E41A1C", "beta": "#377EB8", "delta": "#4DAF4A",
|
| 139 |
+
"gamma": "#984EA3", "ductal": "#FF7F00", "acinar": "#A65628",
|
| 140 |
+
"activated_stellate": "#F781BF", "endothelial": "#66C2A5",
|
| 141 |
+
"macrophage": "#FC8D62", "epsilon": "#8DA0CB", "t_cell": "#E78AC3",
|
| 142 |
+
"schwann": "#B3B3B3", "mast": "#FDBF6F", "quiescent_stellate": "#CAB2D6",
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
# ---------------------------------------------------------------------------
|
| 147 |
+
# Streaming attention extraction + statistics accumulation
|
| 148 |
+
# ---------------------------------------------------------------------------
|
| 149 |
+
|
| 150 |
+
@torch.no_grad()
|
| 151 |
+
def extract_attention_streaming(
|
| 152 |
+
backbone, loader, preprocessor, reindex_state, device,
|
| 153 |
+
cell_labels: np.ndarray, num_modules: int, module_sizes: List[int],
|
| 154 |
+
) -> Tuple:
|
| 155 |
+
"""Extract attention and accumulate statistics in streaming fashion.
|
| 156 |
+
|
| 157 |
+
Returns:
|
| 158 |
+
global_mean, global_std, ct_mean, ct_std, ct_count, n_cells,
|
| 159 |
+
expr_mean, attn_expr_prod_sum
|
| 160 |
+
"""
|
| 161 |
+
backbone.eval()
|
| 162 |
+
module_embed = backbone.module_embed
|
| 163 |
+
|
| 164 |
+
global_sum = {i: np.zeros(module_sizes[i], dtype=np.float64) for i in range(num_modules)}
|
| 165 |
+
global_sum_sq = {i: np.zeros(module_sizes[i], dtype=np.float64) for i in range(num_modules)}
|
| 166 |
+
expr_sum = {i: np.zeros(module_sizes[i], dtype=np.float64) for i in range(num_modules)}
|
| 167 |
+
attn_expr_prod_sum = {i: np.zeros(module_sizes[i], dtype=np.float64) for i in range(num_modules)}
|
| 168 |
+
|
| 169 |
+
celltypes = np.unique(cell_labels)
|
| 170 |
+
ct_sum = {ct: {i: np.zeros(module_sizes[i], dtype=np.float64) for i in range(num_modules)} for ct in celltypes}
|
| 171 |
+
ct_sum_sq = {ct: {i: np.zeros(module_sizes[i], dtype=np.float64) for i in range(num_modules)} for ct in celltypes}
|
| 172 |
+
ct_count = {ct: 0 for ct in celltypes}
|
| 173 |
+
|
| 174 |
+
n_cells = 0
|
| 175 |
+
cell_idx = 0
|
| 176 |
+
|
| 177 |
+
for batch_idx, x in enumerate(loader):
|
| 178 |
+
x = x.to(device)
|
| 179 |
+
if reindex_state is not None:
|
| 180 |
+
x = preprocessor.reindex_tensor_to_model_space(x, reindex_state=reindex_state)
|
| 181 |
+
|
| 182 |
+
batch_size = x.shape[0]
|
| 183 |
+
batch_labels = cell_labels[cell_idx:cell_idx + batch_size]
|
| 184 |
+
cell_idx += batch_size
|
| 185 |
+
|
| 186 |
+
for module_i, gene_idx in enumerate(module_embed.module_sets):
|
| 187 |
+
gene_idx_dev = gene_idx.to(device)
|
| 188 |
+
expr_slice = torch.index_select(x, dim=1, index=gene_idx_dev)
|
| 189 |
+
|
| 190 |
+
gene_emb = module_embed.gene_embed(gene_idx_dev)
|
| 191 |
+
gene_emb = gene_emb.unsqueeze(0).expand(batch_size, -1, -1)
|
| 192 |
+
gene_tok = module_embed._fuse_expr_and_gene_emb(expr_slice, gene_emb)
|
| 193 |
+
gene_tok = module_embed.proj(gene_tok)
|
| 194 |
+
|
| 195 |
+
attn = module_embed.attn_score(gene_tok).squeeze(-1)
|
| 196 |
+
attn = torch.softmax(attn, dim=1)
|
| 197 |
+
attn_np = attn.cpu().numpy().astype(np.float64)
|
| 198 |
+
|
| 199 |
+
# Expression for correlation
|
| 200 |
+
expr_np = expr_slice.cpu().numpy().astype(np.float64)
|
| 201 |
+
expr_sum[module_i] += expr_np.sum(axis=0)
|
| 202 |
+
attn_expr_prod_sum[module_i] += (attn_np * expr_np).sum(axis=0)
|
| 203 |
+
|
| 204 |
+
# Global accumulation
|
| 205 |
+
global_sum[module_i] += attn_np.sum(axis=0)
|
| 206 |
+
global_sum_sq[module_i] += (attn_np ** 2).sum(axis=0)
|
| 207 |
+
|
| 208 |
+
# Per-celltype accumulation
|
| 209 |
+
for ct in celltypes:
|
| 210 |
+
mask = batch_labels == ct
|
| 211 |
+
if mask.any():
|
| 212 |
+
ct_attn = attn_np[mask]
|
| 213 |
+
ct_sum[ct][module_i] += ct_attn.sum(axis=0)
|
| 214 |
+
ct_sum_sq[ct][module_i] += (ct_attn ** 2).sum(axis=0)
|
| 215 |
+
|
| 216 |
+
for ct in celltypes:
|
| 217 |
+
ct_count[ct] += int((batch_labels == ct).sum())
|
| 218 |
+
|
| 219 |
+
n_cells += batch_size
|
| 220 |
+
if (batch_idx + 1) % 50 == 0:
|
| 221 |
+
logger.info(" [attention] batch %d, %d cells processed", batch_idx + 1, n_cells)
|
| 222 |
+
|
| 223 |
+
# Compute means and stds
|
| 224 |
+
global_mean = {}
|
| 225 |
+
global_std = {}
|
| 226 |
+
for i in range(num_modules):
|
| 227 |
+
global_mean[i] = (global_sum[i] / n_cells).astype(np.float32)
|
| 228 |
+
variance = global_sum_sq[i] / n_cells - global_mean[i].astype(np.float64) ** 2
|
| 229 |
+
global_std[i] = np.sqrt(np.maximum(variance, 0)).astype(np.float32)
|
| 230 |
+
|
| 231 |
+
ct_mean = {}
|
| 232 |
+
ct_std = {}
|
| 233 |
+
for ct in celltypes:
|
| 234 |
+
ct_mean[ct] = {}
|
| 235 |
+
ct_std[ct] = {}
|
| 236 |
+
for i in range(num_modules):
|
| 237 |
+
if ct_count[ct] > 0:
|
| 238 |
+
ct_mean[ct][i] = (ct_sum[ct][i] / ct_count[ct]).astype(np.float32)
|
| 239 |
+
else:
|
| 240 |
+
ct_mean[ct][i] = np.zeros(module_sizes[i], dtype=np.float32)
|
| 241 |
+
if ct_count[ct] > 1:
|
| 242 |
+
var = ct_sum_sq[ct][i] / ct_count[ct] - ct_mean[ct][i].astype(np.float64) ** 2
|
| 243 |
+
ct_std[ct][i] = np.sqrt(np.maximum(var, 0)).astype(np.float32)
|
| 244 |
+
else:
|
| 245 |
+
ct_std[ct][i] = np.zeros(module_sizes[i], dtype=np.float32)
|
| 246 |
+
|
| 247 |
+
expr_mean = {i: (expr_sum[i] / n_cells).astype(np.float32) for i in range(num_modules)}
|
| 248 |
+
|
| 249 |
+
logger.info("Extracted attention for %d cells across %d modules (streaming)", n_cells, num_modules)
|
| 250 |
+
return global_mean, global_std, ct_mean, ct_std, ct_count, n_cells, expr_mean, attn_expr_prod_sum
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
# ---------------------------------------------------------------------------
|
| 254 |
+
# Analysis functions
|
| 255 |
+
# ---------------------------------------------------------------------------
|
| 256 |
+
|
| 257 |
+
def build_global_importance_df(
|
| 258 |
+
global_mean: Dict[int, np.ndarray],
|
| 259 |
+
global_std: Dict[int, np.ndarray],
|
| 260 |
+
module_gene_names: Dict[int, List[str]],
|
| 261 |
+
num_modules: int,
|
| 262 |
+
) -> pd.DataFrame:
|
| 263 |
+
"""Build global gene importance DataFrame from streaming means."""
|
| 264 |
+
rows = []
|
| 265 |
+
for module_i in range(num_modules):
|
| 266 |
+
mean_attn = global_mean[module_i]
|
| 267 |
+
std_attn = global_std[module_i]
|
| 268 |
+
genes = module_gene_names[module_i]
|
| 269 |
+
rank_order = np.argsort(-mean_attn)
|
| 270 |
+
for g_idx, gene in enumerate(genes):
|
| 271 |
+
rows.append({
|
| 272 |
+
"module_idx": module_i,
|
| 273 |
+
"gene": gene,
|
| 274 |
+
"mean_attention": float(mean_attn[g_idx]),
|
| 275 |
+
"std_attention": float(std_attn[g_idx]),
|
| 276 |
+
"rank_in_module": int(np.where(rank_order == g_idx)[0][0]) + 1,
|
| 277 |
+
"module_size": len(genes),
|
| 278 |
+
})
|
| 279 |
+
df = pd.DataFrame(rows)
|
| 280 |
+
df["normalized_rank"] = df["rank_in_module"] / df["module_size"]
|
| 281 |
+
return df
|
| 282 |
+
|
| 283 |
+
|
| 284 |
+
def build_celltype_dfs(
|
| 285 |
+
ct_mean: Dict[str, Dict[int, np.ndarray]],
|
| 286 |
+
ct_count: Dict[str, int],
|
| 287 |
+
module_gene_names: Dict[int, List[str]],
|
| 288 |
+
num_modules: int,
|
| 289 |
+
) -> Dict[str, pd.DataFrame]:
|
| 290 |
+
"""Build per-celltype DataFrames from streaming means."""
|
| 291 |
+
ct_dfs = {}
|
| 292 |
+
for ct, module_means in ct_mean.items():
|
| 293 |
+
rows = []
|
| 294 |
+
for module_i in range(num_modules):
|
| 295 |
+
mean_attn = module_means[module_i]
|
| 296 |
+
genes = module_gene_names[module_i]
|
| 297 |
+
for g_idx, gene in enumerate(genes):
|
| 298 |
+
rows.append({
|
| 299 |
+
"module_idx": module_i,
|
| 300 |
+
"gene": gene,
|
| 301 |
+
"mean_attention": float(mean_attn[g_idx]),
|
| 302 |
+
"n_cells": ct_count[ct],
|
| 303 |
+
})
|
| 304 |
+
ct_dfs[ct] = pd.DataFrame(rows)
|
| 305 |
+
return ct_dfs
|
| 306 |
+
|
| 307 |
+
|
| 308 |
+
def compute_specificity_from_means(
|
| 309 |
+
global_mean: Dict[int, np.ndarray],
|
| 310 |
+
ct_mean: Dict[str, Dict[int, np.ndarray]],
|
| 311 |
+
module_gene_names: Dict[int, List[str]],
|
| 312 |
+
num_modules: int,
|
| 313 |
+
) -> pd.DataFrame:
|
| 314 |
+
"""Fold-change specificity (legacy): max_ct(mean_attn_ct) / global_mean_attn."""
|
| 315 |
+
celltypes = list(ct_mean.keys())
|
| 316 |
+
rows = []
|
| 317 |
+
for module_i in range(num_modules):
|
| 318 |
+
genes = module_gene_names[module_i]
|
| 319 |
+
g_mean = global_mean[module_i]
|
| 320 |
+
for g_idx, gene in enumerate(genes):
|
| 321 |
+
ct_scores = {ct: float(ct_mean[ct][module_i][g_idx]) for ct in celltypes}
|
| 322 |
+
top_ct = max(ct_scores, key=ct_scores.get)
|
| 323 |
+
top_score = ct_scores[top_ct]
|
| 324 |
+
fold_change = top_score / max(float(g_mean[g_idx]), 1e-10)
|
| 325 |
+
rows.append({
|
| 326 |
+
"module_idx": module_i, "gene": gene, "top_celltype": top_ct,
|
| 327 |
+
"top_ct_mean_attn": top_score, "global_mean_attn": float(g_mean[g_idx]),
|
| 328 |
+
"fold_change": fold_change,
|
| 329 |
+
})
|
| 330 |
+
return pd.DataFrame(rows)
|
| 331 |
+
|
| 332 |
+
|
| 333 |
+
def compute_specificity_zscore(
|
| 334 |
+
global_mean: Dict[int, np.ndarray],
|
| 335 |
+
global_std: Dict[int, np.ndarray],
|
| 336 |
+
ct_mean: Dict[str, Dict[int, np.ndarray]],
|
| 337 |
+
ct_count: Dict[str, int],
|
| 338 |
+
module_gene_names: Dict[int, List[str]],
|
| 339 |
+
num_modules: int,
|
| 340 |
+
min_cells: int = 30,
|
| 341 |
+
) -> pd.DataFrame:
|
| 342 |
+
"""Z-score specificity: (ct_mean - global_mean) / global_std."""
|
| 343 |
+
valid_cts = [ct for ct, n in ct_count.items() if n >= min_cells]
|
| 344 |
+
rows = []
|
| 345 |
+
for module_i in range(num_modules):
|
| 346 |
+
genes = module_gene_names[module_i]
|
| 347 |
+
g_mean = global_mean[module_i]
|
| 348 |
+
g_std = global_std[module_i]
|
| 349 |
+
for g_idx, gene in enumerate(genes):
|
| 350 |
+
std_val = max(float(g_std[g_idx]), 1e-8)
|
| 351 |
+
ct_zscores = {}
|
| 352 |
+
for ct in valid_cts:
|
| 353 |
+
z = (float(ct_mean[ct][module_i][g_idx]) - float(g_mean[g_idx])) / std_val
|
| 354 |
+
ct_zscores[ct] = z
|
| 355 |
+
top_ct = max(ct_zscores, key=ct_zscores.get)
|
| 356 |
+
rows.append({
|
| 357 |
+
"module_idx": module_i, "gene": gene, "top_celltype": top_ct,
|
| 358 |
+
"z_score": ct_zscores[top_ct],
|
| 359 |
+
"global_mean_attn": float(g_mean[g_idx]),
|
| 360 |
+
"ct_mean_attn": float(ct_mean[top_ct][module_i][g_idx]),
|
| 361 |
+
})
|
| 362 |
+
return pd.DataFrame(rows)
|
| 363 |
+
|
| 364 |
+
|
| 365 |
+
def compute_cohens_d(
|
| 366 |
+
global_mean: Dict[int, np.ndarray],
|
| 367 |
+
ct_mean: Dict[str, Dict[int, np.ndarray]],
|
| 368 |
+
ct_std: Dict[str, Dict[int, np.ndarray]],
|
| 369 |
+
ct_count: Dict[str, int],
|
| 370 |
+
module_gene_names: Dict[int, List[str]],
|
| 371 |
+
num_modules: int,
|
| 372 |
+
n_cells: int,
|
| 373 |
+
min_cells: int = 30,
|
| 374 |
+
) -> pd.DataFrame:
|
| 375 |
+
"""Cohen's d effect size for cell-type specificity."""
|
| 376 |
+
valid_cts = [ct for ct, n in ct_count.items() if n >= min_cells]
|
| 377 |
+
rows = []
|
| 378 |
+
for module_i in range(num_modules):
|
| 379 |
+
genes = module_gene_names[module_i]
|
| 380 |
+
for g_idx, gene in enumerate(genes):
|
| 381 |
+
best_d = -np.inf
|
| 382 |
+
best_ct = valid_cts[0]
|
| 383 |
+
for ct in valid_cts:
|
| 384 |
+
n_ct = ct_count[ct]
|
| 385 |
+
n_other = n_cells - n_ct
|
| 386 |
+
ct_m = float(ct_mean[ct][module_i][g_idx])
|
| 387 |
+
ct_s = float(ct_std[ct][module_i][g_idx])
|
| 388 |
+
other_m = (float(global_mean[module_i][g_idx]) * n_cells - ct_m * n_ct) / max(n_other, 1)
|
| 389 |
+
pooled = max(ct_s, 1e-10)
|
| 390 |
+
d = (ct_m - other_m) / pooled
|
| 391 |
+
if d > best_d:
|
| 392 |
+
best_d = d
|
| 393 |
+
best_ct = ct
|
| 394 |
+
rows.append({"module_idx": module_i, "gene": gene, "top_celltype": best_ct, "cohens_d": best_d})
|
| 395 |
+
return pd.DataFrame(rows)
|
| 396 |
+
|
| 397 |
+
|
| 398 |
+
def compute_welch_ttest(
|
| 399 |
+
global_mean: Dict[int, np.ndarray],
|
| 400 |
+
global_std: Dict[int, np.ndarray],
|
| 401 |
+
ct_mean: Dict[str, Dict[int, np.ndarray]],
|
| 402 |
+
ct_std: Dict[str, Dict[int, np.ndarray]],
|
| 403 |
+
ct_count: Dict[str, int],
|
| 404 |
+
module_gene_names: Dict[int, List[str]],
|
| 405 |
+
num_modules: int,
|
| 406 |
+
n_cells: int,
|
| 407 |
+
min_cells: int = 30,
|
| 408 |
+
) -> pd.DataFrame:
|
| 409 |
+
"""Welch's t-test from streaming statistics with BH FDR correction."""
|
| 410 |
+
valid_cts = [ct for ct, n in ct_count.items() if n >= min_cells]
|
| 411 |
+
rows = []
|
| 412 |
+
for module_i in range(num_modules):
|
| 413 |
+
genes = module_gene_names[module_i]
|
| 414 |
+
for g_idx, gene in enumerate(genes):
|
| 415 |
+
best_t = -np.inf
|
| 416 |
+
best_ct = valid_cts[0]
|
| 417 |
+
best_pval = 1.0
|
| 418 |
+
for ct in valid_cts:
|
| 419 |
+
n_ct = ct_count[ct]
|
| 420 |
+
ct_m = float(ct_mean[ct][module_i][g_idx])
|
| 421 |
+
ct_v = float(ct_std[ct][module_i][g_idx]) ** 2
|
| 422 |
+
g_m = float(global_mean[module_i][g_idx])
|
| 423 |
+
g_v = float(global_std[module_i][g_idx]) ** 2
|
| 424 |
+
se = np.sqrt(ct_v / max(n_ct, 1) + g_v / max(n_cells, 1))
|
| 425 |
+
if se < 1e-12:
|
| 426 |
+
continue
|
| 427 |
+
t_stat = (ct_m - g_m) / se
|
| 428 |
+
num = (ct_v / n_ct + g_v / n_cells) ** 2
|
| 429 |
+
denom = (ct_v / n_ct) ** 2 / max(n_ct - 1, 1) + (g_v / n_cells) ** 2 / max(n_cells - 1, 1)
|
| 430 |
+
df = num / max(denom, 1e-12)
|
| 431 |
+
pval = float(t_dist.sf(abs(t_stat), df) * 2)
|
| 432 |
+
if t_stat > best_t:
|
| 433 |
+
best_t = t_stat
|
| 434 |
+
best_ct = ct
|
| 435 |
+
best_pval = pval
|
| 436 |
+
rows.append({"module_idx": module_i, "gene": gene, "top_celltype": best_ct,
|
| 437 |
+
"t_stat": best_t, "pval": best_pval})
|
| 438 |
+
|
| 439 |
+
df = pd.DataFrame(rows)
|
| 440 |
+
# BH FDR correction
|
| 441 |
+
pvals = df["pval"].values
|
| 442 |
+
n = len(pvals)
|
| 443 |
+
sorted_idx = np.argsort(pvals)
|
| 444 |
+
sorted_pvals = pvals[sorted_idx]
|
| 445 |
+
fdr = np.minimum(1.0, sorted_pvals * n / (np.arange(n) + 1))
|
| 446 |
+
for i in range(n - 2, -1, -1):
|
| 447 |
+
fdr[i] = min(fdr[i], fdr[i + 1])
|
| 448 |
+
fdr_all = np.empty(n)
|
| 449 |
+
fdr_all[sorted_idx] = fdr
|
| 450 |
+
df["fdr"] = fdr_all
|
| 451 |
+
return df
|
| 452 |
+
|
| 453 |
+
|
| 454 |
+
def compute_attention_expression_corr(
|
| 455 |
+
global_mean: Dict[int, np.ndarray],
|
| 456 |
+
expr_mean: Dict[int, np.ndarray],
|
| 457 |
+
module_gene_names: Dict[int, List[str]],
|
| 458 |
+
num_modules: int,
|
| 459 |
+
) -> Dict[str, float]:
|
| 460 |
+
"""Correlation between mean attention and mean expression across genes."""
|
| 461 |
+
all_attn = []
|
| 462 |
+
all_expr = []
|
| 463 |
+
for module_i in range(num_modules):
|
| 464 |
+
all_attn.extend(global_mean[module_i].tolist())
|
| 465 |
+
all_expr.extend(expr_mean[module_i].tolist())
|
| 466 |
+
all_attn = np.array(all_attn)
|
| 467 |
+
all_expr = np.array(all_expr)
|
| 468 |
+
mask = (all_attn > 0) & (all_expr > 0)
|
| 469 |
+
if mask.sum() > 10:
|
| 470 |
+
pearson_r = float(np.corrcoef(all_attn[mask], all_expr[mask])[0, 1])
|
| 471 |
+
sp_r, sp_p = spearmanr(all_attn[mask], all_expr[mask])
|
| 472 |
+
else:
|
| 473 |
+
pearson_r, sp_r, sp_p = 0.0, 0.0, 1.0
|
| 474 |
+
return {"pearson_r": pearson_r, "spearman_r": float(sp_r),
|
| 475 |
+
"spearman_pval": float(sp_p), "n_genes": int(mask.sum())}
|
| 476 |
+
|
| 477 |
+
|
| 478 |
+
def compute_random_baseline(
|
| 479 |
+
global_mean: Dict[int, np.ndarray],
|
| 480 |
+
num_modules: int,
|
| 481 |
+
) -> Dict[str, float]:
|
| 482 |
+
"""Compare learned attention to uniform baseline."""
|
| 483 |
+
kl_divs = []
|
| 484 |
+
learned_entropies = []
|
| 485 |
+
for module_i in range(num_modules):
|
| 486 |
+
attn = global_mean[module_i].astype(np.float64)
|
| 487 |
+
attn = attn / max(attn.sum(), 1e-10)
|
| 488 |
+
g_size = len(attn)
|
| 489 |
+
uniform = np.ones(g_size) / g_size
|
| 490 |
+
mask = attn > 1e-12
|
| 491 |
+
kl = float(np.sum(attn[mask] * np.log(attn[mask] / uniform[mask])))
|
| 492 |
+
kl_divs.append(kl)
|
| 493 |
+
h_learned = -float(np.sum(attn[mask] * np.log(attn[mask])))
|
| 494 |
+
h_max = np.log(g_size)
|
| 495 |
+
learned_entropies.append(h_learned / max(h_max, 1e-10))
|
| 496 |
+
return {
|
| 497 |
+
"mean_kl_divergence": float(np.mean(kl_divs)),
|
| 498 |
+
"std_kl_divergence": float(np.std(kl_divs)),
|
| 499 |
+
"mean_normalized_entropy": float(np.mean(learned_entropies)),
|
| 500 |
+
"entropy_reduction": float(1.0 - np.mean(learned_entropies)),
|
| 501 |
+
"n_modules": num_modules,
|
| 502 |
+
}
|
| 503 |
+
|
| 504 |
+
|
| 505 |
+
def compute_attention_concentration(
|
| 506 |
+
global_mean: Dict[int, np.ndarray],
|
| 507 |
+
ct_mean: Dict[str, Dict[int, np.ndarray]],
|
| 508 |
+
ct_count: Dict[str, int],
|
| 509 |
+
num_modules: int,
|
| 510 |
+
min_cells: int = 30,
|
| 511 |
+
) -> pd.DataFrame:
|
| 512 |
+
"""Gini coefficient per module measuring attention selectivity."""
|
| 513 |
+
def gini(arr):
|
| 514 |
+
arr = np.sort(arr.flatten())
|
| 515 |
+
n = len(arr)
|
| 516 |
+
if n == 0 or arr.sum() == 0:
|
| 517 |
+
return 0.0
|
| 518 |
+
index = np.arange(1, n + 1)
|
| 519 |
+
return float((2 * np.sum(index * arr) - (n + 1) * np.sum(arr)) / (n * np.sum(arr)))
|
| 520 |
+
|
| 521 |
+
valid_cts = [ct for ct, n in ct_count.items() if n >= min_cells]
|
| 522 |
+
rows = []
|
| 523 |
+
for module_i in range(num_modules):
|
| 524 |
+
g_attn = global_mean[module_i]
|
| 525 |
+
g_gini = gini(g_attn)
|
| 526 |
+
ct_ginis = [gini(ct_mean[ct][module_i]) for ct in valid_cts]
|
| 527 |
+
rows.append({
|
| 528 |
+
"module_idx": module_i, "global_gini": g_gini,
|
| 529 |
+
"mean_ct_gini": float(np.mean(ct_ginis)) if ct_ginis else 0.0,
|
| 530 |
+
"module_size": len(g_attn),
|
| 531 |
+
})
|
| 532 |
+
return pd.DataFrame(rows)
|
| 533 |
+
|
| 534 |
+
|
| 535 |
+
def compute_marker_rank_within_celltype(
|
| 536 |
+
ct_mean: Dict[str, Dict[int, np.ndarray]],
|
| 537 |
+
ct_count: Dict[str, int],
|
| 538 |
+
module_gene_names: Dict[int, List[str]],
|
| 539 |
+
num_modules: int,
|
| 540 |
+
marker_dict: Dict[str, List[str]],
|
| 541 |
+
min_cells: int = 30,
|
| 542 |
+
) -> pd.DataFrame:
|
| 543 |
+
"""Rank marker genes within their expected cell type by attention."""
|
| 544 |
+
rows = []
|
| 545 |
+
for ct, markers in marker_dict.items():
|
| 546 |
+
if ct not in ct_count or ct_count[ct] < min_cells:
|
| 547 |
+
continue
|
| 548 |
+
gene_attn = {}
|
| 549 |
+
for module_i in range(num_modules):
|
| 550 |
+
genes = module_gene_names[module_i]
|
| 551 |
+
attn = ct_mean[ct][module_i]
|
| 552 |
+
for g_idx, gene in enumerate(genes):
|
| 553 |
+
if gene not in gene_attn or float(attn[g_idx]) > gene_attn[gene]:
|
| 554 |
+
gene_attn[gene] = float(attn[g_idx])
|
| 555 |
+
sorted_genes = sorted(gene_attn.items(), key=lambda x: -x[1])
|
| 556 |
+
gene_rank = {g: rank + 1 for rank, (g, _) in enumerate(sorted_genes)}
|
| 557 |
+
n_total = len(gene_rank)
|
| 558 |
+
for marker in markers:
|
| 559 |
+
if marker in gene_rank:
|
| 560 |
+
rank = gene_rank[marker]
|
| 561 |
+
rows.append({
|
| 562 |
+
"celltype": ct, "marker_gene": marker, "rank": rank,
|
| 563 |
+
"percentile": rank / n_total * 100, "n_total_genes": n_total,
|
| 564 |
+
"attention": gene_attn[marker],
|
| 565 |
+
})
|
| 566 |
+
return pd.DataFrame(rows)
|
| 567 |
+
|
| 568 |
+
|
| 569 |
+
# ---------------------------------------------------------------------------
|
| 570 |
+
# Marker gene validation
|
| 571 |
+
# ---------------------------------------------------------------------------
|
| 572 |
+
|
| 573 |
+
def validate_markers(
|
| 574 |
+
specificity_df: pd.DataFrame,
|
| 575 |
+
marker_dict: Dict[str, List[str]],
|
| 576 |
+
sort_col: str = "fold_change",
|
| 577 |
+
) -> Dict[str, object]:
|
| 578 |
+
"""Validate whether high-attention genes match known markers."""
|
| 579 |
+
results = {}
|
| 580 |
+
all_genes_in_model = set(specificity_df["gene"].unique())
|
| 581 |
+
for ct, markers in marker_dict.items():
|
| 582 |
+
markers_in_model = [g for g in markers if g in all_genes_in_model]
|
| 583 |
+
if not markers_in_model:
|
| 584 |
+
continue
|
| 585 |
+
ct_specific = specificity_df[specificity_df["top_celltype"] == ct].copy()
|
| 586 |
+
ct_specific = ct_specific.sort_values(sort_col, ascending=False).reset_index(drop=True)
|
| 587 |
+
marker_set = set(markers_in_model)
|
| 588 |
+
n_total = len(ct_specific)
|
| 589 |
+
if n_total == 0:
|
| 590 |
+
continue
|
| 591 |
+
top_50_genes = set(ct_specific.head(50)["gene"])
|
| 592 |
+
top_100_genes = set(ct_specific.head(100)["gene"])
|
| 593 |
+
hits_50 = len(marker_set & top_50_genes)
|
| 594 |
+
hits_100 = len(marker_set & top_100_genes)
|
| 595 |
+
marker_rows = ct_specific[ct_specific["gene"].isin(marker_set)]
|
| 596 |
+
if len(marker_rows) > 0:
|
| 597 |
+
marker_ranks = (marker_rows.index + 1).tolist()
|
| 598 |
+
mean_rank = np.mean(marker_ranks)
|
| 599 |
+
median_rank = np.median(marker_ranks)
|
| 600 |
+
else:
|
| 601 |
+
mean_rank = median_rank = n_total
|
| 602 |
+
results[ct] = {
|
| 603 |
+
"n_markers_in_model": len(markers_in_model),
|
| 604 |
+
"markers_in_model": markers_in_model,
|
| 605 |
+
"n_genes_assigned_to_ct": n_total,
|
| 606 |
+
"hits_in_top50": hits_50, "hits_in_top100": hits_100,
|
| 607 |
+
"precision_at_50": hits_50 / min(50, n_total),
|
| 608 |
+
"precision_at_100": hits_100 / min(100, n_total),
|
| 609 |
+
"marker_mean_rank": float(mean_rank),
|
| 610 |
+
"marker_median_rank": float(median_rank),
|
| 611 |
+
"recall_at_50": hits_50 / len(markers_in_model) if markers_in_model else 0,
|
| 612 |
+
"recall_at_100": hits_100 / len(markers_in_model) if markers_in_model else 0,
|
| 613 |
+
}
|
| 614 |
+
return results
|
| 615 |
+
|
| 616 |
+
|
| 617 |
+
def validate_deg_correlation(
|
| 618 |
+
ct_dfs: Dict[str, pd.DataFrame],
|
| 619 |
+
deg_df: pd.DataFrame,
|
| 620 |
+
) -> Dict[str, Dict[str, float]]:
|
| 621 |
+
"""Compare attention ranking with DEG ranking per cell type."""
|
| 622 |
+
results = {}
|
| 623 |
+
for ct, attn_df in ct_dfs.items():
|
| 624 |
+
ct_deg = deg_df[deg_df["cell_type"] == ct].copy()
|
| 625 |
+
if len(ct_deg) == 0:
|
| 626 |
+
continue
|
| 627 |
+
merged = attn_df.merge(ct_deg[["gene", "log2fc"]], on="gene", how="inner")
|
| 628 |
+
if len(merged) < 10:
|
| 629 |
+
continue
|
| 630 |
+
rho, pval = spearmanr(merged["mean_attention"], merged["log2fc"].abs())
|
| 631 |
+
results[ct] = {"spearman_r": float(rho), "pval": float(pval), "n_genes": len(merged)}
|
| 632 |
+
return results
|
| 633 |
+
|
| 634 |
+
|
| 635 |
+
# ---------------------------------------------------------------------------
|
| 636 |
+
# Publication-quality visualization
|
| 637 |
+
# ---------------------------------------------------------------------------
|
| 638 |
+
|
| 639 |
+
def plot_global_importance_pub(global_df, specificity_zscore_df, top_k, output_dir):
|
| 640 |
+
"""Fig1: Top-K genes with std error bars, colored by top cell type."""
|
| 641 |
+
setup_publication_style()
|
| 642 |
+
gene_stats = global_df.groupby("gene").agg(
|
| 643 |
+
mean_attn=("mean_attention", "mean"), std_attn=("std_attention", "mean"),
|
| 644 |
+
).nlargest(top_k, "mean_attn")
|
| 645 |
+
gene_ct = specificity_zscore_df.drop_duplicates("gene").set_index("gene")["top_celltype"]
|
| 646 |
+
fig, ax = plt.subplots(figsize=(4.5, 5))
|
| 647 |
+
genes = gene_stats.index.tolist()
|
| 648 |
+
y_pos = np.arange(len(genes))
|
| 649 |
+
colors = [PANCREAS_COLORS.get(gene_ct.get(g, ""), "#999999") for g in genes]
|
| 650 |
+
ax.barh(y_pos, gene_stats["mean_attn"], xerr=gene_stats["std_attn"],
|
| 651 |
+
color=colors, edgecolor="none", height=0.7, capsize=2, error_kw={"linewidth": 0.8})
|
| 652 |
+
ax.set_yticks(y_pos)
|
| 653 |
+
ax.set_yticklabels(genes)
|
| 654 |
+
ax.set_xlabel("Mean attention weight")
|
| 655 |
+
ax.set_title("Top genes by global attention")
|
| 656 |
+
ax.invert_yaxis()
|
| 657 |
+
pub_savefig(fig, output_dir / "fig1_global_importance")
|
| 658 |
+
logger.info("Saved fig1_global_importance (PDF+PNG)")
|
| 659 |
+
|
| 660 |
+
|
| 661 |
+
def plot_celltype_heatmap_pub(ct_dfs, ct_count, top_k, output_dir, min_cells=30):
|
| 662 |
+
"""Fig2: Clustered heatmap of gene attention (z-score normalized)."""
|
| 663 |
+
setup_publication_style()
|
| 664 |
+
valid_cts = {ct for ct, n in ct_count.items() if n >= min_cells}
|
| 665 |
+
all_genes_attn = {}
|
| 666 |
+
for ct, df in ct_dfs.items():
|
| 667 |
+
if ct not in valid_cts:
|
| 668 |
+
continue
|
| 669 |
+
all_genes_attn[ct] = df.groupby("gene")["mean_attention"].mean()
|
| 670 |
+
combined = pd.DataFrame(all_genes_attn)
|
| 671 |
+
gene_var = combined.var(axis=1).nlargest(top_k).index
|
| 672 |
+
heatmap_data = combined.loc[gene_var]
|
| 673 |
+
row_mean = heatmap_data.mean(axis=1)
|
| 674 |
+
row_std = heatmap_data.std(axis=1).replace(0, 1)
|
| 675 |
+
heatmap_z = heatmap_data.sub(row_mean, axis=0).div(row_std, axis=0)
|
| 676 |
+
g = sns.clustermap(heatmap_z, cmap="RdBu_r", center=0, figsize=(6, 7),
|
| 677 |
+
xticklabels=True, yticklabels=True,
|
| 678 |
+
dendrogram_ratio=(0.1, 0.15), cbar_kws={"label": "Z-score"},
|
| 679 |
+
linewidths=0.3)
|
| 680 |
+
g.ax_heatmap.set_xlabel("")
|
| 681 |
+
g.ax_heatmap.set_ylabel("")
|
| 682 |
+
g.fig.suptitle(f"Top {top_k} variable genes (z-scored attention)", y=1.02, fontsize=11)
|
| 683 |
+
pub_savefig(g.fig, output_dir / "fig2_celltype_heatmap")
|
| 684 |
+
logger.info("Saved fig2_celltype_heatmap (PDF+PNG)")
|
| 685 |
+
|
| 686 |
+
|
| 687 |
+
def plot_specificity_volcano(welch_df, zscore_df, top_k, output_dir):
|
| 688 |
+
"""Fig3: Volcano plot (z-score vs -log10 FDR)."""
|
| 689 |
+
setup_publication_style()
|
| 690 |
+
merged = zscore_df[["gene", "top_celltype", "z_score"]].drop_duplicates("gene")
|
| 691 |
+
welch_best = welch_df.loc[welch_df.groupby("gene")["t_stat"].idxmax()][["gene", "fdr"]]
|
| 692 |
+
merged = merged.merge(welch_best, on="gene", how="left")
|
| 693 |
+
merged["neg_log10_fdr"] = -np.log10(merged["fdr"].clip(lower=1e-300))
|
| 694 |
+
fig, ax = plt.subplots(figsize=(5.5, 5))
|
| 695 |
+
for ct in merged["top_celltype"].unique():
|
| 696 |
+
subset = merged[merged["top_celltype"] == ct]
|
| 697 |
+
color = PANCREAS_COLORS.get(ct, "#999999")
|
| 698 |
+
ax.scatter(subset["z_score"], subset["neg_log10_fdr"],
|
| 699 |
+
c=color, s=8, alpha=0.5, label=ct, rasterized=True)
|
| 700 |
+
top_genes = merged.nlargest(top_k, "z_score")
|
| 701 |
+
for _, row in top_genes.iterrows():
|
| 702 |
+
ax.annotate(row["gene"], (row["z_score"], row["neg_log10_fdr"]),
|
| 703 |
+
fontsize=6, ha="left", va="bottom")
|
| 704 |
+
ax.axhline(-np.log10(0.05), color="gray", linestyle="--", linewidth=0.6)
|
| 705 |
+
ax.axvline(2, color="gray", linestyle="--", linewidth=0.6)
|
| 706 |
+
ax.set_xlabel("Z-score (cell-type specificity)")
|
| 707 |
+
ax.set_ylabel(r"$-\log_{10}$(FDR)")
|
| 708 |
+
ax.set_title("Gene specificity volcano")
|
| 709 |
+
ax.legend(bbox_to_anchor=(1.02, 1), loc="upper left", fontsize=7, frameon=False, markerscale=2)
|
| 710 |
+
pub_savefig(fig, output_dir / "fig3_specificity_volcano")
|
| 711 |
+
logger.info("Saved fig3_specificity_volcano (PDF+PNG)")
|
| 712 |
+
|
| 713 |
+
|
| 714 |
+
def plot_marker_dotplot(marker_rank_df, output_dir):
|
| 715 |
+
"""Fig4: Dot plot of marker gene rank percentile per cell type."""
|
| 716 |
+
setup_publication_style()
|
| 717 |
+
if marker_rank_df.empty:
|
| 718 |
+
return
|
| 719 |
+
fig, ax = plt.subplots(figsize=(6, 4.5))
|
| 720 |
+
celltypes = marker_rank_df["celltype"].unique()
|
| 721 |
+
ct_positions = {ct: i for i, ct in enumerate(celltypes)}
|
| 722 |
+
for _, row in marker_rank_df.iterrows():
|
| 723 |
+
y = ct_positions[row["celltype"]]
|
| 724 |
+
size = max(10, 200 * (1 - row["percentile"] / 100))
|
| 725 |
+
color = PANCREAS_COLORS.get(row["celltype"], "#999999")
|
| 726 |
+
ax.scatter(row["percentile"], y, s=size, c=color, alpha=0.7,
|
| 727 |
+
edgecolors="white", linewidth=0.3)
|
| 728 |
+
if row["percentile"] < 5:
|
| 729 |
+
ax.annotate(row["marker_gene"], (row["percentile"], y),
|
| 730 |
+
fontsize=6, ha="left", va="center", xytext=(3, 0),
|
| 731 |
+
textcoords="offset points")
|
| 732 |
+
ax.set_yticks(range(len(celltypes)))
|
| 733 |
+
ax.set_yticklabels(celltypes)
|
| 734 |
+
ax.set_xlabel("Rank percentile (lower = higher attention)")
|
| 735 |
+
ax.set_title("Marker gene rank within expected cell type")
|
| 736 |
+
ax.axvline(5, color="gray", linestyle="--", linewidth=0.6, alpha=0.5, label="Top 5%")
|
| 737 |
+
ax.legend(fontsize=7, frameon=False)
|
| 738 |
+
ax.set_xlim(-2, 105)
|
| 739 |
+
pub_savefig(fig, output_dir / "fig4_marker_dotplot")
|
| 740 |
+
logger.info("Saved fig4_marker_dotplot (PDF+PNG)")
|
| 741 |
+
|
| 742 |
+
|
| 743 |
+
def plot_marker_recall_pub(marker_results, ct_count, output_dir, min_cells=30):
|
| 744 |
+
"""Fig5: Marker recall bar chart with random expectation line."""
|
| 745 |
+
setup_publication_style()
|
| 746 |
+
if not marker_results:
|
| 747 |
+
return
|
| 748 |
+
cts = [ct for ct in marker_results.keys() if ct_count.get(ct, 0) >= min_cells]
|
| 749 |
+
if not cts:
|
| 750 |
+
return
|
| 751 |
+
recall_50 = [marker_results[ct]["recall_at_50"] for ct in cts]
|
| 752 |
+
recall_100 = [marker_results[ct]["recall_at_100"] for ct in cts]
|
| 753 |
+
x = np.arange(len(cts))
|
| 754 |
+
width = 0.35
|
| 755 |
+
fig, ax = plt.subplots(figsize=(max(5, len(cts) * 0.7), 4))
|
| 756 |
+
colors_50 = [PANCREAS_COLORS.get(ct, "#999999") for ct in cts]
|
| 757 |
+
ax.bar(x - width / 2, recall_50, width, color=colors_50, alpha=0.9, label="Recall@50")
|
| 758 |
+
ax.bar(x + width / 2, recall_100, width, color=colors_50, alpha=0.5, label="Recall@100")
|
| 759 |
+
# Random expectation
|
| 760 |
+
for i, ct in enumerate(cts):
|
| 761 |
+
n_genes = marker_results[ct]["n_genes_assigned_to_ct"]
|
| 762 |
+
if n_genes > 0:
|
| 763 |
+
random_50 = min(50, n_genes) / n_genes
|
| 764 |
+
ax.hlines(random_50, i - 0.4, i + 0.4, colors="gray", linestyles=":", linewidth=0.8)
|
| 765 |
+
ax.set_xticks(x)
|
| 766 |
+
ax.set_xticklabels(cts, rotation=45, ha="right")
|
| 767 |
+
ax.set_ylabel("Recall")
|
| 768 |
+
ax.set_title("Marker gene recall by cell type")
|
| 769 |
+
ax.legend(fontsize=7, frameon=False)
|
| 770 |
+
pub_savefig(fig, output_dir / "fig5_marker_recall")
|
| 771 |
+
logger.info("Saved fig5_marker_recall (PDF+PNG)")
|
| 772 |
+
|
| 773 |
+
|
| 774 |
+
def plot_attention_vs_expression(global_mean, expr_mean, module_gene_names, num_modules, output_dir):
|
| 775 |
+
"""Fig6: Scatter of mean attention vs mean expression."""
|
| 776 |
+
setup_publication_style()
|
| 777 |
+
all_attn, all_expr = [], []
|
| 778 |
+
for module_i in range(num_modules):
|
| 779 |
+
all_attn.extend(global_mean[module_i].tolist())
|
| 780 |
+
all_expr.extend(expr_mean[module_i].tolist())
|
| 781 |
+
all_attn = np.array(all_attn)
|
| 782 |
+
all_expr = np.array(all_expr)
|
| 783 |
+
mask = (all_attn > 0) & (all_expr > 0)
|
| 784 |
+
fig, ax = plt.subplots(figsize=(4.5, 4.5))
|
| 785 |
+
ax.scatter(all_expr[mask], all_attn[mask], s=3, alpha=0.3, c="#377EB8", rasterized=True)
|
| 786 |
+
r = np.corrcoef(all_expr[mask], all_attn[mask])[0, 1]
|
| 787 |
+
ax.set_xlabel("Mean expression")
|
| 788 |
+
ax.set_ylabel("Mean attention weight")
|
| 789 |
+
ax.set_title("Attention vs expression")
|
| 790 |
+
ax.text(0.05, 0.95, f"Pearson r = {r:.3f}", transform=ax.transAxes,
|
| 791 |
+
fontsize=9, va="top", ha="left")
|
| 792 |
+
pub_savefig(fig, output_dir / "fig6_attn_vs_expression")
|
| 793 |
+
logger.info("Saved fig6_attn_vs_expression (PDF+PNG)")
|
| 794 |
+
|
| 795 |
+
|
| 796 |
+
def plot_concentration_pub(concentration_df, baseline_stats, output_dir):
|
| 797 |
+
"""Fig7: Gini coefficient distribution (learned vs uniform)."""
|
| 798 |
+
setup_publication_style()
|
| 799 |
+
fig, axes = plt.subplots(1, 2, figsize=(8, 3.5))
|
| 800 |
+
# Panel a: Gini histogram
|
| 801 |
+
ax = axes[0]
|
| 802 |
+
ax.hist(concentration_df["global_gini"], bins=30, alpha=0.7, color="#377EB8", label="Global")
|
| 803 |
+
ax.hist(concentration_df["mean_ct_gini"], bins=30, alpha=0.7, color="#E41A1C", label="Per cell-type")
|
| 804 |
+
ax.axvline(0, color="gray", linestyle="--", linewidth=0.6)
|
| 805 |
+
ax.set_xlabel("Gini coefficient")
|
| 806 |
+
ax.set_ylabel("Number of modules")
|
| 807 |
+
ax.set_title("Attention concentration")
|
| 808 |
+
ax.legend(fontsize=7, frameon=False)
|
| 809 |
+
# Panel b: Entropy comparison
|
| 810 |
+
ax = axes[1]
|
| 811 |
+
labels = ["Learned", "Uniform"]
|
| 812 |
+
values = [baseline_stats["mean_normalized_entropy"], 1.0]
|
| 813 |
+
bars = ax.bar(labels, values, color=["#377EB8", "#CCCCCC"], width=0.5)
|
| 814 |
+
ax.set_ylabel("Normalized entropy")
|
| 815 |
+
ax.set_title(f"Entropy reduction: {baseline_stats['entropy_reduction']:.1%}")
|
| 816 |
+
ax.set_ylim(0, 1.1)
|
| 817 |
+
plt.tight_layout()
|
| 818 |
+
pub_savefig(fig, output_dir / "fig7_concentration")
|
| 819 |
+
logger.info("Saved fig7_concentration (PDF+PNG)")
|
| 820 |
+
|
| 821 |
+
|
| 822 |
+
def plot_baseline_kl(baseline_stats, output_dir):
|
| 823 |
+
"""Fig8: KL divergence summary."""
|
| 824 |
+
setup_publication_style()
|
| 825 |
+
fig, ax = plt.subplots(figsize=(3.5, 3.5))
|
| 826 |
+
ax.bar(["KL(learned||uniform)"], [baseline_stats["mean_kl_divergence"]],
|
| 827 |
+
yerr=[baseline_stats["std_kl_divergence"]], color="#377EB8",
|
| 828 |
+
capsize=5, width=0.4)
|
| 829 |
+
ax.set_ylabel("KL divergence (nats)")
|
| 830 |
+
ax.set_title("Deviation from uniform attention")
|
| 831 |
+
pub_savefig(fig, output_dir / "fig8_baseline_kl")
|
| 832 |
+
logger.info("Saved fig8_baseline_kl (PDF+PNG)")
|
| 833 |
+
|
| 834 |
+
|
| 835 |
+
def plot_deg_correlation_pub(deg_results, output_dir):
|
| 836 |
+
"""Fig9: DEG correlation with significance."""
|
| 837 |
+
setup_publication_style()
|
| 838 |
+
if not deg_results:
|
| 839 |
+
return
|
| 840 |
+
cts = sorted(deg_results.keys(), key=lambda c: deg_results[c]["spearman_r"], reverse=True)
|
| 841 |
+
rhos = [deg_results[ct]["spearman_r"] for ct in cts]
|
| 842 |
+
pvals = [deg_results[ct]["pval"] for ct in cts]
|
| 843 |
+
fig, ax = plt.subplots(figsize=(max(5, len(cts) * 0.6), 4))
|
| 844 |
+
colors = ["#377EB8" if r > 0 else "#E41A1C" for r in rhos]
|
| 845 |
+
bars = ax.bar(range(len(cts)), rhos, color=colors, width=0.6)
|
| 846 |
+
for i, (bar, p) in enumerate(zip(bars, pvals)):
|
| 847 |
+
if p < 0.001:
|
| 848 |
+
ax.text(i, bar.get_height() + 0.02, "***", ha="center", fontsize=7)
|
| 849 |
+
elif p < 0.01:
|
| 850 |
+
ax.text(i, bar.get_height() + 0.02, "**", ha="center", fontsize=7)
|
| 851 |
+
elif p < 0.05:
|
| 852 |
+
ax.text(i, bar.get_height() + 0.02, "*", ha="center", fontsize=7)
|
| 853 |
+
ax.set_xticks(range(len(cts)))
|
| 854 |
+
ax.set_xticklabels(cts, rotation=45, ha="right")
|
| 855 |
+
ax.set_ylabel("Spearman r")
|
| 856 |
+
ax.set_title("Attention vs DEG correlation")
|
| 857 |
+
ax.axhline(0, color="gray", linewidth=0.5)
|
| 858 |
+
pub_savefig(fig, output_dir / "fig9_deg_correlation")
|
| 859 |
+
logger.info("Saved fig9_deg_correlation (PDF+PNG)")
|
| 860 |
+
|
| 861 |
+
|
| 862 |
+
# ---------------------------------------------------------------------------
|
| 863 |
+
# Enrichment analysis (optional)
|
| 864 |
+
# ---------------------------------------------------------------------------
|
| 865 |
+
|
| 866 |
+
def run_enrichment_analysis(
|
| 867 |
+
global_df: pd.DataFrame, top_k: int, output_dir: Path, organism: str = "human",
|
| 868 |
+
) -> Optional[pd.DataFrame]:
|
| 869 |
+
"""Run GO enrichment on top-K attention genes using gseapy."""
|
| 870 |
+
try:
|
| 871 |
+
import gseapy as gp
|
| 872 |
+
except ImportError:
|
| 873 |
+
logger.warning("gseapy not installed, skipping enrichment. Install: pip install gseapy")
|
| 874 |
+
return None
|
| 875 |
+
top_genes = global_df.groupby("gene")["mean_attention"].mean().nlargest(top_k * 5).index.tolist()
|
| 876 |
+
logger.info("Running GO enrichment on top %d genes...", len(top_genes))
|
| 877 |
+
try:
|
| 878 |
+
enr = gp.enrichr(
|
| 879 |
+
gene_list=top_genes,
|
| 880 |
+
gene_sets=["GO_Biological_Process_2021", "KEGG_2021_Human"],
|
| 881 |
+
organism=organism,
|
| 882 |
+
outdir=str(output_dir / "enrichment"),
|
| 883 |
+
no_plot=True,
|
| 884 |
+
)
|
| 885 |
+
results_df = enr.results
|
| 886 |
+
results_df.to_csv(output_dir / "enrichment_results.csv", index=False)
|
| 887 |
+
logger.info("Enrichment results saved (%d terms)", len(results_df))
|
| 888 |
+
return results_df
|
| 889 |
+
except Exception as e:
|
| 890 |
+
logger.warning("Enrichment analysis failed: %s", e)
|
| 891 |
+
return None
|
| 892 |
+
|
| 893 |
+
|
| 894 |
+
# ---------------------------------------------------------------------------
|
| 895 |
+
# Main
|
| 896 |
+
# ---------------------------------------------------------------------------
|
| 897 |
+
|
| 898 |
+
def main():
|
| 899 |
+
args = parse_args()
|
| 900 |
+
seed_all(args.seed)
|
| 901 |
+
|
| 902 |
+
_script_dir = Path(__file__).resolve().parent
|
| 903 |
+
for attr in ("performer_checkpoint", "config_pkl", "module_data_dir", "gene_list_path",
|
| 904 |
+
"gene_mapping_path", "gene2vec_data_path", "gene2ens_file", "data_path"):
|
| 905 |
+
val = getattr(args, attr, None)
|
| 906 |
+
if val is not None and not Path(val).is_absolute():
|
| 907 |
+
setattr(args, attr, str((_script_dir / val).resolve()))
|
| 908 |
+
|
| 909 |
+
output_dir = ensure_dir(Path(args.output_dir))
|
| 910 |
+
device = torch.device(args.device if torch.cuda.is_available() else "cpu")
|
| 911 |
+
logger.info("Device: %s", device)
|
| 912 |
+
|
| 913 |
+
# --- Load model ---
|
| 914 |
+
logger.info("Loading backbone from %s", args.performer_checkpoint)
|
| 915 |
+
backbone, module_data, model_config = load_backbone(args, device)
|
| 916 |
+
backbone.eval()
|
| 917 |
+
|
| 918 |
+
# --- Build module gene name mapping ---
|
| 919 |
+
model_genes_file = args.gene_list_path
|
| 920 |
+
with open(model_genes_file) as f:
|
| 921 |
+
model_genes = [line.strip() for line in f if line.strip()]
|
| 922 |
+
|
| 923 |
+
gene2ens_df = pd.read_csv(args.gene2ens_file)
|
| 924 |
+
ens_to_symbol = dict(zip(gene2ens_df["Ensembl gene ID"], gene2ens_df.iloc[:, 0]))
|
| 925 |
+
|
| 926 |
+
module_sets = backbone.module_embed.module_sets
|
| 927 |
+
num_modules = len(module_sets)
|
| 928 |
+
module_sizes = [len(ps) for ps in module_sets]
|
| 929 |
+
|
| 930 |
+
module_gene_names: Dict[int, List[str]] = {}
|
| 931 |
+
for module_i, gene_idx in enumerate(module_sets):
|
| 932 |
+
names = []
|
| 933 |
+
for idx in gene_idx.cpu().numpy():
|
| 934 |
+
ens_id = model_genes[idx]
|
| 935 |
+
symbol = ens_to_symbol.get(ens_id, ens_id)
|
| 936 |
+
names.append(symbol)
|
| 937 |
+
module_gene_names[module_i] = names
|
| 938 |
+
|
| 939 |
+
logger.info("Modules: %d, total genes: %d", num_modules, sum(module_sizes))
|
| 940 |
+
|
| 941 |
+
# --- Load and preprocess data ---
|
| 942 |
+
logger.info("Loading data from %s", args.data_path)
|
| 943 |
+
preprocessor = DataPreprocessor(
|
| 944 |
+
gene2ens_file=args.gene2ens_file,
|
| 945 |
+
gene2ens_column=args.gene2ens_column,
|
| 946 |
+
model_genes_file=model_genes_file,
|
| 947 |
+
)
|
| 948 |
+
preprocessor.load_gene_mapping()
|
| 949 |
+
preprocessor.load_model_genes()
|
| 950 |
+
|
| 951 |
+
gene_name_col = args.gene_name_column if args.gene_name_column != "auto" else None
|
| 952 |
+
adata, reindex_state = preprocessor.preprocess_adata(
|
| 953 |
+
adata_path=args.data_path,
|
| 954 |
+
max_cells=args.max_cells,
|
| 955 |
+
dataset_name=args.dataset_name,
|
| 956 |
+
gene_name_column=gene_name_col,
|
| 957 |
+
)
|
| 958 |
+
cell_labels = adata.obs[args.celltype_column].values.astype(str)
|
| 959 |
+
logger.info("Data: %d cells, %d cell types", len(adata), len(np.unique(cell_labels)))
|
| 960 |
+
|
| 961 |
+
# --- DataLoader ---
|
| 962 |
+
if hasattr(adata.X, "toarray"):
|
| 963 |
+
X = adata.X.toarray().astype(np.float32)
|
| 964 |
+
else:
|
| 965 |
+
X = np.array(adata.X, dtype=np.float32)
|
| 966 |
+
|
| 967 |
+
dataset = MatrixRowDataset(X)
|
| 968 |
+
loader = DataLoader(dataset, batch_size=args.batch_size, shuffle=False,
|
| 969 |
+
num_workers=args.num_workers, pin_memory=True)
|
| 970 |
+
|
| 971 |
+
# --- Extract attention (streaming) ---
|
| 972 |
+
logger.info("Extracting module attention (streaming mode)...")
|
| 973 |
+
(global_mean, global_std, ct_mean, ct_std, ct_count, n_cells,
|
| 974 |
+
expr_mean, attn_expr_prod_sum) = extract_attention_streaming(
|
| 975 |
+
backbone, loader, preprocessor, reindex_state, device,
|
| 976 |
+
cell_labels, num_modules, module_sizes,
|
| 977 |
+
)
|
| 978 |
+
|
| 979 |
+
# --- Build DataFrames ---
|
| 980 |
+
logger.info("Building analysis DataFrames...")
|
| 981 |
+
global_df = build_global_importance_df(global_mean, global_std, module_gene_names, num_modules)
|
| 982 |
+
ct_dfs = build_celltype_dfs(ct_mean, ct_count, module_gene_names, num_modules)
|
| 983 |
+
specificity_df = compute_specificity_from_means(global_mean, ct_mean, module_gene_names, num_modules)
|
| 984 |
+
|
| 985 |
+
# --- New metrics ---
|
| 986 |
+
logger.info("Computing z-score specificity...")
|
| 987 |
+
zscore_df = compute_specificity_zscore(
|
| 988 |
+
global_mean, global_std, ct_mean, ct_count, module_gene_names, num_modules, args.min_cells)
|
| 989 |
+
|
| 990 |
+
logger.info("Computing Cohen's d...")
|
| 991 |
+
cohens_d_df = compute_cohens_d(
|
| 992 |
+
global_mean, ct_mean, ct_std, ct_count, module_gene_names, num_modules, n_cells, args.min_cells)
|
| 993 |
+
|
| 994 |
+
logger.info("Computing Welch's t-test...")
|
| 995 |
+
welch_df = compute_welch_ttest(
|
| 996 |
+
global_mean, global_std, ct_mean, ct_std, ct_count, module_gene_names,
|
| 997 |
+
num_modules, n_cells, args.min_cells)
|
| 998 |
+
|
| 999 |
+
logger.info("Computing attention-expression correlation...")
|
| 1000 |
+
attn_expr_corr = compute_attention_expression_corr(
|
| 1001 |
+
global_mean, expr_mean, module_gene_names, num_modules)
|
| 1002 |
+
|
| 1003 |
+
logger.info("Computing random baseline comparison...")
|
| 1004 |
+
baseline_stats = compute_random_baseline(global_mean, num_modules)
|
| 1005 |
+
|
| 1006 |
+
logger.info("Computing attention concentration...")
|
| 1007 |
+
concentration_df = compute_attention_concentration(
|
| 1008 |
+
global_mean, ct_mean, ct_count, num_modules, args.min_cells)
|
| 1009 |
+
|
| 1010 |
+
# --- Marker validation ---
|
| 1011 |
+
marker_dict = PANCREAS_MARKERS
|
| 1012 |
+
if args.marker_db:
|
| 1013 |
+
with open(args.marker_db) as f:
|
| 1014 |
+
marker_dict = json.load(f)
|
| 1015 |
+
|
| 1016 |
+
logger.info("Computing marker ranks within cell types...")
|
| 1017 |
+
marker_rank_df = compute_marker_rank_within_celltype(
|
| 1018 |
+
ct_mean, ct_count, module_gene_names, num_modules, marker_dict, args.min_cells)
|
| 1019 |
+
|
| 1020 |
+
marker_results_zscore = validate_markers(zscore_df, marker_dict, sort_col="z_score")
|
| 1021 |
+
marker_results_fc = validate_markers(specificity_df, marker_dict, sort_col="fold_change")
|
| 1022 |
+
|
| 1023 |
+
# --- Save CSVs ---
|
| 1024 |
+
logger.info("Saving CSV results...")
|
| 1025 |
+
global_df.to_csv(output_dir / "global_gene_importance.csv", index=False)
|
| 1026 |
+
specificity_df.to_csv(output_dir / "gene_specificity.csv", index=False)
|
| 1027 |
+
zscore_df.to_csv(output_dir / "gene_specificity_zscore.csv", index=False)
|
| 1028 |
+
cohens_d_df.to_csv(output_dir / "gene_specificity_cohens_d.csv", index=False)
|
| 1029 |
+
welch_df.to_csv(output_dir / "gene_specificity_welch.csv", index=False)
|
| 1030 |
+
concentration_df.to_csv(output_dir / "attention_concentration.csv", index=False)
|
| 1031 |
+
if not marker_rank_df.empty:
|
| 1032 |
+
marker_rank_df.to_csv(output_dir / "marker_ranks_within_celltype.csv", index=False)
|
| 1033 |
+
for ct, df in ct_dfs.items():
|
| 1034 |
+
safe_name = ct.replace("/", "_").replace(" ", "_")
|
| 1035 |
+
df.to_csv(output_dir / f"celltype_{safe_name}_attention.csv", index=False)
|
| 1036 |
+
|
| 1037 |
+
with open(output_dir / "marker_validation_zscore.json", "w") as f:
|
| 1038 |
+
json.dump(marker_results_zscore, f, indent=2, ensure_ascii=False)
|
| 1039 |
+
with open(output_dir / "marker_validation.json", "w") as f:
|
| 1040 |
+
json.dump(marker_results_fc, f, indent=2, ensure_ascii=False)
|
| 1041 |
+
|
| 1042 |
+
# --- DEG correlation ---
|
| 1043 |
+
deg_results = None
|
| 1044 |
+
if args.deg_csv:
|
| 1045 |
+
deg_df = pd.read_csv(args.deg_csv)
|
| 1046 |
+
deg_results = validate_deg_correlation(ct_dfs, deg_df)
|
| 1047 |
+
with open(output_dir / "deg_correlation.json", "w") as f:
|
| 1048 |
+
json.dump(deg_results, f, indent=2)
|
| 1049 |
+
|
| 1050 |
+
# --- Publication figures ---
|
| 1051 |
+
logger.info("Generating publication figures...")
|
| 1052 |
+
plot_global_importance_pub(global_df, zscore_df, args.top_k_genes, output_dir)
|
| 1053 |
+
plot_celltype_heatmap_pub(ct_dfs, ct_count, args.top_k_genes, output_dir, args.min_cells)
|
| 1054 |
+
plot_specificity_volcano(welch_df, zscore_df, args.top_k_genes, output_dir)
|
| 1055 |
+
plot_marker_dotplot(marker_rank_df, output_dir)
|
| 1056 |
+
plot_marker_recall_pub(marker_results_zscore, ct_count, output_dir, args.min_cells)
|
| 1057 |
+
plot_attention_vs_expression(global_mean, expr_mean, module_gene_names, num_modules, output_dir)
|
| 1058 |
+
plot_concentration_pub(concentration_df, baseline_stats, output_dir)
|
| 1059 |
+
plot_baseline_kl(baseline_stats, output_dir)
|
| 1060 |
+
if deg_results:
|
| 1061 |
+
plot_deg_correlation_pub(deg_results, output_dir)
|
| 1062 |
+
|
| 1063 |
+
# --- Enrichment ---
|
| 1064 |
+
run_enrichment_analysis(global_df, args.top_k_genes, output_dir)
|
| 1065 |
+
|
| 1066 |
+
# --- Summary ---
|
| 1067 |
+
filtered_cts = [ct for ct, n in ct_count.items() if n < args.min_cells]
|
| 1068 |
+
summary = {
|
| 1069 |
+
"n_cells": n_cells,
|
| 1070 |
+
"n_modules": num_modules,
|
| 1071 |
+
"n_celltypes": len(ct_count),
|
| 1072 |
+
"celltypes": {ct: int(n) for ct, n in ct_count.items()},
|
| 1073 |
+
"min_cells_filter": args.min_cells,
|
| 1074 |
+
"filtered_celltypes": filtered_cts,
|
| 1075 |
+
"top_10_global_genes": global_df.groupby("gene")["mean_attention"].mean()
|
| 1076 |
+
.nlargest(10).to_dict(),
|
| 1077 |
+
"attn_expr_correlation": attn_expr_corr,
|
| 1078 |
+
"baseline_comparison": baseline_stats,
|
| 1079 |
+
"mean_gini_global": float(concentration_df["global_gini"].mean()),
|
| 1080 |
+
"mean_gini_celltype": float(concentration_df["mean_ct_gini"].mean()),
|
| 1081 |
+
}
|
| 1082 |
+
with open(output_dir / "summary.json", "w") as f:
|
| 1083 |
+
json.dump(summary, f, indent=2, ensure_ascii=False)
|
| 1084 |
+
|
| 1085 |
+
logger.info("Done. Results saved to %s", output_dir)
|
| 1086 |
+
|
| 1087 |
+
|
| 1088 |
+
if __name__ == "__main__":
|
| 1089 |
+
main()
|
| 1090 |
+
|
BioMod/interpretability/module_geometry.py
ADDED
|
@@ -0,0 +1,654 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Experiment 1 — Intrinsic geometry of module embeddings.
|
| 2 |
+
|
| 3 |
+
Question
|
| 4 |
+
--------
|
| 5 |
+
Does scLong's encoder organize its module latent space by pathway family
|
| 6 |
+
without any explicit family-level supervision, If yes, this is a structural
|
| 7 |
+
property gene-level baselines (GenePT / scGPT) cannot exhibit because they
|
| 8 |
+
do not have a module tier.
|
| 9 |
+
|
| 10 |
+
Pipeline
|
| 11 |
+
--------
|
| 12 |
+
1. Load pretrained backbone (forward with mask_ratio=0).
|
| 13 |
+
2. Iterate over a population of cells, accumulate the per-module encoder output
|
| 14 |
+
in a streaming average -> [num_modules, base_dim].
|
| 15 |
+
3. Parse each module's family from merged_modules.json keys
|
| 16 |
+
(Hallmarks / Reactome / Hotspot / Processed) and, where applicable, the
|
| 17 |
+
MSigDB hallmark process subcategory (immune / metabolic / signaling / ...).
|
| 18 |
+
4. Quantify family separation:
|
| 19 |
+
* silhouette score (cosine + euclidean)
|
| 20 |
+
* kNN purity at k=10
|
| 21 |
+
* inter-family / intra-family mean-distance ratio
|
| 22 |
+
Compare each metric against a permutation null (shuffle family labels K
|
| 23 |
+
times) and report empirical p-values.
|
| 24 |
+
5. Visualise with UMAP and PCA, coloured by family / hallmark process.
|
| 25 |
+
6. Save embeddings, metadata, metrics.json, and figures.
|
| 26 |
+
|
| 27 |
+
Data modes
|
| 28 |
+
----------
|
| 29 |
+
--data_mode h5ad Generic h5ad. Goes through DataPreprocessor and
|
| 30 |
+
reindex_tensor_to_model_space. Works for
|
| 31 |
+
cell_annotation / zero_shot / any annotated h5ad.
|
| 32 |
+
--data_mode pretrain_npz Per-cell npz directory (cell0.npz, cell1.npz, ...),
|
| 33 |
+
already in the 27874-dim model gene space. Requires
|
| 34 |
+
--cell_num. Skips reindexing.
|
| 35 |
+
--data_mode pretrain_h5ad h5ad whose var.index is already the 27874-dim
|
| 36 |
+
model gene list (selected_genes_27k.txt). Skips
|
| 37 |
+
reindexing for speed.
|
| 38 |
+
|
| 39 |
+
DDP
|
| 40 |
+
---
|
| 41 |
+
Supports multi-GPU via torchrun. When WORLD_SIZE>1, each rank processes a
|
| 42 |
+
shard of cells; embeddings are all_reduced before metrics/plots. Single-GPU
|
| 43 |
+
and CPU work unchanged.
|
| 44 |
+
|
| 45 |
+
CLI
|
| 46 |
+
---
|
| 47 |
+
python -m performer_module.interpretability.module_geometry \\
|
| 48 |
+
--performer_checkpoint <ckpt.pth> \\
|
| 49 |
+
--config_pkl <config.pkl> \\
|
| 50 |
+
--data_path <path.h5ad> \\
|
| 51 |
+
--data_mode h5ad \\
|
| 52 |
+
--output_dir ./interp/geometry/<run> \\
|
| 53 |
+
--max_cells 5000
|
| 54 |
+
"""
|
| 55 |
+
from __future__ import annotations
|
| 56 |
+
|
| 57 |
+
import argparse
|
| 58 |
+
import json
|
| 59 |
+
import logging
|
| 60 |
+
import sys
|
| 61 |
+
from pathlib import Path
|
| 62 |
+
from typing import Dict, List, Optional, Tuple
|
| 63 |
+
|
| 64 |
+
import numpy as np
|
| 65 |
+
import pandas as pd
|
| 66 |
+
import torch
|
| 67 |
+
import torch.distributed as dist
|
| 68 |
+
from torch.utils.data import DataLoader
|
| 69 |
+
from torch.utils.data.distributed import DistributedSampler
|
| 70 |
+
|
| 71 |
+
import matplotlib
|
| 72 |
+
matplotlib.use("Agg")
|
| 73 |
+
import matplotlib.pyplot as plt
|
| 74 |
+
|
| 75 |
+
from sklearn.decomposition import PCA
|
| 76 |
+
from sklearn.metrics import silhouette_score
|
| 77 |
+
from sklearn.metrics.pairwise import cosine_distances, euclidean_distances
|
| 78 |
+
from sklearn.neighbors import NearestNeighbors
|
| 79 |
+
|
| 80 |
+
# Make sibling modules importable when launched as a script
|
| 81 |
+
HERE = Path(__file__).resolve()
|
| 82 |
+
PERFORMER_MODULE = HERE.parents[1]
|
| 83 |
+
REPO_ROOT = HERE.parents[2]
|
| 84 |
+
for p in (str(PERFORMER_MODULE), str(REPO_ROOT)):
|
| 85 |
+
if p not in sys.path:
|
| 86 |
+
sys.path.insert(0, p)
|
| 87 |
+
|
| 88 |
+
from cell_annotation.performer_module_finetune import ( # noqa: E402
|
| 89 |
+
DataPreprocessor,
|
| 90 |
+
get_rank,
|
| 91 |
+
get_world_size,
|
| 92 |
+
init_distributed_mode,
|
| 93 |
+
is_dist_initialized,
|
| 94 |
+
is_main_process,
|
| 95 |
+
load_backbone,
|
| 96 |
+
reduce_sum,
|
| 97 |
+
resolve_path as ca_resolve_path,
|
| 98 |
+
seed_all,
|
| 99 |
+
)
|
| 100 |
+
|
| 101 |
+
from interpretability.utils import ( # noqa: E402
|
| 102 |
+
MatrixRowDataset,
|
| 103 |
+
NpzCellDataset,
|
| 104 |
+
build_module_metadata,
|
| 105 |
+
ensure_dir,
|
| 106 |
+
load_module_names,
|
| 107 |
+
)
|
| 108 |
+
|
| 109 |
+
logger = logging.getLogger(__name__)
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
# ---------- argparse ----------
|
| 113 |
+
|
| 114 |
+
def parse_args() -> argparse.Namespace:
|
| 115 |
+
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
| 116 |
+
|
| 117 |
+
# Pretrained backbone (mirrors cell_annotation.performer_module_finetune)
|
| 118 |
+
parser.add_argument("--performer_checkpoint", type=str, required=True)
|
| 119 |
+
parser.add_argument("--config_pkl", type=str, required=True)
|
| 120 |
+
parser.add_argument("--module_data_dir", type=str, default="../module_data_all")
|
| 121 |
+
parser.add_argument("--module_json", type=str, default=None,
|
| 122 |
+
help="Path to merged_modules.json. Defaults to <module_data_dir>/merged_modules.json.")
|
| 123 |
+
parser.add_argument("--gene_list_path", type=str, default="../../selected_genes_27k.txt")
|
| 124 |
+
parser.add_argument("--gene_mapping_path", type=str, default="../../gene2ens.csv")
|
| 125 |
+
parser.add_argument("--gene2vec_data_path", type=str, default="../../selected_gene2vec_27k.npy")
|
| 126 |
+
parser.add_argument("--gene2ens_file", type=str, default="../../gene2ens.csv")
|
| 127 |
+
parser.add_argument("--gene2ens_column", type=str, default="Ensembl gene ID")
|
| 128 |
+
parser.add_argument("--shuffle_module_genes", action="store_true",
|
| 129 |
+
help="Randomly reshuffle gene-to-module assignment (control / ablation).")
|
| 130 |
+
parser.add_argument("--shuffle_seed", type=int, default=2021)
|
| 131 |
+
|
| 132 |
+
# Data
|
| 133 |
+
parser.add_argument("--data_path", type=str, required=True,
|
| 134 |
+
help="h5ad file path, or npz directory for pretrain_npz mode.")
|
| 135 |
+
parser.add_argument("--data_mode", type=str, default="h5ad",
|
| 136 |
+
choices=["h5ad", "pretrain_h5ad", "pretrain_npz"],
|
| 137 |
+
help="h5ad: generic, goes through DataPreprocessor reindex. "
|
| 138 |
+
"pretrain_h5ad: var.index is already the 27874 model gene list, skip reindex. "
|
| 139 |
+
"pretrain_npz: per-cell npz directory (cell0.npz, ...), skip reindex.")
|
| 140 |
+
parser.add_argument("--cell_num", type=int, default=None,
|
| 141 |
+
help="Total cell count for pretrain_npz sequential naming (cell0.npz..cellN.npz).")
|
| 142 |
+
parser.add_argument("--gene_name_column", type=str, default="auto",
|
| 143 |
+
help="Which adata.var column holds gene symbols. 'auto' / 'index' / column name. "
|
| 144 |
+
"Only used when data_mode=h5ad.")
|
| 145 |
+
parser.add_argument("--dataset_name", type=str, default="interpretability")
|
| 146 |
+
parser.add_argument("--min_genes", type=int, default=0)
|
| 147 |
+
parser.add_argument("--min_cells", type=int, default=0)
|
| 148 |
+
parser.add_argument("--max_cells", type=int, default=None,
|
| 149 |
+
help="Optional subsample for inference speed. None = use all cells.")
|
| 150 |
+
|
| 151 |
+
# Inference
|
| 152 |
+
parser.add_argument("--device", type=str, default="cuda")
|
| 153 |
+
parser.add_argument("--batch_size", type=int, default=8)
|
| 154 |
+
parser.add_argument("--num_workers", type=int, default=0)
|
| 155 |
+
parser.add_argument("--seed", type=int, default=2021)
|
| 156 |
+
|
| 157 |
+
# Geometry / metrics
|
| 158 |
+
parser.add_argument("--knn_k", type=int, default=10,
|
| 159 |
+
help="k for kNN-purity metric.")
|
| 160 |
+
parser.add_argument("--null_permutations", type=int, default=200,
|
| 161 |
+
help="Number of label-permutation samples for null distribution.")
|
| 162 |
+
parser.add_argument("--include_other_in_metrics", action="store_true",
|
| 163 |
+
help="If false (default), exclude modules whose family is 'Other' from metric computation.")
|
| 164 |
+
|
| 165 |
+
# UMAP
|
| 166 |
+
parser.add_argument("--umap_n_neighbors", type=int, default=15)
|
| 167 |
+
parser.add_argument("--umap_min_dist", type=float, default=0.3)
|
| 168 |
+
|
| 169 |
+
# Output
|
| 170 |
+
parser.add_argument("--output_dir", type=str, required=True)
|
| 171 |
+
parser.add_argument("--run_name", type=str, default="")
|
| 172 |
+
|
| 173 |
+
return parser.parse_args()
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
# ---------- dataset construction ----------
|
| 177 |
+
|
| 178 |
+
def build_inference_loader(
|
| 179 |
+
args: argparse.Namespace,
|
| 180 |
+
preprocessor: Optional[DataPreprocessor],
|
| 181 |
+
use_ddp: bool,
|
| 182 |
+
rank: int,
|
| 183 |
+
world_size: int,
|
| 184 |
+
) -> Tuple[DataLoader, Optional[dict], int]:
|
| 185 |
+
"""Build a DataLoader yielding raw [G_input] expression rows and a reindex_state.
|
| 186 |
+
|
| 187 |
+
Returns: (loader, reindex_state_or_None, n_cells_total)
|
| 188 |
+
"""
|
| 189 |
+
if args.data_mode == "h5ad":
|
| 190 |
+
import anndata as ad
|
| 191 |
+
assert preprocessor is not None
|
| 192 |
+
adata, reindex_state = preprocessor.preprocess_adata(
|
| 193 |
+
adata_path=args.data_path,
|
| 194 |
+
min_genes=args.min_genes,
|
| 195 |
+
min_cells=args.min_cells,
|
| 196 |
+
max_cells=args.max_cells,
|
| 197 |
+
dataset_name=args.dataset_name,
|
| 198 |
+
gene_name_column=args.gene_name_column,
|
| 199 |
+
)
|
| 200 |
+
dataset = MatrixRowDataset(adata.X)
|
| 201 |
+
n_cells = adata.n_obs
|
| 202 |
+
elif args.data_mode == "pretrain_h5ad":
|
| 203 |
+
import anndata as ad
|
| 204 |
+
logger.info("Loading pretrain-mode h5ad %s", args.data_path)
|
| 205 |
+
adata = ad.read_h5ad(args.data_path)
|
| 206 |
+
if args.max_cells is not None and adata.n_obs > args.max_cells:
|
| 207 |
+
rng = np.random.default_rng(args.seed)
|
| 208 |
+
chosen = np.sort(rng.choice(adata.n_obs, size=args.max_cells, replace=False))
|
| 209 |
+
adata = adata[chosen].copy()
|
| 210 |
+
dataset = MatrixRowDataset(adata.X)
|
| 211 |
+
n_cells = adata.n_obs
|
| 212 |
+
reindex_state = None
|
| 213 |
+
elif args.data_mode == "pretrain_npz":
|
| 214 |
+
dataset = NpzCellDataset(
|
| 215 |
+
data_dir=args.data_path,
|
| 216 |
+
cell_num=args.cell_num,
|
| 217 |
+
max_cells=args.max_cells,
|
| 218 |
+
seed=args.seed,
|
| 219 |
+
)
|
| 220 |
+
n_cells = len(dataset)
|
| 221 |
+
reindex_state = None
|
| 222 |
+
else:
|
| 223 |
+
raise ValueError(f"Unsupported data_mode: {args.data_mode}")
|
| 224 |
+
|
| 225 |
+
if use_ddp:
|
| 226 |
+
sampler = DistributedSampler(dataset, num_replicas=world_size, rank=rank,
|
| 227 |
+
shuffle=False, drop_last=False)
|
| 228 |
+
else:
|
| 229 |
+
sampler = None
|
| 230 |
+
|
| 231 |
+
loader = DataLoader(
|
| 232 |
+
dataset,
|
| 233 |
+
batch_size=args.batch_size,
|
| 234 |
+
shuffle=False,
|
| 235 |
+
sampler=sampler,
|
| 236 |
+
num_workers=args.num_workers,
|
| 237 |
+
pin_memory=False,
|
| 238 |
+
)
|
| 239 |
+
logger.info("Built inference loader: %d cells total, batch_size=%d, ddp=%s",
|
| 240 |
+
n_cells, args.batch_size, use_ddp)
|
| 241 |
+
return loader, reindex_state, n_cells
|
| 242 |
+
|
| 243 |
+
|
| 244 |
+
# ---------- inference: cell-averaged module embeddings ----------
|
| 245 |
+
|
| 246 |
+
@torch.no_grad()
|
| 247 |
+
def extract_cell_averaged_module_embeddings(
|
| 248 |
+
backbone,
|
| 249 |
+
loader: DataLoader,
|
| 250 |
+
preprocessor: Optional[DataPreprocessor],
|
| 251 |
+
reindex_state: Optional[dict],
|
| 252 |
+
device: torch.device,
|
| 253 |
+
num_modules: int,
|
| 254 |
+
base_dim: int,
|
| 255 |
+
expected_input_genes: int,
|
| 256 |
+
use_ddp: bool,
|
| 257 |
+
) -> Tuple[np.ndarray, int]:
|
| 258 |
+
"""Stream forward over the loader and accumulate the cell-mean module embedding.
|
| 259 |
+
|
| 260 |
+
Under DDP each rank processes its shard; sum_emb and n_cells are all_reduced
|
| 261 |
+
before computing the mean.
|
| 262 |
+
|
| 263 |
+
Returns: (mean_embedding [num_modules, base_dim] float32, n_cells_total)
|
| 264 |
+
"""
|
| 265 |
+
backbone.eval()
|
| 266 |
+
sum_emb = torch.zeros(num_modules, base_dim, device=device, dtype=torch.float64)
|
| 267 |
+
n_cells = 0
|
| 268 |
+
|
| 269 |
+
for batch_idx, x in enumerate(loader):
|
| 270 |
+
x = x.to(device, non_blocking=False)
|
| 271 |
+
if reindex_state is not None:
|
| 272 |
+
x_model = preprocessor.reindex_tensor_to_model_space(x, reindex_state=reindex_state)
|
| 273 |
+
else:
|
| 274 |
+
if x.shape[1] != expected_input_genes:
|
| 275 |
+
raise ValueError(
|
| 276 |
+
f"pretrain mode expects {expected_input_genes} genes per cell, got {x.shape[1]}. "
|
| 277 |
+
f"Use --data_mode h5ad (with reindex) for arbitrary gene spaces."
|
| 278 |
+
)
|
| 279 |
+
x_model = x
|
| 280 |
+
|
| 281 |
+
out = backbone(x_model, mask_ratio=0.0, return_encodings=True)
|
| 282 |
+
enc = out["module_encodings"] # (B, num_modules, D)
|
| 283 |
+
if enc.shape[1] != num_modules or enc.shape[2] != base_dim:
|
| 284 |
+
raise RuntimeError(
|
| 285 |
+
f"Encoder output shape mismatch: got {tuple(enc.shape)}, "
|
| 286 |
+
f"expected (*, {num_modules}, {base_dim}). "
|
| 287 |
+
"Module order between merged_modules.json and the loaded backbone may have drifted."
|
| 288 |
+
)
|
| 289 |
+
sum_emb += enc.to(torch.float64).sum(dim=0)
|
| 290 |
+
n_cells += enc.shape[0]
|
| 291 |
+
if (batch_idx + 1) % 50 == 0:
|
| 292 |
+
logger.info(" forward batch %d (%d cells on this rank so far)", batch_idx + 1, n_cells)
|
| 293 |
+
|
| 294 |
+
if use_ddp:
|
| 295 |
+
dist.all_reduce(sum_emb, op=dist.ReduceOp.SUM)
|
| 296 |
+
n_cells_tensor = torch.tensor(n_cells, device=device, dtype=torch.long)
|
| 297 |
+
dist.all_reduce(n_cells_tensor, op=dist.ReduceOp.SUM)
|
| 298 |
+
n_cells = int(n_cells_tensor.item())
|
| 299 |
+
|
| 300 |
+
mean_emb = (sum_emb / max(n_cells, 1)).cpu().numpy().astype(np.float32)
|
| 301 |
+
return mean_emb, n_cells
|
| 302 |
+
|
| 303 |
+
|
| 304 |
+
# ---------- structure metrics ----------
|
| 305 |
+
|
| 306 |
+
def _label_to_int(labels: List[str]) -> np.ndarray:
|
| 307 |
+
uniq = sorted(set(labels))
|
| 308 |
+
mapping = {lab: i for i, lab in enumerate(uniq)}
|
| 309 |
+
return np.asarray([mapping[lab] for lab in labels], dtype=np.int64)
|
| 310 |
+
|
| 311 |
+
|
| 312 |
+
def _knn_purity(emb: np.ndarray, labels: np.ndarray, k: int, metric: str = "cosine") -> float:
|
| 313 |
+
n = emb.shape[0]
|
| 314 |
+
k_use = min(k, n - 1)
|
| 315 |
+
nn = NearestNeighbors(n_neighbors=k_use + 1, metric=metric)
|
| 316 |
+
nn.fit(emb)
|
| 317 |
+
_, idx = nn.kneighbors(emb)
|
| 318 |
+
# idx[:, 0] == self; drop it
|
| 319 |
+
neighbor_labels = labels[idx[:, 1:]]
|
| 320 |
+
own_labels = labels[:, None]
|
| 321 |
+
purity = (neighbor_labels == own_labels).mean()
|
| 322 |
+
return float(purity)
|
| 323 |
+
|
| 324 |
+
|
| 325 |
+
def _intra_inter_distance_ratio(distance_matrix: np.ndarray, labels: np.ndarray) -> Dict[str, float]:
|
| 326 |
+
"""Mean intra-family vs inter-family pairwise distance and inter/intra ratio."""
|
| 327 |
+
n = distance_matrix.shape[0]
|
| 328 |
+
iu = np.triu_indices(n, k=1)
|
| 329 |
+
pair_d = distance_matrix[iu]
|
| 330 |
+
same = (labels[iu[0]] == labels[iu[1]])
|
| 331 |
+
intra = float(pair_d[same].mean()) if same.any() else float("nan")
|
| 332 |
+
inter = float(pair_d[~same].mean()) if (~same).any() else float("nan")
|
| 333 |
+
ratio = float(inter / intra) if intra > 0 else float("nan")
|
| 334 |
+
return {"intra_mean": intra, "inter_mean": inter, "inter_over_intra": ratio}
|
| 335 |
+
|
| 336 |
+
|
| 337 |
+
def compute_structure_metrics(
|
| 338 |
+
embedding: np.ndarray,
|
| 339 |
+
labels: np.ndarray,
|
| 340 |
+
knn_k: int,
|
| 341 |
+
) -> Dict[str, float]:
|
| 342 |
+
metrics: Dict[str, float] = {}
|
| 343 |
+
n_classes = len(np.unique(labels))
|
| 344 |
+
if n_classes < 2 or len(labels) < 3:
|
| 345 |
+
return {"silhouette_cosine": float("nan"),
|
| 346 |
+
"silhouette_euclidean": float("nan"),
|
| 347 |
+
"knn_purity_cosine": float("nan"),
|
| 348 |
+
"knn_purity_euclidean": float("nan"),
|
| 349 |
+
"inter_over_intra_cosine": float("nan"),
|
| 350 |
+
"inter_over_intra_euclidean": float("nan")}
|
| 351 |
+
|
| 352 |
+
metrics["silhouette_cosine"] = float(silhouette_score(embedding, labels, metric="cosine"))
|
| 353 |
+
metrics["silhouette_euclidean"] = float(silhouette_score(embedding, labels, metric="euclidean"))
|
| 354 |
+
metrics["knn_purity_cosine"] = _knn_purity(embedding, labels, knn_k, metric="cosine")
|
| 355 |
+
metrics["knn_purity_euclidean"] = _knn_purity(embedding, labels, knn_k, metric="euclidean")
|
| 356 |
+
|
| 357 |
+
cos_d = cosine_distances(embedding)
|
| 358 |
+
euc_d = euclidean_distances(embedding)
|
| 359 |
+
cos_ratio = _intra_inter_distance_ratio(cos_d, labels)
|
| 360 |
+
euc_ratio = _intra_inter_distance_ratio(euc_d, labels)
|
| 361 |
+
metrics["intra_mean_cosine"] = cos_ratio["intra_mean"]
|
| 362 |
+
metrics["inter_mean_cosine"] = cos_ratio["inter_mean"]
|
| 363 |
+
metrics["inter_over_intra_cosine"] = cos_ratio["inter_over_intra"]
|
| 364 |
+
metrics["intra_mean_euclidean"] = euc_ratio["intra_mean"]
|
| 365 |
+
metrics["inter_mean_euclidean"] = euc_ratio["inter_mean"]
|
| 366 |
+
metrics["inter_over_intra_euclidean"] = euc_ratio["inter_over_intra"]
|
| 367 |
+
return metrics
|
| 368 |
+
|
| 369 |
+
|
| 370 |
+
def permutation_null(
|
| 371 |
+
embedding: np.ndarray,
|
| 372 |
+
labels: np.ndarray,
|
| 373 |
+
knn_k: int,
|
| 374 |
+
n_permutations: int,
|
| 375 |
+
seed: int,
|
| 376 |
+
) -> Dict[str, Dict[str, float]]:
|
| 377 |
+
"""Shuffle labels K times, recompute metrics, return per-metric (mean, std, p_value).
|
| 378 |
+
|
| 379 |
+
p-value: empirical right-tail Pr(null >= observed) under random labeling.
|
| 380 |
+
"""
|
| 381 |
+
observed = compute_structure_metrics(embedding, labels, knn_k)
|
| 382 |
+
rng = np.random.default_rng(seed)
|
| 383 |
+
null_records: Dict[str, List[float]] = {k: [] for k in observed}
|
| 384 |
+
|
| 385 |
+
for _ in range(n_permutations):
|
| 386 |
+
permuted = rng.permutation(labels)
|
| 387 |
+
m = compute_structure_metrics(embedding, permuted, knn_k)
|
| 388 |
+
for k, v in m.items():
|
| 389 |
+
null_records[k].append(v)
|
| 390 |
+
|
| 391 |
+
summary: Dict[str, Dict[str, float]] = {}
|
| 392 |
+
for k, obs_v in observed.items():
|
| 393 |
+
arr = np.asarray(null_records[k], dtype=float)
|
| 394 |
+
valid = arr[~np.isnan(arr)]
|
| 395 |
+
if valid.size == 0 or np.isnan(obs_v):
|
| 396 |
+
summary[k] = {"observed": obs_v, "null_mean": float("nan"),
|
| 397 |
+
"null_std": float("nan"), "p_right_tail": float("nan")}
|
| 398 |
+
continue
|
| 399 |
+
p = float((1 + np.sum(valid >= obs_v)) / (valid.size + 1))
|
| 400 |
+
summary[k] = {
|
| 401 |
+
"observed": float(obs_v),
|
| 402 |
+
"null_mean": float(valid.mean()),
|
| 403 |
+
"null_std": float(valid.std()),
|
| 404 |
+
"p_right_tail": p,
|
| 405 |
+
}
|
| 406 |
+
return summary
|
| 407 |
+
|
| 408 |
+
|
| 409 |
+
# ---------- visualization ----------
|
| 410 |
+
|
| 411 |
+
def _color_map(labels: List[str]) -> Dict[str, tuple]:
|
| 412 |
+
uniq = sorted(set(labels))
|
| 413 |
+
cmap = plt.get_cmap("tab20", max(len(uniq), 1))
|
| 414 |
+
return {lab: cmap(i) for i, lab in enumerate(uniq)}
|
| 415 |
+
|
| 416 |
+
|
| 417 |
+
def _scatter(coords: np.ndarray, labels: List[str], title: str, out_path: Path) -> None:
|
| 418 |
+
fig, ax = plt.subplots(figsize=(7, 6))
|
| 419 |
+
cmap = _color_map(labels)
|
| 420 |
+
for lab in sorted(set(labels)):
|
| 421 |
+
mask = np.asarray([lab == l for l in labels])
|
| 422 |
+
ax.scatter(coords[mask, 0], coords[mask, 1],
|
| 423 |
+
color=cmap[lab], label=f"{lab} (n={mask.sum()})",
|
| 424 |
+
s=20, alpha=0.85, edgecolor="none")
|
| 425 |
+
ax.set_title(title)
|
| 426 |
+
ax.set_xlabel("dim 1")
|
| 427 |
+
ax.set_ylabel("dim 2")
|
| 428 |
+
ax.legend(loc="best", fontsize=8, frameon=True)
|
| 429 |
+
fig.tight_layout()
|
| 430 |
+
fig.savefig(out_path, dpi=160)
|
| 431 |
+
plt.close(fig)
|
| 432 |
+
|
| 433 |
+
|
| 434 |
+
def visualize(
|
| 435 |
+
embedding: np.ndarray,
|
| 436 |
+
metadata_df: pd.DataFrame,
|
| 437 |
+
output_dir: Path,
|
| 438 |
+
umap_n_neighbors: int,
|
| 439 |
+
umap_min_dist: float,
|
| 440 |
+
) -> None:
|
| 441 |
+
# PCA 2D
|
| 442 |
+
pca = PCA(n_components=2, random_state=0)
|
| 443 |
+
pca_coords = pca.fit_transform(embedding)
|
| 444 |
+
_scatter(pca_coords, metadata_df["family"].tolist(),
|
| 445 |
+
"Module embedding PCA — family", output_dir / "pca_family.png")
|
| 446 |
+
|
| 447 |
+
has_hallmark = metadata_df["hallmark_process"].notna().any()
|
| 448 |
+
if has_hallmark:
|
| 449 |
+
# Project only hallmark modules to a separate PCA for readability
|
| 450 |
+
hm_mask = metadata_df["hallmark_process"].notna().to_numpy()
|
| 451 |
+
if hm_mask.sum() >= 3:
|
| 452 |
+
hm_pca = PCA(n_components=2, random_state=0).fit_transform(embedding[hm_mask])
|
| 453 |
+
_scatter(hm_pca, metadata_df.loc[hm_mask, "hallmark_process"].tolist(),
|
| 454 |
+
"Hallmark modules PCA — process",
|
| 455 |
+
output_dir / "pca_hallmark_process.png")
|
| 456 |
+
|
| 457 |
+
# UMAP (optional)
|
| 458 |
+
try:
|
| 459 |
+
import umap # type: ignore
|
| 460 |
+
reducer = umap.UMAP(
|
| 461 |
+
n_components=2,
|
| 462 |
+
n_neighbors=min(umap_n_neighbors, max(2, embedding.shape[0] - 1)),
|
| 463 |
+
min_dist=umap_min_dist,
|
| 464 |
+
metric="cosine",
|
| 465 |
+
random_state=0,
|
| 466 |
+
)
|
| 467 |
+
umap_coords = reducer.fit_transform(embedding)
|
| 468 |
+
_scatter(umap_coords, metadata_df["family"].tolist(),
|
| 469 |
+
"Module embedding UMAP — family", output_dir / "umap_family.png")
|
| 470 |
+
|
| 471 |
+
if has_hallmark and hm_mask.sum() >= 3:
|
| 472 |
+
hm_reducer = umap.UMAP(
|
| 473 |
+
n_components=2,
|
| 474 |
+
n_neighbors=min(umap_n_neighbors, max(2, hm_mask.sum() - 1)),
|
| 475 |
+
min_dist=umap_min_dist,
|
| 476 |
+
metric="cosine",
|
| 477 |
+
random_state=0,
|
| 478 |
+
)
|
| 479 |
+
hm_umap = hm_reducer.fit_transform(embedding[hm_mask])
|
| 480 |
+
_scatter(hm_umap, metadata_df.loc[hm_mask, "hallmark_process"].tolist(),
|
| 481 |
+
"Hallmark modules UMAP — process",
|
| 482 |
+
output_dir / "umap_hallmark_process.png")
|
| 483 |
+
except ImportError:
|
| 484 |
+
logger.warning("umap-learn not installed; skipping UMAP plots. `pip install umap-learn` to enable.")
|
| 485 |
+
|
| 486 |
+
|
| 487 |
+
# ---------- driver ----------
|
| 488 |
+
|
| 489 |
+
def setup_logging(output_dir: Path) -> None:
|
| 490 |
+
log_path = output_dir / "run.log"
|
| 491 |
+
logging.basicConfig(
|
| 492 |
+
level=logging.INFO,
|
| 493 |
+
format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
|
| 494 |
+
handlers=[logging.StreamHandler(sys.stdout), logging.FileHandler(log_path, mode="w")],
|
| 495 |
+
force=True,
|
| 496 |
+
)
|
| 497 |
+
|
| 498 |
+
|
| 499 |
+
def resolve_module_json(args: argparse.Namespace) -> str:
|
| 500 |
+
"""Resolve relative paths against performer_module/cell_annotation/ to stay
|
| 501 |
+
consistent with how `load_backbone` reads `module_data_dir`.
|
| 502 |
+
"""
|
| 503 |
+
if args.module_json is not None:
|
| 504 |
+
return str(ca_resolve_path(args.module_json))
|
| 505 |
+
return str(ca_resolve_path(args.module_data_dir) / "merged_modules.json")
|
| 506 |
+
|
| 507 |
+
|
| 508 |
+
def main() -> None:
|
| 509 |
+
args = parse_args()
|
| 510 |
+
|
| 511 |
+
# DDP init (auto-detects torchrun env; falls back to single-process)
|
| 512 |
+
device, use_ddp, rank, world_size, local_rank = init_distributed_mode(args.device)
|
| 513 |
+
seed_all(args.seed + rank)
|
| 514 |
+
|
| 515 |
+
if is_main_process():
|
| 516 |
+
output_dir = ensure_dir(args.output_dir)
|
| 517 |
+
setup_logging(output_dir)
|
| 518 |
+
else:
|
| 519 |
+
output_dir = Path(args.output_dir)
|
| 520 |
+
logging.basicConfig(level=logging.WARNING)
|
| 521 |
+
if use_ddp:
|
| 522 |
+
dist.barrier()
|
| 523 |
+
|
| 524 |
+
logger.info("Args: %s", vars(args))
|
| 525 |
+
logger.info("DDP: use_ddp=%s, rank=%d/%d, device=%s", use_ddp, rank, world_size, device)
|
| 526 |
+
|
| 527 |
+
# 1. Load backbone
|
| 528 |
+
backbone, module_data, model_config = load_backbone(args, device)
|
| 529 |
+
backbone.eval()
|
| 530 |
+
base_dim = int(model_config["base_dim"])
|
| 531 |
+
num_modules = len(module_data)
|
| 532 |
+
logger.info("Loaded backbone: num_modules=%d, base_dim=%d", num_modules, base_dim)
|
| 533 |
+
|
| 534 |
+
# 2. Load module names (must align with module_data order)
|
| 535 |
+
module_json_path = resolve_module_json(args)
|
| 536 |
+
module_names = load_module_names(module_json_path)
|
| 537 |
+
if len(module_names) != num_modules:
|
| 538 |
+
raise RuntimeError(
|
| 539 |
+
f"Module name count ({len(module_names)} from {module_json_path}) does not match "
|
| 540 |
+
f"backbone num_modules ({num_modules}). The merged_modules.json used to build the "
|
| 541 |
+
"model and the one given here must be identical, with no modules dropped during "
|
| 542 |
+
"gene-symbol resolution."
|
| 543 |
+
)
|
| 544 |
+
metadata = build_module_metadata(module_names)
|
| 545 |
+
metadata_df = pd.DataFrame(metadata)
|
| 546 |
+
if is_main_process():
|
| 547 |
+
logger.info("Family distribution:\n%s", metadata_df["family"].value_counts().to_string())
|
| 548 |
+
|
| 549 |
+
# 3. Build dataloader
|
| 550 |
+
if args.data_mode == "h5ad":
|
| 551 |
+
preprocessor = DataPreprocessor(
|
| 552 |
+
gene2ens_file=args.gene2ens_file,
|
| 553 |
+
gene2ens_column=args.gene2ens_column,
|
| 554 |
+
model_genes_file=args.gene_list_path,
|
| 555 |
+
)
|
| 556 |
+
preprocessor.load_gene_mapping()
|
| 557 |
+
preprocessor.load_model_genes()
|
| 558 |
+
expected_input_genes = -1
|
| 559 |
+
else:
|
| 560 |
+
preprocessor = None
|
| 561 |
+
expected_input_genes = int(model_config["num_genes"])
|
| 562 |
+
|
| 563 |
+
loader, reindex_state, n_cells = build_inference_loader(
|
| 564 |
+
args, preprocessor, use_ddp, rank, world_size,
|
| 565 |
+
)
|
| 566 |
+
if n_cells == 0:
|
| 567 |
+
raise RuntimeError("No cells survived preprocessing.")
|
| 568 |
+
|
| 569 |
+
# 4. Inference
|
| 570 |
+
logger.info("Extracting cell-averaged module embeddings over %d cells...", n_cells)
|
| 571 |
+
mean_emb, processed = extract_cell_averaged_module_embeddings(
|
| 572 |
+
backbone, loader, preprocessor, reindex_state, device,
|
| 573 |
+
num_modules=num_modules, base_dim=base_dim,
|
| 574 |
+
expected_input_genes=expected_input_genes,
|
| 575 |
+
use_ddp=use_ddp,
|
| 576 |
+
)
|
| 577 |
+
logger.info("Embedding ready: shape=%s, processed_cells=%d", mean_emb.shape, processed)
|
| 578 |
+
|
| 579 |
+
# --- Everything below is rank-0 only ---
|
| 580 |
+
if is_main_process():
|
| 581 |
+
# 5. Save raw outputs
|
| 582 |
+
np.save(output_dir / "module_embeddings.npy", mean_emb)
|
| 583 |
+
metadata_df.to_csv(output_dir / "module_metadata.csv", index=False)
|
| 584 |
+
|
| 585 |
+
# 6. Metrics — family level
|
| 586 |
+
if args.include_other_in_metrics:
|
| 587 |
+
keep_mask = np.ones(len(metadata_df), dtype=bool)
|
| 588 |
+
else:
|
| 589 |
+
keep_mask = (metadata_df["family"] != "Other").to_numpy()
|
| 590 |
+
family_labels = metadata_df.loc[keep_mask, "family"].tolist()
|
| 591 |
+
family_emb = mean_emb[keep_mask]
|
| 592 |
+
|
| 593 |
+
logger.info("Computing structure metrics — family level (n=%d)", keep_mask.sum())
|
| 594 |
+
family_summary = permutation_null(
|
| 595 |
+
family_emb, _label_to_int(family_labels),
|
| 596 |
+
knn_k=args.knn_k,
|
| 597 |
+
n_permutations=args.null_permutations,
|
| 598 |
+
seed=args.seed,
|
| 599 |
+
)
|
| 600 |
+
|
| 601 |
+
# 6b. Metrics — hallmark process level
|
| 602 |
+
hm_mask = metadata_df["hallmark_process"].notna().to_numpy()
|
| 603 |
+
hallmark_summary: Optional[Dict[str, Dict[str, float]]] = None
|
| 604 |
+
if hm_mask.sum() >= 3 and metadata_df.loc[hm_mask, "hallmark_process"].nunique() >= 2:
|
| 605 |
+
logger.info("Computing structure metrics — hallmark process level (n=%d)", hm_mask.sum())
|
| 606 |
+
hallmark_summary = permutation_null(
|
| 607 |
+
mean_emb[hm_mask],
|
| 608 |
+
_label_to_int(metadata_df.loc[hm_mask, "hallmark_process"].tolist()),
|
| 609 |
+
knn_k=args.knn_k,
|
| 610 |
+
n_permutations=args.null_permutations,
|
| 611 |
+
seed=args.seed,
|
| 612 |
+
)
|
| 613 |
+
|
| 614 |
+
metrics_payload = {
|
| 615 |
+
"n_cells_processed": int(processed),
|
| 616 |
+
"num_modules": int(num_modules),
|
| 617 |
+
"base_dim": int(base_dim),
|
| 618 |
+
"knn_k": int(args.knn_k),
|
| 619 |
+
"null_permutations": int(args.null_permutations),
|
| 620 |
+
"family_metrics": family_summary,
|
| 621 |
+
"hallmark_process_metrics": hallmark_summary,
|
| 622 |
+
"family_counts": metadata_df["family"].value_counts().to_dict(),
|
| 623 |
+
"hallmark_process_counts": metadata_df["hallmark_process"].dropna().value_counts().to_dict(),
|
| 624 |
+
"shuffle_module_genes": bool(args.shuffle_module_genes),
|
| 625 |
+
}
|
| 626 |
+
with open(output_dir / "metrics.json", "w", encoding="utf-8") as fh:
|
| 627 |
+
json.dump(metrics_payload, fh, indent=2)
|
| 628 |
+
|
| 629 |
+
logger.info("=== Family-level structure (vs random-shuffled label null) ===")
|
| 630 |
+
for k, v in family_summary.items():
|
| 631 |
+
logger.info(" %-30s observed=%.4f null=%.4f±%.4f p=%.4f",
|
| 632 |
+
k, v["observed"], v["null_mean"], v["null_std"], v["p_right_tail"])
|
| 633 |
+
if hallmark_summary is not None:
|
| 634 |
+
logger.info("=== Hallmark-process-level structure (Hallmarks subset) ===")
|
| 635 |
+
for k, v in hallmark_summary.items():
|
| 636 |
+
logger.info(" %-30s observed=%.4f null=%.4f±%.4f p=%.4f",
|
| 637 |
+
k, v["observed"], v["null_mean"], v["null_std"], v["p_right_tail"])
|
| 638 |
+
|
| 639 |
+
# 7. Visualisation
|
| 640 |
+
logger.info("Plotting UMAP / PCA...")
|
| 641 |
+
visualize(mean_emb, metadata_df, output_dir,
|
| 642 |
+
umap_n_neighbors=args.umap_n_neighbors,
|
| 643 |
+
umap_min_dist=args.umap_min_dist)
|
| 644 |
+
|
| 645 |
+
logger.info("Done. Outputs saved to %s", output_dir)
|
| 646 |
+
|
| 647 |
+
# Teardown
|
| 648 |
+
if use_ddp:
|
| 649 |
+
dist.barrier()
|
| 650 |
+
dist.destroy_process_group()
|
| 651 |
+
|
| 652 |
+
|
| 653 |
+
if __name__ == "__main__":
|
| 654 |
+
main()
|
BioMod/interpretability/module_importance/README.md
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Experiment 3: Module Importance and Enrichment Analysis
|
| 2 |
+
|
| 3 |
+
This experiment tests whether modules that a finetuned model considers
|
| 4 |
+
important are consistent with known cell-type marker genes.
|
| 5 |
+
|
| 6 |
+
## Method
|
| 7 |
+
|
| 8 |
+
1. Load a finetuned cell annotation model: backbone, attention-pooling head, and classifier.
|
| 9 |
+
2. Mask one module at a time on the test set and measure the drop in the correct-class logit (`DeltaLogit`).
|
| 10 |
+
3. Aggregate by cell type to obtain an importance matrix with shape `(num_classes x 660)`.
|
| 11 |
+
4. For the top-k modules of each cell type, run hypergeometric enrichment on their genes.
|
| 12 |
+
|
| 13 |
+
## Masking Modes
|
| 14 |
+
|
| 15 |
+
| Mode | Principle | Speed |
|
| 16 |
+
| --- | --- | --- |
|
| 17 |
+
| `post_encoder` | Zero out the module token after encoder output | Fast, because the encoder runs once |
|
| 18 |
+
| `pre_encoder` | Zero out the genes corresponding to the module before encoder input | Slow, because it requires 660 x cells forward passes |
|
| 19 |
+
|
| 20 |
+
`pre_encoder` is more accurate because it captures interactions between modules,
|
| 21 |
+
but it is much slower. Use `--importance_cells` for subsampling control.
|
| 22 |
+
|
| 23 |
+
## Run
|
| 24 |
+
|
| 25 |
+
```bash
|
| 26 |
+
cd ../data/250010166/scLong/performer_module/interpretability/module_importance
|
| 27 |
+
torchrun --nproc_per_node=4
|
| 28 |
+
python module_importance.py \
|
| 29 |
+
--finetune_checkpoint mae_tost_48M_cross_attn_only_pure_memory/attn_pool_full_n1_lrh0.0001_lrb0.0001_seed2021/best_model.pt \
|
| 30 |
+
--config_pkl ../../mae_tost_48M_cross_attn_only_pure_memory/mae_tost_48M_cross_attn_only_pure_memory.pkl \
|
| 31 |
+
--module_data_dir ../../module_data_all \
|
| 32 |
+
--gene_list_path ../../../selected_genes_27k.txt \
|
| 33 |
+
--gene_mapping_path ../../../gene2ens.csv \
|
| 34 |
+
--gene2vec_data_path ../../../selected_gene2vec_27k.npy \
|
| 35 |
+
--gene2ens_file ../../../gene2ens.csv \
|
| 36 |
+
--data_path ../../../cell_annotation/Zheng68K.h5ad \
|
| 37 |
+
--label_column celltype \
|
| 38 |
+
--mask_mode post_encoder \
|
| 39 |
+
--batch_size 128 \
|
| 40 |
+
--top_k 10 20 \
|
| 41 |
+
--output_dir ../results/importance/zheng68k_post
|
| 42 |
+
```
|
| 43 |
+
|
| 44 |
+
Pre-encoder mode, more accurate but slower:
|
| 45 |
+
|
| 46 |
+
```bash
|
| 47 |
+
python module_importance.py \
|
| 48 |
+
--finetune_checkpoint mae_tost_48M_cross_attn_only_pure_memory/attn_pool_full_n1_lrh0.0001_lrb0.0001_seed2021/best_model.pt \
|
| 49 |
+
--config_pkl ../../mae_tost_48M_cross_attn_only_pure_memory/mae_tost_48M_cross_attn_only_pure_memory.pkl \
|
| 50 |
+
--module_data_dir ../../module_data_all \
|
| 51 |
+
--gene_list_path ../../../selected_genes_27k.txt \
|
| 52 |
+
--gene_mapping_path ../../../gene2ens.csv \
|
| 53 |
+
--gene2vec_data_path ../../../selected_gene2vec_27k.npy \
|
| 54 |
+
--gene2ens_file ../../../gene2ens.csv \
|
| 55 |
+
--data_path ../../../cell_annotation/Zheng68K.h5ad \
|
| 56 |
+
--label_column celltype \
|
| 57 |
+
--mask_mode pre_encoder \
|
| 58 |
+
--importance_cells 500 \
|
| 59 |
+
--batch_size 256 \
|
| 60 |
+
--top_k 10 20 \
|
| 61 |
+
--output_dir ../results/importance/zheng68k_pre
|
| 62 |
+
```
|
| 63 |
+
|
| 64 |
+
Pre-encoder mode on all cells with DDP multi-GPU acceleration:
|
| 65 |
+
|
| 66 |
+
```bash
|
| 67 |
+
torchrun --nproc_per_node=4 module_importance.py \
|
| 68 |
+
--finetune_checkpoint mae_tost_48M_cross_attn_only_pure_memory/attn_pool_full_n1_lrh0.0001_lrb0.0001_seed2021/best_model.pt \
|
| 69 |
+
--config_pkl ../../mae_tost_48M_cross_attn_only_pure_memory/mae_tost_48M_cross_attn_only_pure_memory.pkl \
|
| 70 |
+
--module_data_dir ../../module_data_all \
|
| 71 |
+
--gene_list_path ../../../selected_genes_27k.txt \
|
| 72 |
+
--gene_mapping_path ../../../gene2ens.csv \
|
| 73 |
+
--gene2vec_data_path ../../../selected_gene2vec_27k.npy \
|
| 74 |
+
--gene2ens_file ../../../gene2ens.csv \
|
| 75 |
+
--data_path ../../../cell_annotation/Zheng68K.h5ad \
|
| 76 |
+
--label_column celltype \
|
| 77 |
+
--mask_mode pre_encoder \
|
| 78 |
+
--batch_size 512 \
|
| 79 |
+
--top_k 10 20 \
|
| 80 |
+
--output_dir ../results/importance/zheng68k_pre_full
|
| 81 |
+
```
|
| 82 |
+
|
| 83 |
+
## Arguments
|
| 84 |
+
|
| 85 |
+
| Argument | Default | Description |
|
| 86 |
+
| --- | --- | --- |
|
| 87 |
+
| `--mask_mode` | post_encoder | Masking strategy |
|
| 88 |
+
| `--importance_cells` | 0 | Maximum number of cells for `pre_encoder`; 0 means all cells |
|
| 89 |
+
| `--top_k` | 10 20 | Top-k modules used for enrichment analysis |
|
| 90 |
+
| `--batch_size` | 64 | Inference batch size |
|
| 91 |
+
| `--test_ratio` | 0.2 | Test-set fraction; should match finetuning |
|
| 92 |
+
| `--seed` | 2021 | Random seed; should match finetuning |
|
| 93 |
+
| `--marker_db_dir` | None | Marker database cache directory |
|
| 94 |
+
|
| 95 |
+
## Outputs
|
| 96 |
+
|
| 97 |
+
```text
|
| 98 |
+
output_dir/
|
| 99 |
+
|-- importance_matrix.npy # (11, 660) DeltaLogit matrix
|
| 100 |
+
|-- importance_matrix.csv # Same matrix with labels
|
| 101 |
+
|-- top_modules_per_celltype.json # Top modules and genes for each cell type
|
| 102 |
+
|-- enrichment_results.csv # Hypergeometric enrichment results
|
| 103 |
+
|-- heatmap_importance.png # Heatmap
|
| 104 |
+
|-- top_modules_bar.png # Bar plot of top modules per cell type
|
| 105 |
+
|-- metrics.json # Summary metrics
|
| 106 |
+
`-- marker_cache/ # PanglaoDB cache
|
| 107 |
+
`-- PanglaoDB_markers.tsv
|
| 108 |
+
```
|
| 109 |
+
|
| 110 |
+
## Expected Conclusion
|
| 111 |
+
|
| 112 |
+
If the model learns meaningful module importance:
|
| 113 |
+
|
| 114 |
+
- Top modules for immune cell types should be enriched for immune-related pathways.
|
| 115 |
+
- Enrichment p-values should be significant after Bonferroni correction, below 0.05.
|
| 116 |
+
- Marker genes for the corresponding cell types in PanglaoDB should appear in top modules.
|
| 117 |
+
|
| 118 |
+
---
|
| 119 |
+
|
| 120 |
+
## Extended Experiments
|
| 121 |
+
|
| 122 |
+
### 3-A: Masking Ablation Curves
|
| 123 |
+
|
| 124 |
+
Progressively mask top, bottom, or random modules and measure the accuracy drop.
|
| 125 |
+
|
| 126 |
+
```bash
|
| 127 |
+
python masking_ablation.py \
|
| 128 |
+
--finetune_checkpoint mae_tost_48M_cross_attn_only_pure_memory/attn_pool_full_n1_lrh0.0001_lrb0.0001_seed2021/best_model.pt \
|
| 129 |
+
--config_pkl ../../mae_tost_48M_cross_attn_only_pure_memory/mae_tost_48M_cross_attn_only_pure_memory.pkl \
|
| 130 |
+
--module_data_dir ../../module_data_all \
|
| 131 |
+
--gene_list_path ../../../selected_genes_27k.txt \
|
| 132 |
+
--gene_mapping_path ../../../gene2ens.csv \
|
| 133 |
+
--gene2vec_data_path ../../../selected_gene2vec_27k.npy \
|
| 134 |
+
--gene2ens_file ../../../gene2ens.csv \
|
| 135 |
+
--data_path ../../../cell_annotation/Zheng68K.h5ad \
|
| 136 |
+
--label_column celltype \
|
| 137 |
+
--importance_matrix ../results/importance/zheng68k_pre_13690/importance_matrix.npy \
|
| 138 |
+
--k_values 1 2 5 10 20 50 100 200 \
|
| 139 |
+
--n_random_trials 10 \
|
| 140 |
+
--batch_size 360 \
|
| 141 |
+
--save_deltas \
|
| 142 |
+
--output_dir ../results/importance/ablation/
|
| 143 |
+
```
|
| 144 |
+
|
| 145 |
+
### 3-B: Permutation Tests
|
| 146 |
+
|
| 147 |
+
```bash
|
| 148 |
+
# Mode A: shuffle modules, GPU and slow.
|
| 149 |
+
python random_baseline.py \
|
| 150 |
+
--mode shuffle_modules \
|
| 151 |
+
--finetune_checkpoint mae_tost_48M_cross_attn_only_pure_memory/attn_pool_full_n1_lrh0.0001_lrb0.0001_seed2021/best_model.pt \
|
| 152 |
+
--config_pkl ../../mae_tost_48M_cross_attn_only_pure_memory/mae_tost_48M_cross_attn_only_pure_memory.pkl \
|
| 153 |
+
--module_data_dir ../../module_data_all \
|
| 154 |
+
--gene_list_path ../../../selected_genes_27k.txt \
|
| 155 |
+
--gene_mapping_path ../../../gene2ens.csv \
|
| 156 |
+
--gene2vec_data_path ../../../selected_gene2vec_27k.npy \
|
| 157 |
+
--gene2ens_file ../../../gene2ens.csv \
|
| 158 |
+
--data_path ../../../cell_annotation/Zheng68K.h5ad \
|
| 159 |
+
--label_column celltype \
|
| 160 |
+
--importance_matrix ../results/importance/zheng68k_pre_13690/importance_matrix.npy\
|
| 161 |
+
--n_permutations 10 \
|
| 162 |
+
--output_dir ../results/importance/permutation/
|
| 163 |
+
|
| 164 |
+
# Mode B: shuffle labels, CPU and fast, requires per_cell_deltas from ablation.
|
| 165 |
+
python random_baseline.py \
|
| 166 |
+
--mode shuffle_labels \
|
| 167 |
+
--importance_matrix ../results/importance/zheng68k_pre_13690/importance_matrix.npy \
|
| 168 |
+
--per_cell_deltas ../results/importance/ablation/per_cell_deltas.npy \
|
| 169 |
+
--per_cell_labels ../results/importance/ablation/per_cell_labels.npy \
|
| 170 |
+
--n_permutations 1000 \
|
| 171 |
+
--output_dir ../results/importance/permutation/
|
| 172 |
+
```
|
| 173 |
+
|
| 174 |
+
### 3-C: Marker Gene Verification, CPU Only
|
| 175 |
+
|
| 176 |
+
```bash
|
| 177 |
+
python marker_gene_verification.py \
|
| 178 |
+
--importance_matrix ../results/importance/zheng68k_pre_13690/importance_matrix.npy \
|
| 179 |
+
--importance_csv <importance_matrix.csv> \
|
| 180 |
+
--module_data_dir ../../module_data_all \
|
| 181 |
+
--output_dir ../results/importance/marker_verification/
|
| 182 |
+
```
|
| 183 |
+
|
| 184 |
+
### Publication Figures
|
| 185 |
+
|
| 186 |
+
```bash
|
| 187 |
+
python module_importance_publication.py \
|
| 188 |
+
--results_dir ../results/importance/zheng68k_pre_13690/ \
|
| 189 |
+
--ablation_dir ../results/importance/ablation \
|
| 190 |
+
--permutation_dir ../results/importance/permutation/ \
|
| 191 |
+
--module_data_dir ../../module_data_all \
|
| 192 |
+
--output_dir ../results/importance/publication_figures/
|
| 193 |
+
```
|
| 194 |
+
|
| 195 |
+
### 3-E: Family-Level Analysis, CPU Only
|
| 196 |
+
|
| 197 |
+
Analyze importance distributions, contribution fractions, and enrichment tests by
|
| 198 |
+
module source family: Hallmarks, Reactome, Hotspot, and WGCNA.
|
| 199 |
+
|
| 200 |
+
```bash
|
| 201 |
+
python family_importance_analysis.py \
|
| 202 |
+
--results_dir ../results/importance/zheng68k_pre \
|
| 203 |
+
--output_dir ../results/importance/family_analysis \
|
| 204 |
+
--top_k 10 20 50 \
|
| 205 |
+
--enrichment_k 50
|
| 206 |
+
```
|
| 207 |
+
|
| 208 |
+
Outputs:
|
| 209 |
+
|
| 210 |
+
- `fig1_family_importance_distribution.png/pdf`: family-wise importance distribution for each cell type, violin + box.
|
| 211 |
+
- `fig2_family_contribution_topk.png/pdf`: family fraction among top-K important modules, stacked bar.
|
| 212 |
+
- `fig3_family_enrichment_heatmap.png/pdf`: Fisher exact-test enrichment-ratio heatmap.
|
| 213 |
+
- `fig4_family_mean_importance.png/pdf`: family mean-importance comparison, grouped bar.
|
| 214 |
+
- `kruskal_wallis_family_test.csv`: Kruskal-Wallis test results.
|
| 215 |
+
- `pairwise_mannwhitney_families.csv`: pairwise Mann-Whitney U tests between families.
|
| 216 |
+
- `family_enrichment_fisher.csv`: detailed Fisher exact-test results with Bonferroni correction.
|
| 217 |
+
- `family_contribution_topk.csv`: numerical family contribution data.
|
| 218 |
+
- `family_mean_importance.csv`: family mean-importance matrix.
|
| 219 |
+
|
| 220 |
+
### 3-F: Family-Level Ablation, GPU
|
| 221 |
+
|
| 222 |
+
Ablate whole families by removing or keeping one module family and measuring
|
| 223 |
+
classification-accuracy changes.
|
| 224 |
+
|
| 225 |
+
```bash
|
| 226 |
+
python family_ablation.py \
|
| 227 |
+
--finetune_checkpoint mae_tost_48M_cross_attn_only_pure_memory/attn_pool_full_n1_lrh0.0001_lrb0.0001_seed2021/best_model.pt \
|
| 228 |
+
--config_pkl ../../mae_tost_48M_cross_attn_only_pure_memory/mae_tost_48M_cross_attn_only_pure_memory.pkl \
|
| 229 |
+
--module_data_dir ../../module_data_all \
|
| 230 |
+
--gene_list_path ../../../selected_genes_27k.txt \
|
| 231 |
+
--gene_mapping_path ../../../gene2ens.csv \
|
| 232 |
+
--gene2vec_data_path ../../../selected_gene2vec_27k.npy \
|
| 233 |
+
--gene2ens_file ../../../gene2ens.csv \
|
| 234 |
+
--data_path ../../../cell_annotation/Zheng68K.h5ad \
|
| 235 |
+
--label_column celltype \
|
| 236 |
+
--batch_size 256 \
|
| 237 |
+
--output_dir ../results/importance/family_analysis_gpu
|
| 238 |
+
```
|
| 239 |
+
|
| 240 |
+
Outputs:
|
| 241 |
+
|
| 242 |
+
- `family_ablation_results.json`: overall and per-class accuracy under all conditions.
|
| 243 |
+
- `fig_family_ablation.png/pdf`: two-panel overall plot for remove-one vs keep-one.
|
| 244 |
+
- `fig_family_ablation_per_class.png/pdf`: two-panel heatmap of per-cell-type ablation results.
|
| 245 |
+
|
| 246 |
+
### 3-G: Cross-Class Similarity, CPU Only
|
| 247 |
+
|
| 248 |
+
Analyze correlations and similarity between important modules across the 11 cell
|
| 249 |
+
classes.
|
| 250 |
+
|
| 251 |
+
```bash
|
| 252 |
+
python cross_class_similarity.py \
|
| 253 |
+
--results_dir ../results/importance/zheng68k_pre \
|
| 254 |
+
--output_dir ../results/importance/publication_figures \
|
| 255 |
+
--top_k 50
|
| 256 |
+
```
|
| 257 |
+
|
| 258 |
+
### 3-H: 1B Module Gating + 2B Family Embedding Finetuning Experiment
|
| 259 |
+
|
| 260 |
+
Based on module-importance findings, most modules have near-zero importance and
|
| 261 |
+
different cell types depend on different families. During finetuning, add two
|
| 262 |
+
lightweight modules in a LoRA-style setup: pretrained weights are frozen and new
|
| 263 |
+
parameters are trained from zero initialization.
|
| 264 |
+
|
| 265 |
+
- **1B, Sparse Module Gate**: `LayerNorm -> Linear(E,1) -> Sigmoid`, learning a 0-1 gate scalar for each module.
|
| 266 |
+
- **2B, Family Embedding**: `nn.Parameter(4, E)`, one learnable vector per family, added to `pos_emb`.
|
| 267 |
+
|
| 268 |
+
```bash
|
| 269 |
+
# Baseline, no new modules.
|
| 270 |
+
python performer_module_finetune.py \
|
| 271 |
+
--config_pkl ../../mae_tost_48M_cross_attn_only_pure_memory/mae_tost_48M_cross_attn_only_pure_memory.pkl \
|
| 272 |
+
--performer_checkpoint ../../mae_tost_48M_cross_attn_only_pure_memory/mae_tost_48M_cross_attn_only_pure_memory_epoch6.pth \
|
| 273 |
+
--module_data_dir ../../module_data_all \
|
| 274 |
+
--gene_list_path ../../../selected_genes_27k.txt \
|
| 275 |
+
--gene_mapping_path ../../../gene2ens.csv \
|
| 276 |
+
--gene2vec_data_path ../../../selected_gene2vec_27k.npy \
|
| 277 |
+
--gene2ens_file ../../../gene2ens.csv \
|
| 278 |
+
--data_path ../../../cell_annotation/Zheng68K.h5ad \
|
| 279 |
+
--label_column celltype \
|
| 280 |
+
--head_type attn_pool \
|
| 281 |
+
--finetune_mode last_n --unfreeze_last_n 1 \
|
| 282 |
+
--lr_head 0.0001 --lr_backbone 0.0001 \
|
| 283 |
+
--batch_size 256 --epochs 300 \
|
| 284 |
+
--run_name baseline_zheng68k \
|
| 285 |
+
--output_dir ../results/family_exp
|
| 286 |
+
```
|
| 287 |
+
|
| 288 |
+
The same command can be extended with:
|
| 289 |
+
|
| 290 |
+
- `--use_family_emb` to enable family-type embedding, adding `4 x 200 = 800` parameters.
|
| 291 |
+
- `--use_module_gate` to enable sparse module gating, adding LayerNorm + Linear, 601 parameters.
|
| 292 |
+
- Both flags can be used together.
|
| 293 |
+
|
| 294 |
+
The new module parameters use `lr_head`, a higher learning rate, and are
|
| 295 |
+
independent of frozen backbone parameters.
|
| 296 |
+
|
| 297 |
+
## File Descriptions
|
| 298 |
+
|
| 299 |
+
| File | Description |
|
| 300 |
+
| --- | --- |
|
| 301 |
+
| `module_importance.py` | Core importance computation with DDP support |
|
| 302 |
+
| `masking_ablation.py` | Ablation-curve experiment, GPU |
|
| 303 |
+
| `random_baseline.py` | Permutation tests, GPU/CPU |
|
| 304 |
+
| `marker_gene_verification.py` | Marker verification, CPU |
|
| 305 |
+
| `module_importance_publication.py` | Publication figures |
|
| 306 |
+
| `family_importance_analysis.py` | Family-level importance analysis, CPU |
|
| 307 |
+
| `family_ablation.py` | Family-level ablation, GPU |
|
| 308 |
+
| `cross_class_similarity.py` | Cross-class similarity analysis, CPU |
|
| 309 |
+
| `pub_utils.py` | Shared utilities |
|
| 310 |
+
| `RESULTS.md` | Result documentation with figure placeholders |
|
| 311 |
+
| `TODO.md` | Progress tracker |
|
BioMod/interpretability/module_importance/RESULTS.md
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Module Importance: Result Documentation
|
| 2 |
+
|
| 3 |
+
> For the manuscript subsection "Model Interpretability: Module Importance Analysis".
|
| 4 |
+
|
| 5 |
+
## Dataset
|
| 6 |
+
|
| 7 |
+
- **Zheng68K**: 11 cell types, 68,450 cells, with 13,690 test cells.
|
| 8 |
+
- Model: PerformerModuleCellClassifier with attention-pooling head and 48M backbone.
|
| 9 |
+
- Baseline test accuracy: **83.1%**.
|
| 10 |
+
|
| 11 |
+
## Data Sources
|
| 12 |
+
|
| 13 |
+
All analyses are based on the following core files, generated by `module_importance.py`
|
| 14 |
+
with `pre_encoder` over all 13,690 test cells:
|
| 15 |
+
|
| 16 |
+
| File | Path | Description |
|
| 17 |
+
| --- | --- | --- |
|
| 18 |
+
| `importance_matrix.npy` | `results/importance/zheng68k_pre_13690/importance_matrix.npy` | `(11 x 660)` DeltaLogit matrix |
|
| 19 |
+
| `importance_matrix.csv` | `results/importance/zheng68k_pre_13690/importance_matrix.csv` | Same matrix with row and column labels |
|
| 20 |
+
| `enrichment_results.csv` | `results/importance/zheng68k_pre_13690/enrichment_results.csv` | Enrichment-test results |
|
| 21 |
+
| `top_modules_per_celltype.json` | `results/importance/zheng68k_pre_13690/top_modules_per_celltype.json` | Top-module details |
|
| 22 |
+
| `merged_modules.json` | `module_data_all/merged_modules.json` | Gene composition of 660 modules |
|
| 23 |
+
|
| 24 |
+
Marker verification uses `importance_matrix.npy`, `importance_matrix.csv`, and
|
| 25 |
+
`merged_modules.json`.
|
| 26 |
+
|
| 27 |
+
Publication figures use all files above plus ablation and permutation results.
|
| 28 |
+
|
| 29 |
+
---
|
| 30 |
+
|
| 31 |
+
## Experiment 3 Core: Module Importance Matrix
|
| 32 |
+
|
| 33 |
+
### Method
|
| 34 |
+
|
| 35 |
+
For each test cell, mask one module at a time using `pre_encoder`, which zeros out
|
| 36 |
+
the corresponding gene inputs, and measure the drop in the correct-class logit
|
| 37 |
+
as the importance score. Aggregate by cell type to obtain an `(11 x 660)`
|
| 38 |
+
importance matrix.
|
| 39 |
+
|
| 40 |
+
### Results
|
| 41 |
+
|
| 42 |
+
- Masking mode: **pre_encoder** over all 13,690 cells.
|
| 43 |
+
- 660 modules: 491 Reactome, 50 Hallmarks, 73 Hotspot, and 46 Processed.
|
| 44 |
+
|
| 45 |
+
**Representative top modules per cell type:**
|
| 46 |
+
|
| 47 |
+
| Cell Type | #1 Module | #2 Module | #3 Module |
|
| 48 |
+
| --- | --- | --- | --- |
|
| 49 |
+
| CD14+ Monocyte | Neutrophil Degranulation | TLR4 Cascade | MHC-II Antigen Presentation |
|
| 50 |
+
| CD19+ B | MHC-II | IFN-gamma Signaling | CD28 Co-stimulation |
|
| 51 |
+
| CD56+ NK | SM_22 (cytotoxic) | module3_2 | Allograft Rejection |
|
| 52 |
+
| CD8+ Cytotoxic T | module3_2 | SM_22 | Allograft Rejection |
|
| 53 |
+
| Dendritic | MHC-II | Signaling by Interleukins | IFN-gamma |
|
| 54 |
+
|
| 55 |
+
### Figure
|
| 56 |
+
|
| 57 |
+

|
| 58 |
+
|
| 59 |
+
---
|
| 60 |
+
|
| 61 |
+
## Experiment 3-A: Masking Ablation Curves
|
| 62 |
+
|
| 63 |
+
### Method
|
| 64 |
+
|
| 65 |
+
Progressively mask top-k, bottom-k, or random-k modules, with
|
| 66 |
+
`k = 1, 2, 5, 10, 20, 50, 100, 200`, and measure classification accuracy. This
|
| 67 |
+
uses `post_encoder` masking so the encoder runs only once.
|
| 68 |
+
|
| 69 |
+
### Expected Result
|
| 70 |
+
|
| 71 |
+
- Masking top modules should rapidly decrease accuracy.
|
| 72 |
+
- Masking bottom modules should have little effect.
|
| 73 |
+
- Random masking should fall between the two.
|
| 74 |
+
|
| 75 |
+
### Results, Completed
|
| 76 |
+
|
| 77 |
+
- Baseline accuracy: **83.0%** on 13,690 test cells.
|
| 78 |
+
- Post-encoder masking with k = [1, 2, 5, 10, 20, 50, 100, 200].
|
| 79 |
+
|
| 80 |
+
| Masked k | Top Acc | Bottom Acc | Random Acc |
|
| 81 |
+
| --- | --- | --- | --- |
|
| 82 |
+
| 1 | 83.0% | 83.0% | 83.0% |
|
| 83 |
+
| 2 | 82.6% | 83.0% | 83.0% |
|
| 84 |
+
| 5 | 82.0% | 83.0% | 83.0% |
|
| 85 |
+
| 10 | 77.1% | 83.0% | 83.0% |
|
| 86 |
+
| 20 | 73.7% | 83.1% | 82.7% |
|
| 87 |
+
| 50 | 69.5% | 83.2% | 82.4% |
|
| 88 |
+
| 100 | 65.1% | 82.7% | 81.7% |
|
| 89 |
+
| 200 | 49.7% | 81.9% | 78.6% |
|
| 90 |
+
|
| 91 |
+
**Interpretation:** masking the top 200 modules, 30% of all modules, reduces
|
| 92 |
+
accuracy from 83% to 50%, a 33 percentage-point drop. Masking the bottom 200
|
| 93 |
+
modules has almost no effect, with only a 1.1 point drop. Random masking falls
|
| 94 |
+
between the two. This shows that the importance ranking captures modules that
|
| 95 |
+
are truly important for classification.
|
| 96 |
+
|
| 97 |
+

|
| 98 |
+
|
| 99 |
+

|
| 100 |
+
|
| 101 |
+
---
|
| 102 |
+
|
| 103 |
+
## Experiment 3-B: Permutation Tests, Random Baseline
|
| 104 |
+
|
| 105 |
+
### Method
|
| 106 |
+
|
| 107 |
+
**Mode A, Module-Shuffle:** shuffle module token order to break the module-gene
|
| 108 |
+
correspondence, then recompute importance. Repeat 10 times.
|
| 109 |
+
|
| 110 |
+
**Mode B, Label-Shuffle:** shuffle cell type labels and re-aggregate per-cell
|
| 111 |
+
deltas. Repeat 1000 times.
|
| 112 |
+
|
| 113 |
+
### Expected Result
|
| 114 |
+
|
| 115 |
+
- The true importance maximum should far exceed the null distribution.
|
| 116 |
+
- Empirical p-value should be below 0.001.
|
| 117 |
+
|
| 118 |
+
### Results, Completed
|
| 119 |
+
|
| 120 |
+
**Mode A, Module-Shuffle** (10 permutations, GPU):
|
| 121 |
+
|
| 122 |
+
| Metric | Value |
|
| 123 |
+
| --- | --- |
|
| 124 |
+
| Global p-value | 0.091, limited by n=10; minimum possible is 1/11 |
|
| 125 |
+
| Effect size | **1474.5** |
|
| 126 |
+
| Real global max | 2.046 |
|
| 127 |
+
| Null global max | 0.452 +/- 0.001 |
|
| 128 |
+
|
| 129 |
+
**Mode B, Label-Shuffle** (1000 permutations, CPU):
|
| 130 |
+
|
| 131 |
+
| Metric | Value |
|
| 132 |
+
| --- | --- |
|
| 133 |
+
| Global p-value | **< 0.001** |
|
| 134 |
+
| Per-class p-values | All < 0.001 (11/11) |
|
| 135 |
+
| Effect size | **48.1** |
|
| 136 |
+
| Real global max | 2.046 |
|
| 137 |
+
| Null global max | 0.145 +/- 0.039 |
|
| 138 |
+
|
| 139 |
+
**Interpretation:**
|
| 140 |
+
|
| 141 |
+
- **Module-Shuffle:** after shuffling the module-gene correspondence, the maximum importance drops from 2.046 to about 0.452. The effect size is extremely large, showing that importance depends on the correct module-gene mapping rather than random noise. The p-value is 0.091 because only 10 permutations were run, making the minimum possible p-value about 1/11; in practice, 0 of 10 null runs exceeded the observed value.
|
| 142 |
+
- **Label-Shuffle:** after shuffling cell type labels, the maximum importance drops from 2.046 to about 0.145. All 11 cell types have p < 0.001, showing that importance is cell-type-specific rather than a generic pattern shared by all cells.
|
| 143 |
+
|
| 144 |
+

|
| 145 |
+
|
| 146 |
+
---
|
| 147 |
+
|
| 148 |
+
## Experiment 3-C: Canonical Marker Gene Verification
|
| 149 |
+
|
| 150 |
+
### Method
|
| 151 |
+
|
| 152 |
+
Check the importance ranks of modules containing known cell-type canonical
|
| 153 |
+
markers such as CD14, CD19, CD8A, and NKG7. Test whether markers are enriched in
|
| 154 |
+
top modules.
|
| 155 |
+
|
| 156 |
+
### Results, Completed
|
| 157 |
+
|
| 158 |
+
| Metric | Value |
|
| 159 |
+
| --- | --- |
|
| 160 |
+
| Total markers tested | 59 |
|
| 161 |
+
| Markers not found | 2 |
|
| 162 |
+
| **Median rank percentile** | **0.9%** |
|
| 163 |
+
| Markers in top 5% | **47/59 (80%)** |
|
| 164 |
+
| Markers in top 10% | 47/59 (80%) |
|
| 165 |
+
| Markers in top 20% | 50/59 (85%) |
|
| 166 |
+
| Binomial p-value, top 10% | **< 1e-50** |
|
| 167 |
+
| Wilcoxon p-value, <50% | **2.5e-11** |
|
| 168 |
+
|
| 169 |
+
**Per cell type:**
|
| 170 |
+
|
| 171 |
+
| Cell Type | Median Percentile | Best Marker | Best Rank |
|
| 172 |
+
| --- | --- | --- | --- |
|
| 173 |
+
| CD14+ Monocyte | 0.0% | LYZ | #1 |
|
| 174 |
+
| CD19+ B | 1.3% | MS4A1 | #7 |
|
| 175 |
+
| CD34+ | 18.5% | THY1 | #7 |
|
| 176 |
+
| CD4+ T Helper2 | 1.7% | CD4 | #1 |
|
| 177 |
+
| CD4+/CD25 T Reg | 0.9% | CD4 | #1 |
|
| 178 |
+
| CD4+/CD45RA+/CD25- Naive T | 10.5% | CD4 | #1 |
|
| 179 |
+
| CD4+/CD45RO+ Memory | 0.0% | CD4 | #1 |
|
| 180 |
+
| CD56+ NK | 0.0% | NKG7 | #1 |
|
| 181 |
+
| CD8+ Cytotoxic T | 0.0% | CD8A | #1 |
|
| 182 |
+
| CD8+/CD45RA+ Naive Cytotoxic | 20.3% | CD8A | #1 |
|
| 183 |
+
| Dendritic | 2.1% | ITGAX | #1 |
|
| 184 |
+
|
| 185 |
+
**Interpretation:** 80% of canonical markers are in the top 5% importance
|
| 186 |
+
modules for the corresponding cell type. In 9 of 11 cell types, the best marker
|
| 187 |
+
is in the rank #1 module. This strongly shows that the model learns biologically
|
| 188 |
+
meaningful module importance.
|
| 189 |
+
|
| 190 |
+

|
| 191 |
+
|
| 192 |
+

|
| 193 |
+
|
| 194 |
+
---
|
| 195 |
+
|
| 196 |
+
## Experiment 3-D: PanglaoDB Enrichment Analysis
|
| 197 |
+
|
| 198 |
+
### Method
|
| 199 |
+
|
| 200 |
+
For genes in each cell type's top-k modules, run a hypergeometric enrichment test
|
| 201 |
+
against marker gene sets for 178 PanglaoDB cell types, with Bonferroni
|
| 202 |
+
correction.
|
| 203 |
+
|
| 204 |
+
### Results
|
| 205 |
+
|
| 206 |
+
| Metric | Value |
|
| 207 |
+
| --- | --- |
|
| 208 |
+
| Significant enrichment hits, p_adj < 0.05 | **1011 / 3916** |
|
| 209 |
+
| Hit rate | **25.8%** |
|
| 210 |
+
|
| 211 |
+

|
| 212 |
+
|
| 213 |
+
---
|
| 214 |
+
|
| 215 |
+
## Manuscript Writing Points
|
| 216 |
+
|
| 217 |
+
1. **Core conclusion:** the model learns biologically meaningful gene organization through the module-based architecture. Top-importance modules strongly match known cell-type markers, with 80% of markers in the top 5%, and masking these modules causes a large classification-performance drop, from 83% to 50%.
|
| 218 |
+
2. **Methodological contribution:** pre-encoder masking gives a more accurate importance estimate than post-encoder masking because it accounts for interactions between modules. DDP support makes all-cell computation feasible.
|
| 219 |
+
3. **Statistical rigor:** label-shuffle p < 0.001 with effect size 48.1 shows that importance is cell-type-specific; module-shuffle effect size 1474 shows that importance depends on the correct module-gene correspondence. Top-k masking rapidly reduces accuracy while bottom-k masking has almost no effect, and the gap widens as k increases.
|
| 220 |
+
4. **Biological interpretation:** monocytes use Neutrophil Degranulation and TLR4, representing innate immune pathways; B cells use MHC-II, IFN-gamma, and CD28, representing antigen presentation and T-B cooperation; NK/CD8 T share cytotoxic modules; dendritic cells use MHC-II and interleukins, matching professional antigen-presenting cells.
|
| 221 |
+
5. **Key numbers for the main text:** 83.0% baseline accuracy; 80% canonical markers in top 5% importance modules; 9 of 11 cell types have the best marker in the rank #1 module; masking top-200 modules drops accuracy by 33 percentage points; label-shuffle permutation p < 0.001 with effect size 48.1; PanglaoDB enrichment hit rate 25.8% (1011/3916 significant).
|
BioMod/interpretability/module_importance/TODO.md
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Module Importance: TODO / Progress Tracker
|
| 2 |
+
|
| 3 |
+
## Status Legend
|
| 4 |
+
|
| 5 |
+
- [x] Completed
|
| 6 |
+
- [ ] To do
|
| 7 |
+
- [~] In progress
|
| 8 |
+
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
## Phase 1: CPU-Only Analysis, No GPU Required
|
| 12 |
+
|
| 13 |
+
- [x] `pub_utils.py`: shared utilities for colors, styles, markers, and loading functions.
|
| 14 |
+
- [x] `marker_gene_verification.py`: canonical marker verification.
|
| 15 |
+
- Result: 80% of markers are in the top 5%, median percentile = 0.9%.
|
| 16 |
+
- Figures: `fig_marker_ranks.pdf`, `fig_marker_summary_bar.pdf`.
|
| 17 |
+
- [x] `module_importance_publication.py`: publication-figure generator.
|
| 18 |
+
- [x] `RESULTS.md`: result documentation.
|
| 19 |
+
- [x] `TODO.md`: this file.
|
| 20 |
+
|
| 21 |
+
---
|
| 22 |
+
|
| 23 |
+
## Phase 2: GPU Experiments, Run on Server
|
| 24 |
+
|
| 25 |
+
- [x] `masking_ablation.py`: masking ablation curves.
|
| 26 |
+
- Result: top-200 masking reduces accuracy from 83% to 50%, while bottom-200 has almost no effect.
|
| 27 |
+
- Outputs: `ablation_curves.npz`, `ablation_metrics.json`, `per_cell_deltas.npy`, `attention_weights.npy`.
|
| 28 |
+
- [x] `random_baseline.py` Mode A: module-shuffle permutation, GPU.
|
| 29 |
+
- Result: effect size = 1474, null max = 0.452 vs real max = 2.046.
|
| 30 |
+
- Output: `null_module_shuffle.npy`.
|
| 31 |
+
- [x] `random_baseline.py` Mode B: label-shuffle permutation, CPU.
|
| 32 |
+
- Result: p < 0.001 for all 11 classes, effect size = 48.1.
|
| 33 |
+
- Output: `null_label_shuffle.npy`.
|
| 34 |
+
|
| 35 |
+
---
|
| 36 |
+
|
| 37 |
+
## Phase 3: Publication Figures
|
| 38 |
+
|
| 39 |
+
- [x] `fig1_ablation_curves`: ablation curves for top, bottom, and random modules.
|
| 40 |
+
- [x] `fig2_permutation_null`: permutation-test null distribution.
|
| 41 |
+
- [x] `fig4_importance_heatmap_clustered`: clustered heatmap.
|
| 42 |
+
- [x] `fig5_enrichment_summary`: PanglaoDB enrichment summary.
|
| 43 |
+
- [x] `fig_s1_ablation_per_class`: ablation curves per cell type.
|
| 44 |
+
- [x] `fig_marker_ranks`: marker gene rank percentile.
|
| 45 |
+
- [x] `fig_marker_summary_bar`: marker summary bar plot.
|
| 46 |
+
|
| 47 |
+
---
|
| 48 |
+
|
| 49 |
+
## Phase 4: Extensions, Optional
|
| 50 |
+
|
| 51 |
+
- [ ] Cross-dataset validation, requiring finetuned checkpoints on other datasets.
|
| 52 |
+
- [ ] Correlation analysis between attention weights and masking importance.
|
| 53 |
+
- [ ] Module specificity analysis, using entropy across cell types.
|
| 54 |
+
- [ ] Secondary GO/KEGG enrichment analysis.
|
| 55 |
+
|
| 56 |
+
---
|
| 57 |
+
|
| 58 |
+
## Completion Summary
|
| 59 |
+
|
| 60 |
+
The core experiments are complete and the conclusion is clear:
|
| 61 |
+
|
| 62 |
+
1. **Ablation** shows that top modules are necessary for classification, because accuracy drops strongly after masking them.
|
| 63 |
+
2. **Permutation** shows that importance is non-random under module-shuffle and cell-type-specific under label-shuffle.
|
| 64 |
+
3. **Marker verification** shows that the model learns biologically meaningful module importance, with 80% of markers in the top 5%.
|
| 65 |
+
4. **Enrichment** shows that top modules are enriched for marker genes of the corresponding cell type, with a 25.8% hit rate.
|
| 66 |
+
|
| 67 |
+
These results are sufficient to support a complete Model Interpretability
|
| 68 |
+
subsection in the manuscript.
|
BioMod/interpretability/module_importance/__init__.py
ADDED
|
File without changes
|