beiken1201 commited on
Commit
5e449c6
·
verified ·
1 Parent(s): 01f14a9

Upload BioMod with datasets and the 0810 checkpoint

Browse files

Publish 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
Files changed (50) hide show
  1. .gitattributes +24 -0
  2. .gitignore +42 -0
  3. BioMod/.env.example +13 -0
  4. BioMod/.gitattributes +24 -0
  5. BioMod/.gitignore +56 -0
  6. BioMod/README.md +428 -0
  7. BioMod/cell_annotation/cmd_finetune.md +15 -0
  8. BioMod/cell_annotation/module_data/load_module.py +33 -0
  9. BioMod/cell_annotation/module_data/module1_hallmarks.json +0 -0
  10. BioMod/cell_annotation/module_data/module2_reactome_2022_filtered.json +0 -0
  11. BioMod/cell_annotation/performer_module_finetune.py +1591 -0
  12. BioMod/command.txt +31 -0
  13. BioMod/drug_combination/README.md +130 -0
  14. BioMod/drug_combination/train_drug_combination_performer_module.py +895 -0
  15. BioMod/drug_response/cmd.txt +10 -0
  16. BioMod/drug_response/train_drug_performer_module.py +1065 -0
  17. BioMod/environment.yml +68 -0
  18. BioMod/eval_new_data/cmd.txtx +19 -0
  19. BioMod/eval_new_data/evaluate_new_data.py +375 -0
  20. BioMod/gene_inverse_norman/README.md +217 -0
  21. BioMod/gene_inverse_norman/build_norman_gene_inverse_benchmark.py +300 -0
  22. BioMod/gene_inverse_norman/common.py +660 -0
  23. BioMod/gene_inverse_norman/evaluate_gene_inverse.py +405 -0
  24. BioMod/gene_inverse_norman/finetune_gene_inverse.py +837 -0
  25. BioMod/gene_inverse_norman/protocols/cellfm/genes.txt +20 -0
  26. BioMod/gene_inverse_norman/protocols/cellfm/split.json +59 -0
  27. BioMod/gene_inverse_norman/protocols/scgpt/genes.txt +20 -0
  28. BioMod/gene_inverse_norman/protocols/scgpt/split.json +61 -0
  29. BioMod/gene_inverse_norman/utils.py +166 -0
  30. BioMod/gene_perturbation/README.md +265 -0
  31. BioMod/gene_perturbation/data_utils_local.py +501 -0
  32. BioMod/gene_perturbation/gears_reduce_local.py +761 -0
  33. BioMod/gene_perturbation/inference_local.py +1034 -0
  34. BioMod/gene_perturbation/model_module.py +760 -0
  35. BioMod/gene_perturbation/pertdata_local.py +524 -0
  36. BioMod/gene_perturbation/scfm_utils_local.py +777 -0
  37. BioMod/gene_perturbation/train_module.py +325 -0
  38. BioMod/gene_perturbation/utils.py +489 -0
  39. BioMod/interpretability/README.md +412 -0
  40. BioMod/interpretability/__init__.py +8 -0
  41. BioMod/interpretability/attention/README.md +108 -0
  42. BioMod/interpretability/attention/README_gene_attention.md +114 -0
  43. BioMod/interpretability/attention/__init__.py +0 -0
  44. BioMod/interpretability/attention/module_crosstalk.py +605 -0
  45. BioMod/interpretability/attention/module_gene_attention.py +1090 -0
  46. BioMod/interpretability/module_geometry.py +654 -0
  47. BioMod/interpretability/module_importance/README.md +311 -0
  48. BioMod/interpretability/module_importance/RESULTS.md +221 -0
  49. BioMod/interpretability/module_importance/TODO.md +68 -0
  50. 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
+ ![Importance Heatmap (clustered)](publication_figures/fig4_importance_heatmap_clustered.png)
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
+ ![Ablation Curves](publication_figures/fig1_ablation_curves.png)
98
+
99
+ ![Per-class Ablation](publication_figures/fig_s1_ablation_per_class.png)
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
+ ![Permutation Null](publication_figures/fig2_permutation_null.png)
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
+ ![Marker Ranks](marker_verification/fig_marker_ranks.png)
191
+
192
+ ![Marker Summary Bar](marker_verification/fig_marker_summary_bar.png)
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
+ ![Enrichment Summary](publication_figures/fig5_enrichment_summary.png)
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