beiken1201 commited on
Commit
49ee329
Β·
verified Β·
1 Parent(s): eaa8608

Remove interpretability directory and update README guides

Browse files

Remove the 31 files under BioMod/interpretability as requested. Update the two README introductions, workflow references, checkpoint labels and directory trees. No external code or notebook imports depend on the removed directory. Model weights, paired configuration, raw data and all other repository files are unchanged.

Files changed (33) hide show
  1. BioMod/README.md +1 -36
  2. BioMod/interpretability/README.md +0 -361
  3. BioMod/interpretability/__init__.py +0 -8
  4. BioMod/interpretability/attention/README.md +0 -111
  5. BioMod/interpretability/attention/README_gene_attention.md +0 -112
  6. BioMod/interpretability/attention/__init__.py +0 -0
  7. BioMod/interpretability/attention/biomod_crosstalk.py +0 -606
  8. BioMod/interpretability/attention/biomod_gene_attention.py +0 -1093
  9. BioMod/interpretability/biomod_geometry.py +0 -655
  10. BioMod/interpretability/biomod_importance/README.md +0 -319
  11. BioMod/interpretability/biomod_importance/RESULTS.md +0 -226
  12. BioMod/interpretability/biomod_importance/TODO.md +0 -68
  13. BioMod/interpretability/biomod_importance/__init__.py +0 -0
  14. BioMod/interpretability/biomod_importance/biomod_importance.py +0 -851
  15. BioMod/interpretability/biomod_importance/biomod_importance_publication.py +0 -382
  16. BioMod/interpretability/biomod_importance/cross_class_similarity.py +0 -247
  17. BioMod/interpretability/biomod_importance/family_ablation.py +0 -370
  18. BioMod/interpretability/biomod_importance/family_importance_analysis.py +0 -417
  19. BioMod/interpretability/biomod_importance/marker_gene_verification.py +0 -321
  20. BioMod/interpretability/biomod_importance/masking_ablation.py +0 -375
  21. BioMod/interpretability/biomod_importance/pub_utils.py +0 -206
  22. BioMod/interpretability/biomod_importance/random_baseline.py +0 -333
  23. BioMod/interpretability/biomod_importance/tcell_reactome_analysis.py +0 -466
  24. BioMod/interpretability/biomod_text_alignment/DESIGN.md +0 -235
  25. BioMod/interpretability/biomod_text_alignment/MANUSCRIPT_SECTION.md +0 -208
  26. BioMod/interpretability/biomod_text_alignment/__init__.py +0 -0
  27. BioMod/interpretability/biomod_text_alignment/biomod_text_alignment.py +0 -665
  28. BioMod/interpretability/biomod_text_alignment/biomod_text_alignment_extended.py +0 -537
  29. BioMod/interpretability/biomod_text_alignment/biomod_text_alignment_publication.py +0 -1277
  30. BioMod/interpretability/biomod_text_alignment/fetch_reactome_descriptions.py +0 -97
  31. BioMod/interpretability/biomod_text_alignment/reactome_descriptions.json +0 -0
  32. BioMod/interpretability/utils.py +0 -200
  33. README.md +5 -42
BioMod/README.md CHANGED
@@ -27,7 +27,7 @@ This is the `BioMod/` source directory inside the project root. See the
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
  β”‚ β”œβ”€β”€ biomod_data/ # Hallmark and Reactome modules with loading utilities
33
  β”‚ β”‚ β”œβ”€β”€ load_biomod.py
@@ -51,41 +51,6 @@ BioMod/ # Task code: training,
51
  β”‚ β”œβ”€β”€ scfm_utils_local.py
52
  β”‚ β”œβ”€β”€ train_biomod.py
53
  β”‚ └── utils.py
54
- β”œβ”€β”€ interpretability/ # Module representations and biological interpretability
55
- β”‚ β”œβ”€β”€ attention/ # Module crosstalk and within-module gene attention
56
- β”‚ β”‚ β”œβ”€β”€ __init__.py
57
- β”‚ β”‚ β”œβ”€β”€ biomod_crosstalk.py
58
- β”‚ β”‚ β”œβ”€β”€ biomod_gene_attention.py
59
- β”‚ β”‚ β”œβ”€β”€ README.md
60
- β”‚ β”‚ └── README_gene_attention.md
61
- β”‚ β”œβ”€β”€ biomod_importance/ # Module importance, ablation studies and marker-gene validation
62
- β”‚ β”‚ β”œβ”€β”€ __init__.py
63
- β”‚ β”‚ β”œβ”€β”€ cross_class_similarity.py
64
- β”‚ β”‚ β”œβ”€β”€ family_ablation.py
65
- β”‚ β”‚ β”œβ”€β”€ family_importance_analysis.py
66
- β”‚ β”‚ β”œβ”€β”€ marker_gene_verification.py
67
- β”‚ β”‚ β”œβ”€β”€ masking_ablation.py
68
- β”‚ β”‚ β”œβ”€β”€ biomod_importance.py
69
- β”‚ β”‚ β”œβ”€β”€ biomod_importance_publication.py
70
- β”‚ β”‚ β”œβ”€β”€ pub_utils.py
71
- β”‚ β”‚ β”œβ”€β”€ random_baseline.py
72
- β”‚ β”‚ β”œβ”€β”€ README.md
73
- β”‚ β”‚ β”œβ”€β”€ RESULTS.md
74
- β”‚ β”‚ β”œβ”€β”€ tcell_reactome_analysis.py
75
- β”‚ β”‚ └── TODO.md
76
- β”‚ β”œβ”€β”€ biomod_text_alignment/ # Semantic alignment of module representations and pathway text
77
- β”‚ β”‚ β”œβ”€β”€ __init__.py
78
- β”‚ β”‚ β”œβ”€β”€ DESIGN.md
79
- β”‚ β”‚ β”œβ”€β”€ fetch_reactome_descriptions.py
80
- β”‚ β”‚ β”œβ”€β”€ MANUSCRIPT_SECTION.md
81
- β”‚ β”‚ β”œβ”€β”€ biomod_text_alignment.py
82
- β”‚ β”‚ β”œβ”€β”€ biomod_text_alignment_extended.py
83
- β”‚ β”‚ β”œβ”€β”€ biomod_text_alignment_publication.py
84
- β”‚ β”‚ └── reactome_descriptions.json
85
- β”‚ β”œβ”€β”€ __init__.py
86
- β”‚ β”œβ”€β”€ biomod_geometry.py
87
- β”‚ β”œβ”€β”€ README.md
88
- β”‚ └── utils.py
89
  β”œβ”€β”€ tests/ # CPU tests for shared training utilities
90
  β”‚ └── test_training_utils.py
91
  β”œβ”€β”€ toturials/ # Jupyter tutorials for five downstream tasks
 
27
  from these repository trees.
28
 
29
  ```text
30
+ BioMod/ # Task code: training, evaluation and tutorials
31
  β”œβ”€β”€ cell_annotation/ # Cell-type annotation and model fine-tuning
32
  β”‚ β”œβ”€β”€ biomod_data/ # Hallmark and Reactome modules with loading utilities
33
  β”‚ β”‚ β”œβ”€β”€ load_biomod.py
 
51
  β”‚ β”œβ”€β”€ scfm_utils_local.py
52
  β”‚ β”œβ”€β”€ train_biomod.py
53
  β”‚ └── utils.py
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
  β”œβ”€β”€ tests/ # CPU tests for shared training utilities
55
  β”‚ └── test_training_utils.py
56
  β”œβ”€β”€ toturials/ # Jupyter tutorials for five downstream tasks
BioMod/interpretability/README.md DELETED
@@ -1,361 +0,0 @@
1
- # Module Interpretability Experiments for BioMod
2
-
3
- This directory contains analyses of BioMod module representations, covering
4
- latent geometry, pathway-text alignment, module importance, and attention.
5
- These workflows examine structural, semantic, and functional properties of the
6
- learned modules. Use the
7
- [BioMod model weights and matching configuration](../../README.md#biomod-model-weights)
8
- when loading the pretrained backbone.
9
-
10
- ## Experiment Roadmap, Ordered by Priority
11
-
12
- | # | Script | Core question | Status |
13
- | --- | --- | --- | --- |
14
- | 1 | `biomod_geometry.py` | Does the module latent space self-organize by pathway family, | Completed |
15
- | 2 | `biomod_text_alignment/biomod_text_alignment.py` | Do module embeddings align with natural-language pathway descriptions, | Implemented |
16
- | 3 | `biomod_importance/biomod_importance.py` | Does task-driven module importance match known pathway-cell-type or pathway-disease associations, | Implemented |
17
- | 4 | `module_vs_gene.py` | Is BioMod better than GenePT/scGPT for pathway-level interpretability, | TODO |
18
- | 6 | `attention/biomod_crosstalk.py` | Do module interactions match Reactome/KEGG pathway relationships, | Implemented |
19
-
20
- Experiments 1 and 2 are the core differentiators because gene-level baselines
21
- cannot structurally reproduce them. Experiment 3 provides task-level functional
22
- validation. Experiment 4 provides direct comparison to baselines. Additional
23
- experiments, such as module crosstalk vs Reactome, module dynamics along
24
- developmental trajectories, and retraining-based module-granularity ablation, can
25
- be pursued after the core experiments are complete.
26
-
27
- ## Experiment 1: Intrinsic Geometry of Module Embeddings (`biomod_geometry.py`)
28
-
29
- **Hypothesis:** the BioMod encoder is trained with a masked-module
30
- reconstruction task, but it should still encode semantically related modules,
31
- such as modules from the same pathway family or MSigDB hallmark biological
32
- process, into nearby regions of latent space, even though family labels are
33
- never seen during training.
34
-
35
- **Workflow:**
36
-
37
- 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]`.
38
- 2. Average over the cell population to obtain `[num_modules, base_dim]`, using streaming accumulation rather than storing the full table in memory.
39
- 3. Parse family assignments from the keys in `merged_biomods.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.
40
- 4. Quantify family separation:
41
- - silhouette coefficient, using both cosine and Euclidean metrics.
42
- - kNN purity at `k=10`, using both cosine and Euclidean metrics.
43
- - ratio of mean inter-family to intra-family distance.
44
- 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.
45
- 6. Visualize with UMAP and PCA in 2D, colored by family or hallmark biological process.
46
-
47
- **Differentiation from gene-level baselines:** gene-level foundation models embed
48
- single genes rather than pathway groups. To reproduce this experiment, they would
49
- first have to manually aggregate gene embeddings by pathway, which compresses
50
- gene-level signals and introduces a hand-designed step. BioMod learns this
51
- aggregation end to end during pretraining.
52
-
53
- ## File Structure
54
-
55
- ```text
56
- BioMod/interpretability/ # Module representations and biological interpretability
57
- β”œβ”€β”€ attention/ # Module crosstalk and within-module gene attention
58
- β”‚ β”œβ”€β”€ __init__.py
59
- β”‚ β”œβ”€β”€ biomod_crosstalk.py
60
- β”‚ β”œβ”€β”€ biomod_gene_attention.py
61
- β”‚ β”œβ”€β”€ README.md
62
- β”‚ └── README_gene_attention.md
63
- β”œβ”€β”€ biomod_importance/ # Module importance, ablation studies and marker-gene validation
64
- β”‚ β”œβ”€β”€ __init__.py
65
- β”‚ β”œβ”€β”€ cross_class_similarity.py
66
- β”‚ β”œβ”€β”€ family_ablation.py
67
- β”‚ β”œβ”€β”€ family_importance_analysis.py
68
- β”‚ β”œβ”€β”€ marker_gene_verification.py
69
- β”‚ β”œβ”€β”€ masking_ablation.py
70
- β”‚ β”œβ”€β”€ biomod_importance.py
71
- β”‚ β”œβ”€β”€ biomod_importance_publication.py
72
- β”‚ β”œβ”€β”€ pub_utils.py
73
- β”‚ β”œβ”€β”€ random_baseline.py
74
- β”‚ β”œβ”€β”€ README.md
75
- β”‚ β”œβ”€β”€ RESULTS.md
76
- β”‚ β”œβ”€β”€ tcell_reactome_analysis.py
77
- β”‚ └── TODO.md
78
- β”œβ”€β”€ biomod_text_alignment/ # Semantic alignment of module representations and pathway text
79
- β”‚ β”œβ”€β”€ __init__.py
80
- β”‚ β”œβ”€β”€ DESIGN.md
81
- β”‚ β”œβ”€β”€ fetch_reactome_descriptions.py
82
- β”‚ β”œβ”€β”€ MANUSCRIPT_SECTION.md
83
- β”‚ β”œβ”€β”€ biomod_text_alignment.py
84
- β”‚ β”œβ”€β”€ biomod_text_alignment_extended.py
85
- β”‚ β”œβ”€β”€ biomod_text_alignment_publication.py
86
- β”‚ └── reactome_descriptions.json
87
- β”œβ”€β”€ __init__.py
88
- β”œβ”€β”€ biomod_geometry.py
89
- β”œβ”€β”€ README.md
90
- └── utils.py
91
- ```
92
-
93
- ## Command-Line Usage
94
-
95
- Run all commands below from the BioMod repository root. Bundled default assets
96
- are located relative to the script files, so those defaults do not depend on the
97
- working directory. Explicit relative CLI paths still resolve from your current
98
- working directory.
99
-
100
- The h5ad examples use the bundled pancreas dataset. Pretraining `.npz` cell files
101
- are not included: replace `/absolute/path/to/pretraining_cell_npz` with your own
102
- prepared directory. Generated embeddings and analysis results are also not
103
- bundled; run the corresponding upstream analysis before using those paths.
104
-
105
- ```bash
106
- # General h5ad input: cell annotation, zero-shot, or any annotated dataset.
107
- python BioMod/interpretability/biomod_geometry.py \
108
- --biomod_checkpoint weight/BioMod.pth \
109
- --config_pkl weight/BioMod.pkl \
110
- --biomod_data_dir model_dependencies/biomod_data_all \
111
- --gene_list_path model_dependencies/selected_genes_27k.txt \
112
- --gene_mapping_path model_dependencies/gene2ens.csv \
113
- --gene2vec_data_path model_dependencies/selected_gene2vec_27k.npy \
114
- --gene2ens_file model_dependencies/gene2ens.csv \
115
- --data_path data/downstream/final_data/human_pancreas_norm_complexBatch_with_gene_name.h5ad \
116
- --data_mode h5ad \
117
- --gene_name_column auto \
118
- --batch_size 640 \
119
- --output_dir outputs/interpretability/geometry/pancreas_scib
120
- ```
121
-
122
- For pretraining data, where each cell is an `.npz` file already in the 27,874
123
- model-gene space:
124
-
125
- ```bash
126
- python BioMod/interpretability/biomod_geometry.py \
127
- --biomod_checkpoint weight/BioMod.pth \
128
- --config_pkl weight/BioMod.pkl \
129
- --biomod_data_dir model_dependencies/biomod_data_all \
130
- --gene_list_path model_dependencies/selected_genes_27k.txt \
131
- --gene_mapping_path model_dependencies/gene2ens.csv \
132
- --gene2vec_data_path model_dependencies/selected_gene2vec_27k.npy \
133
- --data_path /absolute/path/to/pretraining_cell_npz \
134
- --data_mode pretrain_npz \
135
- --max_cells 50000 \
136
- --batch_size 512 \
137
- --output_dir outputs/interpretability/geometry/pretrain_subset
138
- ```
139
-
140
- ### Multi-GPU DDP Acceleration
141
-
142
- `torchrun` multi-GPU inference is supported. When `WORLD_SIZE > 1`, each GPU
143
- processes a subset of cells, embeddings are aggregated with `all_reduce`, and
144
- rank 0 computes metrics and figures. Single-GPU and CPU behavior is unchanged.
145
-
146
- ```bash
147
- torchrun --nproc_per_node=2 BioMod/interpretability/biomod_geometry.py \
148
- --biomod_checkpoint weight/BioMod.pth \
149
- --config_pkl weight/BioMod.pkl \
150
- --biomod_data_dir model_dependencies/biomod_data_all \
151
- --gene_list_path model_dependencies/selected_genes_27k.txt \
152
- --gene_mapping_path model_dependencies/gene2ens.csv \
153
- --gene2vec_data_path model_dependencies/selected_gene2vec_27k.npy \
154
- --data_path /absolute/path/to/pretraining_cell_npz \
155
- --data_mode pretrain_npz \
156
- --max_cells 100000 \
157
- --batch_size 16 \
158
- --output_dir outputs/interpretability/geometry/pretrain_ddp
159
- ```
160
-
161
- ### Common Arguments
162
-
163
- | Argument | Default | Description |
164
- | --- | --- | --- |
165
- | `--max_cells` | None | Number of cells to subsample; the mean is stable from a few thousand cells onward. |
166
- | `--cell_num` | None | Total cell count in `pretrain_npz` mode, used for sequential names `cell0.npz` to `cellN.npz`. |
167
- | `--knn_k` | 10 | `k` for kNN purity. |
168
- | `--null_permutations` | 200 | Number of label permutations for the empirical null distribution. |
169
- | `--include_other_in_metrics` | Off | By default, modules from the `Other` family are excluded. |
170
- | `--shuffle_biomod_genes` | Off | Randomly shuffle gene-to-module assignments as a negative control or ablation. Uses the same flag as pretraining and downstream finetuning. |
171
- | `--umap_n_neighbors` | 15 | UMAP graph-construction parameter. |
172
- | `--umap_min_dist` | 0.3 | UMAP layout parameter. |
173
-
174
- ## Output Files
175
-
176
- Files are written to `<output_dir>/`:
177
-
178
- | File | Content |
179
- | --- | --- |
180
- | `module_embeddings.npy` | Cell-averaged module embeddings with shape `[num_modules, base_dim]` |
181
- | `module_metadata.csv` | Metadata for each module: `module_name`, `family`, and `hallmark_process` |
182
- | `metrics.json` | Observed metrics, null mean/std, and right-tail p-values for family-level and hallmark-process-level analyses |
183
- | `pca_family.png` | 2D PCA colored by family |
184
- | `umap_family.png` | 2D UMAP colored by family; requires `umap-learn` |
185
- | `pca_hallmark_process.png` | PCA for Hallmark modules only, colored by MSigDB biological process |
186
- | `umap_hallmark_process.png` | UMAP for Hallmark modules only, colored by MSigDB biological process |
187
- | `run.log` | Full log with formatted core metrics |
188
-
189
- ## Recommended Negative Controls
190
-
191
- Two control experiments make Experiment 1 more rigorous:
192
-
193
- 1. **Random module assignment**: rerun with `--shuffle_biomod_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.
194
-
195
- 2. **Untrained backbone**: set `--biomod_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.
196
-
197
- Together, these controls support the claim that the family structure of the module
198
- latent space is produced by pathway-based module design plus pretraining, rather
199
- than by module design alone or by a generic encoder architecture.
200
-
201
- ### Control 1: Random Module Assignment (`shuffle_module_genes`)
202
-
203
- Add `--shuffle_biomod_genes` to the original command and write to a control output
204
- directory.
205
-
206
- For h5ad data, using `pancreas_scib` as an example:
207
-
208
- ```bash
209
- python BioMod/interpretability/biomod_geometry.py \
210
- --biomod_checkpoint weight/BioMod.pth \
211
- --config_pkl weight/BioMod.pkl \
212
- --biomod_data_dir model_dependencies/biomod_data_all \
213
- --gene_list_path model_dependencies/selected_genes_27k.txt \
214
- --gene_mapping_path model_dependencies/gene2ens.csv \
215
- --gene2vec_data_path model_dependencies/selected_gene2vec_27k.npy \
216
- --gene2ens_file model_dependencies/gene2ens.csv \
217
- --data_path data/downstream/final_data/human_pancreas_norm_complexBatch_with_gene_name.h5ad \
218
- --data_mode h5ad \
219
- --gene_name_column auto \
220
- --batch_size 640 \
221
- --shuffle_biomod_genes \
222
- --output_dir outputs/interpretability/geometry/pancreas_scib_shuffle
223
- ```
224
-
225
- For pretraining npz data:
226
-
227
- ```bash
228
- python BioMod/interpretability/biomod_geometry.py \
229
- --biomod_checkpoint weight/BioMod.pth \
230
- --config_pkl weight/BioMod.pkl \
231
- --biomod_data_dir model_dependencies/biomod_data_all \
232
- --gene_list_path model_dependencies/selected_genes_27k.txt \
233
- --gene_mapping_path model_dependencies/gene2ens.csv \
234
- --gene2vec_data_path model_dependencies/selected_gene2vec_27k.npy \
235
- --gene2ens_file model_dependencies/gene2ens.csv \
236
- --data_path /absolute/path/to/pretraining_cell_npz \
237
- --data_mode pretrain_npz \
238
- --max_cells 50000 \
239
- --batch_size 640 \
240
- --shuffle_biomod_genes \
241
- --output_dir outputs/interpretability/geometry/pretrain_subset_shuffle
242
- ```
243
-
244
- How `--shuffle_biomod_genes` works: inside `load_backbone()`, module sizes are
245
- kept unchanged, all genes are collected into one list, the list is randomly
246
- shuffled, and genes are reassigned to modules. The seed is controlled by
247
- `--shuffle_seed`, defaulting to 2021. Model weights are unchanged; only the
248
- gene-to-module mapping is shuffled.
249
-
250
- **Expected result:** kNN purity should drop to about 0.576, the null level, and
251
- the inter-/intra-family distance ratio should drop to about 1.0. `metrics.json`
252
- records `"shuffle_module_genes": true`.
253
-
254
- ### Control 2: Untrained Backbone with Random Initialization
255
-
256
- First generate a randomly initialized checkpoint, then run `biomod_geometry.py`
257
- with it.
258
-
259
- A matching randomly initialized checkpoint and configuration are not bundled.
260
- Create them separately with the pretraining script's `--save_init_ckpt_and_exit`
261
- option and architecture settings matching the BioMod checkpoint being compared.
262
- Keep the generated control files in a separate output directory. Then pass their
263
- paths explicitly; do not use the pretrained BioMod weights for this control.
264
-
265
- ```bash
266
- python BioMod/interpretability/biomod_geometry.py \
267
- --biomod_checkpoint /absolute/path/to/random_init.pth \
268
- --config_pkl /absolute/path/to/random_init.pkl \
269
- --data_path data/downstream/final_data/human_pancreas_norm_complexBatch_with_gene_name.h5ad \
270
- --data_mode h5ad \
271
- --gene_name_column auto \
272
- --batch_size 8 \
273
- --output_dir outputs/interpretability/geometry/pancreas_init
274
- ```
275
-
276
- **Expected result:** all family-level metrics should be close to the null
277
- distribution: kNN purity about 0.576, distance ratio about 1.0, and p-value
278
- above 0.05. This indicates that a randomly initialized encoder cannot separate
279
- pathway families.
280
-
281
- ### Result Comparison Matrix
282
-
283
- After running all controls, construct the following table for the paper:
284
-
285
- | Condition | kNN purity, cosine | Inter-/intra-family ratio, cosine | Conclusion |
286
- | --- | --- | --- | --- |
287
- | **Normal**, trained + pathway module | about 0.72, p<0.005 | 3.8-6.2, p<0.005 | Significant family structure |
288
- | **Shuffle**, trained + random module | about 0.576 expected | about 1.0 expected | Structure is driven by pathway design |
289
- | **Init**, untrained + pathway module | about 0.576 expected | about 1.0 expected | Structure is learned during pretraining |
290
-
291
- The three experiments jointly show that the family structure of the module latent
292
- space is generated by both pathway-based module design and pretraining; both are
293
- necessary.
294
-
295
- ## Experiment 2: Module Embedding to Pathway Text Alignment (`biomod_text_alignment/`)
296
-
297
- **Hypothesis:** the structure of the BioMod module embedding space significantly
298
- aligns with natural-language pathway semantics. Semantically similar pathways
299
- are also encoded nearby by the model. Gene-level models cannot reproduce this
300
- experiment because individual gene names do not carry phrase-level semantics.
301
-
302
- **Method:**
303
-
304
- 1. Parse 541 semantically named modules from `merged_biomods.json`: 50 Hallmark and 491 Reactome.
305
- 2. Encode pathway names with S-PubMedBert-MS-MARCO, a sentence-level fine-tuned PubMedBERT model.
306
- 3. Load `module_embeddings.npy` from Experiment 1, containing cell-averaged module embeddings.
307
- 4. Measure alignment:
308
- - **RSA / Mantel test**, the main metric: Spearman correlation between paired distance matrices from the two spaces, with a permutation test.
309
- - **CKA**: global representation-structure alignment.
310
- - **LOO Ridge retrieval**: cross-modal retrieval recall@k.
311
-
312
- **Data requirement:** only `module_embeddings.npy` from Experiment 1 is needed;
313
- raw expression data are not required.
314
-
315
- ### Commands
316
-
317
- ```bash
318
- # Run from the BioMod repository root.
319
-
320
- # Download the sentence encoder.
321
- # The sentence-transformers loader downloads this model on first use.
322
- # Network access or an existing local model cache is required.
323
-
324
- # Main experiment: RSA + CKA + LOO retrieval.
325
- python BioMod/interpretability/biomod_text_alignment/biomod_text_alignment.py \
326
- --biomod_embedding_path outputs/interpretability/geometry/pretrain_subset/module_embeddings.npy \
327
- --biomod_json model_dependencies/biomod_data_all/merged_biomods.json \
328
- --sentence_model pritamdeka/S-PubMedBert-MS-MARCO \
329
- --n_permutations 1000 \
330
- --output_dir outputs/interpretability/text_alignment/pretrain_subset_v2
331
-
332
- # Extended analysis: broad Reactome categories and Hallmark deep dive.
333
- python BioMod/interpretability/biomod_text_alignment/biomod_text_alignment_extended.py \
334
- --biomod_embedding_path outputs/interpretability/geometry/pretrain_subset/module_embeddings.npy \
335
- --biomod_json model_dependencies/biomod_data_all/merged_biomods.json \
336
- --sentence_model pritamdeka/S-PubMedBert-MS-MARCO \
337
- --descriptions_file BioMod/interpretability/biomod_text_alignment/reactome_descriptions.json \
338
- --n_permutations 1000 \
339
- --output_dir outputs/interpretability/text_alignment/pretrain_subset_v2_extended
340
- ```
341
-
342
- ### Output Files
343
-
344
- | File | Content |
345
- | --- | --- |
346
- | `alignment_metrics.json` | RSA, CKA, retrieval recall@k, and p-values |
347
- | `rsa_scatter.png` | Paired-distance scatter plot, module space vs text space |
348
- | `rsa_null_distribution.png` | Mantel test: observed value vs null distribution |
349
- | `retrieval_ranks.png` | Histogram of retrieval ranks |
350
- | `retrieval_examples.csv` | Top-5 retrieval results for representative pathways |
351
- | `extended_metrics.json` | Extended-analysis results: broad-category RSA and Hallmark deep dive |
352
- | `reactome_category_heatmaps.png` | Distance-matrix comparison at the broad Reactome category level |
353
- | `hallmarks_deep_dive.png` | Hallmark per-module alignment scores and similarity matrix |
354
- | `hallmarks_per_module_alignment.csv` | Per-module alignment ranking for 50 Hallmarks |
355
-
356
- ## Dependencies
357
-
358
- `torch`, `numpy`, `pandas`, `scikit-learn`, `matplotlib`, `anndata`, and `scipy`.
359
- UMAP plots additionally require `umap-learn`; if it is not installed, the script
360
- warns and skips only UMAP plots. Experiment 2 also requires
361
- `sentence-transformers`, installable with `pip install sentence-transformers`.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
BioMod/interpretability/__init__.py DELETED
@@ -1,8 +0,0 @@
1
- """Module interpretability experiments for scLong (biomod).
2
-
3
- Roadmap (priority order):
4
- 1. biomod_geometry.py β€” intrinsic module-embedding geometry (current).
5
- 2. biomod_text_alignment.py β€” module ↔ pathway natural-language alignment.
6
- 3. biomod_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 DELETED
@@ -1,111 +0,0 @@
1
- # Experiment 6: Module Crosstalk Graph vs Pathway Interaction Database
2
-
3
- This experiment tests whether module-module interactions learned by the BioMod
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
- Run from the BioMod repository root. The h5ad examples use the bundled pancreas
26
- dataset and BioMod model weights. The optional pretraining `.npz` dataset is not
27
- included; replace `/absolute/path/to/pretraining_cell_npz` with your own directory.
28
-
29
- ```bash
30
- # Run from the BioMod repository root.
31
-
32
- # Full run, methods A + C.
33
- python BioMod/interpretability/attention/biomod_crosstalk.py \
34
- --biomod_checkpoint weight/BioMod.pth \
35
- --config_pkl weight/BioMod.pkl \
36
- --biomod_data_dir model_dependencies/biomod_data_all \
37
- --gene_list_path model_dependencies/selected_genes_27k.txt \
38
- --gene_mapping_path model_dependencies/gene2ens.csv \
39
- --gene2vec_data_path model_dependencies/selected_gene2vec_27k.npy \
40
- --gene2ens_file model_dependencies/gene2ens.csv \
41
- --data_path data/downstream/final_data/human_pancreas_norm_complexBatch_with_gene_name.h5ad \
42
- --data_mode h5ad \
43
- --batch_size 640 \
44
- --jacobian_cells 200 \
45
- --output_dir outputs/interpretability/crosstalk/pancreas_max \
46
- --pool_mode max
47
-
48
- # Method A only, for quick validation.
49
- python BioMod/interpretability/attention/biomod_crosstalk.py \
50
- --biomod_checkpoint weight/BioMod.pth \
51
- --config_pkl weight/BioMod.pkl \
52
- --biomod_data_dir model_dependencies/biomod_data_all \
53
- --gene_list_path model_dependencies/selected_genes_27k.txt \
54
- --gene_mapping_path model_dependencies/gene2ens.csv \
55
- --gene2vec_data_path model_dependencies/selected_gene2vec_27k.npy \
56
- --gene2ens_file model_dependencies/gene2ens.csv \
57
- --data_path data/downstream/final_data/human_pancreas_norm_complexBatch_with_gene_name.h5ad \
58
- --data_mode h5ad \
59
- --batch_size 64 \
60
- --skip_jacobian \
61
- --output_dir outputs/interpretability/crosstalk/pancreas_cosine_only
62
-
63
- # Pretraining npz data.
64
- python BioMod/interpretability/attention/biomod_crosstalk.py \
65
- --biomod_checkpoint weight/BioMod.pth \
66
- --config_pkl weight/BioMod.pkl \
67
- --biomod_data_dir model_dependencies/biomod_data_all \
68
- --gene_list_path model_dependencies/selected_genes_27k.txt \
69
- --gene_mapping_path model_dependencies/gene2ens.csv \
70
- --gene2vec_data_path model_dependencies/selected_gene2vec_27k.npy \
71
- --gene2ens_file model_dependencies/gene2ens.csv \
72
- --data_path /absolute/path/to/pretraining_cell_npz \
73
- --data_mode pretrain_npz \
74
- --max_cells 5000 \
75
- --batch_size 16 \
76
- --jacobian_cells 100 \
77
- --output_dir outputs/interpretability/crosstalk/pretrain
78
- ```
79
-
80
- ## Key Arguments
81
-
82
- | Argument | Default | Description |
83
- | --- | --- | --- |
84
- | `--jacobian_cells` | 200 | Number of cells sampled for Jacobian computation; more is stabler but slower |
85
- | `--skip_jacobian` | off | Run method A only |
86
- | `--max_cells` | 5000 | Maximum cell count used by method A |
87
- | `--null_permutations` | 200 | Number of permutations |
88
- | `--top_k_fractions` | 0.01,0.05,0.1 | Fractions used for Precision@k |
89
-
90
- ## Outputs
91
-
92
- ```text
93
- output_dir/
94
- |-- cosine_interaction.npy # (660, 660) method A interaction matrix
95
- |-- jacobian_interaction.npy # (660, 660) method C interaction matrix
96
- |-- mean_module_embeddings.npy # (660, 1024) cell-averaged module embeddings
97
- |-- gt_jaccard.npy # (660, 660) gene Jaccard overlap
98
- |-- gt_overlap_coef.npy # (660, 660) overlap coefficient
99
- |-- metrics.json # All evaluation metrics and p-values
100
- |-- heatmap_cosine.png # Method A heatmap, sorted by family
101
- |-- heatmap_jacobian.png # Method C heatmap
102
- |-- heatmap_gt_jaccard.png # Ground-truth heatmap
103
- |-- scatter_cosine_vs_jaccard.png
104
- `-- scatter_jacobian_vs_jaccard.png
105
- ```
106
-
107
- ## Notes
108
-
109
- - Jacobian computation requires 660 backward passes per cell. `--jacobian_cells 200` takes about 10-30 minutes on one A100.
110
- - Method A has almost no extra overhead, so use `--skip_jacobian` for quick validation first.
111
- - 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 DELETED
@@ -1,112 +0,0 @@
1
- # Gene Attention Interpretability: BioModEmbedding
2
-
3
- Analyze gene contributions within modules using the content-based attention
4
- pooling in BioModEmbedding.
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
- Run from the BioMod repository root. The examples use bundled BioMod model
25
- weights and pancreas data. Custom DEG and marker CSV files are optional external
26
- inputs; replace their example paths with your own files before using those flags.
27
- Set `CELL_TYPE_COLUMN` below to the actual cell-type field in your h5ad metadata;
28
- the column name has not been assumed for the bundled pancreas file.
29
-
30
- ### Basic Usage on the Pancreas Dataset with Built-In Markers
31
-
32
- ```bash
33
- # Run from the BioMod repository root.
34
-
35
- python BioMod/interpretability/attention/biomod_gene_attention.py \
36
- --biomod_checkpoint weight/BioMod.pth \
37
- --config_pkl weight/BioMod.pkl \
38
- --biomod_data_dir model_dependencies/biomod_data_all \
39
- --gene_list_path model_dependencies/selected_genes_27k.txt \
40
- --gene_mapping_path model_dependencies/gene2ens.csv \
41
- --gene2vec_data_path model_dependencies/selected_gene2vec_27k.npy \
42
- --gene2ens_file model_dependencies/gene2ens.csv \
43
- --data_path data/downstream/final_data/human_pancreas_norm_complexBatch_with_gene_name.h5ad \
44
- --celltype_column CELL_TYPE_COLUMN \
45
- --batch_size 640 \
46
- --top_k_genes 20 \
47
- --output_dir outputs/interpretability/gene_attention/pancreas
48
- ```
49
-
50
- ### With DEG Comparison
51
-
52
- ```bash
53
- python BioMod/interpretability/attention/biomod_gene_attention.py \
54
- --biomod_checkpoint weight/BioMod.pth \
55
- --config_pkl weight/BioMod.pkl \
56
- --biomod_data_dir model_dependencies/biomod_data_all \
57
- --gene_list_path model_dependencies/selected_genes_27k.txt \
58
- --gene_mapping_path model_dependencies/gene2ens.csv \
59
- --gene2vec_data_path model_dependencies/selected_gene2vec_27k.npy \
60
- --gene2ens_file model_dependencies/gene2ens.csv \
61
- --data_path data/downstream/final_data/human_pancreas_norm_complexBatch_with_gene_name.h5ad \
62
- --celltype_column CELL_TYPE_COLUMN \
63
- --batch_size 64 \
64
- --deg_csv /path/to/deg_results.csv \
65
- --output_dir outputs/interpretability/gene_attention/pancreas_with_deg
66
- ```
67
-
68
- DEG CSV columns must include `gene`, `cell_type`, `log2fc`, and `pval`.
69
-
70
- ### Custom Marker Database
71
-
72
- Add `--marker_db /path/to/markers.csv` to the complete basic command above and
73
- change `--output_dir` to `outputs/interpretability/gene_attention/custom_markers`.
74
-
75
- Marker CSV columns must include `cell_type` and `gene_symbol`.
76
-
77
- ### Limit Cell Count for Faster Testing
78
-
79
- Add `--max_cells 5000` to the complete basic command above and change
80
- `--output_dir` to `outputs/interpretability/gene_attention/pancreas_5k`.
81
-
82
- ## Key Arguments
83
-
84
- | Argument | Default | Description |
85
- | --- | --- | --- |
86
- | `--batch_size` | 64 | Inference batch size |
87
- | `--top_k_genes` | 20 | Number of top genes shown for each cell type in visualizations |
88
- | `--max_cells` | None | Number of cells to subsample |
89
- | `--marker_db` | None | Custom marker CSV; built-in pancreas markers are used when omitted |
90
- | `--deg_csv` | None | DEG result CSV; DEG correlation analysis is skipped when omitted |
91
- | `--celltype_column` | celltype | Cell-type column name in h5ad |
92
- | `--shuffle_biomod_genes` | off | Randomly shuffle gene-to-module assignment as a negative control |
93
-
94
- ## Output Files
95
-
96
- ```text
97
- output_dir/
98
- |-- global_gene_importance.csv # Global gene importance across all modules and genes
99
- |-- global_top_genes.png # Top-N gene bar chart
100
- |-- celltype_<ct>_attention.csv # Gene attention for each cell type
101
- |-- celltype_attention_heatmap.png # Cell type x gene heatmap
102
- |-- gene_specificity.csv # Gene specificity with fold change and p-value
103
- |-- specificity_volcano.png # Volcano plot
104
- |-- marker_validation.json # Marker validation results
105
- |-- marker_recall.png # Recall@50/100 bar chart
106
- |-- deg_correlation.json # DEG correlation, if --deg_csv is provided
107
- |-- deg_correlation.png # Spearman rho bar chart
108
- |-- enrichment/ # Pathway enrichment, if gseapy is installed
109
- | |-- <ct>_enrichment.csv
110
- | `-- enrichment_summary.json
111
- `-- summary.json # Run summary
112
- ```
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
BioMod/interpretability/attention/__init__.py DELETED
File without changes
BioMod/interpretability/attention/biomod_crosstalk.py DELETED
@@ -1,606 +0,0 @@
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 (run from the BioMod repository root):
22
- python BioMod/interpretability/attention/biomod_crosstalk.py \
23
- --biomod_checkpoint weight/BioMod.pth \
24
- --config_pkl weight/BioMod.pkl \
25
- --biomod_data_dir model_dependencies/biomod_data_all \
26
- --gene_list_path model_dependencies/selected_genes_27k.txt \
27
- --gene_mapping_path model_dependencies/gene2ens.csv \
28
- --gene2vec_data_path model_dependencies/selected_gene2vec_27k.npy \
29
- --data_path data/downstream/final_data/human_pancreas_norm_complexBatch_with_gene_name.h5ad \
30
- --data_mode h5ad \
31
- --batch_size 16 \
32
- --jacobian_cells 200 \
33
- --output_dir outputs/interpretability/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
- BIOMOD_SOURCE = HERE.parents[2]
57
- REPO_ROOT = HERE.parents[3]
58
- ASSET_ROOT = REPO_ROOT / "model_dependencies"
59
- for p in (str(BIOMOD_SOURCE), str(REPO_ROOT)):
60
- if p not in sys.path:
61
- sys.path.insert(0, p)
62
-
63
- from cell_annotation.biomod_finetune import (
64
- DataPreprocessor,
65
- load_backbone,
66
- seed_all,
67
- )
68
-
69
- from interpretability.utils import (
70
- MatrixRowDataset,
71
- NpzCellDataset,
72
- build_biomod_metadata,
73
- ensure_dir,
74
- load_biomod_names,
75
- )
76
-
77
- logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
78
- logger = logging.getLogger(__name__)
79
-
80
- # ---------------------------------------------------------------------------
81
- # Argparse
82
- # ---------------------------------------------------------------------------
83
-
84
- def parse_args() -> argparse.Namespace:
85
- p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
86
-
87
- p.add_argument("--biomod_checkpoint", "--performer_checkpoint", type=str, required=True, dest='performer_checkpoint')
88
- p.add_argument("--config_pkl", type=str, required=True)
89
- p.add_argument("--biomod_data_dir", "--module_data_dir", type=str, default=str(ASSET_ROOT / "biomod_data_all"), dest='module_data_dir')
90
- p.add_argument("--biomod_json", "--module_json", type=str, default=None, dest='module_json')
91
- p.add_argument("--gene_list_path", type=str, default=str(ASSET_ROOT / "selected_genes_27k.txt"))
92
- p.add_argument("--gene_mapping_path", type=str, default=str(ASSET_ROOT / "gene2ens.csv"))
93
- p.add_argument("--gene2vec_data_path", type=str, default=str(ASSET_ROOT / "selected_gene2vec_27k.npy"))
94
- p.add_argument("--gene2ens_file", type=str, default=str(ASSET_ROOT / "gene2ens.csv"))
95
- p.add_argument("--gene2ens_column", type=str, default="Ensembl gene ID")
96
- p.add_argument("--shuffle_biomod_genes", "--shuffle_module_genes", action="store_true", dest='shuffle_module_genes')
97
- p.add_argument("--shuffle_seed", type=int, default=2021)
98
-
99
- p.add_argument("--data_path", type=str, required=True)
100
- p.add_argument("--data_mode", type=str, default="h5ad",
101
- choices=["h5ad", "pretrain_h5ad", "pretrain_npz"])
102
- p.add_argument("--cell_num", type=int, default=None)
103
- p.add_argument("--gene_name_column", type=str, default="auto")
104
- p.add_argument("--dataset_name", type=str, default="crosstalk")
105
- p.add_argument("--max_cells", type=int, default=5000,
106
- help="Max cells for method A (cosine). Method C uses --jacobian_cells.")
107
-
108
- p.add_argument("--batch_size", type=int, default=16)
109
- p.add_argument("--num_workers", type=int, default=0)
110
- p.add_argument("--device", type=str, default="cuda")
111
- p.add_argument("--seed", type=int, default=2021)
112
- p.add_argument("--pool_mode", type=str, default="mean",
113
- choices=["mean", "max"],
114
- help="Pooling over cells: 'mean' or 'max' (element-wise max).")
115
-
116
- p.add_argument("--jacobian_cells", type=int, default=200,
117
- help="Number of cells for Jacobian computation (expensive).")
118
- p.add_argument("--jacobian_batch", type=int, default=4,
119
- help="Cells per Jacobian forward-backward pass.")
120
- p.add_argument("--skip_jacobian", action="store_true",
121
- help="Only run method A (cosine), skip method C (Jacobian).")
122
-
123
- p.add_argument("--null_permutations", type=int, default=200)
124
- p.add_argument("--top_k_fractions", type=str, default="0.01,0.05,0.1",
125
- help="Comma-separated fractions for precision@k.")
126
-
127
- p.add_argument("--output_dir", type=str, required=True)
128
- return p.parse_args()
129
-
130
-
131
- # ---------------------------------------------------------------------------
132
- # Data loading (reuses patterns from biomod_geometry.py)
133
- # ---------------------------------------------------------------------------
134
-
135
- def build_dataset(args, preprocessor):
136
- """Build dataset and optional reindex_state."""
137
- if args.data_mode == "h5ad":
138
- import anndata as ad
139
- adata, reindex_state = preprocessor.preprocess_adata(
140
- adata_path=args.data_path,
141
- min_genes=0, min_cells=0,
142
- max_cells=args.max_cells,
143
- dataset_name=args.dataset_name,
144
- gene_name_column=args.gene_name_column,
145
- )
146
- dataset = MatrixRowDataset(adata.X)
147
- n_cells = adata.n_obs
148
- elif args.data_mode == "pretrain_h5ad":
149
- import anndata as ad
150
- adata = ad.read_h5ad(args.data_path)
151
- if args.max_cells and adata.n_obs > args.max_cells:
152
- rng = np.random.default_rng(args.seed)
153
- chosen = np.sort(rng.choice(adata.n_obs, size=args.max_cells, replace=False))
154
- adata = adata[chosen].copy()
155
- dataset = MatrixRowDataset(adata.X)
156
- n_cells = adata.n_obs
157
- reindex_state = None
158
- elif args.data_mode == "pretrain_npz":
159
- dataset = NpzCellDataset(
160
- data_dir=args.data_path,
161
- cell_num=args.cell_num,
162
- max_cells=args.max_cells,
163
- seed=args.seed,
164
- )
165
- n_cells = len(dataset)
166
- reindex_state = None
167
- else:
168
- raise ValueError(f"Unsupported data_mode: {args.data_mode}")
169
- return dataset, reindex_state, n_cells
170
-
171
-
172
- # ---------------------------------------------------------------------------
173
- # Method A: Cosine similarity of cell-averaged module embeddings
174
- # ---------------------------------------------------------------------------
175
-
176
- @torch.no_grad()
177
- def compute_cosine_interaction(backbone, loader, preprocessor, reindex_state,
178
- device, num_modules, base_dim, expected_genes,
179
- pool_mode="mean"):
180
- """Compute pooled module embeddings, return cosine similarity matrix."""
181
- backbone.eval()
182
- if pool_mode == "mean":
183
- agg_emb = torch.zeros(num_modules, base_dim, device=device, dtype=torch.float64)
184
- else:
185
- agg_emb = torch.full((num_modules, base_dim), -float("inf"), device=device, dtype=torch.float64)
186
- n_cells = 0
187
-
188
- for batch_idx, x in enumerate(loader):
189
- x = x.to(device)
190
- if reindex_state is not None:
191
- x = preprocessor.reindex_tensor_to_model_space(x, reindex_state=reindex_state)
192
- out = backbone(x, mask_ratio=0.0, return_encodings=True)
193
- enc = out["module_encodings"].to(torch.float64) # (B, num_modules, D)
194
- if pool_mode == "mean":
195
- agg_emb += enc.sum(dim=0)
196
- else:
197
- batch_max = enc.max(dim=0).values # (num_modules, D)
198
- agg_emb = torch.maximum(agg_emb, batch_max)
199
- n_cells += enc.shape[0]
200
- if (batch_idx + 1) % 50 == 0:
201
- logger.info(" [cosine/%s] batch %d, %d cells", pool_mode, batch_idx + 1, n_cells)
202
-
203
- if pool_mode == "mean":
204
- pooled_emb = (agg_emb / max(n_cells, 1)).float().cpu().numpy()
205
- else:
206
- pooled_emb = agg_emb.float().cpu().numpy()
207
-
208
- norms = np.linalg.norm(pooled_emb, axis=1, keepdims=True)
209
- norms = np.clip(norms, 1e-8, None)
210
- normed = pooled_emb / norms
211
- cos_sim = normed @ normed.T
212
- np.fill_diagonal(cos_sim, 0.0)
213
- logger.info("Method A (%s) done: %d cells, interaction matrix shape %s", pool_mode, n_cells, cos_sim.shape)
214
- return cos_sim, pooled_emb
215
-
216
-
217
- # ---------------------------------------------------------------------------
218
- # Method C: Jacobian-based module interaction
219
- # ---------------------------------------------------------------------------
220
-
221
- def compute_jacobian_interaction(backbone, loader, preprocessor, reindex_state,
222
- device, num_modules, base_dim, expected_genes,
223
- max_cells, batch_size):
224
- """Compute average ||d encoder_out[j] / d module_emb[i]|| interaction matrix."""
225
- backbone.eval()
226
- for param in backbone.parameters():
227
- param.requires_grad_(False)
228
-
229
- interaction = torch.zeros(num_modules, num_modules, device=device, dtype=torch.float64)
230
- n_cells = 0
231
-
232
- for x in loader:
233
- if n_cells >= max_cells:
234
- break
235
- x = x.to(device)
236
- if reindex_state is not None:
237
- x = preprocessor.reindex_tensor_to_model_space(x, reindex_state=reindex_state)
238
-
239
- actual_batch = min(x.shape[0], max_cells - n_cells)
240
- x = x[:actual_batch]
241
-
242
- for cell_idx in range(x.shape[0]):
243
- cell = x[cell_idx:cell_idx+1]
244
- J = _compute_single_cell_jacobian(backbone, cell, num_modules, base_dim, device)
245
- interaction += J
246
- n_cells += 1
247
- if n_cells % 20 == 0:
248
- logger.info(" [jacobian] %d / %d cells", n_cells, max_cells)
249
- if n_cells >= max_cells:
250
- break
251
-
252
- interaction /= max(n_cells, 1)
253
- result = interaction.cpu().numpy()
254
- np.fill_diagonal(result, 0.0)
255
- logger.info("Method C done: %d cells, interaction matrix shape %s", n_cells, result.shape)
256
- return result
257
-
258
-
259
- def _compute_single_cell_jacobian(backbone, cell, num_modules, base_dim, device):
260
- """For one cell, compute (num_modules, num_modules) Jacobian norm matrix.
261
-
262
- J[i, j] = ||d encoder_out[j] / d module_emb[i]||_F
263
-
264
- Strategy: run module_embed to get intermediate, detach, enable grad,
265
- then replicate the encoder forward path and backprop from each output module.
266
- """
267
- J = torch.zeros(num_modules, num_modules, device=device, dtype=torch.float32)
268
-
269
- with torch.no_grad():
270
- x_raw, _ = backbone.module_embed(cell) # (1, num_modules, D)
271
-
272
- x_modules = x_raw.detach().clone().requires_grad_(True)
273
-
274
- with torch.enable_grad():
275
- if backbone.mask_style == "replace":
276
- x = backbone._add_biomod_position_embedding(x_modules)
277
- else:
278
- x = backbone._add_biomod_position_embedding(x_modules)
279
-
280
- if backbone.use_cls and backbone.cls_token is not None:
281
- cls_token = backbone.cls_token + backbone.pos_emb[:, :1, :]
282
- cls_tokens = cls_token.expand(x.shape[0], -1, -1)
283
- x = torch.cat((cls_tokens, x), dim=1)
284
-
285
- x_emb = backbone.dropout(x)
286
- layer_pos_emb = backbone.layer_pos_emb(x_emb)
287
- enc_out = backbone.encoder(x_emb, pos_emb=layer_pos_emb)
288
- enc_out = backbone.encoder_norm(enc_out) # (1, L, D)
289
-
290
- if backbone.use_cls and backbone.cls_token is not None:
291
- enc_out = enc_out[:, 1:, :] # skip CLS
292
-
293
- for j in range(num_modules):
294
- if x_modules.grad is not None:
295
- x_modules.grad.zero_()
296
- scalar = enc_out[0, j].pow(2).sum()
297
- scalar.backward(retain_graph=(j < num_modules - 1))
298
- grad = x_modules.grad[0] # (num_modules, D)
299
- J[:, j] = grad.norm(dim=1)
300
-
301
- return J.to(torch.float64)
302
-
303
-
304
- # ---------------------------------------------------------------------------
305
- # Ground truth: pathway overlap from module gene sets
306
- # ---------------------------------------------------------------------------
307
-
308
- def compute_pathway_overlap(module_names: List[str], module_gene_dict: dict,
309
- metric: str = "jaccard") -> np.ndarray:
310
- """Compute pairwise pathway overlap matrix from shared genes.
311
-
312
- metric: 'jaccard' (|A∩B| / |AβˆͺB|) or 'overlap_coef' (|A∩B| / min(|A|,|B|))
313
- """
314
- n = len(module_names)
315
- gene_sets = []
316
- for name in module_names:
317
- genes = module_gene_dict.get(name, [])
318
- gene_sets.append(set(genes))
319
-
320
- overlap = np.zeros((n, n), dtype=np.float32)
321
- for i in range(n):
322
- for j in range(i + 1, n):
323
- inter = len(gene_sets[i] & gene_sets[j])
324
- if inter == 0:
325
- continue
326
- if metric == "jaccard":
327
- union = len(gene_sets[i] | gene_sets[j])
328
- score = inter / union if union > 0 else 0.0
329
- else:
330
- min_size = min(len(gene_sets[i]), len(gene_sets[j]))
331
- score = inter / min_size if min_size > 0 else 0.0
332
- overlap[i, j] = score
333
- overlap[j, i] = score
334
- return overlap
335
-
336
-
337
- # ---------------------------------------------------------------------------
338
- # Evaluation metrics
339
- # ---------------------------------------------------------------------------
340
-
341
- def evaluate_interaction(model_matrix: np.ndarray, gt_matrix: np.ndarray,
342
- top_k_fracs: List[float], n_permutations: int = 200):
343
- """Compare model interaction matrix against ground truth overlap.
344
-
345
- Returns dict with spearman_r, auroc, precision@k, and permutation p-values.
346
- """
347
- n = model_matrix.shape[0]
348
- triu_idx = np.triu_indices(n, k=1)
349
- model_flat = model_matrix[triu_idx]
350
- gt_flat = gt_matrix[triu_idx]
351
-
352
- has_positive = gt_flat.max() > 0
353
- has_variation = model_flat.std() > 1e-10
354
-
355
- rho, _ = spearmanr(model_flat, gt_flat) if has_variation else (0.0, 1.0)
356
-
357
- gt_binary = (gt_flat > 0).astype(np.float32)
358
- n_pos = gt_binary.sum()
359
- if n_pos > 0 and n_pos < len(gt_binary) and has_variation:
360
- auroc = roc_auc_score(gt_binary, model_flat)
361
- else:
362
- auroc = 0.5
363
-
364
- precisions = {}
365
- n_pairs = len(model_flat)
366
- for frac in top_k_fracs:
367
- k = max(1, int(n_pairs * frac))
368
- top_k_idx = np.argsort(model_flat)[-k:]
369
- precisions[f"precision@{frac}"] = float(gt_binary[top_k_idx].mean())
370
-
371
- null_rhos = []
372
- null_aurocs = []
373
- rng = np.random.default_rng(42)
374
- for _ in range(n_permutations):
375
- perm = rng.permutation(n)
376
- perm_matrix = model_matrix[perm][:, perm]
377
- perm_flat = perm_matrix[triu_idx]
378
- r, _ = spearmanr(perm_flat, gt_flat) if has_variation else (0.0, 1.0)
379
- null_rhos.append(r)
380
- if n_pos > 0 and n_pos < len(gt_binary):
381
- null_aurocs.append(roc_auc_score(gt_binary, perm_flat))
382
- else:
383
- null_aurocs.append(0.5)
384
-
385
- p_rho = (np.sum(np.array(null_rhos) >= rho) + 1) / (n_permutations + 1)
386
- p_auroc = (np.sum(np.array(null_aurocs) >= auroc) + 1) / (n_permutations + 1)
387
-
388
- return {
389
- "spearman_r": float(rho),
390
- "spearman_p_perm": float(p_rho),
391
- "auroc": float(auroc),
392
- "auroc_p_perm": float(p_auroc),
393
- "null_rho_mean": float(np.mean(null_rhos)),
394
- "null_rho_std": float(np.std(null_rhos)),
395
- **precisions,
396
- }
397
-
398
-
399
- # ---------------------------------------------------------------------------
400
- # Visualization
401
- # ---------------------------------------------------------------------------
402
-
403
- def plot_interaction_matrix(matrix: np.ndarray, title: str, path: Path,
404
- module_families: Optional[List[str]] = None):
405
- """Plot heatmap of interaction matrix, optionally sorted by family."""
406
- if module_families is not None:
407
- order = np.argsort(module_families)
408
- matrix = matrix[order][:, order]
409
- sorted_families = [module_families[i] for i in order]
410
- else:
411
- sorted_families = None
412
-
413
- fig, ax = plt.subplots(figsize=(10, 9))
414
- vmax = np.percentile(np.abs(matrix), 99)
415
- im = ax.imshow(matrix, cmap="RdBu_r", vmin=-vmax, vmax=vmax, aspect="equal")
416
- ax.set_title(title, fontsize=12)
417
- ax.set_xlabel("Module j")
418
- ax.set_ylabel("Module i")
419
- plt.colorbar(im, ax=ax, fraction=0.046)
420
-
421
- if sorted_families is not None:
422
- unique_fams = []
423
- boundaries = [0]
424
- prev = sorted_families[0]
425
- for idx, f in enumerate(sorted_families):
426
- if f != prev:
427
- unique_fams.append(prev)
428
- boundaries.append(idx)
429
- prev = f
430
- unique_fams.append(prev)
431
- boundaries.append(len(sorted_families))
432
- for b in boundaries[1:-1]:
433
- ax.axhline(b - 0.5, color="black", linewidth=0.3, alpha=0.5)
434
- ax.axvline(b - 0.5, color="black", linewidth=0.3, alpha=0.5)
435
-
436
- plt.tight_layout()
437
- fig.savefig(path, dpi=150, bbox_inches="tight")
438
- plt.close(fig)
439
-
440
-
441
- def plot_scatter(model_flat, gt_flat, title, path):
442
- """Scatter plot of model interaction vs ground truth overlap."""
443
- fig, ax = plt.subplots(figsize=(6, 5))
444
- subsample = min(len(model_flat), 50000)
445
- if subsample < len(model_flat):
446
- rng = np.random.default_rng(0)
447
- idx = rng.choice(len(model_flat), size=subsample, replace=False)
448
- model_flat = model_flat[idx]
449
- gt_flat = gt_flat[idx]
450
- ax.scatter(gt_flat, model_flat, alpha=0.1, s=2, rasterized=True)
451
- ax.set_xlabel("Ground truth overlap (Jaccard)")
452
- ax.set_ylabel("Model interaction score")
453
- ax.set_title(title)
454
- plt.tight_layout()
455
- fig.savefig(path, dpi=150, bbox_inches="tight")
456
- plt.close(fig)
457
-
458
-
459
- # ---------------------------------------------------------------------------
460
- # Main
461
- # ---------------------------------------------------------------------------
462
-
463
- def main():
464
- args = parse_args()
465
- seed_all(args.seed)
466
- device = torch.device(args.device if torch.cuda.is_available() else "cpu")
467
- output_dir = Path(args.output_dir)
468
- ensure_dir(output_dir)
469
-
470
- # Resolve all paths to absolute (relative to CWD) so that load_backbone's
471
- # internal resolve_path (which resolves relative to cell_annotation/) is bypassed.
472
- for attr in ("performer_checkpoint", "config_pkl", "module_data_dir",
473
- "gene_list_path", "gene_mapping_path", "gene2vec_data_path",
474
- "gene2ens_file", "data_path"):
475
- val = getattr(args, attr, None)
476
- if val is not None and not Path(val).is_absolute():
477
- setattr(args, attr, str(Path(val).resolve()))
478
-
479
- logger.info("Loading backbone...")
480
- backbone, biomod_data, model_config = load_backbone(args, device)
481
- backbone.eval()
482
-
483
- num_modules = len(biomod_data)
484
- base_dim = int(model_config["base_dim"])
485
- expected_genes = int(model_config.get("num_genes", 27874))
486
- logger.info("Model: %d modules, dim=%d, genes=%d", num_modules, base_dim, expected_genes)
487
-
488
- module_json_path = args.module_json or str(Path(args.module_data_dir) / "merged_biomods.json")
489
- module_names = load_biomod_names(module_json_path)
490
- with open(module_json_path, "r", encoding="utf-8") as f:
491
- module_gene_dict = json.load(f)
492
-
493
- preprocessor = None
494
- if args.data_mode == "h5ad":
495
- preprocessor = DataPreprocessor(
496
- gene2ens_file=args.gene2ens_file,
497
- gene2ens_column=args.gene2ens_column,
498
- model_genes_file=args.gene_list_path,
499
- )
500
- preprocessor.load_gene_mapping()
501
- preprocessor.load_model_genes()
502
-
503
- dataset, reindex_state, n_cells = build_dataset(args, preprocessor)
504
- logger.info("Dataset: %d cells", n_cells)
505
-
506
- # --- Method A: Cosine ---
507
- logger.info("=" * 60)
508
- logger.info("Method A: Cosine similarity of module embeddings")
509
- logger.info("=" * 60)
510
- loader_a = DataLoader(dataset, batch_size=args.batch_size, shuffle=False,
511
- num_workers=args.num_workers, pin_memory=True)
512
- cos_matrix, mean_emb = compute_cosine_interaction(
513
- backbone, loader_a, preprocessor, reindex_state,
514
- device, num_modules, base_dim, expected_genes,
515
- pool_mode=args.pool_mode,
516
- )
517
-
518
- # --- Method C: Jacobian ---
519
- jac_matrix = None
520
- if not args.skip_jacobian:
521
- logger.info("=" * 60)
522
- logger.info("Method C: Jacobian-based module interaction")
523
- logger.info("=" * 60)
524
- jac_n = min(args.jacobian_cells, n_cells)
525
- if jac_n < n_cells:
526
- rng = np.random.default_rng(args.seed)
527
- jac_indices = np.sort(rng.choice(n_cells, size=jac_n, replace=False))
528
- jac_dataset = Subset(dataset, jac_indices.tolist())
529
- else:
530
- jac_dataset = dataset
531
- loader_c = DataLoader(jac_dataset, batch_size=args.jacobian_batch, shuffle=False,
532
- num_workers=0, pin_memory=True)
533
- jac_matrix = compute_jacobian_interaction(
534
- backbone, loader_c, preprocessor, reindex_state,
535
- device, num_modules, base_dim, expected_genes,
536
- max_cells=jac_n, batch_size=args.jacobian_batch,
537
- )
538
-
539
- # --- Ground truth overlap ---
540
- logger.info("Computing pathway gene overlap (Jaccard)...")
541
- gt_jaccard = compute_pathway_overlap(module_names, module_gene_dict, metric="jaccard")
542
- gt_overlap = compute_pathway_overlap(module_names, module_gene_dict, metric="overlap_coef")
543
-
544
- # --- Evaluate ---
545
- top_k_fracs = [float(f) for f in args.top_k_fractions.split(",")]
546
- results = {}
547
-
548
- logger.info("Evaluating Method A vs Jaccard overlap...")
549
- results["cosine_vs_jaccard"] = evaluate_interaction(
550
- cos_matrix, gt_jaccard, top_k_fracs, args.null_permutations)
551
- results["cosine_vs_overlap_coef"] = evaluate_interaction(
552
- cos_matrix, gt_overlap, top_k_fracs, args.null_permutations)
553
-
554
- if jac_matrix is not None:
555
- logger.info("Evaluating Method C vs Jaccard overlap...")
556
- jac_sym = (jac_matrix + jac_matrix.T) / 2
557
- results["jacobian_vs_jaccard"] = evaluate_interaction(
558
- jac_sym, gt_jaccard, top_k_fracs, args.null_permutations)
559
- results["jacobian_vs_overlap_coef"] = evaluate_interaction(
560
- jac_sym, gt_overlap, top_k_fracs, args.null_permutations)
561
-
562
- # --- Save ---
563
- np.save(output_dir / "cosine_interaction.npy", cos_matrix)
564
- np.save(output_dir / "mean_module_embeddings.npy", mean_emb)
565
- np.save(output_dir / "gt_jaccard.npy", gt_jaccard)
566
- np.save(output_dir / "gt_overlap_coef.npy", gt_overlap)
567
- if jac_matrix is not None:
568
- np.save(output_dir / "jacobian_interaction.npy", jac_matrix)
569
-
570
- with open(output_dir / "metrics.json", "w", encoding="utf-8") as f:
571
- json.dump(results, f, indent=2, ensure_ascii=False)
572
-
573
- # --- Plots ---
574
- meta = build_biomod_metadata(module_names)
575
- families = [row["family"] for row in meta]
576
-
577
- plot_interaction_matrix(cos_matrix, "Method A: Cosine Similarity",
578
- output_dir / "heatmap_cosine.png", families)
579
- if jac_matrix is not None:
580
- plot_interaction_matrix(jac_sym, "Method C: Jacobian Interaction",
581
- output_dir / "heatmap_jacobian.png", families)
582
- plot_interaction_matrix(gt_jaccard, "Ground Truth: Jaccard Gene Overlap",
583
- output_dir / "heatmap_gt_jaccard.png", families)
584
-
585
- triu_idx = np.triu_indices(num_modules, k=1)
586
- plot_scatter(cos_matrix[triu_idx], gt_jaccard[triu_idx],
587
- "Cosine vs Jaccard", output_dir / "scatter_cosine_vs_jaccard.png")
588
- if jac_matrix is not None:
589
- plot_scatter(jac_sym[triu_idx], gt_jaccard[triu_idx],
590
- "Jacobian vs Jaccard", output_dir / "scatter_jacobian_vs_jaccard.png")
591
-
592
- # --- Print summary ---
593
- logger.info("=" * 60)
594
- logger.info("Results Summary")
595
- logger.info("=" * 60)
596
- for method_key, metrics in results.items():
597
- logger.info(" %s:", method_key)
598
- logger.info(" Spearman r = %.4f (p_perm = %.4f)", metrics["spearman_r"], metrics["spearman_p_perm"])
599
- logger.info(" AUROC = %.4f (p_perm = %.4f)", metrics["auroc"], metrics["auroc_p_perm"])
600
- for k in top_k_fracs:
601
- logger.info(" Precision@%.2f = %.4f", k, metrics[f"precision@{k}"])
602
- logger.info("Output saved to: %s", output_dir)
603
-
604
-
605
- if __name__ == "__main__":
606
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
BioMod/interpretability/attention/biomod_gene_attention.py DELETED
@@ -1,1093 +0,0 @@
1
- """BioModEmbedding Attention-based Gene Interpretability Analysis (Streaming).
2
-
3
- Interpretability analysis using single-head content-based attention pooling in BioModEmbedding.
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 (run from the BioMod repository root):
17
- python BioMod/interpretability/attention/biomod_gene_attention.py \
18
- --biomod_checkpoint weight/BioMod.pth \
19
- --config_pkl weight/BioMod.pkl \
20
- --biomod_data_dir model_dependencies/biomod_data_all \
21
- --gene_list_path model_dependencies/selected_genes_27k.txt \
22
- --gene_mapping_path model_dependencies/gene2ens.csv \
23
- --gene2vec_data_path model_dependencies/selected_gene2vec_27k.npy \
24
- --gene2ens_file model_dependencies/gene2ens.csv \
25
- --data_path data/downstream/final_data/human_pancreas_norm_complexBatch_with_gene_name.h5ad \
26
- --celltype_column CELL_TYPE_COLUMN \
27
- --batch_size 64 \
28
- --output_dir outputs/interpretability/gene_attention/pancreas
29
-
30
- Replace CELL_TYPE_COLUMN with the cell-type field in the input h5ad metadata.
31
- """
32
- from __future__ import annotations
33
-
34
- import argparse
35
- import json
36
- import logging
37
- import sys
38
- from pathlib import Path
39
- from typing import Dict, List, Optional, Tuple
40
-
41
- import numpy as np
42
- import pandas as pd
43
- import torch
44
- from torch.utils.data import DataLoader
45
-
46
- import matplotlib
47
- matplotlib.use("Agg")
48
- import matplotlib.pyplot as plt
49
- import seaborn as sns
50
-
51
- from scipy.stats import spearmanr, t as t_dist
52
-
53
- HERE = Path(__file__).resolve()
54
- BIOMOD_SOURCE = HERE.parents[2]
55
- REPO_ROOT = HERE.parents[3]
56
- ASSET_ROOT = REPO_ROOT / "model_dependencies"
57
- for p in (str(BIOMOD_SOURCE), str(REPO_ROOT)):
58
- if p not in sys.path:
59
- sys.path.insert(0, p)
60
-
61
- # Publication utilities
62
- BIOMOD_IMPORTANCE_DIR = BIOMOD_SOURCE / "interpretability" / "biomod_importance"
63
- if str(BIOMOD_IMPORTANCE_DIR) not in sys.path:
64
- sys.path.insert(0, str(BIOMOD_IMPORTANCE_DIR))
65
-
66
- from pub_utils import setup_publication_style, savefig as pub_savefig
67
-
68
- from cell_annotation.biomod_finetune import (
69
- DataPreprocessor,
70
- load_backbone,
71
- seed_all,
72
- )
73
- from interpretability.utils import (
74
- MatrixRowDataset,
75
- build_biomod_metadata,
76
- ensure_dir,
77
- load_biomod_names,
78
- )
79
-
80
- logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
81
- logger = logging.getLogger(__name__)
82
-
83
-
84
- # ---------------------------------------------------------------------------
85
- # Argparse
86
- # ---------------------------------------------------------------------------
87
-
88
- def parse_args() -> argparse.Namespace:
89
- p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
90
-
91
- p.add_argument("--biomod_checkpoint", "--performer_checkpoint", type=str, required=True, dest='performer_checkpoint')
92
- p.add_argument("--config_pkl", type=str, required=True)
93
- p.add_argument("--biomod_data_dir", "--module_data_dir", type=str, default=str(ASSET_ROOT / "biomod_data_all"), dest='module_data_dir')
94
- p.add_argument("--biomod_json", "--module_json", type=str, default=None, dest='module_json')
95
- p.add_argument("--gene_list_path", type=str, default=str(ASSET_ROOT / "selected_genes_27k.txt"))
96
- p.add_argument("--gene_mapping_path", type=str, default=str(ASSET_ROOT / "gene2ens.csv"))
97
- p.add_argument("--gene2vec_data_path", type=str, default=str(ASSET_ROOT / "selected_gene2vec_27k.npy"))
98
- p.add_argument("--gene2ens_file", type=str, default=str(ASSET_ROOT / "gene2ens.csv"))
99
- p.add_argument("--gene2ens_column", type=str, default="Ensembl gene ID")
100
- p.add_argument("--shuffle_biomod_genes", "--shuffle_module_genes", action="store_true", dest='shuffle_module_genes')
101
- p.add_argument("--shuffle_seed", type=int, default=2021)
102
-
103
- p.add_argument("--data_path", type=str, required=True)
104
- p.add_argument("--celltype_column", type=str, default="celltype")
105
- p.add_argument("--gene_name_column", type=str, default="auto")
106
- p.add_argument("--dataset_name", type=str, default="gene_attention")
107
- p.add_argument("--max_cells", type=int, default=None)
108
-
109
- p.add_argument("--batch_size", type=int, default=64)
110
- p.add_argument("--num_workers", type=int, default=0)
111
- p.add_argument("--device", type=str, default="cuda")
112
- p.add_argument("--seed", type=int, default=2021)
113
-
114
- p.add_argument("--top_k_genes", type=int, default=20)
115
- p.add_argument("--marker_db", type=str, default=None)
116
- p.add_argument("--deg_csv", type=str, default=None)
117
- p.add_argument("--min_cells", type=int, default=30)
118
-
119
- p.add_argument("--output_dir", type=str, required=True)
120
- return p.parse_args()
121
-
122
-
123
- # ---------------------------------------------------------------------------
124
- # Built-in pancreas marker genes (from PanglaoDB + CellMarker)
125
- # ---------------------------------------------------------------------------
126
-
127
- PANCREAS_MARKERS: Dict[str, List[str]] = {
128
- "alpha": ["GCG", "ARX", "IRX2", "TTR", "LOXL4", "GC", "CRYBA2", "DPP4", "IRX1", "PCSK2"],
129
- "beta": ["INS", "IAPP", "MAFA", "NKX6-1", "PDX1", "HADH", "SLC30A8", "NPTX2", "DLK1", "PCSK1"],
130
- "delta": ["SST", "HHEX", "RBP4", "LEPR", "PCSK1", "SEC11C"],
131
- "gamma": ["PPY", "SERTM1", "CARTPT", "MEIS2", "ETV1", "ABCC9"],
132
- "ductal": ["KRT19", "KRT7", "SPP1", "CFTR", "MUC1", "AMBP", "ANXA4", "BICC1", "SOX9"],
133
- "acinar": ["PRSS1", "CELA3A", "CELA2A", "CPA1", "CPA2", "PNLIP", "CEL", "CLPS", "REG1A", "CTRB1"],
134
- "activated_stellate": ["COL1A1", "COL1A2", "COL3A1", "ACTA2", "TAGLN", "RGS5", "PDGFRB"],
135
- "endothelial": ["PECAM1", "VWF", "CDH5", "KDR", "ENG", "PLVAP", "ESAM"],
136
- "macrophage": ["CD68", "CD163", "CSF1R", "MARCO", "MSR1", "C1QA", "C1QB"],
137
- "epsilon": ["GHRL", "PCSK1", "ARX"],
138
- }
139
-
140
- PANCREAS_COLORS: Dict[str, str] = {
141
- "alpha": "#E41A1C", "beta": "#377EB8", "delta": "#4DAF4A",
142
- "gamma": "#984EA3", "ductal": "#FF7F00", "acinar": "#A65628",
143
- "activated_stellate": "#F781BF", "endothelial": "#66C2A5",
144
- "macrophage": "#FC8D62", "epsilon": "#8DA0CB", "t_cell": "#E78AC3",
145
- "schwann": "#B3B3B3", "mast": "#FDBF6F", "quiescent_stellate": "#CAB2D6",
146
- }
147
-
148
-
149
- # ---------------------------------------------------------------------------
150
- # Streaming attention extraction + statistics accumulation
151
- # ---------------------------------------------------------------------------
152
-
153
- @torch.no_grad()
154
- def extract_attention_streaming(
155
- backbone, loader, preprocessor, reindex_state, device,
156
- cell_labels: np.ndarray, num_modules: int, module_sizes: List[int],
157
- ) -> Tuple:
158
- """Extract attention and accumulate statistics in streaming fashion.
159
-
160
- Returns:
161
- global_mean, global_std, ct_mean, ct_std, ct_count, n_cells,
162
- expr_mean, attn_expr_prod_sum
163
- """
164
- backbone.eval()
165
- module_embed = backbone.module_embed
166
-
167
- global_sum = {i: np.zeros(module_sizes[i], dtype=np.float64) for i in range(num_modules)}
168
- global_sum_sq = {i: np.zeros(module_sizes[i], dtype=np.float64) for i in range(num_modules)}
169
- expr_sum = {i: np.zeros(module_sizes[i], dtype=np.float64) for i in range(num_modules)}
170
- attn_expr_prod_sum = {i: np.zeros(module_sizes[i], dtype=np.float64) for i in range(num_modules)}
171
-
172
- celltypes = np.unique(cell_labels)
173
- ct_sum = {ct: {i: np.zeros(module_sizes[i], dtype=np.float64) for i in range(num_modules)} for ct in celltypes}
174
- ct_sum_sq = {ct: {i: np.zeros(module_sizes[i], dtype=np.float64) for i in range(num_modules)} for ct in celltypes}
175
- ct_count = {ct: 0 for ct in celltypes}
176
-
177
- n_cells = 0
178
- cell_idx = 0
179
-
180
- for batch_idx, x in enumerate(loader):
181
- x = x.to(device)
182
- if reindex_state is not None:
183
- x = preprocessor.reindex_tensor_to_model_space(x, reindex_state=reindex_state)
184
-
185
- batch_size = x.shape[0]
186
- batch_labels = cell_labels[cell_idx:cell_idx + batch_size]
187
- cell_idx += batch_size
188
-
189
- for module_i, gene_idx in enumerate(module_embed.module_sets):
190
- gene_idx_dev = gene_idx.to(device)
191
- expr_slice = torch.index_select(x, dim=1, index=gene_idx_dev)
192
-
193
- gene_emb = module_embed.gene_embed(gene_idx_dev)
194
- gene_emb = gene_emb.unsqueeze(0).expand(batch_size, -1, -1)
195
- gene_tok = module_embed._fuse_expr_and_gene_emb(expr_slice, gene_emb)
196
- gene_tok = module_embed.proj(gene_tok)
197
-
198
- attn = module_embed.attn_score(gene_tok).squeeze(-1)
199
- attn = torch.softmax(attn, dim=1)
200
- attn_np = attn.cpu().numpy().astype(np.float64)
201
-
202
- # Expression for correlation
203
- expr_np = expr_slice.cpu().numpy().astype(np.float64)
204
- expr_sum[module_i] += expr_np.sum(axis=0)
205
- attn_expr_prod_sum[module_i] += (attn_np * expr_np).sum(axis=0)
206
-
207
- # Global accumulation
208
- global_sum[module_i] += attn_np.sum(axis=0)
209
- global_sum_sq[module_i] += (attn_np ** 2).sum(axis=0)
210
-
211
- # Per-celltype accumulation
212
- for ct in celltypes:
213
- mask = batch_labels == ct
214
- if mask.any():
215
- ct_attn = attn_np[mask]
216
- ct_sum[ct][module_i] += ct_attn.sum(axis=0)
217
- ct_sum_sq[ct][module_i] += (ct_attn ** 2).sum(axis=0)
218
-
219
- for ct in celltypes:
220
- ct_count[ct] += int((batch_labels == ct).sum())
221
-
222
- n_cells += batch_size
223
- if (batch_idx + 1) % 50 == 0:
224
- logger.info(" [attention] batch %d, %d cells processed", batch_idx + 1, n_cells)
225
-
226
- # Compute means and stds
227
- global_mean = {}
228
- global_std = {}
229
- for i in range(num_modules):
230
- global_mean[i] = (global_sum[i] / n_cells).astype(np.float32)
231
- variance = global_sum_sq[i] / n_cells - global_mean[i].astype(np.float64) ** 2
232
- global_std[i] = np.sqrt(np.maximum(variance, 0)).astype(np.float32)
233
-
234
- ct_mean = {}
235
- ct_std = {}
236
- for ct in celltypes:
237
- ct_mean[ct] = {}
238
- ct_std[ct] = {}
239
- for i in range(num_modules):
240
- if ct_count[ct] > 0:
241
- ct_mean[ct][i] = (ct_sum[ct][i] / ct_count[ct]).astype(np.float32)
242
- else:
243
- ct_mean[ct][i] = np.zeros(module_sizes[i], dtype=np.float32)
244
- if ct_count[ct] > 1:
245
- var = ct_sum_sq[ct][i] / ct_count[ct] - ct_mean[ct][i].astype(np.float64) ** 2
246
- ct_std[ct][i] = np.sqrt(np.maximum(var, 0)).astype(np.float32)
247
- else:
248
- ct_std[ct][i] = np.zeros(module_sizes[i], dtype=np.float32)
249
-
250
- expr_mean = {i: (expr_sum[i] / n_cells).astype(np.float32) for i in range(num_modules)}
251
-
252
- logger.info("Extracted attention for %d cells across %d modules (streaming)", n_cells, num_modules)
253
- return global_mean, global_std, ct_mean, ct_std, ct_count, n_cells, expr_mean, attn_expr_prod_sum
254
-
255
-
256
- # ---------------------------------------------------------------------------
257
- # Analysis functions
258
- # ---------------------------------------------------------------------------
259
-
260
- def build_global_importance_df(
261
- global_mean: Dict[int, np.ndarray],
262
- global_std: Dict[int, np.ndarray],
263
- module_gene_names: Dict[int, List[str]],
264
- num_modules: int,
265
- ) -> pd.DataFrame:
266
- """Build global gene importance DataFrame from streaming means."""
267
- rows = []
268
- for module_i in range(num_modules):
269
- mean_attn = global_mean[module_i]
270
- std_attn = global_std[module_i]
271
- genes = module_gene_names[module_i]
272
- rank_order = np.argsort(-mean_attn)
273
- for g_idx, gene in enumerate(genes):
274
- rows.append({
275
- "module_idx": module_i,
276
- "gene": gene,
277
- "mean_attention": float(mean_attn[g_idx]),
278
- "std_attention": float(std_attn[g_idx]),
279
- "rank_in_module": int(np.where(rank_order == g_idx)[0][0]) + 1,
280
- "module_size": len(genes),
281
- })
282
- df = pd.DataFrame(rows)
283
- df["normalized_rank"] = df["rank_in_module"] / df["module_size"]
284
- return df
285
-
286
-
287
- def build_celltype_dfs(
288
- ct_mean: Dict[str, Dict[int, np.ndarray]],
289
- ct_count: Dict[str, int],
290
- module_gene_names: Dict[int, List[str]],
291
- num_modules: int,
292
- ) -> Dict[str, pd.DataFrame]:
293
- """Build per-celltype DataFrames from streaming means."""
294
- ct_dfs = {}
295
- for ct, module_means in ct_mean.items():
296
- rows = []
297
- for module_i in range(num_modules):
298
- mean_attn = module_means[module_i]
299
- genes = module_gene_names[module_i]
300
- for g_idx, gene in enumerate(genes):
301
- rows.append({
302
- "module_idx": module_i,
303
- "gene": gene,
304
- "mean_attention": float(mean_attn[g_idx]),
305
- "n_cells": ct_count[ct],
306
- })
307
- ct_dfs[ct] = pd.DataFrame(rows)
308
- return ct_dfs
309
-
310
-
311
- def compute_specificity_from_means(
312
- global_mean: Dict[int, np.ndarray],
313
- ct_mean: Dict[str, Dict[int, np.ndarray]],
314
- module_gene_names: Dict[int, List[str]],
315
- num_modules: int,
316
- ) -> pd.DataFrame:
317
- """Fold-change specificity (legacy): max_ct(mean_attn_ct) / global_mean_attn."""
318
- celltypes = list(ct_mean.keys())
319
- rows = []
320
- for module_i in range(num_modules):
321
- genes = module_gene_names[module_i]
322
- g_mean = global_mean[module_i]
323
- for g_idx, gene in enumerate(genes):
324
- ct_scores = {ct: float(ct_mean[ct][module_i][g_idx]) for ct in celltypes}
325
- top_ct = max(ct_scores, key=ct_scores.get)
326
- top_score = ct_scores[top_ct]
327
- fold_change = top_score / max(float(g_mean[g_idx]), 1e-10)
328
- rows.append({
329
- "module_idx": module_i, "gene": gene, "top_celltype": top_ct,
330
- "top_ct_mean_attn": top_score, "global_mean_attn": float(g_mean[g_idx]),
331
- "fold_change": fold_change,
332
- })
333
- return pd.DataFrame(rows)
334
-
335
-
336
- def compute_specificity_zscore(
337
- global_mean: Dict[int, np.ndarray],
338
- global_std: Dict[int, np.ndarray],
339
- ct_mean: Dict[str, Dict[int, np.ndarray]],
340
- ct_count: Dict[str, int],
341
- module_gene_names: Dict[int, List[str]],
342
- num_modules: int,
343
- min_cells: int = 30,
344
- ) -> pd.DataFrame:
345
- """Z-score specificity: (ct_mean - global_mean) / global_std."""
346
- valid_cts = [ct for ct, n in ct_count.items() if n >= min_cells]
347
- rows = []
348
- for module_i in range(num_modules):
349
- genes = module_gene_names[module_i]
350
- g_mean = global_mean[module_i]
351
- g_std = global_std[module_i]
352
- for g_idx, gene in enumerate(genes):
353
- std_val = max(float(g_std[g_idx]), 1e-8)
354
- ct_zscores = {}
355
- for ct in valid_cts:
356
- z = (float(ct_mean[ct][module_i][g_idx]) - float(g_mean[g_idx])) / std_val
357
- ct_zscores[ct] = z
358
- top_ct = max(ct_zscores, key=ct_zscores.get)
359
- rows.append({
360
- "module_idx": module_i, "gene": gene, "top_celltype": top_ct,
361
- "z_score": ct_zscores[top_ct],
362
- "global_mean_attn": float(g_mean[g_idx]),
363
- "ct_mean_attn": float(ct_mean[top_ct][module_i][g_idx]),
364
- })
365
- return pd.DataFrame(rows)
366
-
367
-
368
- def compute_cohens_d(
369
- global_mean: Dict[int, np.ndarray],
370
- ct_mean: Dict[str, Dict[int, np.ndarray]],
371
- ct_std: Dict[str, Dict[int, np.ndarray]],
372
- ct_count: Dict[str, int],
373
- module_gene_names: Dict[int, List[str]],
374
- num_modules: int,
375
- n_cells: int,
376
- min_cells: int = 30,
377
- ) -> pd.DataFrame:
378
- """Cohen's d effect size for cell-type specificity."""
379
- valid_cts = [ct for ct, n in ct_count.items() if n >= min_cells]
380
- rows = []
381
- for module_i in range(num_modules):
382
- genes = module_gene_names[module_i]
383
- for g_idx, gene in enumerate(genes):
384
- best_d = -np.inf
385
- best_ct = valid_cts[0]
386
- for ct in valid_cts:
387
- n_ct = ct_count[ct]
388
- n_other = n_cells - n_ct
389
- ct_m = float(ct_mean[ct][module_i][g_idx])
390
- ct_s = float(ct_std[ct][module_i][g_idx])
391
- other_m = (float(global_mean[module_i][g_idx]) * n_cells - ct_m * n_ct) / max(n_other, 1)
392
- pooled = max(ct_s, 1e-10)
393
- d = (ct_m - other_m) / pooled
394
- if d > best_d:
395
- best_d = d
396
- best_ct = ct
397
- rows.append({"module_idx": module_i, "gene": gene, "top_celltype": best_ct, "cohens_d": best_d})
398
- return pd.DataFrame(rows)
399
-
400
-
401
- def compute_welch_ttest(
402
- global_mean: Dict[int, np.ndarray],
403
- global_std: Dict[int, np.ndarray],
404
- ct_mean: Dict[str, Dict[int, np.ndarray]],
405
- ct_std: Dict[str, Dict[int, np.ndarray]],
406
- ct_count: Dict[str, int],
407
- module_gene_names: Dict[int, List[str]],
408
- num_modules: int,
409
- n_cells: int,
410
- min_cells: int = 30,
411
- ) -> pd.DataFrame:
412
- """Welch's t-test from streaming statistics with BH FDR correction."""
413
- valid_cts = [ct for ct, n in ct_count.items() if n >= min_cells]
414
- rows = []
415
- for module_i in range(num_modules):
416
- genes = module_gene_names[module_i]
417
- for g_idx, gene in enumerate(genes):
418
- best_t = -np.inf
419
- best_ct = valid_cts[0]
420
- best_pval = 1.0
421
- for ct in valid_cts:
422
- n_ct = ct_count[ct]
423
- ct_m = float(ct_mean[ct][module_i][g_idx])
424
- ct_v = float(ct_std[ct][module_i][g_idx]) ** 2
425
- g_m = float(global_mean[module_i][g_idx])
426
- g_v = float(global_std[module_i][g_idx]) ** 2
427
- se = np.sqrt(ct_v / max(n_ct, 1) + g_v / max(n_cells, 1))
428
- if se < 1e-12:
429
- continue
430
- t_stat = (ct_m - g_m) / se
431
- num = (ct_v / n_ct + g_v / n_cells) ** 2
432
- denom = (ct_v / n_ct) ** 2 / max(n_ct - 1, 1) + (g_v / n_cells) ** 2 / max(n_cells - 1, 1)
433
- df = num / max(denom, 1e-12)
434
- pval = float(t_dist.sf(abs(t_stat), df) * 2)
435
- if t_stat > best_t:
436
- best_t = t_stat
437
- best_ct = ct
438
- best_pval = pval
439
- rows.append({"module_idx": module_i, "gene": gene, "top_celltype": best_ct,
440
- "t_stat": best_t, "pval": best_pval})
441
-
442
- df = pd.DataFrame(rows)
443
- # BH FDR correction
444
- pvals = df["pval"].values
445
- n = len(pvals)
446
- sorted_idx = np.argsort(pvals)
447
- sorted_pvals = pvals[sorted_idx]
448
- fdr = np.minimum(1.0, sorted_pvals * n / (np.arange(n) + 1))
449
- for i in range(n - 2, -1, -1):
450
- fdr[i] = min(fdr[i], fdr[i + 1])
451
- fdr_all = np.empty(n)
452
- fdr_all[sorted_idx] = fdr
453
- df["fdr"] = fdr_all
454
- return df
455
-
456
-
457
- def compute_attention_expression_corr(
458
- global_mean: Dict[int, np.ndarray],
459
- expr_mean: Dict[int, np.ndarray],
460
- module_gene_names: Dict[int, List[str]],
461
- num_modules: int,
462
- ) -> Dict[str, float]:
463
- """Correlation between mean attention and mean expression across genes."""
464
- all_attn = []
465
- all_expr = []
466
- for module_i in range(num_modules):
467
- all_attn.extend(global_mean[module_i].tolist())
468
- all_expr.extend(expr_mean[module_i].tolist())
469
- all_attn = np.array(all_attn)
470
- all_expr = np.array(all_expr)
471
- mask = (all_attn > 0) & (all_expr > 0)
472
- if mask.sum() > 10:
473
- pearson_r = float(np.corrcoef(all_attn[mask], all_expr[mask])[0, 1])
474
- sp_r, sp_p = spearmanr(all_attn[mask], all_expr[mask])
475
- else:
476
- pearson_r, sp_r, sp_p = 0.0, 0.0, 1.0
477
- return {"pearson_r": pearson_r, "spearman_r": float(sp_r),
478
- "spearman_pval": float(sp_p), "n_genes": int(mask.sum())}
479
-
480
-
481
- def compute_random_baseline(
482
- global_mean: Dict[int, np.ndarray],
483
- num_modules: int,
484
- ) -> Dict[str, float]:
485
- """Compare learned attention to uniform baseline."""
486
- kl_divs = []
487
- learned_entropies = []
488
- for module_i in range(num_modules):
489
- attn = global_mean[module_i].astype(np.float64)
490
- attn = attn / max(attn.sum(), 1e-10)
491
- g_size = len(attn)
492
- uniform = np.ones(g_size) / g_size
493
- mask = attn > 1e-12
494
- kl = float(np.sum(attn[mask] * np.log(attn[mask] / uniform[mask])))
495
- kl_divs.append(kl)
496
- h_learned = -float(np.sum(attn[mask] * np.log(attn[mask])))
497
- h_max = np.log(g_size)
498
- learned_entropies.append(h_learned / max(h_max, 1e-10))
499
- return {
500
- "mean_kl_divergence": float(np.mean(kl_divs)),
501
- "std_kl_divergence": float(np.std(kl_divs)),
502
- "mean_normalized_entropy": float(np.mean(learned_entropies)),
503
- "entropy_reduction": float(1.0 - np.mean(learned_entropies)),
504
- "n_modules": num_modules,
505
- }
506
-
507
-
508
- def compute_attention_concentration(
509
- global_mean: Dict[int, np.ndarray],
510
- ct_mean: Dict[str, Dict[int, np.ndarray]],
511
- ct_count: Dict[str, int],
512
- num_modules: int,
513
- min_cells: int = 30,
514
- ) -> pd.DataFrame:
515
- """Gini coefficient per module measuring attention selectivity."""
516
- def gini(arr):
517
- arr = np.sort(arr.flatten())
518
- n = len(arr)
519
- if n == 0 or arr.sum() == 0:
520
- return 0.0
521
- index = np.arange(1, n + 1)
522
- return float((2 * np.sum(index * arr) - (n + 1) * np.sum(arr)) / (n * np.sum(arr)))
523
-
524
- valid_cts = [ct for ct, n in ct_count.items() if n >= min_cells]
525
- rows = []
526
- for module_i in range(num_modules):
527
- g_attn = global_mean[module_i]
528
- g_gini = gini(g_attn)
529
- ct_ginis = [gini(ct_mean[ct][module_i]) for ct in valid_cts]
530
- rows.append({
531
- "module_idx": module_i, "global_gini": g_gini,
532
- "mean_ct_gini": float(np.mean(ct_ginis)) if ct_ginis else 0.0,
533
- "module_size": len(g_attn),
534
- })
535
- return pd.DataFrame(rows)
536
-
537
-
538
- def compute_marker_rank_within_celltype(
539
- ct_mean: Dict[str, Dict[int, np.ndarray]],
540
- ct_count: Dict[str, int],
541
- module_gene_names: Dict[int, List[str]],
542
- num_modules: int,
543
- marker_dict: Dict[str, List[str]],
544
- min_cells: int = 30,
545
- ) -> pd.DataFrame:
546
- """Rank marker genes within their expected cell type by attention."""
547
- rows = []
548
- for ct, markers in marker_dict.items():
549
- if ct not in ct_count or ct_count[ct] < min_cells:
550
- continue
551
- gene_attn = {}
552
- for module_i in range(num_modules):
553
- genes = module_gene_names[module_i]
554
- attn = ct_mean[ct][module_i]
555
- for g_idx, gene in enumerate(genes):
556
- if gene not in gene_attn or float(attn[g_idx]) > gene_attn[gene]:
557
- gene_attn[gene] = float(attn[g_idx])
558
- sorted_genes = sorted(gene_attn.items(), key=lambda x: -x[1])
559
- gene_rank = {g: rank + 1 for rank, (g, _) in enumerate(sorted_genes)}
560
- n_total = len(gene_rank)
561
- for marker in markers:
562
- if marker in gene_rank:
563
- rank = gene_rank[marker]
564
- rows.append({
565
- "celltype": ct, "marker_gene": marker, "rank": rank,
566
- "percentile": rank / n_total * 100, "n_total_genes": n_total,
567
- "attention": gene_attn[marker],
568
- })
569
- return pd.DataFrame(rows)
570
-
571
-
572
- # ---------------------------------------------------------------------------
573
- # Marker gene validation
574
- # ---------------------------------------------------------------------------
575
-
576
- def validate_markers(
577
- specificity_df: pd.DataFrame,
578
- marker_dict: Dict[str, List[str]],
579
- sort_col: str = "fold_change",
580
- ) -> Dict[str, object]:
581
- """Validate whether high-attention genes match known markers."""
582
- results = {}
583
- all_genes_in_model = set(specificity_df["gene"].unique())
584
- for ct, markers in marker_dict.items():
585
- markers_in_model = [g for g in markers if g in all_genes_in_model]
586
- if not markers_in_model:
587
- continue
588
- ct_specific = specificity_df[specificity_df["top_celltype"] == ct].copy()
589
- ct_specific = ct_specific.sort_values(sort_col, ascending=False).reset_index(drop=True)
590
- marker_set = set(markers_in_model)
591
- n_total = len(ct_specific)
592
- if n_total == 0:
593
- continue
594
- top_50_genes = set(ct_specific.head(50)["gene"])
595
- top_100_genes = set(ct_specific.head(100)["gene"])
596
- hits_50 = len(marker_set & top_50_genes)
597
- hits_100 = len(marker_set & top_100_genes)
598
- marker_rows = ct_specific[ct_specific["gene"].isin(marker_set)]
599
- if len(marker_rows) > 0:
600
- marker_ranks = (marker_rows.index + 1).tolist()
601
- mean_rank = np.mean(marker_ranks)
602
- median_rank = np.median(marker_ranks)
603
- else:
604
- mean_rank = median_rank = n_total
605
- results[ct] = {
606
- "n_markers_in_model": len(markers_in_model),
607
- "markers_in_model": markers_in_model,
608
- "n_genes_assigned_to_ct": n_total,
609
- "hits_in_top50": hits_50, "hits_in_top100": hits_100,
610
- "precision_at_50": hits_50 / min(50, n_total),
611
- "precision_at_100": hits_100 / min(100, n_total),
612
- "marker_mean_rank": float(mean_rank),
613
- "marker_median_rank": float(median_rank),
614
- "recall_at_50": hits_50 / len(markers_in_model) if markers_in_model else 0,
615
- "recall_at_100": hits_100 / len(markers_in_model) if markers_in_model else 0,
616
- }
617
- return results
618
-
619
-
620
- def validate_deg_correlation(
621
- ct_dfs: Dict[str, pd.DataFrame],
622
- deg_df: pd.DataFrame,
623
- ) -> Dict[str, Dict[str, float]]:
624
- """Compare attention ranking with DEG ranking per cell type."""
625
- results = {}
626
- for ct, attn_df in ct_dfs.items():
627
- ct_deg = deg_df[deg_df["cell_type"] == ct].copy()
628
- if len(ct_deg) == 0:
629
- continue
630
- merged = attn_df.merge(ct_deg[["gene", "log2fc"]], on="gene", how="inner")
631
- if len(merged) < 10:
632
- continue
633
- rho, pval = spearmanr(merged["mean_attention"], merged["log2fc"].abs())
634
- results[ct] = {"spearman_r": float(rho), "pval": float(pval), "n_genes": len(merged)}
635
- return results
636
-
637
-
638
- # ---------------------------------------------------------------------------
639
- # Publication-quality visualization
640
- # ---------------------------------------------------------------------------
641
-
642
- def plot_global_importance_pub(global_df, specificity_zscore_df, top_k, output_dir):
643
- """Fig1: Top-K genes with std error bars, colored by top cell type."""
644
- setup_publication_style()
645
- gene_stats = global_df.groupby("gene").agg(
646
- mean_attn=("mean_attention", "mean"), std_attn=("std_attention", "mean"),
647
- ).nlargest(top_k, "mean_attn")
648
- gene_ct = specificity_zscore_df.drop_duplicates("gene").set_index("gene")["top_celltype"]
649
- fig, ax = plt.subplots(figsize=(4.5, 5))
650
- genes = gene_stats.index.tolist()
651
- y_pos = np.arange(len(genes))
652
- colors = [PANCREAS_COLORS.get(gene_ct.get(g, ""), "#999999") for g in genes]
653
- ax.barh(y_pos, gene_stats["mean_attn"], xerr=gene_stats["std_attn"],
654
- color=colors, edgecolor="none", height=0.7, capsize=2, error_kw={"linewidth": 0.8})
655
- ax.set_yticks(y_pos)
656
- ax.set_yticklabels(genes)
657
- ax.set_xlabel("Mean attention weight")
658
- ax.set_title("Top genes by global attention")
659
- ax.invert_yaxis()
660
- pub_savefig(fig, output_dir / "fig1_global_importance")
661
- logger.info("Saved fig1_global_importance (PDF+PNG)")
662
-
663
-
664
- def plot_celltype_heatmap_pub(ct_dfs, ct_count, top_k, output_dir, min_cells=30):
665
- """Fig2: Clustered heatmap of gene attention (z-score normalized)."""
666
- setup_publication_style()
667
- valid_cts = {ct for ct, n in ct_count.items() if n >= min_cells}
668
- all_genes_attn = {}
669
- for ct, df in ct_dfs.items():
670
- if ct not in valid_cts:
671
- continue
672
- all_genes_attn[ct] = df.groupby("gene")["mean_attention"].mean()
673
- combined = pd.DataFrame(all_genes_attn)
674
- gene_var = combined.var(axis=1).nlargest(top_k).index
675
- heatmap_data = combined.loc[gene_var]
676
- row_mean = heatmap_data.mean(axis=1)
677
- row_std = heatmap_data.std(axis=1).replace(0, 1)
678
- heatmap_z = heatmap_data.sub(row_mean, axis=0).div(row_std, axis=0)
679
- g = sns.clustermap(heatmap_z, cmap="RdBu_r", center=0, figsize=(6, 7),
680
- xticklabels=True, yticklabels=True,
681
- dendrogram_ratio=(0.1, 0.15), cbar_kws={"label": "Z-score"},
682
- linewidths=0.3)
683
- g.ax_heatmap.set_xlabel("")
684
- g.ax_heatmap.set_ylabel("")
685
- g.fig.suptitle(f"Top {top_k} variable genes (z-scored attention)", y=1.02, fontsize=11)
686
- pub_savefig(g.fig, output_dir / "fig2_celltype_heatmap")
687
- logger.info("Saved fig2_celltype_heatmap (PDF+PNG)")
688
-
689
-
690
- def plot_specificity_volcano(welch_df, zscore_df, top_k, output_dir):
691
- """Fig3: Volcano plot (z-score vs -log10 FDR)."""
692
- setup_publication_style()
693
- merged = zscore_df[["gene", "top_celltype", "z_score"]].drop_duplicates("gene")
694
- welch_best = welch_df.loc[welch_df.groupby("gene")["t_stat"].idxmax()][["gene", "fdr"]]
695
- merged = merged.merge(welch_best, on="gene", how="left")
696
- merged["neg_log10_fdr"] = -np.log10(merged["fdr"].clip(lower=1e-300))
697
- fig, ax = plt.subplots(figsize=(5.5, 5))
698
- for ct in merged["top_celltype"].unique():
699
- subset = merged[merged["top_celltype"] == ct]
700
- color = PANCREAS_COLORS.get(ct, "#999999")
701
- ax.scatter(subset["z_score"], subset["neg_log10_fdr"],
702
- c=color, s=8, alpha=0.5, label=ct, rasterized=True)
703
- top_genes = merged.nlargest(top_k, "z_score")
704
- for _, row in top_genes.iterrows():
705
- ax.annotate(row["gene"], (row["z_score"], row["neg_log10_fdr"]),
706
- fontsize=6, ha="left", va="bottom")
707
- ax.axhline(-np.log10(0.05), color="gray", linestyle="--", linewidth=0.6)
708
- ax.axvline(2, color="gray", linestyle="--", linewidth=0.6)
709
- ax.set_xlabel("Z-score (cell-type specificity)")
710
- ax.set_ylabel(r"$-\log_{10}$(FDR)")
711
- ax.set_title("Gene specificity volcano")
712
- ax.legend(bbox_to_anchor=(1.02, 1), loc="upper left", fontsize=7, frameon=False, markerscale=2)
713
- pub_savefig(fig, output_dir / "fig3_specificity_volcano")
714
- logger.info("Saved fig3_specificity_volcano (PDF+PNG)")
715
-
716
-
717
- def plot_marker_dotplot(marker_rank_df, output_dir):
718
- """Fig4: Dot plot of marker gene rank percentile per cell type."""
719
- setup_publication_style()
720
- if marker_rank_df.empty:
721
- return
722
- fig, ax = plt.subplots(figsize=(6, 4.5))
723
- celltypes = marker_rank_df["celltype"].unique()
724
- ct_positions = {ct: i for i, ct in enumerate(celltypes)}
725
- for _, row in marker_rank_df.iterrows():
726
- y = ct_positions[row["celltype"]]
727
- size = max(10, 200 * (1 - row["percentile"] / 100))
728
- color = PANCREAS_COLORS.get(row["celltype"], "#999999")
729
- ax.scatter(row["percentile"], y, s=size, c=color, alpha=0.7,
730
- edgecolors="white", linewidth=0.3)
731
- if row["percentile"] < 5:
732
- ax.annotate(row["marker_gene"], (row["percentile"], y),
733
- fontsize=6, ha="left", va="center", xytext=(3, 0),
734
- textcoords="offset points")
735
- ax.set_yticks(range(len(celltypes)))
736
- ax.set_yticklabels(celltypes)
737
- ax.set_xlabel("Rank percentile (lower = higher attention)")
738
- ax.set_title("Marker gene rank within expected cell type")
739
- ax.axvline(5, color="gray", linestyle="--", linewidth=0.6, alpha=0.5, label="Top 5%")
740
- ax.legend(fontsize=7, frameon=False)
741
- ax.set_xlim(-2, 105)
742
- pub_savefig(fig, output_dir / "fig4_marker_dotplot")
743
- logger.info("Saved fig4_marker_dotplot (PDF+PNG)")
744
-
745
-
746
- def plot_marker_recall_pub(marker_results, ct_count, output_dir, min_cells=30):
747
- """Fig5: Marker recall bar chart with random expectation line."""
748
- setup_publication_style()
749
- if not marker_results:
750
- return
751
- cts = [ct for ct in marker_results.keys() if ct_count.get(ct, 0) >= min_cells]
752
- if not cts:
753
- return
754
- recall_50 = [marker_results[ct]["recall_at_50"] for ct in cts]
755
- recall_100 = [marker_results[ct]["recall_at_100"] for ct in cts]
756
- x = np.arange(len(cts))
757
- width = 0.35
758
- fig, ax = plt.subplots(figsize=(max(5, len(cts) * 0.7), 4))
759
- colors_50 = [PANCREAS_COLORS.get(ct, "#999999") for ct in cts]
760
- ax.bar(x - width / 2, recall_50, width, color=colors_50, alpha=0.9, label="Recall@50")
761
- ax.bar(x + width / 2, recall_100, width, color=colors_50, alpha=0.5, label="Recall@100")
762
- # Random expectation
763
- for i, ct in enumerate(cts):
764
- n_genes = marker_results[ct]["n_genes_assigned_to_ct"]
765
- if n_genes > 0:
766
- random_50 = min(50, n_genes) / n_genes
767
- ax.hlines(random_50, i - 0.4, i + 0.4, colors="gray", linestyles=":", linewidth=0.8)
768
- ax.set_xticks(x)
769
- ax.set_xticklabels(cts, rotation=45, ha="right")
770
- ax.set_ylabel("Recall")
771
- ax.set_title("Marker gene recall by cell type")
772
- ax.legend(fontsize=7, frameon=False)
773
- pub_savefig(fig, output_dir / "fig5_marker_recall")
774
- logger.info("Saved fig5_marker_recall (PDF+PNG)")
775
-
776
-
777
- def plot_attention_vs_expression(global_mean, expr_mean, module_gene_names, num_modules, output_dir):
778
- """Fig6: Scatter of mean attention vs mean expression."""
779
- setup_publication_style()
780
- all_attn, all_expr = [], []
781
- for module_i in range(num_modules):
782
- all_attn.extend(global_mean[module_i].tolist())
783
- all_expr.extend(expr_mean[module_i].tolist())
784
- all_attn = np.array(all_attn)
785
- all_expr = np.array(all_expr)
786
- mask = (all_attn > 0) & (all_expr > 0)
787
- fig, ax = plt.subplots(figsize=(4.5, 4.5))
788
- ax.scatter(all_expr[mask], all_attn[mask], s=3, alpha=0.3, c="#377EB8", rasterized=True)
789
- r = np.corrcoef(all_expr[mask], all_attn[mask])[0, 1]
790
- ax.set_xlabel("Mean expression")
791
- ax.set_ylabel("Mean attention weight")
792
- ax.set_title("Attention vs expression")
793
- ax.text(0.05, 0.95, f"Pearson r = {r:.3f}", transform=ax.transAxes,
794
- fontsize=9, va="top", ha="left")
795
- pub_savefig(fig, output_dir / "fig6_attn_vs_expression")
796
- logger.info("Saved fig6_attn_vs_expression (PDF+PNG)")
797
-
798
-
799
- def plot_concentration_pub(concentration_df, baseline_stats, output_dir):
800
- """Fig7: Gini coefficient distribution (learned vs uniform)."""
801
- setup_publication_style()
802
- fig, axes = plt.subplots(1, 2, figsize=(8, 3.5))
803
- # Panel a: Gini histogram
804
- ax = axes[0]
805
- ax.hist(concentration_df["global_gini"], bins=30, alpha=0.7, color="#377EB8", label="Global")
806
- ax.hist(concentration_df["mean_ct_gini"], bins=30, alpha=0.7, color="#E41A1C", label="Per cell-type")
807
- ax.axvline(0, color="gray", linestyle="--", linewidth=0.6)
808
- ax.set_xlabel("Gini coefficient")
809
- ax.set_ylabel("Number of modules")
810
- ax.set_title("Attention concentration")
811
- ax.legend(fontsize=7, frameon=False)
812
- # Panel b: Entropy comparison
813
- ax = axes[1]
814
- labels = ["Learned", "Uniform"]
815
- values = [baseline_stats["mean_normalized_entropy"], 1.0]
816
- bars = ax.bar(labels, values, color=["#377EB8", "#CCCCCC"], width=0.5)
817
- ax.set_ylabel("Normalized entropy")
818
- ax.set_title(f"Entropy reduction: {baseline_stats['entropy_reduction']:.1%}")
819
- ax.set_ylim(0, 1.1)
820
- plt.tight_layout()
821
- pub_savefig(fig, output_dir / "fig7_concentration")
822
- logger.info("Saved fig7_concentration (PDF+PNG)")
823
-
824
-
825
- def plot_baseline_kl(baseline_stats, output_dir):
826
- """Fig8: KL divergence summary."""
827
- setup_publication_style()
828
- fig, ax = plt.subplots(figsize=(3.5, 3.5))
829
- ax.bar(["KL(learned||uniform)"], [baseline_stats["mean_kl_divergence"]],
830
- yerr=[baseline_stats["std_kl_divergence"]], color="#377EB8",
831
- capsize=5, width=0.4)
832
- ax.set_ylabel("KL divergence (nats)")
833
- ax.set_title("Deviation from uniform attention")
834
- pub_savefig(fig, output_dir / "fig8_baseline_kl")
835
- logger.info("Saved fig8_baseline_kl (PDF+PNG)")
836
-
837
-
838
- def plot_deg_correlation_pub(deg_results, output_dir):
839
- """Fig9: DEG correlation with significance."""
840
- setup_publication_style()
841
- if not deg_results:
842
- return
843
- cts = sorted(deg_results.keys(), key=lambda c: deg_results[c]["spearman_r"], reverse=True)
844
- rhos = [deg_results[ct]["spearman_r"] for ct in cts]
845
- pvals = [deg_results[ct]["pval"] for ct in cts]
846
- fig, ax = plt.subplots(figsize=(max(5, len(cts) * 0.6), 4))
847
- colors = ["#377EB8" if r > 0 else "#E41A1C" for r in rhos]
848
- bars = ax.bar(range(len(cts)), rhos, color=colors, width=0.6)
849
- for i, (bar, p) in enumerate(zip(bars, pvals)):
850
- if p < 0.001:
851
- ax.text(i, bar.get_height() + 0.02, "***", ha="center", fontsize=7)
852
- elif p < 0.01:
853
- ax.text(i, bar.get_height() + 0.02, "**", ha="center", fontsize=7)
854
- elif p < 0.05:
855
- ax.text(i, bar.get_height() + 0.02, "*", ha="center", fontsize=7)
856
- ax.set_xticks(range(len(cts)))
857
- ax.set_xticklabels(cts, rotation=45, ha="right")
858
- ax.set_ylabel("Spearman r")
859
- ax.set_title("Attention vs DEG correlation")
860
- ax.axhline(0, color="gray", linewidth=0.5)
861
- pub_savefig(fig, output_dir / "fig9_deg_correlation")
862
- logger.info("Saved fig9_deg_correlation (PDF+PNG)")
863
-
864
-
865
- # ---------------------------------------------------------------------------
866
- # Enrichment analysis (optional)
867
- # ---------------------------------------------------------------------------
868
-
869
- def run_enrichment_analysis(
870
- global_df: pd.DataFrame, top_k: int, output_dir: Path, organism: str = "human",
871
- ) -> Optional[pd.DataFrame]:
872
- """Run GO enrichment on top-K attention genes using gseapy."""
873
- try:
874
- import gseapy as gp
875
- except ImportError:
876
- logger.warning("gseapy not installed, skipping enrichment. Install: pip install gseapy")
877
- return None
878
- top_genes = global_df.groupby("gene")["mean_attention"].mean().nlargest(top_k * 5).index.tolist()
879
- logger.info("Running GO enrichment on top %d genes...", len(top_genes))
880
- try:
881
- enr = gp.enrichr(
882
- gene_list=top_genes,
883
- gene_sets=["GO_Biological_Process_2021", "KEGG_2021_Human"],
884
- organism=organism,
885
- outdir=str(output_dir / "enrichment"),
886
- no_plot=True,
887
- )
888
- results_df = enr.results
889
- results_df.to_csv(output_dir / "enrichment_results.csv", index=False)
890
- logger.info("Enrichment results saved (%d terms)", len(results_df))
891
- return results_df
892
- except Exception as e:
893
- logger.warning("Enrichment analysis failed: %s", e)
894
- return None
895
-
896
-
897
- # ---------------------------------------------------------------------------
898
- # Main
899
- # ---------------------------------------------------------------------------
900
-
901
- def main():
902
- args = parse_args()
903
- seed_all(args.seed)
904
-
905
- _script_dir = Path(__file__).resolve().parent
906
- for attr in ("performer_checkpoint", "config_pkl", "module_data_dir", "gene_list_path",
907
- "gene_mapping_path", "gene2vec_data_path", "gene2ens_file", "data_path"):
908
- val = getattr(args, attr, None)
909
- if val is not None and not Path(val).is_absolute():
910
- setattr(args, attr, str((_script_dir / val).resolve()))
911
-
912
- output_dir = ensure_dir(Path(args.output_dir))
913
- device = torch.device(args.device if torch.cuda.is_available() else "cpu")
914
- logger.info("Device: %s", device)
915
-
916
- # --- Load model ---
917
- logger.info("Loading backbone from %s", args.performer_checkpoint)
918
- backbone, biomod_data, model_config = load_backbone(args, device)
919
- backbone.eval()
920
-
921
- # --- Build module gene name mapping ---
922
- model_genes_file = args.gene_list_path
923
- with open(model_genes_file) as f:
924
- model_genes = [line.strip() for line in f if line.strip()]
925
-
926
- gene2ens_df = pd.read_csv(args.gene2ens_file)
927
- ens_to_symbol = dict(zip(gene2ens_df["Ensembl gene ID"], gene2ens_df.iloc[:, 0]))
928
-
929
- module_sets = backbone.module_embed.module_sets
930
- num_modules = len(module_sets)
931
- module_sizes = [len(ps) for ps in module_sets]
932
-
933
- module_gene_names: Dict[int, List[str]] = {}
934
- for module_i, gene_idx in enumerate(module_sets):
935
- names = []
936
- for idx in gene_idx.cpu().numpy():
937
- ens_id = model_genes[idx]
938
- symbol = ens_to_symbol.get(ens_id, ens_id)
939
- names.append(symbol)
940
- module_gene_names[module_i] = names
941
-
942
- logger.info("Modules: %d, total genes: %d", num_modules, sum(module_sizes))
943
-
944
- # --- Load and preprocess data ---
945
- logger.info("Loading data from %s", args.data_path)
946
- preprocessor = DataPreprocessor(
947
- gene2ens_file=args.gene2ens_file,
948
- gene2ens_column=args.gene2ens_column,
949
- model_genes_file=model_genes_file,
950
- )
951
- preprocessor.load_gene_mapping()
952
- preprocessor.load_model_genes()
953
-
954
- gene_name_col = args.gene_name_column if args.gene_name_column != "auto" else None
955
- adata, reindex_state = preprocessor.preprocess_adata(
956
- adata_path=args.data_path,
957
- max_cells=args.max_cells,
958
- dataset_name=args.dataset_name,
959
- gene_name_column=gene_name_col,
960
- )
961
- cell_labels = adata.obs[args.celltype_column].values.astype(str)
962
- logger.info("Data: %d cells, %d cell types", len(adata), len(np.unique(cell_labels)))
963
-
964
- # --- DataLoader ---
965
- if hasattr(adata.X, "toarray"):
966
- X = adata.X.toarray().astype(np.float32)
967
- else:
968
- X = np.array(adata.X, dtype=np.float32)
969
-
970
- dataset = MatrixRowDataset(X)
971
- loader = DataLoader(dataset, batch_size=args.batch_size, shuffle=False,
972
- num_workers=args.num_workers, pin_memory=True)
973
-
974
- # --- Extract attention (streaming) ---
975
- logger.info("Extracting module attention (streaming mode)...")
976
- (global_mean, global_std, ct_mean, ct_std, ct_count, n_cells,
977
- expr_mean, attn_expr_prod_sum) = extract_attention_streaming(
978
- backbone, loader, preprocessor, reindex_state, device,
979
- cell_labels, num_modules, module_sizes,
980
- )
981
-
982
- # --- Build DataFrames ---
983
- logger.info("Building analysis DataFrames...")
984
- global_df = build_global_importance_df(global_mean, global_std, module_gene_names, num_modules)
985
- ct_dfs = build_celltype_dfs(ct_mean, ct_count, module_gene_names, num_modules)
986
- specificity_df = compute_specificity_from_means(global_mean, ct_mean, module_gene_names, num_modules)
987
-
988
- # --- New metrics ---
989
- logger.info("Computing z-score specificity...")
990
- zscore_df = compute_specificity_zscore(
991
- global_mean, global_std, ct_mean, ct_count, module_gene_names, num_modules, args.min_cells)
992
-
993
- logger.info("Computing Cohen's d...")
994
- cohens_d_df = compute_cohens_d(
995
- global_mean, ct_mean, ct_std, ct_count, module_gene_names, num_modules, n_cells, args.min_cells)
996
-
997
- logger.info("Computing Welch's t-test...")
998
- welch_df = compute_welch_ttest(
999
- global_mean, global_std, ct_mean, ct_std, ct_count, module_gene_names,
1000
- num_modules, n_cells, args.min_cells)
1001
-
1002
- logger.info("Computing attention-expression correlation...")
1003
- attn_expr_corr = compute_attention_expression_corr(
1004
- global_mean, expr_mean, module_gene_names, num_modules)
1005
-
1006
- logger.info("Computing random baseline comparison...")
1007
- baseline_stats = compute_random_baseline(global_mean, num_modules)
1008
-
1009
- logger.info("Computing attention concentration...")
1010
- concentration_df = compute_attention_concentration(
1011
- global_mean, ct_mean, ct_count, num_modules, args.min_cells)
1012
-
1013
- # --- Marker validation ---
1014
- marker_dict = PANCREAS_MARKERS
1015
- if args.marker_db:
1016
- with open(args.marker_db) as f:
1017
- marker_dict = json.load(f)
1018
-
1019
- logger.info("Computing marker ranks within cell types...")
1020
- marker_rank_df = compute_marker_rank_within_celltype(
1021
- ct_mean, ct_count, module_gene_names, num_modules, marker_dict, args.min_cells)
1022
-
1023
- marker_results_zscore = validate_markers(zscore_df, marker_dict, sort_col="z_score")
1024
- marker_results_fc = validate_markers(specificity_df, marker_dict, sort_col="fold_change")
1025
-
1026
- # --- Save CSVs ---
1027
- logger.info("Saving CSV results...")
1028
- global_df.to_csv(output_dir / "global_gene_importance.csv", index=False)
1029
- specificity_df.to_csv(output_dir / "gene_specificity.csv", index=False)
1030
- zscore_df.to_csv(output_dir / "gene_specificity_zscore.csv", index=False)
1031
- cohens_d_df.to_csv(output_dir / "gene_specificity_cohens_d.csv", index=False)
1032
- welch_df.to_csv(output_dir / "gene_specificity_welch.csv", index=False)
1033
- concentration_df.to_csv(output_dir / "attention_concentration.csv", index=False)
1034
- if not marker_rank_df.empty:
1035
- marker_rank_df.to_csv(output_dir / "marker_ranks_within_celltype.csv", index=False)
1036
- for ct, df in ct_dfs.items():
1037
- safe_name = ct.replace("/", "_").replace(" ", "_")
1038
- df.to_csv(output_dir / f"celltype_{safe_name}_attention.csv", index=False)
1039
-
1040
- with open(output_dir / "marker_validation_zscore.json", "w") as f:
1041
- json.dump(marker_results_zscore, f, indent=2, ensure_ascii=False)
1042
- with open(output_dir / "marker_validation.json", "w") as f:
1043
- json.dump(marker_results_fc, f, indent=2, ensure_ascii=False)
1044
-
1045
- # --- DEG correlation ---
1046
- deg_results = None
1047
- if args.deg_csv:
1048
- deg_df = pd.read_csv(args.deg_csv)
1049
- deg_results = validate_deg_correlation(ct_dfs, deg_df)
1050
- with open(output_dir / "deg_correlation.json", "w") as f:
1051
- json.dump(deg_results, f, indent=2)
1052
-
1053
- # --- Publication figures ---
1054
- logger.info("Generating publication figures...")
1055
- plot_global_importance_pub(global_df, zscore_df, args.top_k_genes, output_dir)
1056
- plot_celltype_heatmap_pub(ct_dfs, ct_count, args.top_k_genes, output_dir, args.min_cells)
1057
- plot_specificity_volcano(welch_df, zscore_df, args.top_k_genes, output_dir)
1058
- plot_marker_dotplot(marker_rank_df, output_dir)
1059
- plot_marker_recall_pub(marker_results_zscore, ct_count, output_dir, args.min_cells)
1060
- plot_attention_vs_expression(global_mean, expr_mean, module_gene_names, num_modules, output_dir)
1061
- plot_concentration_pub(concentration_df, baseline_stats, output_dir)
1062
- plot_baseline_kl(baseline_stats, output_dir)
1063
- if deg_results:
1064
- plot_deg_correlation_pub(deg_results, output_dir)
1065
-
1066
- # --- Enrichment ---
1067
- run_enrichment_analysis(global_df, args.top_k_genes, output_dir)
1068
-
1069
- # --- Summary ---
1070
- filtered_cts = [ct for ct, n in ct_count.items() if n < args.min_cells]
1071
- summary = {
1072
- "n_cells": n_cells,
1073
- "n_modules": num_modules,
1074
- "n_celltypes": len(ct_count),
1075
- "celltypes": {ct: int(n) for ct, n in ct_count.items()},
1076
- "min_cells_filter": args.min_cells,
1077
- "filtered_celltypes": filtered_cts,
1078
- "top_10_global_genes": global_df.groupby("gene")["mean_attention"].mean()
1079
- .nlargest(10).to_dict(),
1080
- "attn_expr_correlation": attn_expr_corr,
1081
- "baseline_comparison": baseline_stats,
1082
- "mean_gini_global": float(concentration_df["global_gini"].mean()),
1083
- "mean_gini_celltype": float(concentration_df["mean_ct_gini"].mean()),
1084
- }
1085
- with open(output_dir / "summary.json", "w") as f:
1086
- json.dump(summary, f, indent=2, ensure_ascii=False)
1087
-
1088
- logger.info("Done. Results saved to %s", output_dir)
1089
-
1090
-
1091
- if __name__ == "__main__":
1092
- main()
1093
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
BioMod/interpretability/biomod_geometry.py DELETED
@@ -1,655 +0,0 @@
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_biomods.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 BioMod/interpretability/biomod_geometry.py \\
48
- --biomod_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
- BIOMOD_SOURCE = HERE.parents[1]
83
- REPO_ROOT = HERE.parents[2]
84
- ASSET_ROOT = REPO_ROOT / "model_dependencies"
85
- for p in (str(BIOMOD_SOURCE), str(REPO_ROOT)):
86
- if p not in sys.path:
87
- sys.path.insert(0, p)
88
-
89
- from cell_annotation.biomod_finetune import ( # noqa: E402
90
- DataPreprocessor,
91
- get_rank,
92
- get_world_size,
93
- init_distributed_mode,
94
- is_dist_initialized,
95
- is_main_process,
96
- load_backbone,
97
- reduce_sum,
98
- resolve_path as ca_resolve_path,
99
- seed_all,
100
- )
101
-
102
- from interpretability.utils import ( # noqa: E402
103
- MatrixRowDataset,
104
- NpzCellDataset,
105
- build_biomod_metadata,
106
- ensure_dir,
107
- load_biomod_names,
108
- )
109
-
110
- logger = logging.getLogger(__name__)
111
-
112
-
113
- # ---------- argparse ----------
114
-
115
- def parse_args() -> argparse.Namespace:
116
- parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
117
-
118
- # Pretrained backbone (mirrors cell_annotation.biomod_finetune)
119
- parser.add_argument("--biomod_checkpoint", "--performer_checkpoint", type=str, required=True, dest='performer_checkpoint')
120
- parser.add_argument("--config_pkl", type=str, required=True)
121
- parser.add_argument("--biomod_data_dir", "--module_data_dir", type=str, default=str(ASSET_ROOT / "biomod_data_all"), dest='module_data_dir')
122
- parser.add_argument("--biomod_json", "--module_json", type=str, default=None,
123
- help="Path to merged_biomods.json. Defaults to <module_data_dir>/merged_biomods.json.", dest='module_json')
124
- parser.add_argument("--gene_list_path", type=str, default=str(ASSET_ROOT / "selected_genes_27k.txt"))
125
- parser.add_argument("--gene_mapping_path", type=str, default=str(ASSET_ROOT / "gene2ens.csv"))
126
- parser.add_argument("--gene2vec_data_path", type=str, default=str(ASSET_ROOT / "selected_gene2vec_27k.npy"))
127
- parser.add_argument("--gene2ens_file", type=str, default=str(ASSET_ROOT / "gene2ens.csv"))
128
- parser.add_argument("--gene2ens_column", type=str, default="Ensembl gene ID")
129
- parser.add_argument("--shuffle_biomod_genes", "--shuffle_module_genes", action="store_true",
130
- help="Randomly reshuffle gene-to-module assignment (control / ablation).", dest='shuffle_module_genes')
131
- parser.add_argument("--shuffle_seed", type=int, default=2021)
132
-
133
- # Data
134
- parser.add_argument("--data_path", type=str, required=True,
135
- help="h5ad file path, or npz directory for pretrain_npz mode.")
136
- parser.add_argument("--data_mode", type=str, default="h5ad",
137
- choices=["h5ad", "pretrain_h5ad", "pretrain_npz"],
138
- help="h5ad: generic, goes through DataPreprocessor reindex. "
139
- "pretrain_h5ad: var.index is already the 27874 model gene list, skip reindex. "
140
- "pretrain_npz: per-cell npz directory (cell0.npz, ...), skip reindex.")
141
- parser.add_argument("--cell_num", type=int, default=None,
142
- help="Total cell count for pretrain_npz sequential naming (cell0.npz..cellN.npz).")
143
- parser.add_argument("--gene_name_column", type=str, default="auto",
144
- help="Which adata.var column holds gene symbols. 'auto' / 'index' / column name. "
145
- "Only used when data_mode=h5ad.")
146
- parser.add_argument("--dataset_name", type=str, default="interpretability")
147
- parser.add_argument("--min_genes", type=int, default=0)
148
- parser.add_argument("--min_cells", type=int, default=0)
149
- parser.add_argument("--max_cells", type=int, default=None,
150
- help="Optional subsample for inference speed. None = use all cells.")
151
-
152
- # Inference
153
- parser.add_argument("--device", type=str, default="cuda")
154
- parser.add_argument("--batch_size", type=int, default=8)
155
- parser.add_argument("--num_workers", type=int, default=0)
156
- parser.add_argument("--seed", type=int, default=2021)
157
-
158
- # Geometry / metrics
159
- parser.add_argument("--knn_k", type=int, default=10,
160
- help="k for kNN-purity metric.")
161
- parser.add_argument("--null_permutations", type=int, default=200,
162
- help="Number of label-permutation samples for null distribution.")
163
- parser.add_argument("--include_other_in_metrics", action="store_true",
164
- help="If false (default), exclude modules whose family is 'Other' from metric computation.")
165
-
166
- # UMAP
167
- parser.add_argument("--umap_n_neighbors", type=int, default=15)
168
- parser.add_argument("--umap_min_dist", type=float, default=0.3)
169
-
170
- # Output
171
- parser.add_argument("--output_dir", type=str, required=True)
172
- parser.add_argument("--run_name", type=str, default="")
173
-
174
- return parser.parse_args()
175
-
176
-
177
- # ---------- dataset construction ----------
178
-
179
- def build_inference_loader(
180
- args: argparse.Namespace,
181
- preprocessor: Optional[DataPreprocessor],
182
- use_ddp: bool,
183
- rank: int,
184
- world_size: int,
185
- ) -> Tuple[DataLoader, Optional[dict], int]:
186
- """Build a DataLoader yielding raw [G_input] expression rows and a reindex_state.
187
-
188
- Returns: (loader, reindex_state_or_None, n_cells_total)
189
- """
190
- if args.data_mode == "h5ad":
191
- import anndata as ad
192
- assert preprocessor is not None
193
- adata, reindex_state = preprocessor.preprocess_adata(
194
- adata_path=args.data_path,
195
- min_genes=args.min_genes,
196
- min_cells=args.min_cells,
197
- max_cells=args.max_cells,
198
- dataset_name=args.dataset_name,
199
- gene_name_column=args.gene_name_column,
200
- )
201
- dataset = MatrixRowDataset(adata.X)
202
- n_cells = adata.n_obs
203
- elif args.data_mode == "pretrain_h5ad":
204
- import anndata as ad
205
- logger.info("Loading pretrain-mode h5ad %s", args.data_path)
206
- adata = ad.read_h5ad(args.data_path)
207
- if args.max_cells is not None and adata.n_obs > args.max_cells:
208
- rng = np.random.default_rng(args.seed)
209
- chosen = np.sort(rng.choice(adata.n_obs, size=args.max_cells, replace=False))
210
- adata = adata[chosen].copy()
211
- dataset = MatrixRowDataset(adata.X)
212
- n_cells = adata.n_obs
213
- reindex_state = None
214
- elif args.data_mode == "pretrain_npz":
215
- dataset = NpzCellDataset(
216
- data_dir=args.data_path,
217
- cell_num=args.cell_num,
218
- max_cells=args.max_cells,
219
- seed=args.seed,
220
- )
221
- n_cells = len(dataset)
222
- reindex_state = None
223
- else:
224
- raise ValueError(f"Unsupported data_mode: {args.data_mode}")
225
-
226
- if use_ddp:
227
- sampler = DistributedSampler(dataset, num_replicas=world_size, rank=rank,
228
- shuffle=False, drop_last=False)
229
- else:
230
- sampler = None
231
-
232
- loader = DataLoader(
233
- dataset,
234
- batch_size=args.batch_size,
235
- shuffle=False,
236
- sampler=sampler,
237
- num_workers=args.num_workers,
238
- pin_memory=False,
239
- )
240
- logger.info("Built inference loader: %d cells total, batch_size=%d, ddp=%s",
241
- n_cells, args.batch_size, use_ddp)
242
- return loader, reindex_state, n_cells
243
-
244
-
245
- # ---------- inference: cell-averaged module embeddings ----------
246
-
247
- @torch.no_grad()
248
- def extract_cell_averaged_biomod_embeddings(
249
- backbone,
250
- loader: DataLoader,
251
- preprocessor: Optional[DataPreprocessor],
252
- reindex_state: Optional[dict],
253
- device: torch.device,
254
- num_modules: int,
255
- base_dim: int,
256
- expected_input_genes: int,
257
- use_ddp: bool,
258
- ) -> Tuple[np.ndarray, int]:
259
- """Stream forward over the loader and accumulate the cell-mean module embedding.
260
-
261
- Under DDP each rank processes its shard; sum_emb and n_cells are all_reduced
262
- before computing the mean.
263
-
264
- Returns: (mean_embedding [num_modules, base_dim] float32, n_cells_total)
265
- """
266
- backbone.eval()
267
- sum_emb = torch.zeros(num_modules, base_dim, device=device, dtype=torch.float64)
268
- n_cells = 0
269
-
270
- for batch_idx, x in enumerate(loader):
271
- x = x.to(device, non_blocking=False)
272
- if reindex_state is not None:
273
- x_model = preprocessor.reindex_tensor_to_model_space(x, reindex_state=reindex_state)
274
- else:
275
- if x.shape[1] != expected_input_genes:
276
- raise ValueError(
277
- f"pretrain mode expects {expected_input_genes} genes per cell, got {x.shape[1]}. "
278
- f"Use --data_mode h5ad (with reindex) for arbitrary gene spaces."
279
- )
280
- x_model = x
281
-
282
- out = backbone(x_model, mask_ratio=0.0, return_encodings=True)
283
- enc = out["module_encodings"] # (B, num_modules, D)
284
- if enc.shape[1] != num_modules or enc.shape[2] != base_dim:
285
- raise RuntimeError(
286
- f"Encoder output shape mismatch: got {tuple(enc.shape)}, "
287
- f"expected (*, {num_modules}, {base_dim}). "
288
- "Module order between merged_biomods.json and the loaded backbone may have drifted."
289
- )
290
- sum_emb += enc.to(torch.float64).sum(dim=0)
291
- n_cells += enc.shape[0]
292
- if (batch_idx + 1) % 50 == 0:
293
- logger.info(" forward batch %d (%d cells on this rank so far)", batch_idx + 1, n_cells)
294
-
295
- if use_ddp:
296
- dist.all_reduce(sum_emb, op=dist.ReduceOp.SUM)
297
- n_cells_tensor = torch.tensor(n_cells, device=device, dtype=torch.long)
298
- dist.all_reduce(n_cells_tensor, op=dist.ReduceOp.SUM)
299
- n_cells = int(n_cells_tensor.item())
300
-
301
- mean_emb = (sum_emb / max(n_cells, 1)).cpu().numpy().astype(np.float32)
302
- return mean_emb, n_cells
303
-
304
-
305
- # ---------- structure metrics ----------
306
-
307
- def _label_to_int(labels: List[str]) -> np.ndarray:
308
- uniq = sorted(set(labels))
309
- mapping = {lab: i for i, lab in enumerate(uniq)}
310
- return np.asarray([mapping[lab] for lab in labels], dtype=np.int64)
311
-
312
-
313
- def _knn_purity(emb: np.ndarray, labels: np.ndarray, k: int, metric: str = "cosine") -> float:
314
- n = emb.shape[0]
315
- k_use = min(k, n - 1)
316
- nn = NearestNeighbors(n_neighbors=k_use + 1, metric=metric)
317
- nn.fit(emb)
318
- _, idx = nn.kneighbors(emb)
319
- # idx[:, 0] == self; drop it
320
- neighbor_labels = labels[idx[:, 1:]]
321
- own_labels = labels[:, None]
322
- purity = (neighbor_labels == own_labels).mean()
323
- return float(purity)
324
-
325
-
326
- def _intra_inter_distance_ratio(distance_matrix: np.ndarray, labels: np.ndarray) -> Dict[str, float]:
327
- """Mean intra-family vs inter-family pairwise distance and inter/intra ratio."""
328
- n = distance_matrix.shape[0]
329
- iu = np.triu_indices(n, k=1)
330
- pair_d = distance_matrix[iu]
331
- same = (labels[iu[0]] == labels[iu[1]])
332
- intra = float(pair_d[same].mean()) if same.any() else float("nan")
333
- inter = float(pair_d[~same].mean()) if (~same).any() else float("nan")
334
- ratio = float(inter / intra) if intra > 0 else float("nan")
335
- return {"intra_mean": intra, "inter_mean": inter, "inter_over_intra": ratio}
336
-
337
-
338
- def compute_structure_metrics(
339
- embedding: np.ndarray,
340
- labels: np.ndarray,
341
- knn_k: int,
342
- ) -> Dict[str, float]:
343
- metrics: Dict[str, float] = {}
344
- n_classes = len(np.unique(labels))
345
- if n_classes < 2 or len(labels) < 3:
346
- return {"silhouette_cosine": float("nan"),
347
- "silhouette_euclidean": float("nan"),
348
- "knn_purity_cosine": float("nan"),
349
- "knn_purity_euclidean": float("nan"),
350
- "inter_over_intra_cosine": float("nan"),
351
- "inter_over_intra_euclidean": float("nan")}
352
-
353
- metrics["silhouette_cosine"] = float(silhouette_score(embedding, labels, metric="cosine"))
354
- metrics["silhouette_euclidean"] = float(silhouette_score(embedding, labels, metric="euclidean"))
355
- metrics["knn_purity_cosine"] = _knn_purity(embedding, labels, knn_k, metric="cosine")
356
- metrics["knn_purity_euclidean"] = _knn_purity(embedding, labels, knn_k, metric="euclidean")
357
-
358
- cos_d = cosine_distances(embedding)
359
- euc_d = euclidean_distances(embedding)
360
- cos_ratio = _intra_inter_distance_ratio(cos_d, labels)
361
- euc_ratio = _intra_inter_distance_ratio(euc_d, labels)
362
- metrics["intra_mean_cosine"] = cos_ratio["intra_mean"]
363
- metrics["inter_mean_cosine"] = cos_ratio["inter_mean"]
364
- metrics["inter_over_intra_cosine"] = cos_ratio["inter_over_intra"]
365
- metrics["intra_mean_euclidean"] = euc_ratio["intra_mean"]
366
- metrics["inter_mean_euclidean"] = euc_ratio["inter_mean"]
367
- metrics["inter_over_intra_euclidean"] = euc_ratio["inter_over_intra"]
368
- return metrics
369
-
370
-
371
- def permutation_null(
372
- embedding: np.ndarray,
373
- labels: np.ndarray,
374
- knn_k: int,
375
- n_permutations: int,
376
- seed: int,
377
- ) -> Dict[str, Dict[str, float]]:
378
- """Shuffle labels K times, recompute metrics, return per-metric (mean, std, p_value).
379
-
380
- p-value: empirical right-tail Pr(null >= observed) under random labeling.
381
- """
382
- observed = compute_structure_metrics(embedding, labels, knn_k)
383
- rng = np.random.default_rng(seed)
384
- null_records: Dict[str, List[float]] = {k: [] for k in observed}
385
-
386
- for _ in range(n_permutations):
387
- permuted = rng.permutation(labels)
388
- m = compute_structure_metrics(embedding, permuted, knn_k)
389
- for k, v in m.items():
390
- null_records[k].append(v)
391
-
392
- summary: Dict[str, Dict[str, float]] = {}
393
- for k, obs_v in observed.items():
394
- arr = np.asarray(null_records[k], dtype=float)
395
- valid = arr[~np.isnan(arr)]
396
- if valid.size == 0 or np.isnan(obs_v):
397
- summary[k] = {"observed": obs_v, "null_mean": float("nan"),
398
- "null_std": float("nan"), "p_right_tail": float("nan")}
399
- continue
400
- p = float((1 + np.sum(valid >= obs_v)) / (valid.size + 1))
401
- summary[k] = {
402
- "observed": float(obs_v),
403
- "null_mean": float(valid.mean()),
404
- "null_std": float(valid.std()),
405
- "p_right_tail": p,
406
- }
407
- return summary
408
-
409
-
410
- # ---------- visualization ----------
411
-
412
- def _color_map(labels: List[str]) -> Dict[str, tuple]:
413
- uniq = sorted(set(labels))
414
- cmap = plt.get_cmap("tab20", max(len(uniq), 1))
415
- return {lab: cmap(i) for i, lab in enumerate(uniq)}
416
-
417
-
418
- def _scatter(coords: np.ndarray, labels: List[str], title: str, out_path: Path) -> None:
419
- fig, ax = plt.subplots(figsize=(7, 6))
420
- cmap = _color_map(labels)
421
- for lab in sorted(set(labels)):
422
- mask = np.asarray([lab == l for l in labels])
423
- ax.scatter(coords[mask, 0], coords[mask, 1],
424
- color=cmap[lab], label=f"{lab} (n={mask.sum()})",
425
- s=20, alpha=0.85, edgecolor="none")
426
- ax.set_title(title)
427
- ax.set_xlabel("dim 1")
428
- ax.set_ylabel("dim 2")
429
- ax.legend(loc="best", fontsize=8, frameon=True)
430
- fig.tight_layout()
431
- fig.savefig(out_path, dpi=160)
432
- plt.close(fig)
433
-
434
-
435
- def visualize(
436
- embedding: np.ndarray,
437
- metadata_df: pd.DataFrame,
438
- output_dir: Path,
439
- umap_n_neighbors: int,
440
- umap_min_dist: float,
441
- ) -> None:
442
- # PCA 2D
443
- pca = PCA(n_components=2, random_state=0)
444
- pca_coords = pca.fit_transform(embedding)
445
- _scatter(pca_coords, metadata_df["family"].tolist(),
446
- "Module embedding PCA β€” family", output_dir / "pca_family.png")
447
-
448
- has_hallmark = metadata_df["hallmark_process"].notna().any()
449
- if has_hallmark:
450
- # Project only hallmark modules to a separate PCA for readability
451
- hm_mask = metadata_df["hallmark_process"].notna().to_numpy()
452
- if hm_mask.sum() >= 3:
453
- hm_pca = PCA(n_components=2, random_state=0).fit_transform(embedding[hm_mask])
454
- _scatter(hm_pca, metadata_df.loc[hm_mask, "hallmark_process"].tolist(),
455
- "Hallmark modules PCA β€” process",
456
- output_dir / "pca_hallmark_process.png")
457
-
458
- # UMAP (optional)
459
- try:
460
- import umap # type: ignore
461
- reducer = umap.UMAP(
462
- n_components=2,
463
- n_neighbors=min(umap_n_neighbors, max(2, embedding.shape[0] - 1)),
464
- min_dist=umap_min_dist,
465
- metric="cosine",
466
- random_state=0,
467
- )
468
- umap_coords = reducer.fit_transform(embedding)
469
- _scatter(umap_coords, metadata_df["family"].tolist(),
470
- "Module embedding UMAP β€” family", output_dir / "umap_family.png")
471
-
472
- if has_hallmark and hm_mask.sum() >= 3:
473
- hm_reducer = umap.UMAP(
474
- n_components=2,
475
- n_neighbors=min(umap_n_neighbors, max(2, hm_mask.sum() - 1)),
476
- min_dist=umap_min_dist,
477
- metric="cosine",
478
- random_state=0,
479
- )
480
- hm_umap = hm_reducer.fit_transform(embedding[hm_mask])
481
- _scatter(hm_umap, metadata_df.loc[hm_mask, "hallmark_process"].tolist(),
482
- "Hallmark modules UMAP β€” process",
483
- output_dir / "umap_hallmark_process.png")
484
- except ImportError:
485
- logger.warning("umap-learn not installed; skipping UMAP plots. `pip install umap-learn` to enable.")
486
-
487
-
488
- # ---------- driver ----------
489
-
490
- def setup_logging(output_dir: Path) -> None:
491
- log_path = output_dir / "run.log"
492
- logging.basicConfig(
493
- level=logging.INFO,
494
- format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
495
- handlers=[logging.StreamHandler(sys.stdout), logging.FileHandler(log_path, mode="w")],
496
- force=True,
497
- )
498
-
499
-
500
- def resolve_biomod_json(args: argparse.Namespace) -> str:
501
- """Resolve relative paths against biomod/cell_annotation/ to stay
502
- consistent with how `load_backbone` reads `module_data_dir`.
503
- """
504
- if args.module_json is not None:
505
- return str(ca_resolve_path(args.module_json))
506
- return str(ca_resolve_path(args.module_data_dir) / "merged_biomods.json")
507
-
508
-
509
- def main() -> None:
510
- args = parse_args()
511
-
512
- # DDP init (auto-detects torchrun env; falls back to single-process)
513
- device, use_ddp, rank, world_size, local_rank = init_distributed_mode(args.device)
514
- seed_all(args.seed + rank)
515
-
516
- if is_main_process():
517
- output_dir = ensure_dir(args.output_dir)
518
- setup_logging(output_dir)
519
- else:
520
- output_dir = Path(args.output_dir)
521
- logging.basicConfig(level=logging.WARNING)
522
- if use_ddp:
523
- dist.barrier()
524
-
525
- logger.info("Args: %s", vars(args))
526
- logger.info("DDP: use_ddp=%s, rank=%d/%d, device=%s", use_ddp, rank, world_size, device)
527
-
528
- # 1. Load backbone
529
- backbone, biomod_data, model_config = load_backbone(args, device)
530
- backbone.eval()
531
- base_dim = int(model_config["base_dim"])
532
- num_modules = len(biomod_data)
533
- logger.info("Loaded backbone: num_modules=%d, base_dim=%d", num_modules, base_dim)
534
-
535
- # 2. Load module names (must align with biomod_data order)
536
- module_json_path = resolve_biomod_json(args)
537
- module_names = load_biomod_names(module_json_path)
538
- if len(module_names) != num_modules:
539
- raise RuntimeError(
540
- f"Module name count ({len(module_names)} from {module_json_path}) does not match "
541
- f"backbone num_modules ({num_modules}). The merged_modules.json used to build the "
542
- "model and the one given here must be identical, with no modules dropped during "
543
- "gene-symbol resolution."
544
- )
545
- metadata = build_biomod_metadata(module_names)
546
- metadata_df = pd.DataFrame(metadata)
547
- if is_main_process():
548
- logger.info("Family distribution:\n%s", metadata_df["family"].value_counts().to_string())
549
-
550
- # 3. Build dataloader
551
- if args.data_mode == "h5ad":
552
- preprocessor = DataPreprocessor(
553
- gene2ens_file=args.gene2ens_file,
554
- gene2ens_column=args.gene2ens_column,
555
- model_genes_file=args.gene_list_path,
556
- )
557
- preprocessor.load_gene_mapping()
558
- preprocessor.load_model_genes()
559
- expected_input_genes = -1
560
- else:
561
- preprocessor = None
562
- expected_input_genes = int(model_config["num_genes"])
563
-
564
- loader, reindex_state, n_cells = build_inference_loader(
565
- args, preprocessor, use_ddp, rank, world_size,
566
- )
567
- if n_cells == 0:
568
- raise RuntimeError("No cells survived preprocessing.")
569
-
570
- # 4. Inference
571
- logger.info("Extracting cell-averaged module embeddings over %d cells...", n_cells)
572
- mean_emb, processed = extract_cell_averaged_biomod_embeddings(
573
- backbone, loader, preprocessor, reindex_state, device,
574
- num_modules=num_modules, base_dim=base_dim,
575
- expected_input_genes=expected_input_genes,
576
- use_ddp=use_ddp,
577
- )
578
- logger.info("Embedding ready: shape=%s, processed_cells=%d", mean_emb.shape, processed)
579
-
580
- # --- Everything below is rank-0 only ---
581
- if is_main_process():
582
- # 5. Save raw outputs
583
- np.save(output_dir / "module_embeddings.npy", mean_emb)
584
- metadata_df.to_csv(output_dir / "module_metadata.csv", index=False)
585
-
586
- # 6. Metrics β€” family level
587
- if args.include_other_in_metrics:
588
- keep_mask = np.ones(len(metadata_df), dtype=bool)
589
- else:
590
- keep_mask = (metadata_df["family"] != "Other").to_numpy()
591
- family_labels = metadata_df.loc[keep_mask, "family"].tolist()
592
- family_emb = mean_emb[keep_mask]
593
-
594
- logger.info("Computing structure metrics β€” family level (n=%d)", keep_mask.sum())
595
- family_summary = permutation_null(
596
- family_emb, _label_to_int(family_labels),
597
- knn_k=args.knn_k,
598
- n_permutations=args.null_permutations,
599
- seed=args.seed,
600
- )
601
-
602
- # 6b. Metrics β€” hallmark process level
603
- hm_mask = metadata_df["hallmark_process"].notna().to_numpy()
604
- hallmark_summary: Optional[Dict[str, Dict[str, float]]] = None
605
- if hm_mask.sum() >= 3 and metadata_df.loc[hm_mask, "hallmark_process"].nunique() >= 2:
606
- logger.info("Computing structure metrics β€” hallmark process level (n=%d)", hm_mask.sum())
607
- hallmark_summary = permutation_null(
608
- mean_emb[hm_mask],
609
- _label_to_int(metadata_df.loc[hm_mask, "hallmark_process"].tolist()),
610
- knn_k=args.knn_k,
611
- n_permutations=args.null_permutations,
612
- seed=args.seed,
613
- )
614
-
615
- metrics_payload = {
616
- "n_cells_processed": int(processed),
617
- "num_modules": int(num_modules),
618
- "base_dim": int(base_dim),
619
- "knn_k": int(args.knn_k),
620
- "null_permutations": int(args.null_permutations),
621
- "family_metrics": family_summary,
622
- "hallmark_process_metrics": hallmark_summary,
623
- "family_counts": metadata_df["family"].value_counts().to_dict(),
624
- "hallmark_process_counts": metadata_df["hallmark_process"].dropna().value_counts().to_dict(),
625
- "shuffle_module_genes": bool(args.shuffle_module_genes),
626
- }
627
- with open(output_dir / "metrics.json", "w", encoding="utf-8") as fh:
628
- json.dump(metrics_payload, fh, indent=2)
629
-
630
- logger.info("=== Family-level structure (vs random-shuffled label null) ===")
631
- for k, v in family_summary.items():
632
- logger.info(" %-30s observed=%.4f null=%.4fΒ±%.4f p=%.4f",
633
- k, v["observed"], v["null_mean"], v["null_std"], v["p_right_tail"])
634
- if hallmark_summary is not None:
635
- logger.info("=== Hallmark-process-level structure (Hallmarks subset) ===")
636
- for k, v in hallmark_summary.items():
637
- logger.info(" %-30s observed=%.4f null=%.4fΒ±%.4f p=%.4f",
638
- k, v["observed"], v["null_mean"], v["null_std"], v["p_right_tail"])
639
-
640
- # 7. Visualisation
641
- logger.info("Plotting UMAP / PCA...")
642
- visualize(mean_emb, metadata_df, output_dir,
643
- umap_n_neighbors=args.umap_n_neighbors,
644
- umap_min_dist=args.umap_min_dist)
645
-
646
- logger.info("Done. Outputs saved to %s", output_dir)
647
-
648
- # Teardown
649
- if use_ddp:
650
- dist.barrier()
651
- dist.destroy_process_group()
652
-
653
-
654
- if __name__ == "__main__":
655
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
BioMod/interpretability/biomod_importance/README.md DELETED
@@ -1,319 +0,0 @@
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
- Run these commands from the BioMod repository root. First train a matching
26
- cell-annotation model; the example checkpoint path is the output of
27
- `BioMod/toturials/cell_annotation.ipynb`, not a bundled pretrained weight file.
28
- Use the matching BioMod configuration, classifier architecture, preprocessing,
29
- label mapping, and test split for that trained model. Substitute your own
30
- `--finetune_checkpoint` if its output directory differs. Importance matrices,
31
- ablation outputs, and permutation results below must be generated by the
32
- preceding steps; they are not bundled inputs.
33
-
34
- ```bash
35
- # Run from the BioMod repository root.
36
- python BioMod/interpretability/biomod_importance/biomod_importance.py \
37
- --finetune_checkpoint tutorial_runs/cell_annotation_zheng68k_biomod/best_model.pt \
38
- --config_pkl weight/BioMod.pkl \
39
- --biomod_data_dir model_dependencies/biomod_data_all \
40
- --gene_list_path model_dependencies/selected_genes_27k.txt \
41
- --gene_mapping_path model_dependencies/gene2ens.csv \
42
- --gene2vec_data_path model_dependencies/selected_gene2vec_27k.npy \
43
- --gene2ens_file model_dependencies/gene2ens.csv \
44
- --data_path data/downstream/final_data/Zheng68K.h5ad \
45
- --label_column celltype \
46
- --mask_mode post_encoder \
47
- --batch_size 128 \
48
- --top_k 10 20 \
49
- --output_dir outputs/interpretability/importance/zheng68k_post
50
- ```
51
-
52
- Pre-encoder mode, more accurate but slower:
53
-
54
- ```bash
55
- python BioMod/interpretability/biomod_importance/biomod_importance.py \
56
- --finetune_checkpoint tutorial_runs/cell_annotation_zheng68k_biomod/best_model.pt \
57
- --config_pkl weight/BioMod.pkl \
58
- --biomod_data_dir model_dependencies/biomod_data_all \
59
- --gene_list_path model_dependencies/selected_genes_27k.txt \
60
- --gene_mapping_path model_dependencies/gene2ens.csv \
61
- --gene2vec_data_path model_dependencies/selected_gene2vec_27k.npy \
62
- --gene2ens_file model_dependencies/gene2ens.csv \
63
- --data_path data/downstream/final_data/Zheng68K.h5ad \
64
- --label_column celltype \
65
- --mask_mode pre_encoder \
66
- --importance_cells 500 \
67
- --batch_size 256 \
68
- --top_k 10 20 \
69
- --output_dir outputs/interpretability/importance/zheng68k_pre
70
- ```
71
-
72
- Pre-encoder mode on all cells with DDP multi-GPU acceleration:
73
-
74
- ```bash
75
- torchrun --nproc_per_node=4 BioMod/interpretability/biomod_importance/biomod_importance.py \
76
- --finetune_checkpoint tutorial_runs/cell_annotation_zheng68k_biomod/best_model.pt \
77
- --config_pkl weight/BioMod.pkl \
78
- --biomod_data_dir model_dependencies/biomod_data_all \
79
- --gene_list_path model_dependencies/selected_genes_27k.txt \
80
- --gene_mapping_path model_dependencies/gene2ens.csv \
81
- --gene2vec_data_path model_dependencies/selected_gene2vec_27k.npy \
82
- --gene2ens_file model_dependencies/gene2ens.csv \
83
- --data_path data/downstream/final_data/Zheng68K.h5ad \
84
- --label_column celltype \
85
- --mask_mode pre_encoder \
86
- --batch_size 512 \
87
- --top_k 10 20 \
88
- --output_dir outputs/interpretability/importance/zheng68k_pre_full
89
- ```
90
-
91
- ## Arguments
92
-
93
- | Argument | Default | Description |
94
- | --- | --- | --- |
95
- | `--mask_mode` | post_encoder | Masking strategy |
96
- | `--importance_cells` | 0 | Maximum number of cells for `pre_encoder`; 0 means all cells |
97
- | `--top_k` | 10 20 | Top-k modules used for enrichment analysis |
98
- | `--batch_size` | 64 | Inference batch size |
99
- | `--test_ratio` | 0.2 | Test-set fraction; should match finetuning |
100
- | `--seed` | 2021 | Random seed; should match finetuning |
101
- | `--marker_db_dir` | None | Marker database cache directory |
102
-
103
- ## Outputs
104
-
105
- ```text
106
- output_dir/
107
- |-- importance_matrix.npy # (11, 660) DeltaLogit matrix
108
- |-- importance_matrix.csv # Same matrix with labels
109
- |-- top_modules_per_celltype.json # Top modules and genes for each cell type
110
- |-- enrichment_results.csv # Hypergeometric enrichment results
111
- |-- heatmap_importance.png # Heatmap
112
- |-- top_modules_bar.png # Bar plot of top modules per cell type
113
- |-- metrics.json # Summary metrics
114
- `-- marker_cache/ # PanglaoDB cache
115
- `-- PanglaoDB_markers.tsv
116
- ```
117
-
118
- ## Expected Conclusion
119
-
120
- If the model learns meaningful module importance:
121
-
122
- - Top modules for immune cell types should be enriched for immune-related pathways.
123
- - Enrichment p-values should be significant after Bonferroni correction, below 0.05.
124
- - Marker genes for the corresponding cell types in PanglaoDB should appear in top modules.
125
-
126
- ---
127
-
128
- ## Extended Experiments
129
-
130
- ### 3-A: Masking Ablation Curves
131
-
132
- Progressively mask top, bottom, or random modules and measure the accuracy drop.
133
-
134
- ```bash
135
- python BioMod/interpretability/biomod_importance/masking_ablation.py \
136
- --finetune_checkpoint tutorial_runs/cell_annotation_zheng68k_biomod/best_model.pt \
137
- --config_pkl weight/BioMod.pkl \
138
- --biomod_data_dir model_dependencies/biomod_data_all \
139
- --gene_list_path model_dependencies/selected_genes_27k.txt \
140
- --gene_mapping_path model_dependencies/gene2ens.csv \
141
- --gene2vec_data_path model_dependencies/selected_gene2vec_27k.npy \
142
- --gene2ens_file model_dependencies/gene2ens.csv \
143
- --data_path data/downstream/final_data/Zheng68K.h5ad \
144
- --label_column celltype \
145
- --importance_matrix outputs/interpretability/importance/zheng68k_pre_13690/importance_matrix.npy \
146
- --k_values 1 2 5 10 20 50 100 200 \
147
- --n_random_trials 10 \
148
- --batch_size 360 \
149
- --save_deltas \
150
- --output_dir outputs/interpretability/importance/ablation/
151
- ```
152
-
153
- ### 3-B: Permutation Tests
154
-
155
- ```bash
156
- # Mode A: shuffle modules, GPU and slow.
157
- python BioMod/interpretability/biomod_importance/random_baseline.py \
158
- --mode shuffle_modules \
159
- --finetune_checkpoint tutorial_runs/cell_annotation_zheng68k_biomod/best_model.pt \
160
- --config_pkl weight/BioMod.pkl \
161
- --biomod_data_dir model_dependencies/biomod_data_all \
162
- --gene_list_path model_dependencies/selected_genes_27k.txt \
163
- --gene_mapping_path model_dependencies/gene2ens.csv \
164
- --gene2vec_data_path model_dependencies/selected_gene2vec_27k.npy \
165
- --gene2ens_file model_dependencies/gene2ens.csv \
166
- --data_path data/downstream/final_data/Zheng68K.h5ad \
167
- --label_column celltype \
168
- --importance_matrix outputs/interpretability/importance/zheng68k_pre_13690/importance_matrix.npy\
169
- --n_permutations 10 \
170
- --output_dir outputs/interpretability/importance/permutation/
171
-
172
- # Mode B: shuffle labels, CPU and fast, requires per_cell_deltas from ablation.
173
- python BioMod/interpretability/biomod_importance/random_baseline.py \
174
- --mode shuffle_labels \
175
- --importance_matrix outputs/interpretability/importance/zheng68k_pre_13690/importance_matrix.npy \
176
- --per_cell_deltas outputs/interpretability/importance/ablation/per_cell_deltas.npy \
177
- --per_cell_labels outputs/interpretability/importance/ablation/per_cell_labels.npy \
178
- --n_permutations 1000 \
179
- --output_dir outputs/interpretability/importance/permutation/
180
- ```
181
-
182
- ### 3-C: Marker Gene Verification, CPU Only
183
-
184
- ```bash
185
- python BioMod/interpretability/biomod_importance/marker_gene_verification.py \
186
- --importance_matrix outputs/interpretability/importance/zheng68k_pre_13690/importance_matrix.npy \
187
- --importance_csv <importance_matrix.csv> \
188
- --biomod_data_dir model_dependencies/biomod_data_all \
189
- --output_dir outputs/interpretability/importance/marker_verification/
190
- ```
191
-
192
- ### Publication Figures
193
-
194
- ```bash
195
- python BioMod/interpretability/biomod_importance/biomod_importance_publication.py \
196
- --results_dir outputs/interpretability/importance/zheng68k_pre_13690/ \
197
- --ablation_dir outputs/interpretability/importance/ablation \
198
- --permutation_dir outputs/interpretability/importance/permutation/ \
199
- --biomod_data_dir model_dependencies/biomod_data_all \
200
- --output_dir outputs/interpretability/importance/publication_figures/
201
- ```
202
-
203
- ### 3-E: Family-Level Analysis, CPU Only
204
-
205
- Analyze importance distributions, contribution fractions, and enrichment tests by
206
- module source family: Hallmarks, Reactome, Hotspot, and WGCNA.
207
-
208
- ```bash
209
- python BioMod/interpretability/biomod_importance/family_importance_analysis.py \
210
- --results_dir outputs/interpretability/importance/zheng68k_pre \
211
- --output_dir outputs/interpretability/importance/family_analysis \
212
- --top_k 10 20 50 \
213
- --enrichment_k 50
214
- ```
215
-
216
- Outputs:
217
-
218
- - `fig1_family_importance_distribution.png/pdf`: family-wise importance distribution for each cell type, violin + box.
219
- - `fig2_family_contribution_topk.png/pdf`: family fraction among top-K important modules, stacked bar.
220
- - `fig3_family_enrichment_heatmap.png/pdf`: Fisher exact-test enrichment-ratio heatmap.
221
- - `fig4_family_mean_importance.png/pdf`: family mean-importance comparison, grouped bar.
222
- - `kruskal_wallis_family_test.csv`: Kruskal-Wallis test results.
223
- - `pairwise_mannwhitney_families.csv`: pairwise Mann-Whitney U tests between families.
224
- - `family_enrichment_fisher.csv`: detailed Fisher exact-test results with Bonferroni correction.
225
- - `family_contribution_topk.csv`: numerical family contribution data.
226
- - `family_mean_importance.csv`: family mean-importance matrix.
227
-
228
- ### 3-F: Family-Level Ablation, GPU
229
-
230
- Ablate whole families by removing or keeping one module family and measuring
231
- classification-accuracy changes.
232
-
233
- ```bash
234
- python BioMod/interpretability/biomod_importance/family_ablation.py \
235
- --finetune_checkpoint tutorial_runs/cell_annotation_zheng68k_biomod/best_model.pt \
236
- --config_pkl weight/BioMod.pkl \
237
- --biomod_data_dir model_dependencies/biomod_data_all \
238
- --gene_list_path model_dependencies/selected_genes_27k.txt \
239
- --gene_mapping_path model_dependencies/gene2ens.csv \
240
- --gene2vec_data_path model_dependencies/selected_gene2vec_27k.npy \
241
- --gene2ens_file model_dependencies/gene2ens.csv \
242
- --data_path data/downstream/final_data/Zheng68K.h5ad \
243
- --label_column celltype \
244
- --batch_size 256 \
245
- --output_dir outputs/interpretability/importance/family_analysis_gpu
246
- ```
247
-
248
- Outputs:
249
-
250
- - `family_ablation_results.json`: overall and per-class accuracy under all conditions.
251
- - `fig_family_ablation.png/pdf`: two-panel overall plot for remove-one vs keep-one.
252
- - `fig_family_ablation_per_class.png/pdf`: two-panel heatmap of per-cell-type ablation results.
253
-
254
- ### 3-G: Cross-Class Similarity, CPU Only
255
-
256
- Analyze correlations and similarity between important modules across the 11 cell
257
- classes.
258
-
259
- ```bash
260
- python BioMod/interpretability/biomod_importance/cross_class_similarity.py \
261
- --results_dir outputs/interpretability/importance/zheng68k_pre \
262
- --output_dir outputs/interpretability/importance/publication_figures \
263
- --top_k 50
264
- ```
265
-
266
- ### 3-H: 1B Module Gating + 2B Family Embedding Finetuning Experiment
267
-
268
- Based on module-importance findings, most modules have near-zero importance and
269
- different cell types depend on different families. During finetuning, add two
270
- lightweight modules in a LoRA-style setup: pretrained weights are frozen and new
271
- parameters are trained from zero initialization.
272
-
273
- - **1B, Sparse Module Gate**: `LayerNorm -> Linear(E,1) -> Sigmoid`, learning a 0-1 gate scalar for each module.
274
- - **2B, Family Embedding**: `nn.Parameter(4, E)`, one learnable vector per family, added to `pos_emb`.
275
-
276
- ```bash
277
- # Baseline, no new modules.
278
- python BioMod/cell_annotation/biomod_finetune.py \
279
- --config_pkl weight/BioMod.pkl \
280
- --biomod_checkpoint weight/BioMod.pth \
281
- --biomod_data_dir model_dependencies/biomod_data_all \
282
- --gene_list_path model_dependencies/selected_genes_27k.txt \
283
- --gene_mapping_path model_dependencies/gene2ens.csv \
284
- --gene2vec_data_path model_dependencies/selected_gene2vec_27k.npy \
285
- --gene2ens_file model_dependencies/gene2ens.csv \
286
- --data_path data/downstream/final_data/Zheng68K.h5ad \
287
- --label_column celltype \
288
- --head_type attn_pool \
289
- --finetune_mode last_n --unfreeze_last_n 1 \
290
- --lr_head 0.0001 --lr_backbone 0.0001 \
291
- --batch_size 256 --epochs 300 \
292
- --run_name baseline_zheng68k \
293
- --output_dir outputs/interpretability/family_exp
294
- ```
295
-
296
- The same command can be extended with:
297
-
298
- - `--use_family_emb` to enable family-type embedding, adding `4 x 200 = 800` parameters.
299
- - `--use_biomod_gate` to enable sparse module gating, adding LayerNorm + Linear, 601 parameters.
300
- - Both flags can be used together.
301
-
302
- The new module parameters use `lr_head`, a higher learning rate, and are
303
- independent of frozen backbone parameters.
304
-
305
- ## File Descriptions
306
-
307
- | File | Description |
308
- | --- | --- |
309
- | `biomod_importance.py` | Core importance computation with DDP support |
310
- | `masking_ablation.py` | Ablation-curve experiment, GPU |
311
- | `random_baseline.py` | Permutation tests, GPU/CPU |
312
- | `marker_gene_verification.py` | Marker verification, CPU |
313
- | `biomod_importance_publication.py` | Publication figures |
314
- | `family_importance_analysis.py` | Family-level importance analysis, CPU |
315
- | `family_ablation.py` | Family-level ablation, GPU |
316
- | `cross_class_similarity.py` | Cross-class similarity analysis, CPU |
317
- | `pub_utils.py` | Shared utilities |
318
- | `RESULTS.md` | Result documentation with figure placeholders |
319
- | `TODO.md` | Progress tracker |
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
BioMod/interpretability/biomod_importance/RESULTS.md DELETED
@@ -1,226 +0,0 @@
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: BioModCellClassifier with attention-pooling head and 48M backbone.
9
- - Baseline test accuracy: **83.1%**.
10
-
11
- ## Data Sources
12
-
13
- These are historical experiment results. The analysis output files below are not
14
- included in the repository; recreate them using the commands in `README.md` or
15
- point the analysis scripts to the original run directory. The shared module JSON
16
- is bundled under `model_dependencies/biomod_data_all/` at the repository root.
17
-
18
- All analyses are based on the following core files, generated by `biomod_importance.py`
19
- with `pre_encoder` over all 13,690 test cells:
20
-
21
- | File | Path | Description |
22
- | --- | --- | --- |
23
- | `importance_matrix.npy` | `outputs/interpretability/importance/zheng68k_pre_13690/importance_matrix.npy` | `(11 x 660)` DeltaLogit matrix |
24
- | `importance_matrix.csv` | `outputs/interpretability/importance/zheng68k_pre_13690/importance_matrix.csv` | Same matrix with row and column labels |
25
- | `enrichment_results.csv` | `outputs/interpretability/importance/zheng68k_pre_13690/enrichment_results.csv` | Enrichment-test results |
26
- | `top_modules_per_celltype.json` | `outputs/interpretability/importance/zheng68k_pre_13690/top_modules_per_celltype.json` | Top-module details |
27
- | `merged_biomods.json` | `model_dependencies/biomod_data_all/merged_biomods.json` | Gene composition of 660 modules |
28
-
29
- Marker verification uses `importance_matrix.npy`, `importance_matrix.csv`, and
30
- `merged_biomods.json`.
31
-
32
- Publication figures use all files above plus ablation and permutation results.
33
-
34
- ---
35
-
36
- ## Experiment 3 Core: Module Importance Matrix
37
-
38
- ### Method
39
-
40
- For each test cell, mask one module at a time using `pre_encoder`, which zeros out
41
- the corresponding gene inputs, and measure the drop in the correct-class logit
42
- as the importance score. Aggregate by cell type to obtain an `(11 x 660)`
43
- importance matrix.
44
-
45
- ### Results
46
-
47
- - Masking mode: **pre_encoder** over all 13,690 cells.
48
- - 660 modules: 491 Reactome, 50 Hallmarks, 73 Hotspot, and 46 Processed.
49
-
50
- **Representative top modules per cell type:**
51
-
52
- | Cell Type | #1 Module | #2 Module | #3 Module |
53
- | --- | --- | --- | --- |
54
- | CD14+ Monocyte | Neutrophil Degranulation | TLR4 Cascade | MHC-II Antigen Presentation |
55
- | CD19+ B | MHC-II | IFN-gamma Signaling | CD28 Co-stimulation |
56
- | CD56+ NK | SM_22 (cytotoxic) | module3_2 | Allograft Rejection |
57
- | CD8+ Cytotoxic T | module3_2 | SM_22 | Allograft Rejection |
58
- | Dendritic | MHC-II | Signaling by Interleukins | IFN-gamma |
59
-
60
- ### Figure
61
-
62
- Expected generated figure: `publication_figures/fig4_importance_heatmap_clustered.png` (Importance Heatmap (clustered); not bundled).
63
-
64
- ---
65
-
66
- ## Experiment 3-A: Masking Ablation Curves
67
-
68
- ### Method
69
-
70
- Progressively mask top-k, bottom-k, or random-k modules, with
71
- `k = 1, 2, 5, 10, 20, 50, 100, 200`, and measure classification accuracy. This
72
- uses `post_encoder` masking so the encoder runs only once.
73
-
74
- ### Expected Result
75
-
76
- - Masking top modules should rapidly decrease accuracy.
77
- - Masking bottom modules should have little effect.
78
- - Random masking should fall between the two.
79
-
80
- ### Results, Completed
81
-
82
- - Baseline accuracy: **83.0%** on 13,690 test cells.
83
- - Post-encoder masking with k = [1, 2, 5, 10, 20, 50, 100, 200].
84
-
85
- | Masked k | Top Acc | Bottom Acc | Random Acc |
86
- | --- | --- | --- | --- |
87
- | 1 | 83.0% | 83.0% | 83.0% |
88
- | 2 | 82.6% | 83.0% | 83.0% |
89
- | 5 | 82.0% | 83.0% | 83.0% |
90
- | 10 | 77.1% | 83.0% | 83.0% |
91
- | 20 | 73.7% | 83.1% | 82.7% |
92
- | 50 | 69.5% | 83.2% | 82.4% |
93
- | 100 | 65.1% | 82.7% | 81.7% |
94
- | 200 | 49.7% | 81.9% | 78.6% |
95
-
96
- **Interpretation:** masking the top 200 modules, 30% of all modules, reduces
97
- accuracy from 83% to 50%, a 33 percentage-point drop. Masking the bottom 200
98
- modules has almost no effect, with only a 1.1 point drop. Random masking falls
99
- between the two. This shows that the importance ranking captures modules that
100
- are truly important for classification.
101
-
102
- Expected generated figure: `publication_figures/fig1_ablation_curves.png` (Ablation Curves; not bundled).
103
-
104
- Expected generated figure: `publication_figures/fig_s1_ablation_per_class.png` (Per-class Ablation; not bundled).
105
-
106
- ---
107
-
108
- ## Experiment 3-B: Permutation Tests, Random Baseline
109
-
110
- ### Method
111
-
112
- **Mode A, Module-Shuffle:** shuffle module token order to break the module-gene
113
- correspondence, then recompute importance. Repeat 10 times.
114
-
115
- **Mode B, Label-Shuffle:** shuffle cell type labels and re-aggregate per-cell
116
- deltas. Repeat 1000 times.
117
-
118
- ### Expected Result
119
-
120
- - The true importance maximum should far exceed the null distribution.
121
- - Empirical p-value should be below 0.001.
122
-
123
- ### Results, Completed
124
-
125
- **Mode A, Module-Shuffle** (10 permutations, GPU):
126
-
127
- | Metric | Value |
128
- | --- | --- |
129
- | Global p-value | 0.091, limited by n=10; minimum possible is 1/11 |
130
- | Effect size | **1474.5** |
131
- | Real global max | 2.046 |
132
- | Null global max | 0.452 +/- 0.001 |
133
-
134
- **Mode B, Label-Shuffle** (1000 permutations, CPU):
135
-
136
- | Metric | Value |
137
- | --- | --- |
138
- | Global p-value | **< 0.001** |
139
- | Per-class p-values | All < 0.001 (11/11) |
140
- | Effect size | **48.1** |
141
- | Real global max | 2.046 |
142
- | Null global max | 0.145 +/- 0.039 |
143
-
144
- **Interpretation:**
145
-
146
- - **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.
147
- - **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.
148
-
149
- Expected generated figure: `publication_figures/fig2_permutation_null.png` (Permutation Null; not bundled).
150
-
151
- ---
152
-
153
- ## Experiment 3-C: Canonical Marker Gene Verification
154
-
155
- ### Method
156
-
157
- Check the importance ranks of modules containing known cell-type canonical
158
- markers such as CD14, CD19, CD8A, and NKG7. Test whether markers are enriched in
159
- top modules.
160
-
161
- ### Results, Completed
162
-
163
- | Metric | Value |
164
- | --- | --- |
165
- | Total markers tested | 59 |
166
- | Markers not found | 2 |
167
- | **Median rank percentile** | **0.9%** |
168
- | Markers in top 5% | **47/59 (80%)** |
169
- | Markers in top 10% | 47/59 (80%) |
170
- | Markers in top 20% | 50/59 (85%) |
171
- | Binomial p-value, top 10% | **< 1e-50** |
172
- | Wilcoxon p-value, <50% | **2.5e-11** |
173
-
174
- **Per cell type:**
175
-
176
- | Cell Type | Median Percentile | Best Marker | Best Rank |
177
- | --- | --- | --- | --- |
178
- | CD14+ Monocyte | 0.0% | LYZ | #1 |
179
- | CD19+ B | 1.3% | MS4A1 | #7 |
180
- | CD34+ | 18.5% | THY1 | #7 |
181
- | CD4+ T Helper2 | 1.7% | CD4 | #1 |
182
- | CD4+/CD25 T Reg | 0.9% | CD4 | #1 |
183
- | CD4+/CD45RA+/CD25- Naive T | 10.5% | CD4 | #1 |
184
- | CD4+/CD45RO+ Memory | 0.0% | CD4 | #1 |
185
- | CD56+ NK | 0.0% | NKG7 | #1 |
186
- | CD8+ Cytotoxic T | 0.0% | CD8A | #1 |
187
- | CD8+/CD45RA+ Naive Cytotoxic | 20.3% | CD8A | #1 |
188
- | Dendritic | 2.1% | ITGAX | #1 |
189
-
190
- **Interpretation:** 80% of canonical markers are in the top 5% importance
191
- modules for the corresponding cell type. In 9 of 11 cell types, the best marker
192
- is in the rank #1 module. This strongly shows that the model learns biologically
193
- meaningful module importance.
194
-
195
- Expected generated figure: `marker_verification/fig_marker_ranks.png` (Marker Ranks; not bundled).
196
-
197
- Expected generated figure: `marker_verification/fig_marker_summary_bar.png` (Marker Summary Bar; not bundled).
198
-
199
- ---
200
-
201
- ## Experiment 3-D: PanglaoDB Enrichment Analysis
202
-
203
- ### Method
204
-
205
- For genes in each cell type's top-k modules, run a hypergeometric enrichment test
206
- against marker gene sets for 178 PanglaoDB cell types, with Bonferroni
207
- correction.
208
-
209
- ### Results
210
-
211
- | Metric | Value |
212
- | --- | --- |
213
- | Significant enrichment hits, p_adj < 0.05 | **1011 / 3916** |
214
- | Hit rate | **25.8%** |
215
-
216
- Expected generated figure: `publication_figures/fig5_enrichment_summary.png` (Enrichment Summary; not bundled).
217
-
218
- ---
219
-
220
- ## Manuscript Writing Points
221
-
222
- 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%.
223
- 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.
224
- 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.
225
- 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.
226
- 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/biomod_importance/TODO.md DELETED
@@ -1,68 +0,0 @@
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] `biomod_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/biomod_importance/__init__.py DELETED
File without changes
BioMod/interpretability/biomod_importance/biomod_importance.py DELETED
@@ -1,851 +0,0 @@
1
- #!/usr/bin/env python
2
- # -*- coding: utf-8 -*-
3
- """
4
- Experiment β‘’ β€” Module Importance + Enrichment Analysis
5
-
6
- Measures per-cell-type module importance via single-module masking on a
7
- finetuned cell annotation model, then validates top modules against
8
- known cell-type marker gene databases (PanglaoDB / CellMarker).
9
-
10
- Two masking modes:
11
- - pre_encoder: zero-out module *input* before encoder (accurate, slow)
12
- - post_encoder: zero-out module *token* after encoder (fast, encoder runs once)
13
- """
14
-
15
- import argparse
16
- import json
17
- import logging
18
- import os
19
- import pickle
20
- import sys
21
- from pathlib import Path
22
- from typing import Dict, List, Optional, Tuple
23
-
24
- import numpy as np
25
- import pandas as pd
26
- import torch
27
- import torch.distributed as dist
28
- import torch.nn as nn
29
- from scipy import sparse, stats
30
- from sklearn.model_selection import StratifiedShuffleSplit
31
- from sklearn.preprocessing import LabelEncoder
32
- from torch.utils.data import DataLoader, Dataset, Subset
33
-
34
- SCRIPT_DIR = Path(__file__).resolve().parent
35
- INTERP_DIR = SCRIPT_DIR.parent
36
- BIOMOD_ROOT = INTERP_DIR.parent
37
- SCLONG_ROOT = BIOMOD_ROOT.parent
38
-
39
- for p in (str(BIOMOD_ROOT), str(SCLONG_ROOT)):
40
- if p not in sys.path:
41
- sys.path.insert(0, p)
42
-
43
- logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
44
- logger = logging.getLogger(__name__)
45
-
46
-
47
- # ─── DDP Utilities ─────────────────────────────────────────────────────────
48
-
49
- def setup_ddp():
50
- """Initialize DDP if launched via torchrun, otherwise return single-GPU info."""
51
- if "RANK" in os.environ:
52
- dist.init_process_group(backend="nccl")
53
- rank = dist.get_rank()
54
- world_size = dist.get_world_size()
55
- local_rank = int(os.environ.get("LOCAL_RANK", 0))
56
- torch.cuda.set_device(local_rank)
57
- device = torch.device(f"cuda:{local_rank}")
58
- if rank != 0:
59
- logging.getLogger().setLevel(logging.WARNING)
60
- return rank, world_size, device, True
61
- else:
62
- device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
63
- return 0, 1, device, False
64
-
65
-
66
- def cleanup_ddp(use_ddp: bool):
67
- if use_ddp:
68
- dist.destroy_process_group()
69
-
70
-
71
- # ─── Dataset ────────────────────────────────────────────────────────────────
72
-
73
- class CellDataset(Dataset):
74
- def __init__(self, X, labels):
75
- self.X = X
76
- self.labels = labels
77
-
78
- def __len__(self):
79
- return self.X.shape[0]
80
-
81
- def __getitem__(self, idx):
82
- row = self.X[idx]
83
- if sparse.issparse(row):
84
- row = row.toarray().squeeze(0)
85
- return torch.tensor(row, dtype=torch.float32), self.labels[idx]
86
-
87
-
88
- # ─── Model Loading ──────────────────────────────────────────────────────────
89
-
90
- def load_finetuned_model(args, device: torch.device):
91
- """Load the full finetuned classifier (backbone + pooler + head)."""
92
- from models_biomod import BioMod
93
- from biomod_dataset import load_biomod_data
94
-
95
- ckpt_path = Path(args.finetune_checkpoint)
96
- checkpoint = torch.load(ckpt_path, map_location=device)
97
-
98
- saved_args = checkpoint.get("args", {})
99
- backbone_config = checkpoint.get("backbone_config", {})
100
- label_classes = checkpoint["label_classes"]
101
- num_classes = len(label_classes)
102
-
103
- config_pkl_path = Path(args.config_pkl)
104
- config_pkl = None
105
- if config_pkl_path.exists():
106
- with open(config_pkl_path, "rb") as f:
107
- config_pkl = pickle.load(f)
108
-
109
- biomod_data = None
110
- if isinstance(config_pkl, dict) and "module_sets" in config_pkl:
111
- biomod_data = config_pkl["module_sets"]
112
- elif "module_sets" in backbone_config:
113
- biomod_data = backbone_config["module_sets"]
114
- else:
115
- biomod_data = load_biomod_data(
116
- module_data_dir=args.module_data_dir,
117
- gene_list_path=args.gene_list_path,
118
- gene_mapping_path=args.gene_mapping_path,
119
- verbose=True,
120
- )
121
-
122
- base_dim = backbone_config.get("base_dim", 1024)
123
- enc_depth = backbone_config.get("enc_depth", 32)
124
- enc_heads = backbone_config.get("enc_heads", 16)
125
- enc_dim_head = backbone_config.get("enc_dim_head", 64)
126
- num_genes = backbone_config.get("num_genes", 27874)
127
- use_cls_token = backbone_config.get("use_cls_token", False)
128
- gene_embed_dim = backbone_config.get("gene_embed_dim", 200)
129
- fusion_method = backbone_config.get("fusion_method", "add")
130
- use_gene2vec = backbone_config.get("use_gene2vec", True)
131
- loss_mode = backbone_config.get("loss_mode", "module_mean")
132
- recon_target_level = backbone_config.get("recon_target_level", "module")
133
- pretrain_arch = backbone_config.get("pretrain_arch", "mae")
134
- mask_style = backbone_config.get("mask_style", "random")
135
- mask_token_mode = backbone_config.get("mask_token_mode", "learnable")
136
- pos_embed_mode = backbone_config.get("pos_embed_mode", "sincos")
137
- decoder_type = backbone_config.get("decoder_type", "cross_attn")
138
- decoder_gene_agg = backbone_config.get("decoder_gene_agg", "mean")
139
- dec_embed_dim = backbone_config.get("dec_embed_dim", 512)
140
- dec_depth = backbone_config.get("dec_depth", 4)
141
- dec_heads = backbone_config.get("dec_heads", 8)
142
- dec_dim_head = backbone_config.get("dec_dim_head", 64)
143
-
144
- backbone = BioMod(
145
- module_sets=biomod_data,
146
- num_genes=num_genes,
147
- max_seq_len=len(biomod_data),
148
- base_dim=base_dim,
149
- enc_depth=enc_depth,
150
- enc_heads=enc_heads,
151
- enc_dim_head=enc_dim_head,
152
- mask_ratio=0.75,
153
- gene2vec_file=args.gene2vec_data_path,
154
- gene_embed_dim=gene_embed_dim,
155
- fusion_method=fusion_method,
156
- use_cls_token=use_cls_token,
157
- cls_loss_weight=0.0,
158
- cls_head_hidden_dim=0,
159
- use_gene2vec=use_gene2vec,
160
- loss_mode=loss_mode,
161
- recon_target_level=recon_target_level,
162
- pretrain_arch=pretrain_arch,
163
- mask_style=mask_style,
164
- mask_token_mode=mask_token_mode,
165
- pos_embed_mode=pos_embed_mode,
166
- decoder_type=decoder_type,
167
- decoder_gene_agg=decoder_gene_agg,
168
- dec_embed_dim=dec_embed_dim,
169
- dec_depth=dec_depth,
170
- dec_heads=dec_heads,
171
- dec_dim_head=dec_dim_head,
172
- )
173
-
174
- sys.path.insert(0, str(BIOMOD_ROOT / "cell_annotation"))
175
- from biomod_finetune import (
176
- GatedAttentionPool,
177
- BioModCellClassifier,
178
- ScGPTClsDecoder,
179
- )
180
-
181
- head_type = saved_args.get("head_type", args.head_type)
182
- dropout = saved_args.get("dropout", 0.2)
183
- pool_hidden_dim = saved_args.get("pool_hidden_dim", 0)
184
- cls_decoder_layers = saved_args.get("cls_decoder_layers", 3)
185
-
186
- model = BioModCellClassifier(
187
- backbone=backbone,
188
- module_sets=biomod_data,
189
- num_classes=num_classes,
190
- head_type=head_type,
191
- pool_hidden_dim=pool_hidden_dim,
192
- dropout=dropout,
193
- cls_decoder_layers=cls_decoder_layers,
194
- )
195
-
196
- model.load_state_dict(checkpoint["model_state_dict"], strict=True)
197
- model.to(device)
198
- model.eval()
199
-
200
- logger.info(
201
- "Loaded finetuned model: head=%s, classes=%d, modules=%d",
202
- head_type, num_classes, len(biomod_data),
203
- )
204
- return model, biomod_data, label_classes, head_type
205
-
206
-
207
- # ─── Data Loading ───────────────────────────────────────────────────────────
208
-
209
- def load_test_data(args, label_classes: List[str], device: torch.device):
210
- """Load Zheng68K and reproduce the test split."""
211
- import anndata as ad
212
-
213
- sys.path.insert(0, str(BIOMOD_ROOT / "cell_annotation"))
214
- from biomod_finetune import DataPreprocessor
215
-
216
- preprocessor = DataPreprocessor(
217
- gene2ens_file=args.gene2ens_file,
218
- gene2ens_column="Ensembl gene ID",
219
- model_genes_file=args.gene_list_path,
220
- )
221
- preprocessor.load_gene_mapping()
222
- preprocessor.load_model_genes()
223
-
224
- adata, reindex_state = preprocessor.preprocess_adata(
225
- adata_path=args.data_path,
226
- min_genes=10,
227
- min_cells=10,
228
- dataset_name="test_data",
229
- gene_name_column=args.gene_name_column,
230
- )
231
-
232
- labels_raw = adata.obs[args.label_column].values
233
- le = LabelEncoder()
234
- le.classes_ = np.array(label_classes)
235
- labels_encoded = le.transform(labels_raw)
236
-
237
- indices = np.arange(len(labels_encoded))
238
- train_val_split = StratifiedShuffleSplit(
239
- n_splits=1, test_size=args.test_ratio, random_state=args.seed
240
- )
241
- train_val_idx, test_idx = next(train_val_split.split(indices, labels_encoded))
242
-
243
- X_test = adata.X[test_idx]
244
- y_test = labels_encoded[test_idx]
245
-
246
- logger.info("Test set: %d cells, %d classes", len(test_idx), len(le.classes_))
247
- return X_test, y_test, le, preprocessor, reindex_state
248
-
249
-
250
- # ─── Importance Computation ─────────────────────────────────────────────────
251
-
252
- @torch.no_grad()
253
- def compute_importance_post_encoder(
254
- model: nn.Module,
255
- dataloader: DataLoader,
256
- preprocessor,
257
- reindex_state: dict,
258
- num_modules: int,
259
- num_classes: int,
260
- device: torch.device,
261
- ) -> np.ndarray:
262
- """Fast mode: encoder runs once, mask each module token after encoder."""
263
- importance = np.zeros((num_classes, num_modules), dtype=np.float64)
264
- class_counts = np.zeros(num_classes, dtype=np.float64)
265
-
266
- for x_batch, y_batch in dataloader:
267
- x_batch = x_batch.to(device)
268
- y_batch = y_batch.numpy()
269
- x_model = preprocessor.reindex_tensor_to_model_space(
270
- x_batch, reindex_state=reindex_state
271
- )
272
-
273
- latent, _, ids_restore, _ = model.backbone.forward_encoder(
274
- x_model, mask_ratio=0.0
275
- )
276
- if model.head_type == "cls":
277
- module_tokens = latent[:, 1:, :]
278
- else:
279
- module_tokens = model._restore_biomod_order(latent, ids_restore)
280
-
281
- baseline_logits = _classify_from_tokens(model, module_tokens)
282
- y_tensor = torch.from_numpy(y_batch).to(device)
283
- batch_idx = torch.arange(len(y_batch), device=device)
284
- baseline_correct_logits = baseline_logits[
285
- batch_idx, y_tensor
286
- ].cpu().numpy()
287
-
288
- for pi in range(num_modules):
289
- masked_tokens = module_tokens.clone()
290
- masked_tokens[:, pi, :] = 0.0
291
- masked_logits = _classify_from_tokens(model, masked_tokens)
292
- masked_correct_logits = masked_logits[
293
- batch_idx, y_tensor
294
- ].cpu().numpy()
295
-
296
- delta = baseline_correct_logits - masked_correct_logits
297
- for cls_id in range(num_classes):
298
- mask_cls = y_batch == cls_id
299
- if mask_cls.any():
300
- importance[cls_id, pi] += delta[mask_cls].sum()
301
-
302
- for cls_id in range(num_classes):
303
- class_counts[cls_id] += (y_batch == cls_id).sum()
304
-
305
- class_counts[class_counts == 0] = 1.0
306
- importance /= class_counts[:, None]
307
- return importance
308
-
309
-
310
- @torch.no_grad()
311
- def compute_baseline_accuracy(
312
- model: nn.Module,
313
- dataloader: DataLoader,
314
- preprocessor,
315
- reindex_state: dict,
316
- device: torch.device,
317
- ) -> Tuple[float, np.ndarray, np.ndarray]:
318
- """Compute baseline accuracy on the test set."""
319
- preds_all, labels_all = [], []
320
- for x_batch, y_batch in dataloader:
321
- x_batch = x_batch.to(device)
322
- x_model = preprocessor.reindex_tensor_to_model_space(
323
- x_batch, reindex_state=reindex_state
324
- )
325
- logits = model(x_model)
326
- preds_all.append(logits.argmax(dim=-1).cpu().numpy())
327
- labels_all.append(y_batch.numpy())
328
- preds = np.concatenate(preds_all)
329
- labels = np.concatenate(labels_all)
330
- acc = (preds == labels).mean()
331
- return float(acc), preds, labels
332
-
333
-
334
- def _classify_from_tokens(model: nn.Module, module_tokens: torch.Tensor) -> torch.Tensor:
335
- """Run pooler + classifier on module tokens."""
336
- if model.head_type == "module_mean":
337
- cell_emb = module_tokens.mean(dim=1)
338
- elif model.head_type == "max":
339
- cell_emb = module_tokens.amax(dim=1)
340
- elif model.head_type == "attn_pool":
341
- cell_emb, _ = model.pooler(module_tokens)
342
- elif model.head_type == "gene_mean":
343
- gene_tokens = model._biomod_to_gene(module_tokens)
344
- cell_emb = gene_tokens.mean(dim=1)
345
- else:
346
- raise ValueError(f"Unsupported head_type: {model.head_type}")
347
- cell_emb = model.output_dropout(cell_emb)
348
- return model.classifier(cell_emb)
349
-
350
-
351
- @torch.no_grad()
352
- def compute_importance_pre_encoder(
353
- model: nn.Module,
354
- dataloader: DataLoader,
355
- preprocessor,
356
- reindex_state: dict,
357
- num_modules: int,
358
- num_classes: int,
359
- device: torch.device,
360
- max_cells: int = 0,
361
- return_raw: bool = False,
362
- ) -> np.ndarray:
363
- """Accurate mode: mask module input before encoder (slow).
364
-
365
- max_cells: if > 0, stop after processing this many cells (for subsampling).
366
- if 0, process all cells in the dataloader.
367
- return_raw: if True, return (importance_sum, class_counts) without normalizing.
368
- Used for DDP aggregation.
369
- """
370
- importance = np.zeros((num_classes, num_modules), dtype=np.float64)
371
- class_counts = np.zeros(num_classes, dtype=np.float64)
372
- total_cells = 0
373
-
374
- for x_batch, y_batch in dataloader:
375
- if max_cells > 0 and total_cells >= max_cells:
376
- break
377
- x_batch = x_batch.to(device)
378
- y_batch = y_batch.numpy()
379
- x_model = preprocessor.reindex_tensor_to_model_space(
380
- x_batch, reindex_state=reindex_state
381
- )
382
-
383
- baseline_logits = model(x_model)
384
- y_tensor = torch.from_numpy(y_batch).to(device)
385
- batch_idx = torch.arange(len(y_batch), device=device)
386
- baseline_correct_logits = baseline_logits[
387
- batch_idx, y_tensor
388
- ].cpu().numpy()
389
-
390
- module_sets = model.module_sets
391
- for pi in range(num_modules):
392
- x_masked = x_model.clone()
393
- gene_indices = module_sets[pi]
394
- x_masked[:, gene_indices] = 0.0
395
- masked_logits = model(x_masked)
396
- masked_correct_logits = masked_logits[
397
- batch_idx, y_tensor
398
- ].cpu().numpy()
399
-
400
- delta = baseline_correct_logits - masked_correct_logits
401
- for cls_id in range(num_classes):
402
- mask_cls = y_batch == cls_id
403
- if mask_cls.any():
404
- importance[cls_id, pi] += delta[mask_cls].sum()
405
-
406
- for cls_id in range(num_classes):
407
- class_counts[cls_id] += (y_batch == cls_id).sum()
408
- total_cells += len(y_batch)
409
-
410
- if return_raw:
411
- return importance, class_counts
412
-
413
- class_counts[class_counts == 0] = 1.0
414
- importance /= class_counts[:, None]
415
- return importance
416
-
417
-
418
- # ─── Enrichment Analysis ────────────────────────────────────────────────────
419
-
420
- def download_panglaodb_markers(cache_dir: Path) -> pd.DataFrame:
421
- """Download PanglaoDB marker gene database."""
422
- cache_file = cache_dir / "PanglaoDB_markers.tsv"
423
- if cache_file.exists():
424
- logger.info("Loading cached PanglaoDB markers from %s", cache_file)
425
- return pd.read_csv(cache_file, sep="\t")
426
-
427
- url = "https://panglaodb.se/markers/PanglaoDB_markers_27_Mar_2020.tsv.gz"
428
- logger.info("Downloading PanglaoDB markers from %s", url)
429
- import urllib.request
430
- import gzip
431
-
432
- cache_dir.mkdir(parents=True, exist_ok=True)
433
- gz_path = cache_dir / "PanglaoDB_markers.tsv.gz"
434
- urllib.request.urlretrieve(url, gz_path)
435
- with gzip.open(gz_path, "rt") as f_in:
436
- content = f_in.read()
437
- with open(cache_file, "w") as f_out:
438
- f_out.write(content)
439
- gz_path.unlink()
440
-
441
- df = pd.read_csv(cache_file, sep="\t")
442
- logger.info("Downloaded PanglaoDB: %d entries", len(df))
443
- return df
444
-
445
-
446
- def build_marker_dict(
447
- panglaodb_df: pd.DataFrame,
448
- species: str = "Hs",
449
- ) -> Dict[str, set]:
450
- """Build {cell_type: set(gene_symbols)} from PanglaoDB."""
451
- if "species" in panglaodb_df.columns:
452
- df = panglaodb_df[panglaodb_df["species"].str.contains(species, na=False)]
453
- else:
454
- df = panglaodb_df
455
-
456
- col_cell = "cell type" if "cell type" in df.columns else "celltype"
457
- col_gene = (
458
- "official gene symbol" if "official gene symbol" in df.columns else "gene"
459
- )
460
-
461
- marker_dict = {}
462
- for ct, group in df.groupby(col_cell):
463
- genes = set(group[col_gene].dropna().str.strip().str.upper())
464
- if genes:
465
- marker_dict[ct.strip()] = genes
466
- logger.info("Built marker dict: %d cell types", len(marker_dict))
467
- return marker_dict
468
-
469
-
470
- def hypergeometric_enrichment(
471
- query_genes: set,
472
- marker_genes: set,
473
- background_size: int,
474
- ) -> Tuple[float, int, int]:
475
- """One-sided hypergeometric test for over-representation."""
476
- M = background_size
477
- n = len(marker_genes)
478
- N = len(query_genes)
479
- k = len(query_genes & marker_genes)
480
- if N == 0 or n == 0:
481
- return 1.0, k, N
482
- pval = stats.hypergeom.sf(k - 1, M, n, N)
483
- return float(pval), k, N
484
-
485
-
486
- def run_enrichment(
487
- importance: np.ndarray,
488
- module_names: List[str],
489
- module_gene_symbols: Dict[str, List[str]],
490
- label_classes: List[str],
491
- marker_dict: Dict[str, set],
492
- top_k: int = 20,
493
- background_size: int = 27874,
494
- ) -> pd.DataFrame:
495
- """Run hypergeometric enrichment for top-k modules per cell type."""
496
- results = []
497
- num_classes = importance.shape[0]
498
-
499
- for cls_id in range(num_classes):
500
- cls_name = label_classes[cls_id]
501
- ranked_indices = np.argsort(-importance[cls_id])
502
- top_indices = ranked_indices[:top_k]
503
-
504
- query_genes = set()
505
- top_module_names = []
506
- for pi in top_indices:
507
- pname = module_names[pi]
508
- top_module_names.append(pname)
509
- genes = module_gene_symbols.get(pname, [])
510
- query_genes.update(g.upper() for g in genes)
511
-
512
- for db_ct, db_markers in marker_dict.items():
513
- pval, overlap_count, query_size = hypergeometric_enrichment(
514
- query_genes, db_markers, background_size
515
- )
516
- results.append({
517
- "model_cell_type": cls_name,
518
- "db_cell_type": db_ct,
519
- "top_k": top_k,
520
- "query_genes": query_size,
521
- "db_markers": len(db_markers),
522
- "overlap": overlap_count,
523
- "pvalue": pval,
524
- "top_modules": ";".join(top_module_names),
525
- })
526
-
527
- df = pd.DataFrame(results)
528
- df["pvalue_adj"] = np.minimum(
529
- df["pvalue"] * len(marker_dict), 1.0
530
- )
531
- return df
532
-
533
-
534
- # ─── Module Gene Mapping ─────────────────────────────────────────────────────
535
-
536
- def build_biomod_gene_mapping(
537
- module_data_dir: str,
538
- gene_list_path: str,
539
- ) -> Tuple[List[str], Dict[str, List[str]]]:
540
- """Return (module_names, {module_name: [gene_symbols]})."""
541
- merged_path = Path(module_data_dir) / "merged_biomods.json"
542
- with open(merged_path, "r") as f:
543
- merged = json.load(f)
544
-
545
- module_names = list(merged.keys())
546
- module_gene_symbols = {}
547
- for pname, genes in merged.items():
548
- if genes and isinstance(genes[0], str):
549
- module_gene_symbols[pname] = genes
550
- else:
551
- with open(gene_list_path, "r") as f:
552
- gene_list = [line.strip() for line in f if line.strip()]
553
- symbols = [gene_list[idx] for idx in genes if idx < len(gene_list)]
554
- module_gene_symbols[pname] = symbols
555
-
556
- return module_names, module_gene_symbols
557
-
558
-
559
- # ─── Visualization ──────────────────────────────────────────────────────────
560
-
561
- def plot_importance_heatmap(
562
- importance: np.ndarray,
563
- label_classes: List[str],
564
- output_path: Path,
565
- ):
566
- """Plot cell-type Γ— module importance heatmap."""
567
- import matplotlib
568
- matplotlib.use("Agg")
569
- import matplotlib.pyplot as plt
570
-
571
- fig, ax = plt.subplots(figsize=(16, 6))
572
- im = ax.imshow(importance, aspect="auto", cmap="RdBu_r")
573
- ax.set_xlabel("Module index")
574
- ax.set_ylabel("Cell type")
575
- ax.set_yticks(range(len(label_classes)))
576
- ax.set_yticklabels(label_classes, fontsize=8)
577
- plt.colorbar(im, ax=ax, label="Ξ”Logit (importance)")
578
- plt.title("Module Importance per Cell Type")
579
- plt.tight_layout()
580
- plt.savefig(output_path, dpi=150, bbox_inches="tight")
581
- plt.close()
582
- logger.info("Saved heatmap: %s", output_path)
583
-
584
-
585
- def plot_top_biomods_bar(
586
- importance: np.ndarray,
587
- label_classes: List[str],
588
- module_names: List[str],
589
- output_dir: Path,
590
- top_k: int = 15,
591
- ):
592
- """Bar plot of top-k modules for each cell type."""
593
- import matplotlib
594
- matplotlib.use("Agg")
595
- import matplotlib.pyplot as plt
596
-
597
- n_classes = len(label_classes)
598
- cols = min(3, n_classes)
599
- rows = (n_classes + cols - 1) // cols
600
- fig, axes = plt.subplots(rows, cols, figsize=(6 * cols, 4 * rows))
601
- axes = np.array(axes).flatten()
602
-
603
- for cls_id in range(n_classes):
604
- ax = axes[cls_id]
605
- ranked = np.argsort(-importance[cls_id])[:top_k]
606
- values = importance[cls_id, ranked]
607
- names = [module_names[i][:25] for i in ranked]
608
- ax.barh(range(top_k), values[::-1])
609
- ax.set_yticks(range(top_k))
610
- ax.set_yticklabels(names[::-1], fontsize=6)
611
- ax.set_title(label_classes[cls_id], fontsize=9)
612
- ax.set_xlabel("Ξ”Logit")
613
-
614
- for i in range(n_classes, len(axes)):
615
- axes[i].set_visible(False)
616
-
617
- plt.tight_layout()
618
- plt.savefig(output_dir / "top_modules_bar.png", dpi=150, bbox_inches="tight")
619
- plt.close()
620
- logger.info("Saved bar plots: %s", output_dir / "top_modules_bar.png")
621
-
622
-
623
- # ─── Argument Parser ────────────────────────────────────────────────────────
624
-
625
- def parse_args():
626
- p = argparse.ArgumentParser(description="Module Importance + Enrichment")
627
- p.add_argument("--finetune_checkpoint", required=True,
628
- help="Path to best_model.pt from cell annotation finetuning")
629
- p.add_argument("--config_pkl", required=True,
630
- help="Path to pretrained model .pkl config")
631
- p.add_argument("--biomod_data_dir", "--module_data_dir", required=True,
632
- help="Directory containing merged_biomods.json", dest='module_data_dir')
633
- p.add_argument("--gene_list_path", required=True,
634
- help="Path to selected_genes_27k.txt")
635
- p.add_argument("--gene_mapping_path", required=True)
636
- p.add_argument("--gene2vec_data_path", required=True)
637
- p.add_argument("--gene2ens_file", required=True)
638
- p.add_argument("--data_path", required=True,
639
- help="Path to h5ad dataset (e.g. Zheng68K.h5ad)")
640
- p.add_argument("--label_column", default="celltype")
641
- p.add_argument("--gene_name_column", default="auto")
642
- p.add_argument("--head_type", default="attn_pool",
643
- help="Fallback head_type if not in checkpoint")
644
- p.add_argument("--mask_mode", default="post_encoder",
645
- choices=["pre_encoder", "post_encoder"])
646
- p.add_argument("--importance_cells", type=int, default=0,
647
- help="Max cells for pre_encoder mode. 0 = use all cells.")
648
- p.add_argument("--top_k", type=int, nargs="+", default=[10, 20],
649
- help="Top-k modules for enrichment")
650
- p.add_argument("--batch_size", type=int, default=64)
651
- p.add_argument("--test_ratio", type=float, default=0.2)
652
- p.add_argument("--seed", type=int, default=2021)
653
- p.add_argument("--marker_db_dir", default=None,
654
- help="Directory to cache marker DB files")
655
- p.add_argument("--output_dir", required=True)
656
- p.add_argument("--device", default="cuda")
657
- p.add_argument("--num_workers", type=int, default=0,
658
- help="DataLoader num_workers")
659
- return p.parse_args()
660
-
661
-
662
- # ─── Main ───────────────────────────────────────────────────────────────────
663
-
664
- def main():
665
- args = parse_args()
666
- output_dir = Path(args.output_dir)
667
- output_dir.mkdir(parents=True, exist_ok=True)
668
-
669
- rank, world_size, device, use_ddp = setup_ddp()
670
- logger.info("Device: %s (rank %d/%d, ddp=%s)", device, rank, world_size, use_ddp)
671
-
672
- for attr in ("finetune_checkpoint", "config_pkl", "module_data_dir",
673
- "gene_list_path", "gene_mapping_path", "gene2vec_data_path",
674
- "gene2ens_file", "data_path"):
675
- val = getattr(args, attr, None)
676
- if val is not None and not Path(val).is_absolute():
677
- setattr(args, attr, str(Path(val).resolve()))
678
-
679
- logger.info("Loading finetuned model...")
680
- model, biomod_data, label_classes, head_type = load_finetuned_model(args, device)
681
- num_modules = len(biomod_data)
682
- num_classes = len(label_classes)
683
-
684
- logger.info("Loading test data...")
685
- X_test, y_test, le, preprocessor, reindex_state = load_test_data(
686
- args, label_classes, device
687
- )
688
-
689
- dataset = CellDataset(X_test, y_test)
690
-
691
- # DDP: split cells across ranks for pre_encoder mode
692
- if use_ddp and args.mask_mode == "pre_encoder":
693
- total = len(dataset)
694
- per_rank = total // world_size
695
- start = rank * per_rank
696
- end = start + per_rank if rank < world_size - 1 else total
697
- indices = list(range(start, end))
698
- rank_dataset = Subset(dataset, indices)
699
- logger.info("Rank %d: cells [%d, %d) (%d cells)",
700
- rank, start, end, len(indices))
701
- else:
702
- rank_dataset = dataset
703
-
704
- dataloader = DataLoader(
705
- rank_dataset, batch_size=args.batch_size, shuffle=False,
706
- num_workers=args.num_workers,
707
- )
708
-
709
- if rank == 0:
710
- full_dataloader = DataLoader(
711
- dataset, batch_size=args.batch_size, shuffle=False,
712
- num_workers=args.num_workers,
713
- )
714
- logger.info("Computing baseline accuracy...")
715
- baseline_acc, _, _ = compute_baseline_accuracy(
716
- model, full_dataloader, preprocessor, reindex_state, device
717
- )
718
- logger.info("Baseline test accuracy: %.4f", baseline_acc)
719
- else:
720
- baseline_acc = 0.0
721
-
722
- logger.info("Computing module importance (mode=%s)...", args.mask_mode)
723
- if args.mask_mode == "post_encoder":
724
- importance = compute_importance_post_encoder(
725
- model, dataloader, preprocessor, reindex_state,
726
- num_modules, num_classes, device,
727
- )
728
- else:
729
- max_cells = args.importance_cells if args.importance_cells > 0 else 0
730
- if use_ddp:
731
- imp_raw, cls_counts = compute_importance_pre_encoder(
732
- model, dataloader, preprocessor, reindex_state,
733
- num_modules, num_classes, device,
734
- max_cells=max_cells,
735
- return_raw=True,
736
- )
737
- imp_tensor = torch.from_numpy(imp_raw).to(device)
738
- cnt_tensor = torch.from_numpy(cls_counts).to(device)
739
- dist.all_reduce(imp_tensor, op=dist.ReduceOp.SUM)
740
- dist.all_reduce(cnt_tensor, op=dist.ReduceOp.SUM)
741
- cls_counts_total = cnt_tensor.cpu().numpy()
742
- cls_counts_total[cls_counts_total == 0] = 1.0
743
- importance = imp_tensor.cpu().numpy() / cls_counts_total[:, None]
744
- dist.barrier()
745
- else:
746
- importance = compute_importance_pre_encoder(
747
- model, dataloader, preprocessor, reindex_state,
748
- num_modules, num_classes, device,
749
- max_cells=max_cells,
750
- )
751
-
752
- # Only rank 0 does enrichment, plotting, saving
753
- if rank == 0:
754
- logger.info("Building module-gene mapping...")
755
- module_names, module_gene_symbols = build_biomod_gene_mapping(
756
- args.module_data_dir, args.gene_list_path
757
- )
758
-
759
- np.save(output_dir / "importance_matrix.npy", importance)
760
- imp_df = pd.DataFrame(
761
- importance, index=label_classes, columns=module_names
762
- )
763
- imp_df.to_csv(output_dir / "importance_matrix.csv")
764
-
765
- top_modules_info = {}
766
- for cls_id in range(num_classes):
767
- ranked = np.argsort(-importance[cls_id])[:max(args.top_k)]
768
- top_modules_info[label_classes[cls_id]] = [
769
- {
770
- "rank": r + 1,
771
- "module": module_names[pi],
772
- "importance": float(importance[cls_id, pi]),
773
- "genes": module_gene_symbols.get(
774
- module_names[pi], []
775
- )[:10],
776
- }
777
- for r, pi in enumerate(ranked)
778
- ]
779
- with open(output_dir / "top_modules_per_celltype.json", "w") as f:
780
- json.dump(top_modules_info, f, indent=2)
781
-
782
- logger.info("Running enrichment analysis...")
783
- marker_cache = (
784
- Path(args.marker_db_dir)
785
- if args.marker_db_dir
786
- else output_dir / "marker_cache"
787
- )
788
- panglaodb_df = download_panglaodb_markers(marker_cache)
789
- marker_dict = build_marker_dict(panglaodb_df)
790
-
791
- all_enrichment = []
792
- for k in args.top_k:
793
- enrich_df = run_enrichment(
794
- importance, module_names, module_gene_symbols,
795
- label_classes, marker_dict, top_k=k,
796
- )
797
- all_enrichment.append(enrich_df)
798
- enrichment_df = pd.concat(all_enrichment, ignore_index=True)
799
- enrichment_df.to_csv(
800
- output_dir / "enrichment_results.csv", index=False
801
- )
802
-
803
- sig_hits = enrichment_df[enrichment_df["pvalue_adj"] < 0.05]
804
- logger.info(
805
- "Enrichment: %d significant hits (adj p < 0.05) out of %d tests",
806
- len(sig_hits), len(enrichment_df),
807
- )
808
-
809
- logger.info("Generating plots...")
810
- plot_importance_heatmap(
811
- importance, label_classes, output_dir / "heatmap_importance.png"
812
- )
813
- plot_top_biomods_bar(
814
- importance, label_classes, module_names, output_dir
815
- )
816
-
817
- metrics = {
818
- "baseline_accuracy": baseline_acc,
819
- "mask_mode": args.mask_mode,
820
- "num_modules": num_modules,
821
- "num_classes": num_classes,
822
- "num_test_cells": len(y_test),
823
- "top_k_values": args.top_k,
824
- "enrichment_significant_hits": int(len(sig_hits)),
825
- "enrichment_total_tests": int(len(enrichment_df)),
826
- }
827
-
828
- for cls_id in range(num_classes):
829
- cls_name = label_classes[cls_id]
830
- cls_sig = sig_hits[
831
- sig_hits["model_cell_type"] == cls_name
832
- ]
833
- if len(cls_sig) > 0:
834
- best = cls_sig.sort_values("pvalue_adj").iloc[0]
835
- metrics[f"best_match_{cls_name}"] = {
836
- "db_cell_type": best["db_cell_type"],
837
- "pvalue_adj": float(best["pvalue_adj"]),
838
- "overlap": int(best["overlap"]),
839
- }
840
-
841
- with open(output_dir / "metrics.json", "w") as f:
842
- json.dump(metrics, f, indent=2)
843
-
844
- logger.info("Done. Results saved to %s", output_dir)
845
-
846
- cleanup_ddp(use_ddp)
847
-
848
-
849
- if __name__ == "__main__":
850
- main()
851
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
BioMod/interpretability/biomod_importance/biomod_importance_publication.py DELETED
@@ -1,382 +0,0 @@
1
- #!/usr/bin/env python
2
- # -*- coding: utf-8 -*-
3
- """
4
- Module Importance β€” Publication-Quality Figure Generation
5
-
6
- Generates all figures from saved experiment results (CPU only).
7
- Run after masking_ablation.py and random_baseline.py have produced results.
8
-
9
- Figures:
10
- 1. Ablation curves (overall + per-class)
11
- 2. Permutation null distributions
12
- 3. Marker gene rank dot plot (from marker_gene_verification.py)
13
- 4. Improved importance heatmap with clustering + annotations
14
- 5. Enrichment summary heatmap
15
- """
16
-
17
- import argparse
18
- import json
19
- import sys
20
- from pathlib import Path
21
-
22
- import matplotlib.pyplot as plt
23
- import numpy as np
24
- import pandas as pd
25
- import seaborn as sns
26
- from scipy.cluster.hierarchy import dendrogram, linkage
27
-
28
- SCRIPT_DIR = Path(__file__).resolve().parent
29
- sys.path.insert(0, str(SCRIPT_DIR))
30
-
31
- from pub_utils import (
32
- CELL_TYPE_COLORS,
33
- MODULE_FAMILY_COLORS,
34
- get_biomod_families,
35
- load_importance_results,
36
- load_biomod_gene_mapping,
37
- savefig,
38
- setup_publication_style,
39
- )
40
-
41
-
42
- def plot_ablation_curves_overall(ablation_dir, output_dir):
43
- """Fig 1: Overall ablation curves β€” top-masked only."""
44
- data = np.load(ablation_dir / "ablation_curves.npz")
45
- k_values = data["k_values"]
46
-
47
- with open(ablation_dir / "ablation_metrics.json") as f:
48
- metrics = json.load(f)
49
- baseline = metrics["baseline_accuracy"]
50
-
51
- setup_publication_style()
52
- fig, ax = plt.subplots(figsize=(6, 4))
53
-
54
- ax.axhline(baseline, color="gray", linestyle=":", linewidth=1.0,
55
- label=f"Baseline ({baseline:.1%})")
56
-
57
- ax.plot(k_values, data["overall_top"], "o-", color="#E41A1C",
58
- markersize=6, linewidth=2, label="Top modules masked")
59
-
60
- for i, (k, acc) in enumerate(zip(k_values, data["overall_top"])):
61
- drop = baseline - acc
62
- if drop > 0.02:
63
- ax.annotate(f"βˆ’{drop:.0%}", (k, acc),
64
- textcoords="offset points", xytext=(0, -14),
65
- fontsize=7, ha="center", color="#E41A1C")
66
-
67
- ax.set_xlabel("Number of modules masked")
68
- ax.set_ylabel("Classification accuracy")
69
- ax.set_title("Module Masking Ablation (Top Importance Modules)")
70
- ax.legend(loc="upper right", framealpha=0.9)
71
- ax.set_xscale("log")
72
- ax.set_xticks(k_values)
73
- ax.set_xticklabels([str(k) for k in k_values])
74
- ax.set_ylim(0.4, 0.9)
75
-
76
- fig.tight_layout()
77
- savefig(fig, output_dir / "fig1_ablation_curves")
78
- return True
79
-
80
-
81
- def plot_ablation_curves_per_class(ablation_dir, output_dir):
82
- """Fig S1: Per-cell-type ablation curves."""
83
- data = np.load(ablation_dir / "ablation_curves.npz")
84
- k_values = data["k_values"]
85
-
86
- with open(ablation_dir / "ablation_metrics.json") as f:
87
- metrics = json.load(f)
88
- label_classes = metrics["label_classes"]
89
- num_classes = len(label_classes)
90
-
91
- setup_publication_style()
92
- ncols = 4
93
- nrows = (num_classes + ncols - 1) // ncols
94
- fig, axes = plt.subplots(nrows, ncols, figsize=(16, 3.5 * nrows))
95
- axes = axes.flatten()
96
-
97
- for cls_idx in range(num_classes):
98
- ax = axes[cls_idx]
99
- color = CELL_TYPE_COLORS.get(label_classes[cls_idx], "#333333")
100
-
101
- ax.plot(k_values, data["per_class_top"][cls_idx], "o-",
102
- color="#E41A1C", markersize=3, label="Top")
103
- ax.plot(k_values, data["per_class_bottom"][cls_idx], "s-",
104
- color="#377EB8", markersize=3, label="Bottom")
105
- ax.plot(k_values, data["per_class_random_mean"][cls_idx], "^-",
106
- color="#4DAF4A", markersize=3, label="Random")
107
- ax.fill_between(
108
- k_values,
109
- data["per_class_random_mean"][cls_idx] - data["per_class_random_std"][cls_idx],
110
- data["per_class_random_mean"][cls_idx] + data["per_class_random_std"][cls_idx],
111
- alpha=0.15, color="#4DAF4A",
112
- )
113
-
114
- ax.set_title(label_classes[cls_idx], fontsize=9, color=color)
115
- ax.set_xscale("log")
116
- ax.set_ylim(0, 1.05)
117
- if cls_idx == 0:
118
- ax.legend(fontsize=7, loc="lower left")
119
-
120
- for idx in range(num_classes, len(axes)):
121
- axes[idx].set_visible(False)
122
-
123
- fig.supxlabel("Number of modules masked", fontsize=10)
124
- fig.supylabel("Accuracy", fontsize=10)
125
- fig.tight_layout()
126
- savefig(fig, output_dir / "fig_s1_ablation_per_class")
127
- return True
128
-
129
-
130
- def plot_permutation_null(permutation_dir, output_dir):
131
- """Fig 2: Permutation null distributions."""
132
- setup_publication_style()
133
-
134
- metrics_path = permutation_dir / "permutation_metrics.json"
135
- if not metrics_path.exists():
136
- return False
137
-
138
- with open(metrics_path) as f:
139
- metrics = json.load(f)
140
-
141
- has_modules = (
142
- (permutation_dir / "null_module_shuffle.npy").exists()
143
- and "shuffle_modules" in metrics
144
- )
145
- has_labels = (
146
- (permutation_dir / "null_label_shuffle.npy").exists()
147
- and "shuffle_labels" in metrics
148
- )
149
-
150
- if not has_modules and not has_labels:
151
- return False
152
-
153
- n_panels = has_modules + has_labels
154
- fig, axes = plt.subplots(1, n_panels, figsize=(5 * n_panels, 4))
155
- if n_panels == 1:
156
- axes = [axes]
157
-
158
- panel_idx = 0
159
-
160
- if has_modules:
161
- null_data = np.load(permutation_dir / "null_module_shuffle.npy")
162
- null_max = null_data.max(axis=(1, 2))
163
- stats = metrics["shuffle_modules"]
164
- real_max = stats["real_global_max"]
165
-
166
- ax = axes[panel_idx]
167
- ax.hist(null_max, bins=20, color="#377EB8", alpha=0.7, edgecolor="white")
168
- ax.axvline(real_max, color="#E41A1C", linewidth=2, linestyle="--",
169
- label=f"Observed ({real_max:.3f})")
170
- ax.set_xlabel("Max importance (global)")
171
- ax.set_ylabel("Count")
172
- ax.set_title(f"Module-Shuffle Null (n={stats['n_permutations']})\n"
173
- f"p = {stats['pvalue_global_max']:.4f}")
174
- ax.legend()
175
- panel_idx += 1
176
-
177
- if has_labels:
178
- null_data = np.load(permutation_dir / "null_label_shuffle.npy")
179
- null_max = null_data.max(axis=(1, 2))
180
- stats = metrics["shuffle_labels"]
181
- real_max = stats["real_global_max"]
182
-
183
- ax = axes[panel_idx]
184
- ax.hist(null_max, bins=30, color="#4DAF4A", alpha=0.7, edgecolor="white")
185
- ax.axvline(real_max, color="#E41A1C", linewidth=2, linestyle="--",
186
- label=f"Observed ({real_max:.3f})")
187
- ax.set_xlabel("Max importance (global)")
188
- ax.set_ylabel("Count")
189
- ax.set_title(f"Label-Shuffle Null (n={stats['n_permutations']})\n"
190
- f"p = {stats['pvalue_global_max']:.4f}")
191
- ax.legend()
192
-
193
- fig.tight_layout()
194
- savefig(fig, output_dir / "fig2_permutation_null")
195
- return True
196
-
197
-
198
- def plot_importance_heatmap_clustered(results_dir, module_data_dir, output_dir):
199
- """Fig 4: Improved importance heatmap with clustering and annotations."""
200
- setup_publication_style()
201
-
202
- data = load_importance_results(results_dir)
203
- importance = data["importance"]
204
- label_classes = data["label_classes"]
205
- module_names = data["module_names"]
206
-
207
- families = get_biomod_families(module_names)
208
- family_colors = [MODULE_FAMILY_COLORS.get(f, "#CCCCCC") for f in families]
209
-
210
- # Cluster modules
211
- linkage_modules = linkage(importance.T, method="ward")
212
- from scipy.cluster.hierarchy import leaves_list
213
- module_order = leaves_list(linkage_modules)
214
-
215
- importance_ordered = importance[:, module_order]
216
- families_ordered = [families[i] for i in module_order]
217
- family_colors_ordered = [family_colors[i] for i in module_order]
218
-
219
- from matplotlib.patches import Patch as LegendHandle
220
- fig = plt.figure(figsize=(14, 6))
221
- gs = fig.add_gridspec(
222
- 2, 2, height_ratios=[1, 12], width_ratios=[50, 1],
223
- hspace=0.05, wspace=0.02,
224
- )
225
-
226
- # Top bar: module family annotation
227
- ax_bar = fig.add_subplot(gs[0, 0])
228
- for i, color in enumerate(family_colors_ordered):
229
- ax_bar.axvspan(i - 0.5, i + 0.5, color=color, alpha=0.8)
230
- ax_bar.set_xlim(-0.5, len(module_order) - 0.5)
231
- ax_bar.set_yticks([])
232
- ax_bar.set_xticks([])
233
- ax_bar.spines[:].set_visible(False)
234
-
235
- # Legend for families β€” placed above the bar using figure coordinates
236
- legend_modules = [
237
- LegendHandle(facecolor=c, label=f) for f, c in MODULE_FAMILY_COLORS.items()
238
- ]
239
- ax_bar.legend(
240
- handles=legend_modules, loc="lower center",
241
- bbox_to_anchor=(0.5, 1.05), ncol=4, fontsize=8,
242
- frameon=False,
243
- )
244
-
245
- # Main heatmap
246
- ax_heat = fig.add_subplot(gs[1, 0])
247
- vmax = np.percentile(np.abs(importance), 98)
248
- im = ax_heat.imshow(
249
- importance_ordered, aspect="auto", cmap="RdBu_r",
250
- vmin=-vmax, vmax=vmax, interpolation="nearest",
251
- )
252
- ax_heat.set_yticks(range(len(label_classes)))
253
- ax_heat.set_yticklabels(label_classes, fontsize=9)
254
- ax_heat.set_xlabel("Modules (hierarchically clustered)")
255
- ax_heat.set_xticks([])
256
-
257
- # Colorbar in its own subplot area
258
- ax_cbar = fig.add_subplot(gs[1, 1])
259
- cbar = fig.colorbar(im, cax=ax_cbar)
260
- cbar.set_label("Ξ”Logit (importance)", fontsize=9)
261
-
262
- # Hide unused top-right cell
263
- ax_empty = fig.add_subplot(gs[0, 1])
264
- ax_empty.set_visible(False)
265
-
266
- fig.suptitle("Module Importance Matrix (hierarchically clustered)", fontsize=11, y=0.98)
267
- savefig(fig, output_dir / "fig4_importance_heatmap_clustered")
268
- return True
269
-
270
-
271
- def plot_enrichment_summary(results_dir, output_dir):
272
- """Fig 5: Enrichment summary heatmap."""
273
- setup_publication_style()
274
-
275
- data = load_importance_results(results_dir)
276
- if "enrichment" not in data:
277
- return False
278
-
279
- enrichment = data["enrichment"]
280
- label_classes = data["label_classes"]
281
-
282
- sig = enrichment[enrichment["pvalue_adj"] < 0.05].copy()
283
- if sig.empty:
284
- return False
285
-
286
- # Get top db_cell_types per model cell type
287
- top_matches = []
288
- for ct in label_classes:
289
- ct_sig = sig[sig["model_cell_type"] == ct].sort_values("pvalue_adj")
290
- top_matches.append(ct_sig.head(5))
291
- top_df = pd.concat(top_matches)
292
-
293
- db_types = top_df["db_cell_type"].unique()
294
-
295
- # Build matrix
296
- matrix = np.zeros((len(label_classes), len(db_types)))
297
- for i, ct in enumerate(label_classes):
298
- for j, db in enumerate(db_types):
299
- row = enrichment[
300
- (enrichment["model_cell_type"] == ct) &
301
- (enrichment["db_cell_type"] == db)
302
- ]
303
- if not row.empty:
304
- pval = row["pvalue_adj"].min()
305
- matrix[i, j] = -np.log10(max(pval, 1e-50))
306
-
307
- fig, ax = plt.subplots(figsize=(max(10, len(db_types) * 0.5), 5))
308
- im = ax.imshow(matrix, aspect="auto", cmap="YlOrRd", interpolation="nearest")
309
- ax.set_yticks(range(len(label_classes)))
310
- ax.set_yticklabels(label_classes, fontsize=8)
311
- ax.set_xticks(range(len(db_types)))
312
- ax.set_xticklabels(db_types, fontsize=7, rotation=45, ha="right")
313
- ax.set_xlabel("PanglaoDB Cell Types")
314
- ax.set_title("Enrichment Significance: -log10(adjusted p-value)")
315
-
316
- cbar = fig.colorbar(im, ax=ax, fraction=0.02, pad=0.01)
317
- cbar.set_label("-log10(p_adj)", fontsize=9)
318
-
319
- fig.tight_layout()
320
- savefig(fig, output_dir / "fig5_enrichment_summary")
321
- return True
322
-
323
-
324
- def parse_args():
325
- p = argparse.ArgumentParser(description="Generate publication figures")
326
- p.add_argument("--results_dir", required=True,
327
- help="Base importance results directory")
328
- p.add_argument("--ablation_dir",
329
- help="Ablation results directory")
330
- p.add_argument("--permutation_dir",
331
- help="Permutation results directory")
332
- p.add_argument("--biomod_data_dir", "--module_data_dir", required=True,
333
- help="Module data directory (merged_biomods.json)", dest='module_data_dir')
334
- p.add_argument("--output_dir", required=True)
335
- return p.parse_args()
336
-
337
-
338
- def main():
339
- args = parse_args()
340
- output_dir = Path(args.output_dir)
341
- output_dir.mkdir(parents=True, exist_ok=True)
342
-
343
- results_dir = Path(args.results_dir)
344
- module_data_dir = Path(args.module_data_dir)
345
-
346
- generated = []
347
-
348
- # Fig 4: Improved heatmap (always available)
349
- print("Generating Fig 4: Importance heatmap (clustered)...")
350
- if plot_importance_heatmap_clustered(results_dir, module_data_dir, output_dir):
351
- generated.append("fig4_importance_heatmap_clustered")
352
-
353
- # Fig 5: Enrichment summary (always available)
354
- print("Generating Fig 5: Enrichment summary...")
355
- if plot_enrichment_summary(results_dir, output_dir):
356
- generated.append("fig5_enrichment_summary")
357
-
358
- # Fig 1: Ablation curves (if available)
359
- if args.ablation_dir:
360
- ablation_dir = Path(args.ablation_dir)
361
- if (ablation_dir / "ablation_curves.npz").exists():
362
- print("Generating Fig 1: Ablation curves...")
363
- if plot_ablation_curves_overall(ablation_dir, output_dir):
364
- generated.append("fig1_ablation_curves")
365
- print("Generating Fig S1: Per-class ablation...")
366
- if plot_ablation_curves_per_class(ablation_dir, output_dir):
367
- generated.append("fig_s1_ablation_per_class")
368
-
369
- # Fig 2: Permutation null (if available)
370
- if args.permutation_dir:
371
- permutation_dir = Path(args.permutation_dir)
372
- if (permutation_dir / "permutation_metrics.json").exists():
373
- print("Generating Fig 2: Permutation null...")
374
- if plot_permutation_null(permutation_dir, output_dir):
375
- generated.append("fig2_permutation_null")
376
-
377
- print(f"\nGenerated {len(generated)} figures: {generated}")
378
- print(f"Output: {output_dir}")
379
-
380
-
381
- if __name__ == "__main__":
382
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
BioMod/interpretability/biomod_importance/cross_class_similarity.py DELETED
@@ -1,247 +0,0 @@
1
- #!/usr/bin/env python
2
- # -*- coding: utf-8 -*-
3
- """
4
- Cross-class module importance similarity analysis.
5
-
6
- Generates a multi-panel figure showing:
7
- A) Spearman correlation of importance profiles across cell types
8
- B) Jaccard similarity of top-K important modules
9
- C) Shared vs. class-specific module counts (UpSet-style bar)
10
-
11
- Runs entirely on saved results (CPU only).
12
- """
13
-
14
- import argparse
15
- import sys
16
- from pathlib import Path
17
-
18
- import matplotlib.pyplot as plt
19
- import matplotlib as mpl
20
- import numpy as np
21
- import pandas as pd
22
- from scipy.cluster.hierarchy import linkage, leaves_list
23
- from scipy.spatial.distance import squareform
24
- from scipy.stats import spearmanr
25
-
26
- SCRIPT_DIR = Path(__file__).resolve().parent
27
- sys.path.insert(0, str(SCRIPT_DIR))
28
-
29
- from pub_utils import (
30
- CELL_TYPE_COLORS,
31
- load_importance_results,
32
- savefig,
33
- setup_publication_style,
34
- )
35
-
36
-
37
- def compute_spearman_corr(importance: np.ndarray, label_classes: list):
38
- """Pairwise Spearman correlation between cell-type importance profiles."""
39
- n = len(label_classes)
40
- corr_mat = np.zeros((n, n))
41
- pval_mat = np.ones((n, n))
42
- for i in range(n):
43
- for j in range(i, n):
44
- if np.std(importance[i]) == 0 or np.std(importance[j]) == 0:
45
- corr_mat[i, j] = corr_mat[j, i] = np.nan
46
- pval_mat[i, j] = pval_mat[j, i] = np.nan
47
- else:
48
- r, p = spearmanr(importance[i], importance[j])
49
- corr_mat[i, j] = corr_mat[j, i] = r
50
- pval_mat[i, j] = pval_mat[j, i] = p
51
- return corr_mat, pval_mat
52
-
53
-
54
- def compute_jaccard(importance: np.ndarray, K: int):
55
- """Jaccard similarity of top-K modules between every pair of cell types."""
56
- n = importance.shape[0]
57
- top_sets = [set(np.argsort(-importance[i])[:K]) for i in range(n)]
58
- jac = np.zeros((n, n))
59
- for i in range(n):
60
- for j in range(n):
61
- union = len(top_sets[i] | top_sets[j])
62
- jac[i, j] = len(top_sets[i] & top_sets[j]) / union if union > 0 else 0
63
- return jac, top_sets
64
-
65
-
66
- def compute_sharing_stats(importance: np.ndarray, label_classes: list, K: int):
67
- """Count how many cell types each top-K module is important for."""
68
- n = len(label_classes)
69
- top_sets = [set(np.argsort(-importance[i])[:K]) for i in range(n)]
70
- all_top = set()
71
- for s in top_sets:
72
- all_top |= s
73
-
74
- sharing = {}
75
- for p in all_top:
76
- count = sum(1 for s in top_sets if p in s)
77
- sharing[p] = count
78
-
79
- counts = np.array(list(sharing.values()))
80
- unique_per_class = {}
81
- for i, ct in enumerate(label_classes):
82
- unique_per_class[ct] = sum(1 for p in top_sets[i] if sharing[p] == 1)
83
-
84
- return sharing, unique_per_class, counts
85
-
86
-
87
- def plot_cross_class_similarity(results_dir, output_dir, K=50):
88
- setup_publication_style()
89
-
90
- data = load_importance_results(results_dir)
91
- importance = data["importance"]
92
- label_classes = data["label_classes"]
93
-
94
- # --- Compute ---
95
- corr_mat, pval_mat = compute_spearman_corr(importance, label_classes)
96
- jac_mat, top_sets = compute_jaccard(importance, K)
97
- sharing, unique_per_class, sharing_counts = compute_sharing_stats(
98
- importance, label_classes, K
99
- )
100
-
101
- # --- Cluster order (from Jaccard, excluding NaN rows) ---
102
- valid_mask = ~np.isnan(corr_mat).all(axis=1)
103
- valid_idx = np.where(valid_mask)[0]
104
-
105
- if len(valid_idx) >= 2:
106
- jac_valid = jac_mat[np.ix_(valid_idx, valid_idx)]
107
- dist = 1 - jac_valid
108
- np.fill_diagonal(dist, 0)
109
- link = linkage(squareform(dist), method="average")
110
- order_valid = leaves_list(link)
111
- display_order = valid_idx[order_valid]
112
- # Append NaN rows at the end
113
- nan_idx = np.where(~valid_mask)[0]
114
- display_order = np.concatenate([display_order, nan_idx])
115
- else:
116
- display_order = np.arange(len(label_classes))
117
-
118
- # Reorder matrices
119
- corr_ordered = corr_mat[np.ix_(display_order, display_order)]
120
- jac_ordered = jac_mat[np.ix_(display_order, display_order)]
121
- labels_ordered = [label_classes[i] for i in display_order]
122
-
123
- # --- Figure ---
124
- fig, axes_all = plt.subplots(1, 3, figsize=(22, 8),
125
- gridspec_kw={"width_ratios": [1, 1, 0.65], "wspace": 0.45})
126
-
127
- # Panel A: Spearman correlation
128
- ax_a = axes_all[0]
129
- mask_a = np.zeros_like(corr_ordered, dtype=bool)
130
- mask_a[np.triu_indices_from(mask_a, k=1)] = True
131
-
132
- im_a = ax_a.imshow(corr_ordered, cmap="RdBu_r", vmin=-1, vmax=1, aspect="equal")
133
- ax_a.set_xticks(range(len(labels_ordered)))
134
- ax_a.set_xticklabels(labels_ordered, rotation=45, ha="right", fontsize=7)
135
- ax_a.set_yticks(range(len(labels_ordered)))
136
- ax_a.set_yticklabels(labels_ordered, fontsize=7)
137
-
138
- # Annotate cells
139
- n = len(labels_ordered)
140
- for i in range(n):
141
- for j in range(n):
142
- val = corr_ordered[i, j]
143
- if np.isnan(val):
144
- ax_a.text(j, i, "β€”", ha="center", va="center", fontsize=6, color="gray")
145
- else:
146
- color = "white" if abs(val) > 0.5 else "black"
147
- ax_a.text(j, i, f"{val:.2f}", ha="center", va="center",
148
- fontsize=5.5, color=color)
149
-
150
- cb_a = fig.colorbar(im_a, ax=ax_a, fraction=0.046, pad=0.02, shrink=0.85)
151
- cb_a.set_label("Spearman ρ", fontsize=9)
152
- ax_a.set_title("(A) Importance Profile Correlation", fontsize=10, pad=10)
153
-
154
- # Panel B: Jaccard similarity
155
- ax_b = axes_all[1]
156
- im_b = ax_b.imshow(jac_ordered, cmap="YlOrRd", vmin=0, vmax=0.35, aspect="equal")
157
- ax_b.set_xticks(range(len(labels_ordered)))
158
- ax_b.set_xticklabels(labels_ordered, rotation=45, ha="right", fontsize=7)
159
- ax_b.set_yticks(range(len(labels_ordered)))
160
- ax_b.set_yticklabels(labels_ordered, fontsize=7)
161
-
162
- for i in range(n):
163
- for j in range(n):
164
- val = jac_ordered[i, j]
165
- color = "white" if val > 0.2 else "black"
166
- ax_b.text(j, i, f"{val:.2f}", ha="center", va="center",
167
- fontsize=5.5, color=color)
168
-
169
- cb_b = fig.colorbar(im_b, ax=ax_b, fraction=0.046, pad=0.02, shrink=0.85)
170
- cb_b.set_label("Jaccard Index", fontsize=9)
171
- ax_b.set_title(f"(B) Top-{K} Module Overlap (Jaccard)", fontsize=10, pad=10)
172
-
173
- # Panel C: Sharing distribution bar chart
174
- ax_c = axes_all[2]
175
-
176
- # How many of the union of all top-K modules are shared by 1, 2, ..., n classes
177
- max_share = sharing_counts.max()
178
- bins = np.arange(1, max_share + 2) - 0.5
179
- ax_c.hist(sharing_counts, bins=bins, color="#377EB8", edgecolor="white", alpha=0.85)
180
- ax_c.set_xlabel("# cell types sharing the module", fontsize=9)
181
- ax_c.set_ylabel("# modules", fontsize=9)
182
- ax_c.set_title(f"(C) Module Sharing Distribution\n(top-{K} per class)", fontsize=10, pad=10)
183
- ax_c.set_xticks(range(1, int(max_share) + 1))
184
-
185
- total_union = len(sharing)
186
- unique_total = sum(1 for v in sharing.values() if v == 1)
187
- pct = 100 * unique_total / total_union
188
- ax_c.annotate(
189
- f"{unique_total}/{total_union} modules\n({pct:.0f}%) class-specific",
190
- xy=(0.95, 0.92), xycoords="axes fraction",
191
- fontsize=8, color="#E41A1C", fontweight="bold",
192
- ha="right", va="top",
193
- )
194
-
195
- fig.suptitle(
196
- "Cross-Class Similarity of Module Importance Profiles",
197
- fontsize=12, y=0.98,
198
- )
199
- fig.subplots_adjust(bottom=0.22, top=0.90, left=0.08, right=0.95)
200
- savefig(fig, output_dir / "fig_cross_class_similarity")
201
-
202
- # --- Also save numerical results ---
203
- corr_df = pd.DataFrame(corr_mat, index=label_classes, columns=label_classes)
204
- corr_df.to_csv(output_dir / "cross_class_spearman_corr.csv")
205
-
206
- jac_df = pd.DataFrame(jac_mat, index=label_classes, columns=label_classes)
207
- jac_df.to_csv(output_dir / f"cross_class_jaccard_top{K}.csv")
208
-
209
- unique_df = pd.DataFrame({
210
- "cell_type": label_classes,
211
- "unique_modules_in_top50": [unique_per_class[ct] for ct in label_classes],
212
- })
213
- unique_df.to_csv(output_dir / "class_specific_module_counts.csv", index=False)
214
-
215
- print(f"\nSummary:")
216
- print(f" Total unique modules in union of top-{K}: {total_union}")
217
- print(f" Class-specific (appear in only 1 class top-{K}): {unique_total} ({pct:.1f}%)")
218
- print(f" Shared by β‰₯2 classes: {total_union - unique_total}")
219
- print(f"\nSpearman corr range (excl. diagonal & NaN): "
220
- f"[{np.nanmin(corr_mat[~np.eye(n, dtype=bool)]):.3f}, "
221
- f"{np.nanmax(corr_mat[~np.eye(n, dtype=bool)]):.3f}]")
222
- print(f"Jaccard range (excl. diagonal): "
223
- f"[{jac_mat[~np.eye(n, dtype=bool)].min():.3f}, "
224
- f"{jac_mat[~np.eye(n, dtype=bool)].max():.3f}]")
225
-
226
- return True
227
-
228
-
229
- def parse_args():
230
- p = argparse.ArgumentParser(description="Cross-class module importance similarity")
231
- p.add_argument("--results_dir", required=True,
232
- help="Importance results directory (with importance_matrix.npy)")
233
- p.add_argument("--output_dir", required=True)
234
- p.add_argument("--top_k", type=int, default=50,
235
- help="Number of top modules for Jaccard (default: 50)")
236
- return p.parse_args()
237
-
238
-
239
- def main():
240
- args = parse_args()
241
- output_dir = Path(args.output_dir)
242
- output_dir.mkdir(parents=True, exist_ok=True)
243
- plot_cross_class_similarity(Path(args.results_dir), output_dir, K=args.top_k)
244
-
245
-
246
- if __name__ == "__main__":
247
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
BioMod/interpretability/biomod_importance/family_ablation.py DELETED
@@ -1,370 +0,0 @@
1
- #!/usr/bin/env python
2
- # -*- coding: utf-8 -*-
3
- """
4
- Experiment β‘’-D: Family-Level Ablation
5
-
6
- Masks entire module families (Hallmarks / Reactome / Hotspot / WGCNA) and measures
7
- accuracy drop. Two modes:
8
- (a) Remove-one-family: mask all modules of a single family
9
- (b) Keep-one-family: mask all modules EXCEPT one family
10
-
11
- Requires GPU (model inference). Uses post_encoder masking.
12
-
13
- Outputs:
14
- - family_ablation_results.json: accuracy for each condition
15
- - fig_family_ablation.png/pdf: publication figure
16
- """
17
-
18
- import argparse
19
- import json
20
- import logging
21
- import sys
22
- from pathlib import Path
23
-
24
- import matplotlib as mpl
25
- import matplotlib.pyplot as plt
26
- import numpy as np
27
- import torch
28
- from torch.utils.data import DataLoader
29
-
30
- SCRIPT_DIR = Path(__file__).resolve().parent
31
- INTERP_DIR = SCRIPT_DIR.parent
32
- BIOMOD_ROOT = INTERP_DIR.parent
33
- SCLONG_ROOT = BIOMOD_ROOT.parent
34
-
35
- for p in (str(SCRIPT_DIR), str(BIOMOD_ROOT), str(SCLONG_ROOT)):
36
- if p not in sys.path:
37
- sys.path.insert(0, p)
38
-
39
- from masking_ablation import (
40
- compute_masked_accuracy_post_encoder,
41
- encode_all_cells,
42
- )
43
- from biomod_importance import (
44
- CellDataset,
45
- _classify_from_tokens,
46
- build_biomod_gene_mapping,
47
- load_finetuned_model,
48
- load_test_data,
49
- )
50
- from pub_utils import (
51
- CELL_TYPE_COLORS,
52
- savefig,
53
- setup_publication_style,
54
- )
55
-
56
- logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
57
- logger = logging.getLogger(__name__)
58
-
59
- FAMILY_PREFIXES = {
60
- "Hallmarks": "module1_hallmarks",
61
- "Reactome": "module2_reactome",
62
- "Hotspot": "module3_hotspot",
63
- "WGCNA": "module3_processed",
64
- }
65
-
66
- FAMILY_ORDER = ["Hallmarks", "Reactome", "Hotspot", "WGCNA"]
67
-
68
- FAMILY_COLORS = {
69
- "Hallmarks": "#D95F02",
70
- "Reactome": "#1B9E77",
71
- "Hotspot": "#7570B3",
72
- "WGCNA": "#E7298A",
73
- }
74
-
75
-
76
- def get_family_indices(module_names):
77
- """Return dict: family_name -> list of module indices."""
78
- indices = {f: [] for f in FAMILY_ORDER}
79
- for i, name in enumerate(module_names):
80
- for fam, prefix in FAMILY_PREFIXES.items():
81
- if name.startswith(prefix):
82
- indices[fam].append(i)
83
- break
84
- return indices
85
-
86
-
87
- @torch.no_grad()
88
- def compute_per_class_accuracy(model, all_tokens, all_labels, mask_indices,
89
- label_classes, device, batch_size=256):
90
- """Compute overall and per-class accuracy with given modules masked."""
91
- n_cells = all_tokens.shape[0]
92
- num_classes = len(label_classes)
93
- correct_per_class = np.zeros(num_classes)
94
- total_per_class = np.zeros(num_classes)
95
-
96
- for start in range(0, n_cells, batch_size):
97
- end = min(start + batch_size, n_cells)
98
- tokens = all_tokens[start:end].to(device).clone()
99
- labels = all_labels[start:end]
100
-
101
- for pi in mask_indices:
102
- tokens[:, pi, :] = 0.0
103
-
104
- logits = _classify_from_tokens(model, tokens)
105
- preds = logits.argmax(dim=-1).cpu().numpy()
106
-
107
- for cls_idx in range(num_classes):
108
- cls_mask = labels == cls_idx
109
- correct_per_class[cls_idx] += (preds[cls_mask] == labels[cls_mask]).sum()
110
- total_per_class[cls_idx] += cls_mask.sum()
111
-
112
- per_class_acc = np.where(total_per_class > 0,
113
- correct_per_class / total_per_class, 0.0)
114
- overall_acc = correct_per_class.sum() / total_per_class.sum()
115
- return overall_acc, per_class_acc
116
-
117
-
118
- def run_family_ablation(model, all_tokens, all_labels, module_names,
119
- label_classes, device, batch_size):
120
- """Run remove-one and keep-one family ablation experiments."""
121
- family_indices = get_family_indices(module_names)
122
- all_module_indices = list(range(all_tokens.shape[1]))
123
- results = {}
124
-
125
- # Baseline (no masking)
126
- logger.info("Computing baseline accuracy...")
127
- baseline_overall, baseline_per_class = compute_per_class_accuracy(
128
- model, all_tokens, all_labels, [], label_classes, device, batch_size
129
- )
130
- results["baseline"] = {
131
- "overall": float(baseline_overall),
132
- "per_class": {ct: float(baseline_per_class[i]) for i, ct in enumerate(label_classes)},
133
- }
134
- logger.info("Baseline accuracy: %.4f", baseline_overall)
135
-
136
- # Remove-one-family
137
- results["remove_one"] = {}
138
- for fam in FAMILY_ORDER:
139
- mask_indices = family_indices[fam]
140
- logger.info("Remove %s (%d modules)...", fam, len(mask_indices))
141
- overall, per_class = compute_per_class_accuracy(
142
- model, all_tokens, all_labels, mask_indices, label_classes, device, batch_size
143
- )
144
- results["remove_one"][fam] = {
145
- "n_masked": len(mask_indices),
146
- "overall": float(overall),
147
- "delta_overall": float(baseline_overall - overall),
148
- "per_class": {ct: float(per_class[i]) for i, ct in enumerate(label_classes)},
149
- "delta_per_class": {ct: float(baseline_per_class[i] - per_class[i])
150
- for i, ct in enumerate(label_classes)},
151
- }
152
- logger.info(" Accuracy: %.4f (Ξ”=%.4f)", overall, baseline_overall - overall)
153
-
154
- # Keep-one-family (mask all others)
155
- results["keep_one"] = {}
156
- for fam in FAMILY_ORDER:
157
- keep_indices = set(family_indices[fam])
158
- mask_indices = [i for i in all_module_indices if i not in keep_indices]
159
- logger.info("Keep only %s (%d modules, mask %d)...",
160
- fam, len(keep_indices), len(mask_indices))
161
- overall, per_class = compute_per_class_accuracy(
162
- model, all_tokens, all_labels, mask_indices, label_classes, device, batch_size
163
- )
164
- results["keep_one"][fam] = {
165
- "n_kept": len(keep_indices),
166
- "n_masked": len(mask_indices),
167
- "overall": float(overall),
168
- "delta_overall": float(baseline_overall - overall),
169
- "per_class": {ct: float(per_class[i]) for i, ct in enumerate(label_classes)},
170
- "delta_per_class": {ct: float(baseline_per_class[i] - per_class[i])
171
- for i, ct in enumerate(label_classes)},
172
- }
173
- logger.info(" Accuracy: %.4f (Ξ”=%.4f)", overall, baseline_overall - overall)
174
-
175
- return results
176
-
177
-
178
- def plot_family_ablation(results, label_classes, output_dir):
179
- """Generate publication figure from ablation results."""
180
- setup_publication_style()
181
-
182
- fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 5))
183
-
184
- baseline = results["baseline"]["overall"]
185
- x = np.arange(len(FAMILY_ORDER))
186
- width = 0.35
187
-
188
- # Panel A: Remove-one-family (overall)
189
- remove_accs = [results["remove_one"][f]["overall"] for f in FAMILY_ORDER]
190
- remove_deltas = [results["remove_one"][f]["delta_overall"] for f in FAMILY_ORDER]
191
- bars1 = ax1.bar(x, remove_accs, width=0.6,
192
- color=[FAMILY_COLORS[f] for f in FAMILY_ORDER],
193
- edgecolor="white", linewidth=0.5)
194
- ax1.axhline(baseline, color="gray", linestyle="--", linewidth=1.2,
195
- label=f"Baseline ({baseline:.1%})")
196
-
197
- for i, (acc, delta) in enumerate(zip(remove_accs, remove_deltas)):
198
- if delta > 0.005:
199
- ax1.annotate(f"βˆ’{delta:.1%}", (i, acc), textcoords="offset points",
200
- xytext=(0, -14), ha="center", fontsize=8, color="#E41A1C",
201
- fontweight="bold")
202
- else:
203
- ax1.annotate(f"βˆ’{delta:.1%}", (i, acc), textcoords="offset points",
204
- xytext=(0, 6), ha="center", fontsize=8, color="gray")
205
-
206
- ax1.set_xticks(x)
207
- ax1.set_xticklabels([f"{f}\n(n={results['remove_one'][f]['n_masked']})"
208
- for f in FAMILY_ORDER], fontsize=9)
209
- ax1.set_ylabel("Accuracy", fontsize=10)
210
- ax1.set_title("(A) Remove One Family", fontsize=11)
211
- ax1.legend(fontsize=8, loc="lower right")
212
- ax1.set_ylim(0, min(1.05, baseline * 1.15))
213
-
214
- # Panel B: Keep-one-family (overall)
215
- keep_accs = [results["keep_one"][f]["overall"] for f in FAMILY_ORDER]
216
- keep_deltas = [results["keep_one"][f]["delta_overall"] for f in FAMILY_ORDER]
217
- bars2 = ax2.bar(x, keep_accs, width=0.6,
218
- color=[FAMILY_COLORS[f] for f in FAMILY_ORDER],
219
- edgecolor="white", linewidth=0.5)
220
- ax2.axhline(baseline, color="gray", linestyle="--", linewidth=1.2,
221
- label=f"Baseline ({baseline:.1%})")
222
-
223
- for i, (acc, delta) in enumerate(zip(keep_accs, keep_deltas)):
224
- ax2.annotate(f"{acc:.1%}", (i, acc), textcoords="offset points",
225
- xytext=(0, 6), ha="center", fontsize=8, fontweight="bold")
226
-
227
- ax2.set_xticks(x)
228
- ax2.set_xticklabels([f"{f}\n(n={results['keep_one'][f]['n_kept']})"
229
- for f in FAMILY_ORDER], fontsize=9)
230
- ax2.set_ylabel("Accuracy", fontsize=10)
231
- ax2.set_title("(B) Keep Only One Family", fontsize=11)
232
- ax2.legend(fontsize=8, loc="upper right")
233
- ax2.set_ylim(0, min(1.05, baseline * 1.15))
234
-
235
- fig.suptitle("Family-Level Ablation: Module Type Contribution to Classification",
236
- fontsize=12, y=1.02)
237
- fig.tight_layout()
238
- savefig(fig, output_dir / "fig_family_ablation")
239
- logger.info("Saved fig_family_ablation")
240
-
241
- # --- Per-class heatmap for remove-one ---
242
- fig2, (ax_r, ax_k) = plt.subplots(1, 2, figsize=(14, 6))
243
-
244
- # Remove-one per-class delta heatmap
245
- delta_remove = np.zeros((len(label_classes), len(FAMILY_ORDER)))
246
- for fi, fam in enumerate(FAMILY_ORDER):
247
- for ci, ct in enumerate(label_classes):
248
- delta_remove[ci, fi] = results["remove_one"][fam]["delta_per_class"][ct]
249
-
250
- vmax_r = max(np.abs(delta_remove).max(), 0.05)
251
- im_r = ax_r.imshow(delta_remove, cmap="RdYlGn_r", aspect="auto",
252
- vmin=-vmax_r, vmax=vmax_r)
253
- ax_r.set_xticks(range(len(FAMILY_ORDER)))
254
- ax_r.set_xticklabels(FAMILY_ORDER, fontsize=9)
255
- ax_r.set_yticks(range(len(label_classes)))
256
- ax_r.set_yticklabels(label_classes, fontsize=8)
257
- ax_r.set_title("(A) Accuracy Drop: Remove One Family", fontsize=10)
258
- for i in range(len(label_classes)):
259
- for j in range(len(FAMILY_ORDER)):
260
- val = delta_remove[i, j]
261
- color = "white" if abs(val) > vmax_r * 0.6 else "black"
262
- sign = "+" if val < 0 else ""
263
- ax_r.text(j, i, f"{sign}{-val:.1%}" if val != 0 else "0%",
264
- ha="center", va="center", fontsize=7, color=color)
265
- cb_r = fig2.colorbar(im_r, ax=ax_r, fraction=0.03, pad=0.02)
266
- cb_r.set_label("Ξ”Accuracy", fontsize=8)
267
-
268
- # Keep-one per-class accuracy heatmap
269
- acc_keep = np.zeros((len(label_classes), len(FAMILY_ORDER)))
270
- for fi, fam in enumerate(FAMILY_ORDER):
271
- for ci, ct in enumerate(label_classes):
272
- acc_keep[ci, fi] = results["keep_one"][fam]["per_class"][ct]
273
-
274
- im_k = ax_k.imshow(acc_keep, cmap="YlGn", aspect="auto", vmin=0, vmax=1)
275
- ax_k.set_xticks(range(len(FAMILY_ORDER)))
276
- ax_k.set_xticklabels(FAMILY_ORDER, fontsize=9)
277
- ax_k.set_yticks(range(len(label_classes)))
278
- ax_k.set_yticklabels(label_classes, fontsize=8)
279
- ax_k.set_title("(B) Per-Class Accuracy: Keep Only One Family", fontsize=10)
280
- for i in range(len(label_classes)):
281
- for j in range(len(FAMILY_ORDER)):
282
- val = acc_keep[i, j]
283
- color = "white" if val > 0.6 else "black"
284
- ax_k.text(j, i, f"{val:.0%}", ha="center", va="center",
285
- fontsize=7, color=color)
286
- cb_k = fig2.colorbar(im_k, ax=ax_k, fraction=0.03, pad=0.02)
287
- cb_k.set_label("Accuracy", fontsize=8)
288
-
289
- fig2.suptitle("Family-Level Ablation: Per-Cell-Type Breakdown", fontsize=12, y=1.01)
290
- fig2.tight_layout()
291
- savefig(fig2, output_dir / "fig_family_ablation_per_class")
292
- logger.info("Saved fig_family_ablation_per_class")
293
-
294
-
295
- def parse_args():
296
- p = argparse.ArgumentParser(description="Family-level ablation experiment")
297
- p.add_argument("--finetune_checkpoint", required=True)
298
- p.add_argument("--config_pkl", required=True)
299
- p.add_argument("--biomod_data_dir", "--module_data_dir", required=True, dest='module_data_dir')
300
- p.add_argument("--gene_list_path", required=True)
301
- p.add_argument("--gene_mapping_path", required=True)
302
- p.add_argument("--gene2vec_data_path", required=True)
303
- p.add_argument("--gene2ens_file", required=True)
304
- p.add_argument("--data_path", required=True)
305
- p.add_argument("--label_column", default="celltype")
306
- p.add_argument("--head_type", default="attn_pool")
307
- p.add_argument("--gene_name_column", default="auto")
308
- p.add_argument("--batch_size", type=int, default=256)
309
- p.add_argument("--test_ratio", type=float, default=0.2)
310
- p.add_argument("--seed", type=int, default=2021)
311
- p.add_argument("--output_dir", required=True)
312
- p.add_argument("--device", default="cuda")
313
- return p.parse_args()
314
-
315
-
316
- def main():
317
- args = parse_args()
318
- output_dir = Path(args.output_dir)
319
- output_dir.mkdir(parents=True, exist_ok=True)
320
-
321
- device = torch.device(args.device if torch.cuda.is_available() else "cpu")
322
- logger.info("Device: %s", device)
323
-
324
- for attr in ("finetune_checkpoint", "config_pkl", "module_data_dir",
325
- "gene_list_path", "gene_mapping_path", "gene2vec_data_path",
326
- "gene2ens_file", "data_path"):
327
- val = getattr(args, attr, None)
328
- if val is not None and not Path(val).is_absolute():
329
- setattr(args, attr, str(Path(val).resolve()))
330
-
331
- logger.info("Loading model...")
332
- model, biomod_data, label_classes, head_type = load_finetuned_model(args, device)
333
- module_names, _ = build_biomod_gene_mapping(args.module_data_dir, args.gene_list_path)
334
- family_indices = get_family_indices(module_names)
335
- for fam in FAMILY_ORDER:
336
- logger.info(" %s: %d modules", fam, len(family_indices[fam]))
337
-
338
- logger.info("Loading test data...")
339
- X_test, y_test, le, preprocessor, reindex_state = load_test_data(
340
- args, label_classes, device
341
- )
342
- dataset = CellDataset(X_test, y_test)
343
- dataloader = DataLoader(dataset, batch_size=args.batch_size, shuffle=False)
344
-
345
- logger.info("Encoding all cells...")
346
- all_tokens, all_labels = encode_all_cells(
347
- model, dataloader, preprocessor, reindex_state, device
348
- )
349
- logger.info("Encoded %d cells, %d modules", all_tokens.shape[0], all_tokens.shape[1])
350
-
351
- logger.info("Running family ablation...")
352
- results = run_family_ablation(
353
- model, all_tokens, all_labels, module_names,
354
- label_classes, device, args.batch_size
355
- )
356
-
357
- # Save raw results
358
- with open(output_dir / "family_ablation_results.json", "w") as f:
359
- json.dump(results, f, indent=2)
360
- logger.info("Saved family_ablation_results.json")
361
-
362
- # Generate figures
363
- logger.info("Generating figures...")
364
- plot_family_ablation(results, label_classes, output_dir)
365
-
366
- logger.info("Done. Results saved to %s", output_dir)
367
-
368
-
369
- if __name__ == "__main__":
370
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
BioMod/interpretability/biomod_importance/family_importance_analysis.py DELETED
@@ -1,417 +0,0 @@
1
- #!/usr/bin/env python
2
- # -*- coding: utf-8 -*-
3
- """
4
- Family-Level Module Importance Analysis (CPU only)
5
-
6
- Analyzes importance at the module-family level (Hallmarks / Reactome / Hotspot / WGCNA):
7
- 1. Importance distribution per family (violin + box plot)
8
- 2. Family contribution to top-K important modules (stacked bar)
9
- 3. Enrichment test: is each family over/under-represented in top-K, (Fisher's exact)
10
-
11
- All analyses run from saved importance_matrix β€” no GPU needed.
12
- """
13
-
14
- import argparse
15
- import json
16
- import sys
17
- from pathlib import Path
18
-
19
- import matplotlib as mpl
20
- import matplotlib.pyplot as plt
21
- import numpy as np
22
- import pandas as pd
23
- from scipy.stats import fisher_exact, kruskal, mannwhitneyu
24
-
25
- SCRIPT_DIR = Path(__file__).resolve().parent
26
- sys.path.insert(0, str(SCRIPT_DIR))
27
-
28
- from pub_utils import (
29
- CELL_TYPE_COLORS,
30
- load_importance_results,
31
- savefig,
32
- setup_publication_style,
33
- )
34
-
35
- FAMILY_PREFIXES = {
36
- "Hallmarks": "module1_hallmarks",
37
- "Reactome": "module2_reactome",
38
- "Hotspot": "module3_hotspot",
39
- "WGCNA": "module3_processed",
40
- }
41
-
42
- FAMILY_ORDER = ["Hallmarks", "Reactome", "Hotspot", "WGCNA"]
43
-
44
- FAMILY_COLORS = {
45
- "Hallmarks": "#D95F02",
46
- "Reactome": "#1B9E77",
47
- "Hotspot": "#7570B3",
48
- "WGCNA": "#E7298A",
49
- }
50
-
51
-
52
- def assign_families(module_names):
53
- """Assign each module to a family. Returns list of family labels."""
54
- families = []
55
- for name in module_names:
56
- matched = False
57
- for fam, prefix in FAMILY_PREFIXES.items():
58
- if name.startswith(prefix):
59
- families.append(fam)
60
- matched = True
61
- break
62
- if not matched:
63
- families.append("Unknown")
64
- return families
65
-
66
-
67
- def compute_family_indices(module_names):
68
- """Return dict: family_name -> list of column indices."""
69
- families = assign_families(module_names)
70
- indices = {f: [] for f in FAMILY_ORDER}
71
- for i, fam in enumerate(families):
72
- if fam in indices:
73
- indices[fam].append(i)
74
- return indices
75
-
76
-
77
- # ─── Analysis 1: Importance Distribution ────────────────────────────────────
78
-
79
- def plot_family_importance_distribution(importance, label_classes, module_names, output_dir):
80
- """Violin + box plot of importance scores per family, for each cell type."""
81
- setup_publication_style()
82
- family_indices = compute_family_indices(module_names)
83
-
84
- ncols = 4
85
- nrows = (len(label_classes) + ncols - 1) // ncols
86
- fig, axes = plt.subplots(nrows, ncols, figsize=(16, 3.8 * nrows), sharey=False)
87
- axes = axes.flatten()
88
-
89
- for cls_idx, ct in enumerate(label_classes):
90
- ax = axes[cls_idx]
91
- data_list = []
92
- positions = []
93
- for fi, fam in enumerate(FAMILY_ORDER):
94
- vals = importance[cls_idx, family_indices[fam]]
95
- data_list.append(vals)
96
- positions.append(fi)
97
-
98
- vp = ax.violinplot(data_list, positions=positions, showextrema=False,
99
- showmedians=False, widths=0.7)
100
- for fi, body in enumerate(vp["bodies"]):
101
- body.set_facecolor(FAMILY_COLORS[FAMILY_ORDER[fi]])
102
- body.set_alpha(0.4)
103
-
104
- bp = ax.boxplot(data_list, positions=positions, widths=0.3,
105
- patch_artist=True, showfliers=False)
106
- for fi, module in enumerate(bp["boxes"]):
107
- module.set_facecolor(FAMILY_COLORS[FAMILY_ORDER[fi]])
108
- module.set_alpha(0.7)
109
- for element in ["whiskers", "caps", "medians"]:
110
- for line in bp[element]:
111
- line.set_color("black")
112
- line.set_linewidth(0.8)
113
-
114
- color = CELL_TYPE_COLORS.get(ct, "#333")
115
- ax.set_title(ct, fontsize=8, fontweight="bold", color=color)
116
- ax.set_xticks(positions)
117
- ax.set_xticklabels(FAMILY_ORDER, fontsize=7, rotation=30, ha="right")
118
- ax.axhline(0, color="gray", linewidth=0.5, linestyle=":")
119
-
120
- if cls_idx % ncols == 0:
121
- ax.set_ylabel("Ξ”Logit", fontsize=8)
122
-
123
- for idx in range(len(label_classes), len(axes)):
124
- axes[idx].set_visible(False)
125
-
126
- fig.suptitle("Module Importance Distribution by Family", fontsize=12, y=1.01)
127
- fig.tight_layout()
128
- savefig(fig, output_dir / "fig1_family_importance_distribution")
129
- print(" Saved fig1_family_importance_distribution")
130
-
131
- # --- Kruskal-Wallis test per cell type ---
132
- stats_rows = []
133
- for cls_idx, ct in enumerate(label_classes):
134
- groups = [importance[cls_idx, family_indices[fam]] for fam in FAMILY_ORDER]
135
- if np.std(importance[cls_idx]) == 0:
136
- stats_rows.append({"cell_type": ct, "H_statistic": np.nan,
137
- "p_value": np.nan, "significant": False})
138
- continue
139
- H, p = kruskal(*groups)
140
- stats_rows.append({
141
- "cell_type": ct, "H_statistic": H, "p_value": p,
142
- "significant": p < 0.05,
143
- })
144
-
145
- stats_df = pd.DataFrame(stats_rows)
146
- stats_df.to_csv(output_dir / "kruskal_wallis_family_test.csv", index=False)
147
- print(" Saved kruskal_wallis_family_test.csv")
148
-
149
- # --- Pairwise Mann-Whitney U between families (overall mean importance) ---
150
- mean_importance = importance.mean(axis=0)
151
- pairwise_rows = []
152
- for i, fam_a in enumerate(FAMILY_ORDER):
153
- for j, fam_b in enumerate(FAMILY_ORDER):
154
- if j <= i:
155
- continue
156
- vals_a = mean_importance[family_indices[fam_a]]
157
- vals_b = mean_importance[family_indices[fam_b]]
158
- U, p = mannwhitneyu(vals_a, vals_b, alternative="two-sided")
159
- pairwise_rows.append({
160
- "family_a": fam_a, "family_b": fam_b,
161
- "U_statistic": U, "p_value": p,
162
- "mean_a": vals_a.mean(), "mean_b": vals_b.mean(),
163
- })
164
- pairwise_df = pd.DataFrame(pairwise_rows)
165
- pairwise_df.to_csv(output_dir / "pairwise_mannwhitney_families.csv", index=False)
166
- print(" Saved pairwise_mannwhitney_families.csv")
167
-
168
- return stats_df
169
-
170
-
171
- # ─── Analysis 2: Family Contribution to Top-K ───────────────────────────────
172
-
173
- def plot_family_contribution_topk(importance, label_classes, module_names, output_dir,
174
- k_values=(10, 20, 50)):
175
- """Stacked bar chart: fraction of top-K modules from each family."""
176
- setup_publication_style()
177
- family_indices = compute_family_indices(module_names)
178
- families_arr = np.array(assign_families(module_names))
179
-
180
- n_k = len(k_values)
181
- fig, axes = plt.subplots(1, n_k, figsize=(5.5 * n_k, 6), sharey=True)
182
- if n_k == 1:
183
- axes = [axes]
184
-
185
- contribution_data = {}
186
-
187
- for ki, K in enumerate(k_values):
188
- ax = axes[ki]
189
- counts = {fam: [] for fam in FAMILY_ORDER}
190
-
191
- for cls_idx, ct in enumerate(label_classes):
192
- top_k_idx = np.argsort(-importance[cls_idx])[:K]
193
- top_families = families_arr[top_k_idx]
194
- for fam in FAMILY_ORDER:
195
- counts[fam].append(np.sum(top_families == fam))
196
-
197
- contribution_data[K] = counts
198
- x = np.arange(len(label_classes))
199
- bottom = np.zeros(len(label_classes))
200
-
201
- for fam in FAMILY_ORDER:
202
- vals = np.array(counts[fam], dtype=float)
203
- pcts = 100.0 * vals / K
204
- ax.bar(x, pcts, bottom=bottom, color=FAMILY_COLORS[fam],
205
- label=fam, width=0.7, edgecolor="white", linewidth=0.3)
206
- bottom += pcts
207
-
208
- ax.set_xticks(x)
209
- ax.set_xticklabels(label_classes, rotation=45, ha="right", fontsize=7)
210
- ax.set_ylabel("% of top modules" if ki == 0 else "")
211
- ax.set_title(f"Top-{K} Modules", fontsize=10)
212
- ax.set_ylim(0, 105)
213
-
214
- if ki == n_k - 1:
215
- ax.legend(loc="upper right", fontsize=8, framealpha=0.9)
216
-
217
- fig.suptitle("Family Contribution to Most Important Modules", fontsize=12, y=1.01)
218
- fig.tight_layout()
219
- savefig(fig, output_dir / "fig2_family_contribution_topk")
220
- print(" Saved fig2_family_contribution_topk")
221
-
222
- # Save numerical data
223
- rows = []
224
- for K, counts in contribution_data.items():
225
- for cls_idx, ct in enumerate(label_classes):
226
- row = {"cell_type": ct, "top_k": K}
227
- for fam in FAMILY_ORDER:
228
- row[f"{fam}_count"] = counts[fam][cls_idx]
229
- row[f"{fam}_pct"] = 100.0 * counts[fam][cls_idx] / K
230
- rows.append(row)
231
- contrib_df = pd.DataFrame(rows)
232
- contrib_df.to_csv(output_dir / "family_contribution_topk.csv", index=False)
233
- print(" Saved family_contribution_topk.csv")
234
-
235
- return contrib_df
236
-
237
-
238
- # ─── Analysis 3: Family Enrichment Test ─────────────────────────────────────
239
-
240
- def run_family_enrichment(importance, label_classes, module_names, output_dir, K=50):
241
- """Fisher's exact test: is each family over/under-represented in top-K, """
242
- setup_publication_style()
243
- family_indices = compute_family_indices(module_names)
244
- families_arr = np.array(assign_families(module_names))
245
- n_total = len(module_names)
246
-
247
- enrichment_rows = []
248
- for cls_idx, ct in enumerate(label_classes):
249
- top_k_idx = set(np.argsort(-importance[cls_idx])[:K].tolist())
250
- for fam in FAMILY_ORDER:
251
- fam_set = set(family_indices[fam])
252
- n_fam = len(fam_set)
253
- in_top_and_fam = len(top_k_idx & fam_set)
254
- in_top_not_fam = K - in_top_and_fam
255
- not_top_and_fam = n_fam - in_top_and_fam
256
- not_top_not_fam = (n_total - K) - not_top_and_fam
257
-
258
- table = [[in_top_and_fam, in_top_not_fam],
259
- [not_top_and_fam, not_top_not_fam]]
260
- odds_ratio, p = fisher_exact(table, alternative="two-sided")
261
-
262
- expected = K * n_fam / n_total
263
- fold_enrichment = in_top_and_fam / expected if expected > 0 else np.inf
264
-
265
- enrichment_rows.append({
266
- "cell_type": ct,
267
- "family": fam,
268
- "n_family_total": n_fam,
269
- "n_in_topK": in_top_and_fam,
270
- "expected_in_topK": round(expected, 2),
271
- "fold_enrichment": round(fold_enrichment, 3),
272
- "odds_ratio": round(odds_ratio, 3),
273
- "p_value": p,
274
- })
275
-
276
- enrich_df = pd.DataFrame(enrichment_rows)
277
-
278
- # Bonferroni correction
279
- n_tests = len(enrich_df)
280
- enrich_df["p_adjusted"] = np.minimum(enrich_df["p_value"] * n_tests, 1.0)
281
- enrich_df["significant"] = enrich_df["p_adjusted"] < 0.05
282
- enrich_df["direction"] = np.where(
283
- enrich_df["fold_enrichment"] > 1, "enriched", "depleted"
284
- )
285
-
286
- enrich_df.to_csv(output_dir / "family_enrichment_fisher.csv", index=False)
287
- print(" Saved family_enrichment_fisher.csv")
288
-
289
- # --- Heatmap: fold enrichment ---
290
- pivot = enrich_df.pivot(index="cell_type", columns="family", values="fold_enrichment")
291
- pivot = pivot.reindex(index=label_classes, columns=FAMILY_ORDER)
292
- sig_pivot = enrich_df.pivot(index="cell_type", columns="family", values="significant")
293
- sig_pivot = sig_pivot.reindex(index=label_classes, columns=FAMILY_ORDER)
294
-
295
- fig, ax = plt.subplots(figsize=(7, 6))
296
- vmax = max(pivot.max().max(), 2.5)
297
- im = ax.imshow(pivot.values, cmap="RdYlBu_r", aspect="auto",
298
- vmin=0, vmax=vmax)
299
- ax.set_xticks(range(len(FAMILY_ORDER)))
300
- ax.set_xticklabels(FAMILY_ORDER, fontsize=9)
301
- ax.set_yticks(range(len(label_classes)))
302
- ax.set_yticklabels(label_classes, fontsize=8)
303
-
304
- for i in range(len(label_classes)):
305
- for j in range(len(FAMILY_ORDER)):
306
- val = pivot.values[i, j]
307
- sig = sig_pivot.values[i, j]
308
- txt = f"{val:.1f}"
309
- if sig:
310
- txt += " *"
311
- color = "white" if val > vmax * 0.65 else "black"
312
- ax.text(j, i, txt, ha="center", va="center", fontsize=8, color=color)
313
-
314
- cb = fig.colorbar(im, ax=ax, fraction=0.03, pad=0.02)
315
- cb.set_label("Fold Enrichment", fontsize=9)
316
- ax.set_title(f"Family Enrichment in Top-{K} Modules\n(* p_adj < 0.05, Fisher's exact)",
317
- fontsize=10, pad=10)
318
-
319
- fig.tight_layout()
320
- savefig(fig, output_dir / "fig3_family_enrichment_heatmap")
321
- print(" Saved fig3_family_enrichment_heatmap")
322
-
323
- return enrich_df
324
-
325
-
326
- # ─── Summary Figure ─────────────────────────────────────────────────────────
327
-
328
- def plot_family_summary(importance, label_classes, module_names, output_dir, K=50):
329
- """Combined summary: mean importance per family per cell type."""
330
- setup_publication_style()
331
- family_indices = compute_family_indices(module_names)
332
-
333
- mean_by_family = np.zeros((len(label_classes), len(FAMILY_ORDER)))
334
- for cls_idx in range(len(label_classes)):
335
- for fi, fam in enumerate(FAMILY_ORDER):
336
- mean_by_family[cls_idx, fi] = importance[cls_idx, family_indices[fam]].mean()
337
-
338
- fig, ax = plt.subplots(figsize=(8, 5))
339
- x = np.arange(len(label_classes))
340
- width = 0.18
341
-
342
- for fi, fam in enumerate(FAMILY_ORDER):
343
- offset = (fi - 1.5) * width
344
- ax.bar(x + offset, mean_by_family[:, fi], width=width,
345
- color=FAMILY_COLORS[fam], label=fam, edgecolor="white", linewidth=0.3)
346
-
347
- ax.set_xticks(x)
348
- ax.set_xticklabels(label_classes, rotation=45, ha="right", fontsize=8)
349
- ax.set_ylabel("Mean Ξ”Logit (importance)", fontsize=9)
350
- ax.set_title("Mean Module Importance by Family and Cell Type", fontsize=11)
351
- ax.legend(fontsize=8, loc="upper right")
352
- ax.axhline(0, color="gray", linewidth=0.5, linestyle=":")
353
-
354
- fig.tight_layout()
355
- savefig(fig, output_dir / "fig4_family_mean_importance")
356
- print(" Saved fig4_family_mean_importance")
357
-
358
- # Save data
359
- summary_df = pd.DataFrame(mean_by_family, index=label_classes, columns=FAMILY_ORDER)
360
- summary_df.to_csv(output_dir / "family_mean_importance.csv")
361
- print(" Saved family_mean_importance.csv")
362
-
363
-
364
- def parse_args():
365
- p = argparse.ArgumentParser(description="Family-level module importance analysis")
366
- p.add_argument("--results_dir", required=True,
367
- help="Importance results directory (with importance_matrix.npy)")
368
- p.add_argument("--output_dir", required=True)
369
- p.add_argument("--top_k", type=int, nargs="+", default=[10, 20, 50],
370
- help="Top-K values for contribution analysis")
371
- p.add_argument("--enrichment_k", type=int, default=50,
372
- help="K for enrichment test")
373
- return p.parse_args()
374
-
375
-
376
- def main():
377
- args = parse_args()
378
- output_dir = Path(args.output_dir)
379
- output_dir.mkdir(parents=True, exist_ok=True)
380
-
381
- print(f"Loading results from {args.results_dir}...")
382
- data = load_importance_results(Path(args.results_dir))
383
- importance = data["importance"]
384
- label_classes = data["label_classes"]
385
- module_names = data["module_names"]
386
-
387
- family_indices = compute_family_indices(module_names)
388
- print(f"Module families: { {f: len(idx) for f, idx in family_indices.items()} }")
389
- print(f"Cell types: {len(label_classes)}")
390
- print()
391
-
392
- print("[1/4] Importance distribution by family...")
393
- plot_family_importance_distribution(importance, label_classes, module_names, output_dir)
394
- print()
395
-
396
- print("[2/4] Family contribution to top-K...")
397
- plot_family_contribution_topk(importance, label_classes, module_names, output_dir,
398
- k_values=args.top_k)
399
- print()
400
-
401
- print("[3/4] Family enrichment test...")
402
- enrich_df = run_family_enrichment(importance, label_classes, module_names, output_dir,
403
- K=args.enrichment_k)
404
- sig_count = enrich_df["significant"].sum()
405
- print(f" Significant enrichments (Bonferroni p<0.05): {sig_count}/{len(enrich_df)}")
406
- print()
407
-
408
- print("[4/4] Mean importance summary...")
409
- plot_family_summary(importance, label_classes, module_names, output_dir, K=args.enrichment_k)
410
- print()
411
-
412
- print("All analyses complete.")
413
- print(f"Output directory: {output_dir}")
414
-
415
-
416
- if __name__ == "__main__":
417
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
BioMod/interpretability/biomod_importance/marker_gene_verification.py DELETED
@@ -1,321 +0,0 @@
1
- #!/usr/bin/env python
2
- # -*- coding: utf-8 -*-
3
- """
4
- Experiment β‘’-C: Canonical Marker Gene Verification
5
-
6
- Verifies that known cell-type marker genes reside in high-importance modules.
7
- Runs entirely from saved results (CPU only, no model inference needed).
8
-
9
- Outputs:
10
- - marker_ranks.csv: per-marker rank information
11
- - marker_metrics.json: summary statistics
12
- - fig_marker_ranks.pdf/png: publication-quality dot plot
13
- """
14
-
15
- import argparse
16
- import json
17
- import sys
18
- from pathlib import Path
19
-
20
- import matplotlib.pyplot as plt
21
- import numpy as np
22
- import pandas as pd
23
- from scipy import stats
24
-
25
- SCRIPT_DIR = Path(__file__).resolve().parent
26
- sys.path.insert(0, str(SCRIPT_DIR))
27
-
28
- from pub_utils import (
29
- CANONICAL_MARKERS,
30
- CELL_TYPE_COLORS,
31
- find_gene_biomods,
32
- get_biomod_family,
33
- importance_rank_percentile,
34
- load_importance_results,
35
- load_biomod_gene_mapping,
36
- savefig,
37
- setup_publication_style,
38
- )
39
-
40
-
41
- def compute_marker_ranks(
42
- importance: np.ndarray,
43
- label_classes: list,
44
- module_names: list,
45
- module_gene_symbols: dict,
46
- ) -> pd.DataFrame:
47
- """For each cell type's canonical markers, find their importance rank."""
48
- rows = []
49
- module_name_to_idx = {n: i for i, n in enumerate(module_names)}
50
- num_modules = len(module_names)
51
-
52
- for cls_idx, cls_name in enumerate(label_classes):
53
- markers = CANONICAL_MARKERS.get(cls_name, [])
54
- imp_row = importance[cls_idx]
55
- ranked_indices = np.argsort(-imp_row)
56
-
57
- for marker in markers:
58
- containing_modules = find_gene_biomods(marker, module_gene_symbols)
59
- if not containing_modules:
60
- rows.append({
61
- "cell_type": cls_name,
62
- "marker_gene": marker,
63
- "found_in_modules": False,
64
- "num_modules_containing": 0,
65
- "best_module": None,
66
- "best_module_family": None,
67
- "best_rank": None,
68
- "rank_percentile": None,
69
- "best_importance": None,
70
- })
71
- continue
72
-
73
- module_indices = [
74
- module_name_to_idx[p] for p in containing_modules
75
- if p in module_name_to_idx
76
- ]
77
- if not module_indices:
78
- continue
79
-
80
- ranks = []
81
- for pi in module_indices:
82
- rank = int(np.where(ranked_indices == pi)[0][0])
83
- ranks.append((rank, pi))
84
- best_rank, best_pi = min(ranks, key=lambda x: x[0])
85
-
86
- rows.append({
87
- "cell_type": cls_name,
88
- "marker_gene": marker,
89
- "found_in_modules": True,
90
- "num_modules_containing": len(containing_modules),
91
- "best_module": module_names[best_pi],
92
- "best_module_family": get_biomod_family(module_names[best_pi]),
93
- "best_rank": best_rank + 1,
94
- "rank_percentile": 100.0 * best_rank / num_modules,
95
- "best_importance": float(imp_row[best_pi]),
96
- })
97
-
98
- return pd.DataFrame(rows)
99
-
100
-
101
- def compute_enrichment_statistics(
102
- marker_df: pd.DataFrame, num_modules: int
103
- ) -> dict:
104
- """Test whether markers are enriched in top modules vs random expectation."""
105
- found = marker_df[marker_df["found_in_modules"]].copy()
106
- if found.empty:
107
- return {}
108
-
109
- percentiles = found["rank_percentile"].values
110
-
111
- top_5_count = (percentiles < 5).sum()
112
- top_10_count = (percentiles < 10).sum()
113
- top_20_count = (percentiles < 20).sum()
114
- total = len(percentiles)
115
-
116
- binom_p5 = 1 - stats.binom.cdf(top_5_count - 1, total, 0.05)
117
- binom_p10 = 1 - stats.binom.cdf(top_10_count - 1, total, 0.10)
118
- binom_p20 = 1 - stats.binom.cdf(top_20_count - 1, total, 0.20)
119
-
120
- median_percentile = float(np.median(percentiles))
121
- wilcoxon_stat, wilcoxon_p = stats.wilcoxon(
122
- percentiles - 50.0, alternative="less"
123
- )
124
-
125
- return {
126
- "total_markers_tested": total,
127
- "markers_not_found": int((~marker_df["found_in_modules"]).sum()),
128
- "median_rank_percentile": median_percentile,
129
- "mean_rank_percentile": float(np.mean(percentiles)),
130
- "in_top_5pct": int(top_5_count),
131
- "in_top_10pct": int(top_10_count),
132
- "in_top_20pct": int(top_20_count),
133
- "binom_pvalue_top5": float(binom_p5),
134
- "binom_pvalue_top10": float(binom_p10),
135
- "binom_pvalue_top20": float(binom_p20),
136
- "wilcoxon_stat": float(wilcoxon_stat),
137
- "wilcoxon_pvalue": float(wilcoxon_p),
138
- }
139
-
140
-
141
- def plot_marker_ranks(marker_df: pd.DataFrame, output_path):
142
- """Publication-quality table figure of marker gene rank percentiles."""
143
- setup_publication_style()
144
-
145
- found = marker_df[marker_df["found_in_modules"]].copy()
146
- if found.empty:
147
- return
148
-
149
- cell_types = list(CANONICAL_MARKERS.keys())
150
- cell_types = [ct for ct in cell_types if ct in found["cell_type"].values]
151
-
152
- # Build table data: each row = cell type, columns = marker genes with ranks
153
- table_rows = []
154
- for ct in cell_types:
155
- ct_data = found[found["cell_type"] == ct].sort_values("rank_percentile")
156
- markers_str = []
157
- for _, row in ct_data.iterrows():
158
- rank = int(row["best_rank"])
159
- pct = row["rank_percentile"]
160
- markers_str.append(f"{row['marker_gene']} (#{rank}, {pct:.1f}%)")
161
- table_rows.append(markers_str)
162
-
163
- # Determine max markers per cell type for column count
164
- max_markers = max(len(r) for r in table_rows)
165
- col_labels = [f"Marker {i+1}" for i in range(min(max_markers, 6))]
166
-
167
- # Truncate to 6 columns max for readability
168
- table_data = []
169
- for row in table_rows:
170
- table_data.append(row[:6] + [""] * (6 - len(row[:6])))
171
-
172
- fig, ax = plt.subplots(figsize=(12, 0.5 * len(cell_types) + 2))
173
- ax.axis("off")
174
-
175
- table = ax.table(
176
- cellText=table_data,
177
- rowLabels=cell_types,
178
- colLabels=col_labels,
179
- cellLoc="center",
180
- rowLoc="right",
181
- loc="center",
182
- )
183
- table.auto_set_font_size(False)
184
- table.set_fontsize(7.5)
185
- table.scale(1.0, 1.4)
186
-
187
- # Color cells by rank percentile
188
- for i, row in enumerate(table_data):
189
- for j, cell_text in enumerate(row):
190
- cell = table[i + 1, j]
191
- if cell_text and "%" in cell_text:
192
- pct = float(cell_text.split(",")[-1].strip().rstrip("%)"))
193
- if pct < 1:
194
- cell.set_facecolor("#fee0d2")
195
- elif pct < 5:
196
- cell.set_facecolor("#fff5eb")
197
-
198
- ax.set_title(
199
- "Canonical Marker Genes: Importance Rank (format: Gene (#rank, percentile))",
200
- fontsize=10, pad=20,
201
- )
202
-
203
- fig.tight_layout()
204
- savefig(fig, output_path)
205
-
206
-
207
- def plot_marker_summary_bar(marker_df: pd.DataFrame, output_path):
208
- """Bar chart showing fraction of markers in top-k% per cell type."""
209
- setup_publication_style()
210
-
211
- found = marker_df[marker_df["found_in_modules"]].copy()
212
- if found.empty:
213
- return
214
-
215
- cell_types = [ct for ct in CANONICAL_MARKERS if ct in found["cell_type"].values]
216
- thresholds = [1, 2, 3, 5]
217
- colors = ["#a50f15", "#E41A1C", "#FF7F00", "#4DAF4A"]
218
-
219
- fig, ax = plt.subplots(figsize=(10, 4.5))
220
- x = np.arange(len(cell_types))
221
- width = 0.2
222
-
223
- for j, (thresh, color) in enumerate(zip(thresholds, colors)):
224
- fracs = []
225
- for ct in cell_types:
226
- ct_data = found[found["cell_type"] == ct]
227
- n_in = (ct_data["rank_percentile"] < thresh).sum()
228
- fracs.append(n_in / len(ct_data) if len(ct_data) > 0 else 0)
229
- offset = (j - 1.5) * width
230
- ax.bar(x + offset, fracs, width, color=color, alpha=0.85,
231
- label=f"Top {thresh}%")
232
-
233
- # Random expectation lines
234
- for thresh in thresholds:
235
- ax.axhline(thresh / 100.0, color="gray", linestyle=":",
236
- linewidth=0.5, alpha=0.5)
237
-
238
- ax.set_xticks(x)
239
- ax.set_xticklabels(cell_types, rotation=45, ha="right")
240
- ax.set_ylabel("Fraction of markers in top-k%")
241
- ax.set_title("Marker Gene Enrichment in Top Importance Modules")
242
- ax.legend(loc="upper right", fontsize=8)
243
- ax.set_ylim(0, 1.05)
244
-
245
- fig.tight_layout()
246
- savefig(fig, output_path)
247
-
248
-
249
- def parse_args():
250
- p = argparse.ArgumentParser(description="Marker gene verification")
251
- p.add_argument("--importance_matrix", required=True,
252
- help="Path to importance_matrix.npy")
253
- p.add_argument("--importance_csv", required=True,
254
- help="Path to importance_matrix.csv (for labels)")
255
- p.add_argument("--biomod_data_dir", "--module_data_dir", required=True,
256
- help="Directory containing merged_biomods.json", dest='module_data_dir')
257
- p.add_argument("--output_dir", required=True)
258
- return p.parse_args()
259
-
260
-
261
- def main():
262
- args = parse_args()
263
- output_dir = Path(args.output_dir)
264
- output_dir.mkdir(parents=True, exist_ok=True)
265
-
266
- importance = np.load(args.importance_matrix)
267
- imp_csv = pd.read_csv(args.importance_csv, index_col=0)
268
- label_classes = list(imp_csv.index)
269
- module_names = list(imp_csv.columns)
270
-
271
- _, module_gene_symbols = load_biomod_gene_mapping(args.module_data_dir)
272
-
273
- print(f"Importance matrix: {importance.shape}")
274
- print(f"Cell types: {len(label_classes)}")
275
- print(f"Modules: {len(module_names)}")
276
- print(f"Canonical markers defined for: {len(CANONICAL_MARKERS)} cell types")
277
-
278
- marker_df = compute_marker_ranks(
279
- importance, label_classes, module_names, module_gene_symbols
280
- )
281
- marker_df.to_csv(output_dir / "marker_ranks.csv", index=False)
282
-
283
- stats_dict = compute_enrichment_statistics(marker_df, len(module_names))
284
-
285
- per_ct_stats = {}
286
- for ct in label_classes:
287
- ct_found = marker_df[
288
- (marker_df["cell_type"] == ct) & marker_df["found_in_modules"]
289
- ]
290
- if len(ct_found) > 0:
291
- per_ct_stats[ct] = {
292
- "n_markers": len(ct_found),
293
- "median_percentile": float(ct_found["rank_percentile"].median()),
294
- "best_marker": ct_found.loc[
295
- ct_found["rank_percentile"].idxmin(), "marker_gene"
296
- ],
297
- "best_rank": int(ct_found["best_rank"].min()),
298
- }
299
-
300
- metrics = {**stats_dict, "per_cell_type": per_ct_stats}
301
- with open(output_dir / "marker_metrics.json", "w") as f:
302
- json.dump(metrics, f, indent=2)
303
-
304
- print(f"\nResults:")
305
- print(f" Markers found in modules: {stats_dict['total_markers_tested']}")
306
- print(f" Markers NOT found: {stats_dict['markers_not_found']}")
307
- print(f" Median rank percentile: {stats_dict['median_rank_percentile']:.1f}%")
308
- print(f" In top 5%: {stats_dict['in_top_5pct']}")
309
- print(f" In top 10%: {stats_dict['in_top_10pct']}")
310
- print(f" In top 20%: {stats_dict['in_top_20pct']}")
311
- print(f" Binomial p-value (top 10%): {stats_dict['binom_pvalue_top10']:.2e}")
312
- print(f" Wilcoxon p-value (< 50%): {stats_dict['wilcoxon_pvalue']:.2e}")
313
-
314
- plot_marker_ranks(marker_df, output_dir / "fig_marker_ranks")
315
- plot_marker_summary_bar(marker_df, output_dir / "fig_marker_summary_bar")
316
-
317
- print(f"\nFigures saved to {output_dir}")
318
-
319
-
320
- if __name__ == "__main__":
321
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
BioMod/interpretability/biomod_importance/masking_ablation.py DELETED
@@ -1,375 +0,0 @@
1
- #!/usr/bin/env python
2
- # -*- coding: utf-8 -*-
3
- """
4
- Experiment β‘’-A: Masking Ablation Curves
5
-
6
- Progressively mask top-k / bottom-k / random-k modules and measure accuracy
7
- drop. Proves that top-importance modules are necessary for classification.
8
-
9
- Requires GPU (model inference). Uses post_encoder masking for efficiency.
10
-
11
- Outputs:
12
- - ablation_curves.npz: accuracy arrays
13
- - ablation_metrics.json: summary statistics
14
- - per_cell_deltas.npy: per-cell importance deltas (for permutation tests)
15
- - attention_weights.npy: GatedAttentionPool weights (bonus)
16
- """
17
-
18
- import argparse
19
- import json
20
- import logging
21
- import sys
22
- from pathlib import Path
23
- from typing import List, Tuple
24
-
25
- import numpy as np
26
- import torch
27
- import torch.nn as nn
28
- from torch.utils.data import DataLoader
29
-
30
- SCRIPT_DIR = Path(__file__).resolve().parent
31
- INTERP_DIR = SCRIPT_DIR.parent
32
- BIOMOD_ROOT = INTERP_DIR.parent
33
- SCLONG_ROOT = BIOMOD_ROOT.parent
34
-
35
- for p in (str(SCRIPT_DIR), str(BIOMOD_ROOT), str(SCLONG_ROOT)):
36
- if p not in sys.path:
37
- sys.path.insert(0, p)
38
-
39
- from biomod_importance import (
40
- CellDataset,
41
- _classify_from_tokens,
42
- load_finetuned_model,
43
- load_test_data,
44
- )
45
-
46
- logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
47
- logger = logging.getLogger(__name__)
48
-
49
-
50
- @torch.no_grad()
51
- def encode_all_cells(model, dataloader, preprocessor, reindex_state, device):
52
- """Encode all test cells once, return module tokens and labels."""
53
- all_tokens = []
54
- all_labels = []
55
- for x_batch, y_batch in dataloader:
56
- x_batch = x_batch.to(device)
57
- x_model = preprocessor.reindex_tensor_to_model_space(
58
- x_batch, reindex_state=reindex_state
59
- )
60
- latent, _, ids_restore, _ = model.backbone.forward_encoder(
61
- x_model, mask_ratio=0.0
62
- )
63
- if model.head_type == "cls":
64
- module_tokens = latent[:, 1:, :]
65
- else:
66
- module_tokens = model._restore_biomod_order(latent, ids_restore)
67
- all_tokens.append(module_tokens.cpu())
68
- all_labels.append(y_batch.numpy())
69
-
70
- return torch.cat(all_tokens, dim=0), np.concatenate(all_labels)
71
-
72
-
73
- @torch.no_grad()
74
- def compute_per_cell_deltas(model, all_tokens, all_labels, device, batch_size=256):
75
- """Compute per-cell, per-module importance deltas.
76
-
77
- Returns: np.ndarray of shape (n_cells, n_modules)
78
- """
79
- n_cells, n_modules, _ = all_tokens.shape
80
- deltas = np.zeros((n_cells, n_modules), dtype=np.float32)
81
-
82
- for start in range(0, n_cells, batch_size):
83
- end = min(start + batch_size, n_cells)
84
- tokens = all_tokens[start:end].to(device)
85
- labels = torch.from_numpy(all_labels[start:end]).to(device)
86
- batch_idx = torch.arange(end - start, device=device)
87
-
88
- baseline_logits = _classify_from_tokens(model, tokens)
89
- baseline_correct = baseline_logits[batch_idx, labels].cpu().numpy()
90
-
91
- for pi in range(n_modules):
92
- masked = tokens.clone()
93
- masked[:, pi, :] = 0.0
94
- masked_logits = _classify_from_tokens(model, masked)
95
- masked_correct = masked_logits[batch_idx, labels].cpu().numpy()
96
- deltas[start:end, pi] = baseline_correct - masked_correct
97
-
98
- return deltas
99
-
100
-
101
- @torch.no_grad()
102
- def extract_attention_weights(model, all_tokens, device, batch_size=256):
103
- """Extract GatedAttentionPool weights for all cells.
104
-
105
- Returns: np.ndarray of shape (n_cells, n_modules) or None if not attn_pool.
106
- """
107
- if model.head_type != "attn_pool":
108
- return None
109
-
110
- n_cells = all_tokens.shape[0]
111
- all_weights = []
112
-
113
- for start in range(0, n_cells, batch_size):
114
- end = min(start + batch_size, n_cells)
115
- tokens = all_tokens[start:end].to(device)
116
- _, weights = model.pooler(tokens)
117
- all_weights.append(weights.cpu().numpy())
118
-
119
- return np.concatenate(all_weights, axis=0)
120
-
121
-
122
- @torch.no_grad()
123
- def compute_masked_accuracy_post_encoder(
124
- model, all_tokens, all_labels, mask_module_indices, device, batch_size=256
125
- ):
126
- """Compute accuracy with specified modules zeroed out (post-encoder)."""
127
- n_cells = all_tokens.shape[0]
128
- correct = 0
129
-
130
- for start in range(0, n_cells, batch_size):
131
- end = min(start + batch_size, n_cells)
132
- tokens = all_tokens[start:end].to(device).clone()
133
- labels = all_labels[start:end]
134
-
135
- for pi in mask_module_indices:
136
- tokens[:, pi, :] = 0.0
137
-
138
- logits = _classify_from_tokens(model, tokens)
139
- preds = logits.argmax(dim=-1).cpu().numpy()
140
- correct += (preds == labels).sum()
141
-
142
- return correct / n_cells
143
-
144
-
145
- def run_ablation_curves(
146
- model, all_tokens, all_labels, importance_matrix,
147
- label_classes, k_values, n_random_trials, device, batch_size=256,
148
- ):
149
- """Run ablation for all cell types and masking orders."""
150
- num_classes = len(label_classes)
151
- num_modules = all_tokens.shape[1]
152
- n_k = len(k_values)
153
-
154
- # Overall accuracy (all cell types together)
155
- overall_top = np.zeros(n_k)
156
- overall_bottom = np.zeros(n_k)
157
- overall_random_mean = np.zeros(n_k)
158
- overall_random_std = np.zeros(n_k)
159
-
160
- # Per-class accuracy
161
- per_class_top = np.zeros((num_classes, n_k))
162
- per_class_bottom = np.zeros((num_classes, n_k))
163
- per_class_random_mean = np.zeros((num_classes, n_k))
164
- per_class_random_std = np.zeros((num_classes, n_k))
165
-
166
- # --- Overall ablation (use mean importance across classes) ---
167
- mean_importance = importance_matrix.mean(axis=0)
168
- overall_ranking = np.argsort(-mean_importance)
169
-
170
- logger.info("Computing overall ablation curves...")
171
- for ki, k in enumerate(k_values):
172
- top_indices = overall_ranking[:k].tolist()
173
- bottom_indices = overall_ranking[-k:].tolist()
174
-
175
- overall_top[ki] = compute_masked_accuracy_post_encoder(
176
- model, all_tokens, all_labels, top_indices, device, batch_size
177
- )
178
- overall_bottom[ki] = compute_masked_accuracy_post_encoder(
179
- model, all_tokens, all_labels, bottom_indices, device, batch_size
180
- )
181
-
182
- random_accs = []
183
- rng = np.random.default_rng(2021)
184
- for _ in range(n_random_trials):
185
- rand_indices = rng.choice(num_modules, k, replace=False).tolist()
186
- acc = compute_masked_accuracy_post_encoder(
187
- model, all_tokens, all_labels, rand_indices, device, batch_size
188
- )
189
- random_accs.append(acc)
190
- overall_random_mean[ki] = np.mean(random_accs)
191
- overall_random_std[ki] = np.std(random_accs)
192
-
193
- logger.info(
194
- " k=%d: top=%.4f, bottom=%.4f, random=%.4fΒ±%.4f",
195
- k, overall_top[ki], overall_bottom[ki],
196
- overall_random_mean[ki], overall_random_std[ki],
197
- )
198
-
199
- # --- Per-class ablation ---
200
- for cls_idx in range(num_classes):
201
- cls_mask = all_labels == cls_idx
202
- if cls_mask.sum() == 0:
203
- continue
204
- cls_tokens = all_tokens[cls_mask]
205
- cls_labels = all_labels[cls_mask]
206
- cls_ranking = np.argsort(-importance_matrix[cls_idx])
207
-
208
- logger.info("Computing ablation for %s (%d cells)...",
209
- label_classes[cls_idx], cls_mask.sum())
210
-
211
- for ki, k in enumerate(k_values):
212
- top_indices = cls_ranking[:k].tolist()
213
- bottom_indices = cls_ranking[-k:].tolist()
214
-
215
- per_class_top[cls_idx, ki] = compute_masked_accuracy_post_encoder(
216
- model, cls_tokens, cls_labels, top_indices, device, batch_size
217
- )
218
- per_class_bottom[cls_idx, ki] = compute_masked_accuracy_post_encoder(
219
- model, cls_tokens, cls_labels, bottom_indices, device, batch_size
220
- )
221
-
222
- random_accs = []
223
- rng = np.random.default_rng(2021 + cls_idx)
224
- for _ in range(n_random_trials):
225
- rand_indices = rng.choice(num_modules, k, replace=False).tolist()
226
- acc = compute_masked_accuracy_post_encoder(
227
- model, cls_tokens, cls_labels, rand_indices, device, batch_size
228
- )
229
- random_accs.append(acc)
230
- per_class_random_mean[cls_idx, ki] = np.mean(random_accs)
231
- per_class_random_std[cls_idx, ki] = np.std(random_accs)
232
-
233
- return {
234
- "k_values": np.array(k_values),
235
- "overall_top": overall_top,
236
- "overall_bottom": overall_bottom,
237
- "overall_random_mean": overall_random_mean,
238
- "overall_random_std": overall_random_std,
239
- "per_class_top": per_class_top,
240
- "per_class_bottom": per_class_bottom,
241
- "per_class_random_mean": per_class_random_mean,
242
- "per_class_random_std": per_class_random_std,
243
- "label_classes": label_classes,
244
- }
245
-
246
-
247
- def parse_args():
248
- p = argparse.ArgumentParser(description="Masking ablation curves")
249
- p.add_argument("--finetune_checkpoint", required=True)
250
- p.add_argument("--config_pkl", required=True)
251
- p.add_argument("--biomod_data_dir", "--module_data_dir", required=True, dest='module_data_dir')
252
- p.add_argument("--gene_list_path", required=True)
253
- p.add_argument("--gene_mapping_path", required=True)
254
- p.add_argument("--gene2vec_data_path", required=True)
255
- p.add_argument("--gene2ens_file", required=True)
256
- p.add_argument("--data_path", required=True)
257
- p.add_argument("--label_column", default="celltype")
258
- p.add_argument("--head_type", default="attn_pool",
259
- help="Classification head type (attn_pool, module_mean, etc.)")
260
- p.add_argument("--gene_name_column", default="auto",
261
- help="Gene name column in h5ad (auto=detect)")
262
- p.add_argument("--importance_matrix", required=True,
263
- help="Path to pre-computed importance_matrix.npy")
264
- p.add_argument("--k_values", type=int, nargs="+",
265
- default=[1, 2, 5, 10, 20, 50, 100, 200])
266
- p.add_argument("--n_random_trials", type=int, default=10)
267
- p.add_argument("--batch_size", type=int, default=256)
268
- p.add_argument("--test_ratio", type=float, default=0.2)
269
- p.add_argument("--seed", type=int, default=2021)
270
- p.add_argument("--output_dir", required=True)
271
- p.add_argument("--save_deltas", action="store_true",
272
- help="Save per-cell delta matrix (for permutation tests)")
273
- p.add_argument("--device", default="cuda")
274
- return p.parse_args()
275
-
276
-
277
- def main():
278
- args = parse_args()
279
- output_dir = Path(args.output_dir)
280
- output_dir.mkdir(parents=True, exist_ok=True)
281
-
282
- device = torch.device(args.device if torch.cuda.is_available() else "cpu")
283
- logger.info("Device: %s", device)
284
-
285
- for attr in ("finetune_checkpoint", "config_pkl", "module_data_dir",
286
- "gene_list_path", "gene_mapping_path", "gene2vec_data_path",
287
- "gene2ens_file", "data_path", "importance_matrix"):
288
- val = getattr(args, attr, None)
289
- if val is not None and not Path(val).is_absolute():
290
- setattr(args, attr, str(Path(val).resolve()))
291
-
292
- logger.info("Loading model...")
293
- model, biomod_data, label_classes, head_type = load_finetuned_model(args, device)
294
- num_modules = len(biomod_data)
295
-
296
- logger.info("Loading test data...")
297
- X_test, y_test, le, preprocessor, reindex_state = load_test_data(
298
- args, label_classes, device
299
- )
300
- dataset = CellDataset(X_test, y_test)
301
- dataloader = DataLoader(dataset, batch_size=args.batch_size, shuffle=False)
302
-
303
- logger.info("Loading importance matrix...")
304
- importance_matrix = np.load(args.importance_matrix)
305
-
306
- logger.info("Encoding all cells (encoder runs once)...")
307
- all_tokens, all_labels = encode_all_cells(
308
- model, dataloader, preprocessor, reindex_state, device
309
- )
310
- logger.info("Encoded %d cells, %d modules", all_tokens.shape[0], all_tokens.shape[1])
311
-
312
- # Baseline accuracy
313
- baseline_acc = compute_masked_accuracy_post_encoder(
314
- model, all_tokens, all_labels, [], device, args.batch_size
315
- )
316
- logger.info("Baseline accuracy: %.4f", baseline_acc)
317
-
318
- # Extract attention weights (bonus)
319
- logger.info("Extracting attention weights...")
320
- attn_weights = extract_attention_weights(model, all_tokens, device, args.batch_size)
321
- if attn_weights is not None:
322
- np.save(output_dir / "attention_weights.npy", attn_weights)
323
- logger.info("Saved attention weights: %s", attn_weights.shape)
324
-
325
- # Save per-cell deltas if requested
326
- if args.save_deltas:
327
- logger.info("Computing per-cell deltas (for permutation tests)...")
328
- deltas = compute_per_cell_deltas(
329
- model, all_tokens, all_labels, device, args.batch_size
330
- )
331
- np.save(output_dir / "per_cell_deltas.npy", deltas)
332
- np.save(output_dir / "per_cell_labels.npy", all_labels)
333
- logger.info("Saved per-cell deltas: %s", deltas.shape)
334
-
335
- # Run ablation curves
336
- logger.info("Running ablation curves...")
337
- results = run_ablation_curves(
338
- model, all_tokens, all_labels, importance_matrix,
339
- label_classes, args.k_values, args.n_random_trials,
340
- device, args.batch_size,
341
- )
342
-
343
- # Save results
344
- np.savez(
345
- output_dir / "ablation_curves.npz",
346
- k_values=results["k_values"],
347
- overall_top=results["overall_top"],
348
- overall_bottom=results["overall_bottom"],
349
- overall_random_mean=results["overall_random_mean"],
350
- overall_random_std=results["overall_random_std"],
351
- per_class_top=results["per_class_top"],
352
- per_class_bottom=results["per_class_bottom"],
353
- per_class_random_mean=results["per_class_random_mean"],
354
- per_class_random_std=results["per_class_random_std"],
355
- )
356
-
357
- metrics = {
358
- "baseline_accuracy": baseline_acc,
359
- "k_values": args.k_values,
360
- "n_random_trials": args.n_random_trials,
361
- "num_test_cells": int(all_tokens.shape[0]),
362
- "num_modules": num_modules,
363
- "label_classes": label_classes,
364
- "overall_top_acc": results["overall_top"].tolist(),
365
- "overall_bottom_acc": results["overall_bottom"].tolist(),
366
- "overall_random_mean_acc": results["overall_random_mean"].tolist(),
367
- }
368
- with open(output_dir / "ablation_metrics.json", "w") as f:
369
- json.dump(metrics, f, indent=2)
370
-
371
- logger.info("Done. Results saved to %s", output_dir)
372
-
373
-
374
- if __name__ == "__main__":
375
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
BioMod/interpretability/biomod_importance/pub_utils.py DELETED
@@ -1,206 +0,0 @@
1
- """Shared utilities for publication-quality module importance analysis."""
2
-
3
- import json
4
- from pathlib import Path
5
- from typing import Dict, List, Tuple
6
-
7
- import matplotlib as mpl
8
- import matplotlib.pyplot as plt
9
- import numpy as np
10
- import pandas as pd
11
-
12
- # ─── Canonical Marker Genes ─────────────────────────────────────────────────
13
- # Well-established markers for Zheng68K cell types (from literature)
14
-
15
- CANONICAL_MARKERS: Dict[str, List[str]] = {
16
- "CD14+ Monocyte": [
17
- "CD14", "LYZ", "S100A8", "S100A9", "VCAN", "FCN1", "CD68",
18
- ],
19
- "CD19+ B": [
20
- "CD19", "MS4A1", "CD79A", "CD79B", "PAX5", "BANK1",
21
- ],
22
- "CD34+": [
23
- "CD34", "KIT", "THY1", "CRHBP", "SPINK2",
24
- ],
25
- "CD4+ T Helper2": [
26
- "CD4", "GATA3", "IL7R", "MAL", "CCR4",
27
- ],
28
- "CD4+/CD25 T Reg": [
29
- "CD4", "FOXP3", "IL2RA", "CTLA4", "TIGIT", "IKZF2",
30
- ],
31
- "CD4+/CD45RA+/CD25- Naive T": [
32
- "CD4", "CCR7", "SELL", "LEF1", "TCF7",
33
- ],
34
- "CD4+/CD45RO+ Memory": [
35
- "CD4", "IL7R", "S100A4", "ANXA1", "GPR183",
36
- ],
37
- "CD56+ NK": [
38
- "NCAM1", "NKG7", "GNLY", "KLRD1", "KLRF1", "GZMB",
39
- ],
40
- "CD8+ Cytotoxic T": [
41
- "CD8A", "CD8B", "GZMB", "PRF1", "IFNG", "NKG7",
42
- ],
43
- "CD8+/CD45RA+ Naive Cytotoxic": [
44
- "CD8A", "CCR7", "SELL", "LEF1", "TCF7",
45
- ],
46
- "Dendritic": [
47
- "FCER1A", "CD1C", "CLEC10A", "HLA-DRA", "ITGAX",
48
- ],
49
- }
50
-
51
- # ─── Color Palettes ─────────────────────────────────────────────────────────
52
-
53
- CELL_TYPE_COLORS: Dict[str, str] = {
54
- "CD14+ Monocyte": "#E41A1C",
55
- "CD19+ B": "#377EB8",
56
- "CD34+": "#4DAF4A",
57
- "CD4+ T Helper2": "#984EA3",
58
- "CD4+/CD25 T Reg": "#FF7F00",
59
- "CD4+/CD45RA+/CD25- Naive T": "#A65628",
60
- "CD4+/CD45RO+ Memory": "#F781BF",
61
- "CD56+ NK": "#66C2A5",
62
- "CD8+ Cytotoxic T": "#FC8D62",
63
- "CD8+/CD45RA+ Naive Cytotoxic": "#8DA0CB",
64
- "Dendritic": "#E78AC3",
65
- }
66
-
67
- MODULE_FAMILY_COLORS: Dict[str, str] = {
68
- "Reactome": "#1B9E77",
69
- "Hallmarks": "#D95F02",
70
- "Hotspot": "#7570B3",
71
- "Processed": "#E7298A",
72
- }
73
-
74
-
75
- # ─── Publication Style ───────────────────────────────────────────────────────
76
-
77
- def setup_publication_style():
78
- """Set matplotlib rcParams for publication-quality figures."""
79
- mpl.rcParams.update({
80
- "figure.dpi": 300,
81
- "savefig.dpi": 300,
82
- "font.family": "sans-serif",
83
- "font.sans-serif": ["Arial", "DejaVu Sans", "Helvetica"],
84
- "font.size": 9,
85
- "axes.titlesize": 11,
86
- "axes.labelsize": 10,
87
- "xtick.labelsize": 8,
88
- "ytick.labelsize": 8,
89
- "legend.fontsize": 8,
90
- "figure.titlesize": 12,
91
- "lines.linewidth": 1.5,
92
- "lines.markersize": 4,
93
- "axes.linewidth": 0.8,
94
- "xtick.major.width": 0.8,
95
- "ytick.major.width": 0.8,
96
- "axes.spines.top": False,
97
- "axes.spines.right": False,
98
- "pdf.fonttype": 42,
99
- "ps.fonttype": 42,
100
- })
101
-
102
-
103
- def savefig(fig, path_stem, dpi=300, tight=True):
104
- """Save figure as both PDF and PNG."""
105
- path = Path(path_stem)
106
- path.parent.mkdir(parents=True, exist_ok=True)
107
- kwargs = {"dpi": dpi, "bbox_inches": "tight"} if tight else {"dpi": dpi}
108
- fig.savefig(path.with_suffix(".pdf"), **kwargs)
109
- fig.savefig(path.with_suffix(".png"), **kwargs)
110
- plt.close(fig)
111
-
112
-
113
- # ─── Data Loading ────────────────────────────────────────────────────────────
114
-
115
- def load_importance_results(results_dir) -> dict:
116
- """Load importance matrix and associated metadata from a results directory."""
117
- results_dir = Path(results_dir)
118
- data = {}
119
- data["importance"] = np.load(results_dir / "importance_matrix.npy")
120
- data["importance_csv"] = pd.read_csv(
121
- results_dir / "importance_matrix.csv", index_col=0
122
- )
123
- data["label_classes"] = list(data["importance_csv"].index)
124
- data["module_names"] = list(data["importance_csv"].columns)
125
-
126
- metrics_path = results_dir / "metrics.json"
127
- if metrics_path.exists():
128
- with open(metrics_path) as f:
129
- data["metrics"] = json.load(f)
130
-
131
- top_modules_path = results_dir / "top_modules_per_celltype.json"
132
- if top_modules_path.exists():
133
- with open(top_modules_path) as f:
134
- data["top_modules"] = json.load(f)
135
-
136
- enrichment_path = results_dir / "enrichment_results.csv"
137
- if enrichment_path.exists():
138
- data["enrichment"] = pd.read_csv(enrichment_path)
139
-
140
- return data
141
-
142
-
143
- def load_biomod_gene_mapping(module_data_dir) -> Tuple[List[str], Dict[str, List[str]]]:
144
- """Load merged_biomods.json and return (module_names, module_gene_symbols).
145
-
146
- Returns:
147
- module_names: ordered list of module names
148
- module_gene_symbols: dict mapping module_name -> list of gene symbols
149
- """
150
- module_data_dir = Path(module_data_dir)
151
- with open(module_data_dir / "merged_biomods.json") as f:
152
- modules = json.load(f)
153
- module_names = list(modules.keys())
154
- module_gene_symbols = {}
155
- for name, genes in modules.items():
156
- if genes and isinstance(genes[0], str):
157
- module_gene_symbols[name] = genes
158
- else:
159
- module_gene_symbols[name] = [str(g) for g in genes]
160
- return module_names, module_gene_symbols
161
-
162
-
163
- def get_biomod_family(module_name: str) -> str:
164
- """Determine which family a module belongs to based on its name prefix."""
165
- if module_name.startswith("module1_hallmarks"):
166
- return "Hallmarks"
167
- elif module_name.startswith("module2_reactome"):
168
- return "Reactome"
169
- elif module_name.startswith("module3_hotspot"):
170
- return "Hotspot"
171
- elif module_name.startswith("module3_processed"):
172
- return "Processed"
173
- return "Unknown"
174
-
175
-
176
- def get_biomod_families(module_names: List[str]) -> List[str]:
177
- """Get family labels for a list of module names."""
178
- return [get_biomod_family(p) for p in module_names]
179
-
180
-
181
- # ─── Analysis Helpers ────────────────────────────────────────────────────────
182
-
183
- def find_gene_biomods(
184
- gene_symbol: str, module_gene_symbols: Dict[str, List[str]]
185
- ) -> List[str]:
186
- """Find all modules containing a given gene symbol."""
187
- gene_upper = gene_symbol.upper()
188
- return [
189
- name for name, genes in module_gene_symbols.items()
190
- if gene_upper in [g.upper() for g in genes]
191
- ]
192
-
193
-
194
- def importance_rank_percentile(
195
- importance_row: np.ndarray, module_indices: List[int]
196
- ) -> float:
197
- """Compute the best (lowest) rank percentile for given module indices.
198
-
199
- Returns percentile in [0, 100] where 0 = most important.
200
- """
201
- if not module_indices:
202
- return 100.0
203
- ranked = np.argsort(-importance_row)
204
- ranks = np.array([np.where(ranked == pi)[0][0] for pi in module_indices])
205
- best_rank = ranks.min()
206
- return 100.0 * best_rank / len(importance_row)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
BioMod/interpretability/biomod_importance/random_baseline.py DELETED
@@ -1,333 +0,0 @@
1
- #!/usr/bin/env python
2
- # -*- coding: utf-8 -*-
3
- """
4
- Experiment β‘’-B: Random Baseline / Permutation Tests
5
-
6
- Two modes:
7
- A) shuffle_modules (GPU): Permute module-gene assignments, recompute importance.
8
- Proves importance is not random.
9
- B) shuffle_labels (CPU): Permute cell type labels, re-aggregate per-cell deltas.
10
- Proves importance is cell-type-specific.
11
-
12
- Outputs:
13
- - permutation_results.npz: null distributions
14
- - permutation_metrics.json: p-values and effect sizes
15
- """
16
-
17
- import argparse
18
- import json
19
- import logging
20
- import sys
21
- from pathlib import Path
22
-
23
- import numpy as np
24
- import torch
25
- import torch.nn as nn
26
- from torch.utils.data import DataLoader
27
-
28
- SCRIPT_DIR = Path(__file__).resolve().parent
29
- INTERP_DIR = SCRIPT_DIR.parent
30
- BIOMOD_ROOT = INTERP_DIR.parent
31
- SCLONG_ROOT = BIOMOD_ROOT.parent
32
-
33
- for p in (str(SCRIPT_DIR), str(BIOMOD_ROOT), str(SCLONG_ROOT)):
34
- if p not in sys.path:
35
- sys.path.insert(0, p)
36
-
37
- from biomod_importance import (
38
- CellDataset,
39
- _classify_from_tokens,
40
- load_finetuned_model,
41
- load_test_data,
42
- )
43
-
44
- logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
45
- logger = logging.getLogger(__name__)
46
-
47
-
48
- # ─── Mode A: Shuffle Modules (GPU) ──────────────────────────────────────────
49
-
50
- @torch.no_grad()
51
- def compute_importance_shuffled_biomods(
52
- model, dataloader, preprocessor, reindex_state,
53
- num_modules, num_classes, device, seed,
54
- ):
55
- """Compute importance with shuffled module-gene assignments.
56
-
57
- Strategy: After encoding, randomly permute the module token order.
58
- This is equivalent to shuffling which module each token represents,
59
- breaking the learned module-gene correspondence.
60
- """
61
- rng = np.random.default_rng(seed)
62
- perm = rng.permutation(num_modules)
63
-
64
- importance = np.zeros((num_classes, num_modules), dtype=np.float64)
65
- class_counts = np.zeros(num_classes, dtype=np.float64)
66
-
67
- for x_batch, y_batch in dataloader:
68
- x_batch = x_batch.to(device)
69
- y_batch = y_batch.numpy()
70
- x_model = preprocessor.reindex_tensor_to_model_space(
71
- x_batch, reindex_state=reindex_state
72
- )
73
-
74
- latent, _, ids_restore, _ = model.backbone.forward_encoder(
75
- x_model, mask_ratio=0.0
76
- )
77
- if model.head_type == "cls":
78
- module_tokens = latent[:, 1:, :]
79
- else:
80
- module_tokens = model._restore_biomod_order(latent, ids_restore)
81
-
82
- # Permute module token order (breaks module identity)
83
- module_tokens_shuffled = module_tokens[:, perm, :]
84
-
85
- baseline_logits = _classify_from_tokens(model, module_tokens_shuffled)
86
- y_tensor = torch.from_numpy(y_batch).to(device)
87
- batch_idx = torch.arange(len(y_batch), device=device)
88
- baseline_correct = baseline_logits[batch_idx, y_tensor].cpu().numpy()
89
-
90
- for pi in range(num_modules):
91
- masked = module_tokens_shuffled.clone()
92
- masked[:, pi, :] = 0.0
93
- masked_logits = _classify_from_tokens(model, masked)
94
- masked_correct = masked_logits[batch_idx, y_tensor].cpu().numpy()
95
-
96
- delta = baseline_correct - masked_correct
97
- for cls_id in range(num_classes):
98
- mask_cls = y_batch == cls_id
99
- if mask_cls.any():
100
- importance[cls_id, pi] += delta[mask_cls].sum()
101
-
102
- for cls_id in range(num_classes):
103
- class_counts[cls_id] += (y_batch == cls_id).sum()
104
-
105
- class_counts[class_counts == 0] = 1.0
106
- importance /= class_counts[:, None]
107
- return importance
108
-
109
-
110
- # ─── Mode B: Shuffle Labels (CPU) ───────────────────────────────────────────
111
-
112
- def compute_importance_shuffled_labels(
113
- per_cell_deltas: np.ndarray,
114
- labels: np.ndarray,
115
- num_classes: int,
116
- n_permutations: int,
117
- seed: int,
118
- ) -> np.ndarray:
119
- """Re-aggregate per-cell deltas with shuffled labels.
120
-
121
- Args:
122
- per_cell_deltas: shape (n_cells, n_modules)
123
- labels: shape (n_cells,) integer class labels
124
- num_classes: number of classes
125
- n_permutations: number of permutations
126
- seed: random seed
127
-
128
- Returns:
129
- null_importance: shape (n_permutations, num_classes, n_modules)
130
- """
131
- n_cells, n_modules = per_cell_deltas.shape
132
- rng = np.random.default_rng(seed)
133
- null_importance = np.zeros(
134
- (n_permutations, num_classes, n_modules), dtype=np.float32
135
- )
136
-
137
- for perm_idx in range(n_permutations):
138
- shuffled_labels = rng.permutation(labels)
139
- for cls_id in range(num_classes):
140
- mask = shuffled_labels == cls_id
141
- count = mask.sum()
142
- if count > 0:
143
- null_importance[perm_idx, cls_id] = (
144
- per_cell_deltas[mask].sum(axis=0) / count
145
- )
146
-
147
- return null_importance
148
-
149
-
150
- # ─── Statistics ──────────────────────────────────────────────────────────────
151
-
152
- def compute_permutation_pvalues(
153
- real_importance: np.ndarray,
154
- null_importance: np.ndarray,
155
- ) -> dict:
156
- """Compute empirical p-values from permutation null.
157
-
158
- Args:
159
- real_importance: shape (num_classes, num_modules)
160
- null_importance: shape (n_perms, num_classes, num_modules)
161
- """
162
- n_perms = null_importance.shape[0]
163
-
164
- # Per-cell-type max importance
165
- real_max = real_importance.max(axis=1) # (num_classes,)
166
- null_max = null_importance.max(axis=2) # (n_perms, num_classes)
167
-
168
- pvalues_max = np.zeros(real_importance.shape[0])
169
- for cls_id in range(real_importance.shape[0]):
170
- pvalues_max[cls_id] = (
171
- (null_max[:, cls_id] >= real_max[cls_id]).sum() + 1
172
- ) / (n_perms + 1)
173
-
174
- # Global max
175
- real_global_max = real_importance.max()
176
- null_global_max = null_importance.max(axis=(1, 2))
177
- pvalue_global = ((null_global_max >= real_global_max).sum() + 1) / (n_perms + 1)
178
-
179
- # Mean importance (top-10 per class)
180
- real_top10_mean = np.sort(real_importance, axis=1)[:, -10:].mean(axis=1)
181
- null_top10_mean = np.sort(null_importance, axis=2)[:, :, -10:].mean(axis=2)
182
-
183
- pvalues_top10 = np.zeros(real_importance.shape[0])
184
- for cls_id in range(real_importance.shape[0]):
185
- pvalues_top10[cls_id] = (
186
- (null_top10_mean[:, cls_id] >= real_top10_mean[cls_id]).sum() + 1
187
- ) / (n_perms + 1)
188
-
189
- return {
190
- "pvalue_global_max": float(pvalue_global),
191
- "pvalues_per_class_max": pvalues_max.tolist(),
192
- "pvalues_per_class_top10_mean": pvalues_top10.tolist(),
193
- "real_global_max": float(real_global_max),
194
- "null_global_max_mean": float(null_global_max.mean()),
195
- "null_global_max_std": float(null_global_max.std()),
196
- "effect_size": float(
197
- (real_global_max - null_global_max.mean()) / max(null_global_max.std(), 1e-8)
198
- ),
199
- }
200
-
201
-
202
- def parse_args():
203
- p = argparse.ArgumentParser(description="Permutation tests for module importance")
204
- p.add_argument("--mode", choices=["shuffle_modules", "shuffle_labels", "both"],
205
- default="both")
206
- # Model args (for shuffle_modules mode)
207
- p.add_argument("--finetune_checkpoint")
208
- p.add_argument("--config_pkl")
209
- p.add_argument("--biomod_data_dir", "--module_data_dir", dest='module_data_dir')
210
- p.add_argument("--gene_list_path")
211
- p.add_argument("--gene_mapping_path")
212
- p.add_argument("--gene2vec_data_path")
213
- p.add_argument("--gene2ens_file")
214
- p.add_argument("--data_path")
215
- p.add_argument("--head_type", default="attn_pool")
216
- p.add_argument("--gene_name_column", default="auto",
217
- help="Gene name column in h5ad (auto=detect)")
218
- p.add_argument("--label_column", default="celltype")
219
- p.add_argument("--test_ratio", type=float, default=0.2)
220
- # Shared args
221
- p.add_argument("--importance_matrix", required=True,
222
- help="Path to real importance_matrix.npy")
223
- p.add_argument("--per_cell_deltas",
224
- help="Path to per_cell_deltas.npy (for shuffle_labels mode)")
225
- p.add_argument("--per_cell_labels",
226
- help="Path to per_cell_labels.npy")
227
- p.add_argument("--n_permutations", type=int, default=100)
228
- p.add_argument("--batch_size", type=int, default=256)
229
- p.add_argument("--seed", type=int, default=2021)
230
- p.add_argument("--output_dir", required=True)
231
- p.add_argument("--device", default="cuda")
232
- return p.parse_args()
233
-
234
-
235
- def main():
236
- args = parse_args()
237
- output_dir = Path(args.output_dir)
238
- output_dir.mkdir(parents=True, exist_ok=True)
239
-
240
- real_importance = np.load(args.importance_matrix)
241
- num_classes, num_modules = real_importance.shape
242
- logger.info("Real importance: %s", real_importance.shape)
243
-
244
- results = {}
245
-
246
- # ─── Mode A: Shuffle Modules ────────────────────────────────────────
247
- if args.mode in ("shuffle_modules", "both"):
248
- assert args.finetune_checkpoint, "--finetune_checkpoint required for shuffle_modules"
249
- device = torch.device(args.device if torch.cuda.is_available() else "cpu")
250
-
251
- for attr in ("finetune_checkpoint", "config_pkl", "module_data_dir",
252
- "gene_list_path", "gene_mapping_path", "gene2vec_data_path",
253
- "gene2ens_file", "data_path"):
254
- val = getattr(args, attr, None)
255
- if val is not None and not Path(val).is_absolute():
256
- setattr(args, attr, str(Path(val).resolve()))
257
-
258
- logger.info("Loading model for shuffle_modules mode...")
259
- model, biomod_data, label_classes, _ = load_finetuned_model(args, device)
260
-
261
- logger.info("Loading test data...")
262
- X_test, y_test, le, preprocessor, reindex_state = load_test_data(
263
- args, label_classes, device
264
- )
265
- dataset = CellDataset(X_test, y_test)
266
- dataloader = DataLoader(dataset, batch_size=args.batch_size, shuffle=False)
267
-
268
- n_perms_a = min(args.n_permutations, 10)
269
- logger.info("Running %d module-shuffle permutations...", n_perms_a)
270
-
271
- null_module_shuffle = np.zeros(
272
- (n_perms_a, num_classes, num_modules), dtype=np.float32
273
- )
274
- for pi in range(n_perms_a):
275
- logger.info(" Permutation %d/%d...", pi + 1, n_perms_a)
276
- null_module_shuffle[pi] = compute_importance_shuffled_biomods(
277
- model, dataloader, preprocessor, reindex_state,
278
- num_modules, num_classes, device,
279
- seed=args.seed + pi + 1000,
280
- )
281
-
282
- np.save(output_dir / "null_module_shuffle.npy", null_module_shuffle)
283
- stats_a = compute_permutation_pvalues(real_importance, null_module_shuffle)
284
- stats_a["n_permutations"] = n_perms_a
285
- results["shuffle_modules"] = stats_a
286
- logger.info("Module-shuffle: global p=%.4f, effect_size=%.2f",
287
- stats_a["pvalue_global_max"], stats_a["effect_size"])
288
-
289
- # ─── Mode B: Shuffle Labels ─────────────────────────────────────────
290
- if args.mode in ("shuffle_labels", "both"):
291
- if args.per_cell_deltas:
292
- deltas_path = Path(args.per_cell_deltas)
293
- labels_path = Path(args.per_cell_labels) if args.per_cell_labels else (
294
- deltas_path.parent / "per_cell_labels.npy"
295
- )
296
- logger.info("Loading per-cell deltas from %s", deltas_path)
297
- per_cell_deltas = np.load(deltas_path)
298
- labels = np.load(labels_path)
299
- else:
300
- logger.error("--per_cell_deltas required for shuffle_labels mode")
301
- logger.error("Run masking_ablation.py with --save_deltas first")
302
- sys.exit(1)
303
-
304
- n_perms_b = args.n_permutations
305
- logger.info("Running %d label-shuffle permutations...", n_perms_b)
306
-
307
- null_label_shuffle = compute_importance_shuffled_labels(
308
- per_cell_deltas, labels, num_classes, n_perms_b, args.seed
309
- )
310
-
311
- np.save(output_dir / "null_label_shuffle.npy", null_label_shuffle)
312
- stats_b = compute_permutation_pvalues(real_importance, null_label_shuffle)
313
- stats_b["n_permutations"] = n_perms_b
314
- results["shuffle_labels"] = stats_b
315
- logger.info("Label-shuffle: global p=%.4f, effect_size=%.2f",
316
- stats_b["pvalue_global_max"], stats_b["effect_size"])
317
-
318
- # ─── Save metrics (merge with existing to avoid overwriting) ────────
319
- metrics_path = output_dir / "permutation_metrics.json"
320
- if metrics_path.exists():
321
- with open(metrics_path) as f:
322
- existing = json.load(f)
323
- existing.update(results)
324
- results = existing
325
-
326
- with open(metrics_path, "w") as f:
327
- json.dump(results, f, indent=2)
328
-
329
- logger.info("Done. Results saved to %s", output_dir)
330
-
331
-
332
- if __name__ == "__main__":
333
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
BioMod/interpretability/biomod_importance/tcell_reactome_analysis.py DELETED
@@ -1,466 +0,0 @@
1
- #!/usr/bin/env python
2
- # -*- coding: utf-8 -*-
3
- """
4
- T Cell Reactome Pathway Importance Analysis
5
-
6
- Analyzes which Reactome pathways are most important for T cell subtype
7
- classification, using pre-computed importance matrix (no GPU needed).
8
-
9
- Outputs:
10
- 1. Per T cell subtype top Reactome pathway ranking (table + bar chart)
11
- 2. Shared vs unique critical pathways across T cell subtypes
12
- 3. T cell-focused Reactome importance heatmap
13
- 4. Summary statistics CSV
14
- """
15
-
16
- import argparse
17
- import sys
18
- from pathlib import Path
19
-
20
- import matplotlib as mpl
21
- import matplotlib.pyplot as plt
22
- import numpy as np
23
- import pandas as pd
24
- from matplotlib.patches import Patch as LegendHandle
25
-
26
- SCRIPT_DIR = Path(__file__).resolve().parent
27
- sys.path.insert(0, str(SCRIPT_DIR))
28
-
29
- from pub_utils import (
30
- CELL_TYPE_COLORS,
31
- load_importance_results,
32
- savefig,
33
- setup_publication_style,
34
- )
35
-
36
- REACTOME_PREFIX = "module2_reactome"
37
-
38
- T_CELL_SUBTYPES = [
39
- "CD4+ T Helper2",
40
- "CD4+/CD25 T Reg",
41
- "CD4+/CD45RA+/CD25- Naive T",
42
- "CD4+/CD45RO+ Memory",
43
- "CD8+ Cytotoxic T",
44
- "CD8+/CD45RA+ Naive Cytotoxic",
45
- ]
46
-
47
- T_CELL_SHORT_NAMES = {
48
- "CD4+ T Helper2": "CD4+ Th2",
49
- "CD4+/CD25 T Reg": "CD4+ Treg",
50
- "CD4+/CD45RA+/CD25- Naive T": "CD4+ Naive",
51
- "CD4+/CD45RO+ Memory": "CD4+ Memory",
52
- "CD8+ Cytotoxic T": "CD8+ Cyto",
53
- "CD8+/CD45RA+ Naive Cytotoxic": "CD8+ Naive",
54
- }
55
-
56
-
57
- def clean_reactome_name(module_name: str) -> str:
58
- """Extract readable pathway name from full module name."""
59
- parts = module_name.split("__", 1)
60
- if len(parts) < 2:
61
- return module_name
62
- pathway = parts[1]
63
- pathway = pathway.rsplit(" R-HSA-", 1)[0]
64
- return pathway
65
-
66
-
67
- def extract_reactome_importance(importance_csv: pd.DataFrame):
68
- """Extract Reactome-only importance sub-matrix for T cell subtypes."""
69
- reactome_cols = [c for c in importance_csv.columns if c.startswith(REACTOME_PREFIX)]
70
- t_cell_rows = [ct for ct in T_CELL_SUBTYPES if ct in importance_csv.index]
71
-
72
- sub = importance_csv.loc[t_cell_rows, reactome_cols].copy()
73
- sub.columns = [clean_reactome_name(c) for c in sub.columns]
74
- return sub
75
-
76
-
77
- # ─── Figure 1: Top-N Reactome per T cell subtype ─────────────────────────────
78
-
79
- def plot_top_reactome_per_subtype(sub_df, output_dir, top_n=15):
80
- """Horizontal bar chart: top-N Reactome pathways per T cell subtype."""
81
- setup_publication_style()
82
- n_types = len(sub_df.index)
83
- fig, axes = plt.subplots(n_types, 1, figsize=(10, 3.2 * n_types))
84
- if n_types == 1:
85
- axes = [axes]
86
-
87
- for idx, ct in enumerate(sub_df.index):
88
- ax = axes[idx]
89
- row = sub_df.loc[ct].sort_values(ascending=False)
90
- top = row.head(top_n)
91
-
92
- colors = [CELL_TYPE_COLORS.get(ct, "#333")] * len(top)
93
- y_pos = np.arange(len(top))
94
- ax.barh(y_pos, top.values, color=colors, alpha=0.8, edgecolor="white",
95
- linewidth=0.3, height=0.7)
96
-
97
- short = T_CELL_SHORT_NAMES.get(ct, ct)
98
- ax.set_yticks(y_pos)
99
- ax.set_yticklabels(top.index, fontsize=7)
100
- ax.invert_yaxis()
101
- ax.set_xlabel("Ξ”Logit (importance)", fontsize=9)
102
- ax.set_title(f"{short}: Top-{top_n} Reactome Pathways", fontsize=10,
103
- color=CELL_TYPE_COLORS.get(ct, "#333"), fontweight="bold")
104
- ax.axvline(0, color="gray", linewidth=0.5, linestyle=":")
105
-
106
- for i, v in enumerate(top.values):
107
- if v > 0:
108
- ax.text(v + 0.001, i, f"{v:.3f}", va="center", fontsize=6.5)
109
-
110
- fig.tight_layout(h_pad=2.0)
111
- savefig(fig, output_dir / "fig1_top_reactome_per_tcell")
112
- print(" Saved fig1_top_reactome_per_tcell")
113
-
114
-
115
- # ─── Figure 2: Shared vs Unique critical pathways ────────────────────────────
116
-
117
- def analyze_shared_unique(sub_df, output_dir, top_n=20):
118
- """Identify shared and unique top Reactome pathways across T cell subtypes."""
119
- setup_publication_style()
120
-
121
- top_sets = {}
122
- for ct in sub_df.index:
123
- row = sub_df.loc[ct].sort_values(ascending=False)
124
- top_sets[ct] = set(row.head(top_n).index)
125
-
126
- all_top = set()
127
- for s in top_sets.values():
128
- all_top |= s
129
-
130
- pathway_counts = {}
131
- for pw in all_top:
132
- cnt = sum(1 for s in top_sets.values() if pw in s)
133
- pathway_counts[pw] = cnt
134
-
135
- shared = {pw: cnt for pw, cnt in pathway_counts.items() if cnt >= 3}
136
- unique = {pw: cnt for pw, cnt in pathway_counts.items() if cnt == 1}
137
-
138
- shared_sorted = sorted(shared.items(), key=lambda x: -x[1])
139
- unique_by_type = {ct: [] for ct in sub_df.index}
140
- for pw, cnt in unique.items():
141
- for ct, s in top_sets.items():
142
- if pw in s:
143
- unique_by_type[ct].append(pw)
144
-
145
- rows = []
146
- for pw, cnt in pathway_counts.items():
147
- present_in = [T_CELL_SHORT_NAMES.get(ct, ct)
148
- for ct, s in top_sets.items() if pw in s]
149
- mean_imp = sub_df[pw].mean()
150
- max_imp = sub_df[pw].max()
151
- max_type = sub_df[pw].idxmax()
152
- rows.append({
153
- "pathway": pw,
154
- "n_subtypes": cnt,
155
- "present_in": ", ".join(present_in),
156
- "mean_importance": round(mean_imp, 5),
157
- "max_importance": round(max_imp, 5),
158
- "max_subtype": T_CELL_SHORT_NAMES.get(max_type, max_type),
159
- "category": "shared (β‰₯3)" if cnt >= 3 else ("unique" if cnt == 1 else "partial"),
160
- })
161
- summary_df = pd.DataFrame(rows).sort_values(
162
- ["n_subtypes", "mean_importance"], ascending=[False, False]
163
- )
164
- summary_df.to_csv(output_dir / "tcell_reactome_shared_unique.csv", index=False)
165
- print(" Saved tcell_reactome_shared_unique.csv")
166
-
167
- # --- Figure: Upset-style count bar ---
168
- fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 5), gridspec_kw={"width_ratios": [2, 1]})
169
-
170
- # Left: shared pathways bar
171
- if shared_sorted:
172
- pw_names = [x[0] for x in shared_sorted]
173
- pw_counts = [x[1] for x in shared_sorted]
174
- pw_importance = [sub_df[pw].mean() for pw in pw_names]
175
-
176
- display_names = [n[:50] + "..." if len(n) > 50 else n for n in pw_names]
177
- y_pos = np.arange(len(pw_names))
178
- bars = ax1.barh(y_pos, pw_importance, color="#1B9E77", alpha=0.8,
179
- edgecolor="white", linewidth=0.3, height=0.7)
180
- ax1.set_yticks(y_pos)
181
- ax1.set_yticklabels(display_names, fontsize=7)
182
- ax1.invert_yaxis()
183
- ax1.set_xlabel("Mean Ξ”Logit across T cell subtypes", fontsize=9)
184
- ax1.set_title(f"(A) Shared Reactome Pathways (in β‰₯3 of 6 subtypes, top-{top_n})",
185
- fontsize=10)
186
-
187
- for i, (imp, cnt) in enumerate(zip(pw_importance, pw_counts)):
188
- ax1.text(max(imp, 0) + 0.0005, i, f"{cnt}/6", va="center", fontsize=7,
189
- color="#555", fontweight="bold")
190
-
191
- # Right: unique counts per subtype
192
- unique_counts = [len(unique_by_type[ct]) for ct in sub_df.index]
193
- short_names = [T_CELL_SHORT_NAMES.get(ct, ct) for ct in sub_df.index]
194
- colors = [CELL_TYPE_COLORS.get(ct, "#333") for ct in sub_df.index]
195
- y_pos2 = np.arange(len(sub_df.index))
196
- ax2.barh(y_pos2, unique_counts, color=colors, alpha=0.8, edgecolor="white",
197
- linewidth=0.3, height=0.6)
198
- ax2.set_yticks(y_pos2)
199
- ax2.set_yticklabels(short_names, fontsize=8)
200
- ax2.invert_yaxis()
201
- ax2.set_xlabel(f"# Unique pathways (in top-{top_n})", fontsize=9)
202
- ax2.set_title("(B) Subtype-Specific Pathways", fontsize=10)
203
-
204
- for i, v in enumerate(unique_counts):
205
- if v > 0:
206
- ax2.text(v + 0.2, i, str(v), va="center", fontsize=8, fontweight="bold")
207
-
208
- fig.tight_layout(w_pad=3.0)
209
- savefig(fig, output_dir / "fig2_shared_unique_reactome")
210
- print(" Saved fig2_shared_unique_reactome")
211
-
212
- return summary_df
213
-
214
-
215
- # ─── Figure 3: Heatmap of top Reactome pathways across T cell subtypes ───────
216
-
217
- def plot_tcell_reactome_heatmap(sub_df, output_dir, top_n=30):
218
- """Heatmap: importance of top Reactome pathways across all T cell subtypes."""
219
- setup_publication_style()
220
-
221
- mean_importance = sub_df.mean(axis=0).sort_values(ascending=False)
222
- top_pathways = mean_importance.head(top_n).index.tolist()
223
-
224
- heatmap_data = sub_df[top_pathways].copy()
225
- short_labels = [T_CELL_SHORT_NAMES.get(ct, ct) for ct in heatmap_data.index]
226
-
227
- display_pathways = [n[:55] + "..." if len(n) > 55 else n for n in top_pathways]
228
-
229
- fig, ax = plt.subplots(figsize=(10, 8))
230
- vmax = heatmap_data.values.max()
231
- vmin = min(0, heatmap_data.values.min())
232
-
233
- im = ax.imshow(heatmap_data.values.T, cmap="YlOrRd", aspect="auto",
234
- vmin=vmin, vmax=vmax)
235
-
236
- ax.set_xticks(range(len(short_labels)))
237
- ax.set_xticklabels(short_labels, fontsize=8, rotation=45, ha="right")
238
- ax.set_yticks(range(len(display_pathways)))
239
- ax.set_yticklabels(display_pathways, fontsize=7)
240
-
241
- for i in range(len(top_pathways)):
242
- for j in range(len(short_labels)):
243
- val = heatmap_data.values[j, i]
244
- color = "white" if val > vmax * 0.65 else "black"
245
- ax.text(j, i, f"{val:.3f}", ha="center", va="center",
246
- fontsize=5.5, color=color)
247
-
248
- cb = fig.colorbar(im, ax=ax, fraction=0.02, pad=0.02)
249
- cb.set_label("Ξ”Logit (importance)", fontsize=9)
250
- ax.set_title(f"Top-{top_n} Reactome Pathways: T Cell Subtype Importance",
251
- fontsize=11, pad=10)
252
-
253
- fig.tight_layout()
254
- savefig(fig, output_dir / "fig3_tcell_reactome_heatmap")
255
- print(" Saved fig3_tcell_reactome_heatmap")
256
-
257
-
258
- # ─── Figure 4: CD4 vs CD8 comparison ─────────────────────────────────────────
259
-
260
- def plot_cd4_vs_cd8_comparison(sub_df, output_dir, top_n=20):
261
- """Compare most important Reactome pathways between CD4+ and CD8+ T cells."""
262
- setup_publication_style()
263
-
264
- cd4_types = [ct for ct in sub_df.index if "CD4" in ct]
265
- cd8_types = [ct for ct in sub_df.index if "CD8" in ct]
266
-
267
- cd4_mean = sub_df.loc[cd4_types].mean(axis=0)
268
- cd8_mean = sub_df.loc[cd8_types].mean(axis=0)
269
-
270
- cd4_top = set(cd4_mean.sort_values(ascending=False).head(top_n).index)
271
- cd8_top = set(cd8_mean.sort_values(ascending=False).head(top_n).index)
272
-
273
- shared = cd4_top & cd8_top
274
- cd4_only = cd4_top - cd8_top
275
- cd8_only = cd8_top - cd4_top
276
-
277
- all_pathways = sorted(cd4_top | cd8_top,
278
- key=lambda p: -(cd4_mean[p] + cd8_mean[p]))
279
-
280
- fig, ax = plt.subplots(figsize=(12, max(6, len(all_pathways) * 0.28)))
281
-
282
- display_names = [n[:55] + "..." if len(n) > 55 else n for n in all_pathways]
283
- y_pos = np.arange(len(all_pathways))
284
- width = 0.35
285
-
286
- bars_cd4 = ax.barh(y_pos - width / 2, [cd4_mean[p] for p in all_pathways],
287
- height=width, color="#984EA3", alpha=0.8, label="CD4+ (mean)")
288
- bars_cd8 = ax.barh(y_pos + width / 2, [cd8_mean[p] for p in all_pathways],
289
- height=width, color="#FC8D62", alpha=0.8, label="CD8+ (mean)")
290
-
291
- for i, pw in enumerate(all_pathways):
292
- if pw in shared:
293
- ax.plot(-0.001, i, "s", color="#333", markersize=4)
294
- elif pw in cd4_only:
295
- ax.plot(-0.001, i, "s", color="#984EA3", markersize=4)
296
- else:
297
- ax.plot(-0.001, i, "s", color="#FC8D62", markersize=4)
298
-
299
- ax.set_yticks(y_pos)
300
- ax.set_yticklabels(display_names, fontsize=7)
301
- ax.invert_yaxis()
302
- ax.set_xlabel("Mean Ξ”Logit (importance)", fontsize=9)
303
- ax.set_title(f"CD4+ vs CD8+ T Cells: Top-{top_n} Reactome Pathways", fontsize=11)
304
- ax.axvline(0, color="gray", linewidth=0.5, linestyle=":")
305
-
306
- legend_elements = [
307
- LegendHandle(facecolor="#984EA3", alpha=0.8, label=f"CD4+ subtypes (n={len(cd4_types)})"),
308
- LegendHandle(facecolor="#FC8D62", alpha=0.8, label=f"CD8+ subtypes (n={len(cd8_types)})"),
309
- LegendHandle(facecolor="#333", alpha=0.4, label=f"Shared (n={len(shared)})"),
310
- ]
311
- ax.legend(handles=legend_elements, loc="lower right", fontsize=8)
312
-
313
- fig.tight_layout()
314
- savefig(fig, output_dir / "fig4_cd4_vs_cd8_reactome")
315
- print(" Saved fig4_cd4_vs_cd8_reactome")
316
-
317
- comparison_rows = []
318
- for pw in all_pathways:
319
- comparison_rows.append({
320
- "pathway": pw,
321
- "cd4_mean": round(cd4_mean[pw], 5),
322
- "cd8_mean": round(cd8_mean[pw], 5),
323
- "diff_cd4_minus_cd8": round(cd4_mean[pw] - cd8_mean[pw], 5),
324
- "category": "shared" if pw in shared else ("CD4-specific" if pw in cd4_only else "CD8-specific"),
325
- })
326
- comp_df = pd.DataFrame(comparison_rows)
327
- comp_df.to_csv(output_dir / "cd4_vs_cd8_reactome.csv", index=False)
328
- print(" Saved cd4_vs_cd8_reactome.csv")
329
-
330
- return len(shared), len(cd4_only), len(cd8_only)
331
-
332
-
333
- # ─── Figure 5: Naive vs Effector/Memory ──────────────────────────────────────
334
-
335
- def plot_naive_vs_effector(sub_df, output_dir, top_n=20):
336
- """Compare naive T cells vs effector/memory T cells."""
337
- setup_publication_style()
338
-
339
- naive_types = [ct for ct in sub_df.index
340
- if "Naive" in ct]
341
- effector_types = [ct for ct in sub_df.index
342
- if ct not in naive_types]
343
-
344
- naive_mean = sub_df.loc[naive_types].mean(axis=0)
345
- effector_mean = sub_df.loc[effector_types].mean(axis=0)
346
-
347
- diff = effector_mean - naive_mean
348
- diff_sorted = diff.sort_values()
349
-
350
- bottom_n = diff_sorted.head(top_n)
351
- top_n_vals = diff_sorted.tail(top_n)
352
- selected = pd.concat([bottom_n, top_n_vals])
353
-
354
- fig, ax = plt.subplots(figsize=(10, max(6, len(selected) * 0.25)))
355
-
356
- display_names = [n[:55] + "..." if len(n) > 55 else n for n in selected.index]
357
- y_pos = np.arange(len(selected))
358
- colors = ["#2166AC" if v < 0 else "#B2182B" for v in selected.values]
359
-
360
- ax.barh(y_pos, selected.values, color=colors, alpha=0.8,
361
- edgecolor="white", linewidth=0.3, height=0.7)
362
- ax.set_yticks(y_pos)
363
- ax.set_yticklabels(display_names, fontsize=7)
364
- ax.axvline(0, color="black", linewidth=0.8)
365
- ax.set_xlabel("Ξ”Importance (Effector/Memory βˆ’ Naive)", fontsize=9)
366
- ax.set_title(f"Reactome Pathways: Effector/Memory vs Naive T Cells (top {top_n} each direction)",
367
- fontsize=10)
368
-
369
- legend_elements = [
370
- LegendHandle(facecolor="#B2182B", alpha=0.8, label="More important for Effector/Memory"),
371
- LegendHandle(facecolor="#2166AC", alpha=0.8, label="More important for Naive"),
372
- ]
373
- ax.legend(handles=legend_elements, loc="lower right", fontsize=8)
374
-
375
- fig.tight_layout()
376
- savefig(fig, output_dir / "fig5_naive_vs_effector_reactome")
377
- print(" Saved fig5_naive_vs_effector_reactome")
378
-
379
- rows = []
380
- for pw in diff_sorted.index:
381
- rows.append({
382
- "pathway": pw,
383
- "naive_mean": round(naive_mean[pw], 5),
384
- "effector_memory_mean": round(effector_mean[pw], 5),
385
- "diff_effector_minus_naive": round(diff[pw], 5),
386
- })
387
- diff_df = pd.DataFrame(rows)
388
- diff_df.to_csv(output_dir / "naive_vs_effector_reactome.csv", index=False)
389
- print(" Saved naive_vs_effector_reactome.csv")
390
-
391
-
392
- def parse_args():
393
- p = argparse.ArgumentParser(description="T cell Reactome pathway importance analysis")
394
- p.add_argument("--results_dir", required=True,
395
- help="Importance results directory (with importance_matrix.csv)")
396
- p.add_argument("--output_dir", required=True)
397
- p.add_argument("--top_n", type=int, default=15,
398
- help="Number of top pathways per subtype (default: 15)")
399
- p.add_argument("--top_n_shared", type=int, default=20,
400
- help="Top-N for shared/unique analysis (default: 20)")
401
- p.add_argument("--top_n_heatmap", type=int, default=30,
402
- help="Top-N for heatmap (default: 30)")
403
- return p.parse_args()
404
-
405
-
406
- def main():
407
- args = parse_args()
408
- output_dir = Path(args.output_dir)
409
- output_dir.mkdir(parents=True, exist_ok=True)
410
-
411
- print(f"Loading results from {args.results_dir}...")
412
- data = load_importance_results(Path(args.results_dir))
413
- importance_csv = data["importance_csv"]
414
-
415
- print(f"Cell types: {list(importance_csv.index)}")
416
- t_cell_present = [ct for ct in T_CELL_SUBTYPES if ct in importance_csv.index]
417
- print(f"T cell subtypes found: {t_cell_present}")
418
-
419
- sub_df = extract_reactome_importance(importance_csv)
420
- print(f"Reactome sub-matrix: {sub_df.shape[0]} T cell types Γ— {sub_df.shape[1]} pathways")
421
- print()
422
-
423
- print("[1/5] Top Reactome pathways per T cell subtype...")
424
- plot_top_reactome_per_subtype(sub_df, output_dir, top_n=args.top_n)
425
- top_per_type_rows = []
426
- for ct in sub_df.index:
427
- row = sub_df.loc[ct].sort_values(ascending=False)
428
- for rank, (pw, val) in enumerate(row.head(args.top_n).items(), 1):
429
- top_per_type_rows.append({
430
- "cell_type": T_CELL_SHORT_NAMES.get(ct, ct),
431
- "rank": rank,
432
- "pathway": pw,
433
- "importance": round(val, 5),
434
- })
435
- top_df = pd.DataFrame(top_per_type_rows)
436
- top_df.to_csv(output_dir / "top_reactome_per_tcell.csv", index=False)
437
- print(" Saved top_reactome_per_tcell.csv")
438
- print()
439
-
440
- print("[2/5] Shared vs unique pathway analysis...")
441
- summary_df = analyze_shared_unique(sub_df, output_dir, top_n=args.top_n_shared)
442
- n_shared = len(summary_df[summary_df["category"] == "shared (β‰₯3)"])
443
- n_unique = len(summary_df[summary_df["category"] == "unique"])
444
- print(f" Shared (β‰₯3 subtypes): {n_shared} pathways")
445
- print(f" Unique (1 subtype): {n_unique} pathways")
446
- print()
447
-
448
- print("[3/5] T cell Reactome importance heatmap...")
449
- plot_tcell_reactome_heatmap(sub_df, output_dir, top_n=args.top_n_heatmap)
450
- print()
451
-
452
- print("[4/5] CD4+ vs CD8+ comparison...")
453
- n_sh, n_cd4, n_cd8 = plot_cd4_vs_cd8_comparison(sub_df, output_dir, top_n=args.top_n_shared)
454
- print(f" Shared: {n_sh}, CD4-specific: {n_cd4}, CD8-specific: {n_cd8}")
455
- print()
456
-
457
- print("[5/5] Naive vs Effector/Memory comparison...")
458
- plot_naive_vs_effector(sub_df, output_dir, top_n=args.top_n)
459
- print()
460
-
461
- print("All analyses complete.")
462
- print(f"Output directory: {output_dir}")
463
-
464
-
465
- if __name__ == "__main__":
466
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
BioMod/interpretability/biomod_text_alignment/DESIGN.md DELETED
@@ -1,235 +0,0 @@
1
- # Experiment 2: Module Embedding to Pathway Text Alignment
2
-
3
- ## Overview
4
-
5
- This experiment tests whether the structure of the BioMod module embedding space
6
- significantly aligns with the semantic space of natural-language pathway
7
- descriptions. Gene-level models cannot reproduce this experiment because single
8
- gene names do not carry phrase-level semantics.
9
-
10
- ## Scripts
11
-
12
- | Script | Function |
13
- | --- | --- |
14
- | `biomod_text_alignment.py` | Main script: RSA/Mantel test, CKA, and leave-one-out Ridge retrieval |
15
- | `biomod_text_alignment_extended.py` | Extended analysis: Reactome broad-category RSA, Reactome description-based RSA, and Hallmark deep dive |
16
- | `biomod_text_alignment_publication.py` | Publication-level analysis: six main figures, bootstrap CIs, multi-model comparison, cross-dataset validation, and deep analysis |
17
- | `fetch_reactome_descriptions.py` | Fetch `summaryText` for 491 pathways from the Reactome API |
18
-
19
- ## Commands
20
-
21
- Run from the BioMod repository root. Generate each `module_embeddings.npy` with
22
- `BioMod/interpretability/biomod_geometry.py` before using it here. The historical
23
- `pretrain_subset` output requires a separately supplied pretraining dataset;
24
- the `pancreas_scib` example uses the bundled pancreas h5ad file. Existing result
25
- tables below describe historical runs; their raw output directories are not
26
- included in this repository.
27
-
28
- ### 1. Download the Sentence Encoder
29
-
30
- ```bash
31
- # Run from the BioMod repository root.
32
- # Activate the environment configured for this project.
33
-
34
- # The sentence-transformers loader downloads this model on first use.
35
- # Network access or an existing local model cache is required.
36
-
37
- pip install sentence-transformers -i https://pypi.tuna.tsinghua.edu.cn/simple
38
- ```
39
-
40
- ### 2. Main Experiment: RSA + CKA + LOO Retrieval
41
-
42
- ```bash
43
- # pretrain_subset is recommended because averaging over 50k cells is more stable.
44
- python BioMod/interpretability/biomod_text_alignment/biomod_text_alignment.py \
45
- --biomod_embedding_path outputs/interpretability/geometry/pretrain_subset/module_embeddings.npy \
46
- --biomod_json model_dependencies/biomod_data_all/merged_biomods.json \
47
- --sentence_model pritamdeka/S-PubMedBert-MS-MARCO \
48
- --n_permutations 1000 \
49
- --output_dir outputs/interpretability/text_alignment/pretrain_subset_v2
50
-
51
- # pancreas_scib validates cross-dataset consistency.
52
- python BioMod/interpretability/biomod_text_alignment/biomod_text_alignment.py \
53
- --biomod_embedding_path outputs/interpretability/geometry/pancreas_scib/module_embeddings.npy \
54
- --biomod_json model_dependencies/biomod_data_all/merged_biomods.json \
55
- --sentence_model pritamdeka/S-PubMedBert-MS-MARCO \
56
- --n_permutations 1000 \
57
- --output_dir outputs/interpretability/text_alignment/pancreas_scib_v2
58
-
59
- # Quick mode: skip LOO retrieval and run only RSA + CKA.
60
- python BioMod/interpretability/biomod_text_alignment/biomod_text_alignment.py \
61
- --biomod_embedding_path outputs/interpretability/geometry/pretrain_subset/module_embeddings.npy \
62
- --sentence_model pritamdeka/S-PubMedBert-MS-MARCO \
63
- --skip_loo \
64
- --output_dir outputs/interpretability/text_alignment/pretrain_subset_quick
65
- ```
66
-
67
- ### 3. Fetch Official Reactome Descriptions (Completed, 486/491)
68
-
69
- ```bash
70
- # Re-fetch if needed. Requires network access to reactome.org and takes about 2 minutes.
71
- python BioMod/interpretability/biomod_text_alignment/fetch_reactome_descriptions.py \
72
- --biomod_json model_dependencies/biomod_data_all/merged_biomods.json \
73
- --output reactome_descriptions.json --delay 0.05
74
- ```
75
-
76
- The command writes refreshed descriptions to `reactome_descriptions.json` in the
77
- repository root. The analysis examples below use the bundled
78
- `BioMod/interpretability/biomod_text_alignment/reactome_descriptions.json` instead;
79
- set `--descriptions_file reactome_descriptions.json` to use the refreshed output.
80
-
81
- ### 4. Extended Analysis: Reactome Categories, Description-Based RSA, Hallmark Deep Dive
82
-
83
- ```bash
84
- python BioMod/interpretability/biomod_text_alignment/biomod_text_alignment_extended.py \
85
- --biomod_embedding_path outputs/interpretability/geometry/pretrain_subset/module_embeddings.npy \
86
- --biomod_json model_dependencies/biomod_data_all/merged_biomods.json \
87
- --sentence_model pritamdeka/S-PubMedBert-MS-MARCO \
88
- --descriptions_file BioMod/interpretability/biomod_text_alignment/reactome_descriptions.json \
89
- --n_permutations 1000 \
90
- --output_dir outputs/interpretability/text_alignment/pretrain_subset_v2_extended
91
- ```
92
-
93
- ### 5. Publication-Level Analysis
94
-
95
- ```bash
96
- # Basic run: one dataset and one model.
97
- python BioMod/interpretability/biomod_text_alignment/biomod_text_alignment_publication.py \
98
- --biomod_embedding_path outputs/interpretability/geometry/pretrain_subset/module_embeddings.npy \
99
- --biomod_json model_dependencies/biomod_data_all/merged_biomods.json \
100
- --sentence_model pritamdeka/S-PubMedBert-MS-MARCO \
101
- --descriptions_file BioMod/interpretability/biomod_text_alignment/reactome_descriptions.json \
102
- --n_permutations 1000 \
103
- --n_bootstrap 2000 \
104
- --output_dir outputs/interpretability/text_alignment/publication
105
-
106
- # Full run: cross-dataset validation and multi-model comparison.
107
- python BioMod/interpretability/biomod_text_alignment/biomod_text_alignment_publication.py \
108
- --biomod_embedding_path outputs/interpretability/geometry/pretrain_subset/module_embeddings.npy \
109
- --biomod_embedding_path2 outputs/interpretability/geometry/pancreas_scib/module_embeddings.npy \
110
- --biomod_json model_dependencies/biomod_data_all/merged_biomods.json \
111
- --sentence_model pritamdeka/S-PubMedBert-MS-MARCO \
112
- --sentence_models_compare "sentence-transformers/all-mpnet-base-v2,sentence-transformers/all-MiniLM-L6-v2" \
113
- --descriptions_file BioMod/interpretability/biomod_text_alignment/reactome_descriptions.json \
114
- --n_permutations 1000 \
115
- --n_bootstrap 2000 \
116
- --output_dir outputs/interpretability/text_alignment/publication_full
117
- ```
118
-
119
- **Arguments:**
120
-
121
- | Argument | Description |
122
- | --- | --- |
123
- | `--biomod_embedding_path` | `module_embeddings.npy` for the main dataset, from Experiment 1 |
124
- | `--biomod_embedding_path2` | Optional second dataset for cross-dataset validation |
125
- | `--sentence_models_compare` | Comma-separated model names for comparison, downloaded automatically from HuggingFace |
126
- | `--n_permutations` | Mantel-test permutations; 1000 is standard and 10000 gives more precise p-values |
127
- | `--n_bootstrap` | Bootstrap resampling iterations; 2000 is standard for 95% CIs |
128
- | `--device` | Encoding device, `cpu` or `cuda`; default is CPU |
129
-
130
- ## Dependencies
131
-
132
- ```bash
133
- pip install sentence-transformers -i https://pypi.tuna.tsinghua.edu.cn/simple
134
- ```
135
-
136
- ## Output Files
137
-
138
- ### Main Experiment Outputs (`biomod_text_alignment.py`)
139
-
140
- | File | Content |
141
- | --- | --- |
142
- | `pathway_descriptions.json` | Text descriptions for 541 modules |
143
- | `text_embeddings.npy` | Sentence-encoder embeddings with shape `[541, 768]` |
144
- | `module_embeddings_subset.npy` | Corresponding model module embeddings with shape `[541, 1024]` |
145
- | `alignment_metrics.json` | RSA, CKA, retrieval recall@k, and p-values |
146
- | `rsa_scatter.png` | Paired-distance scatter plot: module space vs text space |
147
- | `rsa_null_distribution.png` | Mantel test: observed value vs null distribution |
148
- | `retrieval_ranks.png` | Histogram of retrieval ranks |
149
- | `retrieval_examples.csv` | Top-5 retrieval results for representative pathways |
150
- | `run.log` | Full log |
151
-
152
- ### Extended Analysis Outputs (`biomod_text_alignment_extended.py`)
153
-
154
- | File | Content |
155
- | --- | --- |
156
- | `extended_metrics.json` | Summary metrics for the three analyses |
157
- | `reactome_category_heatmaps.png` | Broad Reactome category distance-matrix comparison, module vs text |
158
- | `hallmarks_deep_dive.png` | Hallmark similarity matrices and per-module alignment-score bar chart |
159
- | `hallmarks_per_module_alignment.csv` | Per-module alignment ranking for 50 Hallmarks |
160
- | `run_extended.log` | Extended-analysis log |
161
-
162
- ### Publication-Level Outputs (`biomod_text_alignment_publication.py`)
163
-
164
- | File | Content |
165
- | --- | --- |
166
- | `fig1_rsa_overview.{pdf,png}` | 2D hexbin density plus Mantel null distribution with bootstrap CI |
167
- | `fig2_hallmarks_heatmaps.{pdf,png}` | Three 50 x 50 similarity matrices: module, text, and difference; hierarchical clustering order and adaptive color scale |
168
- | `fig3_per_hallmark_alignment.{pdf,png}` | Per-Hallmark alignment-score lollipop plot, colored by MSigDB process category |
169
- | `fig4_category_alignment.{pdf,png}` | Box + strip plot grouped by biological process category with Kruskal-Wallis test |
170
- | `fig5_granularity_effect.{pdf,png}` | Bar plot of RSA across granularity levels: fine-grained, category, superclass, Hallmark |
171
- | `fig6_model_comparison.{pdf,png}` | Optional RSA comparison across models and datasets |
172
- | `fig_alignment_vs_size.{pdf,png}` | Alignment score vs module gene count scatter plot |
173
- | `publication_metrics.json` | Summary of all metrics, including bootstrap 95% CIs |
174
- | `hallmarks_alignment_table.csv` | Alignment scores and process categories for 50 Hallmarks |
175
- | `negative_analysis.csv` | Neighbor comparison for the worst- and best-aligned modules |
176
- | `run_publication.log` | Full log |
177
-
178
- ## Results (v2, pretrain_subset)
179
-
180
- ### Main Experiment
181
-
182
- | Metric | All (541) | Hallmarks (50) | Reactome (491) |
183
- | --- | --- | --- | --- |
184
- | RSA Spearman r | 0.050 | **0.160** | -0.007 |
185
- | Mantel p-value | 0.012 | 0.030 | 0.586 |
186
- | z-score | 2.2 sigma | 2.1 sigma | -0.3 sigma |
187
- | CKA | 0.043 (p=0.000) | - | - |
188
- | Retrieval median rank | 232 (random=270) | - | - |
189
-
190
- ### Interpretation
191
-
192
- - **Hallmark alignment is significant (r=0.16, p=0.03):** among 50 broad biological processes, distance structure in model space significantly correlates with semantic distance in text space.
193
- - **Reactome does not align (r=-0.007, p=0.59):** among 491 fine-grained pathways there is no alignment, likely because fine-grained pathway gene-composition similarity, encoded by the model, and pathway-name language similarity, encoded by text, live on different axes.
194
- - **Global CKA is significant (p=0.000):** the overall representation structure contains an alignment signal.
195
-
196
- ### Improvement History
197
-
198
- **v1, BioLinkBERT + 1-to-1 retrieval:**
199
-
200
- - BioLinkBERT was used as a raw BERT model with mean pooling rather than a sentence-level fine-tuned encoder.
201
- - The main metric was exact 1-to-1 retrieval, which is too strict for a 541-dimensional space.
202
- - CKA=0.047 was statistically significant but weak; retrieval was close to random.
203
-
204
- **v2, S-PubMedBert-MS-MARCO + RSA/Mantel test:**
205
-
206
- 1. Switched to a sentence encoder: PubMedBERT fine-tuned on MS-MARCO sentence similarity.
207
- 2. Added RSA/Mantel test as the main metric, testing structural rather than exact retrieval alignment.
208
- 3. Per-family RSA revealed that the alignment signal is concentrated in Hallmarks.
209
-
210
- ### Extended Analysis Directions
211
-
212
- 1. **Reactome broad-category aggregation:** aggregate the 491 pathways into about 16 top-level categories and retest RSA; broad-category alignment is expected to be significant.
213
- 2. **Reactome descriptions:** use API-fetched `summaryText` as richer text input and test whether alignment improves.
214
- 3. **Hallmark deep dive:** compute per-module alignment scores, kNN overlap, and the best/worst aligned Hallmarks.
215
-
216
- ## Design Decisions
217
-
218
- ### Why Use RSA Instead of 1-to-1 Retrieval,
219
-
220
- 1-to-1 retrieval asks whether the paired text can be found exactly from a module
221
- embedding, requiring a very strong cross-modal mapping. RSA asks whether modules
222
- that are close in model space are also close in text space, which is a more
223
- natural definition of structural alignment.
224
-
225
- ### Why Does Reactome Not Align,
226
-
227
- - Reactome pathways overlap heavily because parent and child pathways share many genes.
228
- - The model encodes gene-composition similarity, while text names reflect hierarchy and naming similarity; the two do not perfectly match.
229
- - The granularity is too fine. For example, text distance between `Signaling by FGFR1` and `Signaling by FGFR2` does not necessarily reflect biological distance.
230
-
231
- ### Why Exclude Hotspot/Processed,
232
-
233
- - Hotspot modules (73) are data-driven co-expression modules with only numeric IDs and no pathway semantics.
234
- - Processed modules (46) are pipeline-derived gene groups such as `SM_2` and have no pathway names.
235
- - Without text pairs, they cannot participate in this alignment experiment.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
BioMod/interpretability/biomod_text_alignment/MANUSCRIPT_SECTION.md DELETED
@@ -1,208 +0,0 @@
1
- # Experiment 2: Module Embedding to Pathway Text Alignment
2
-
3
- > This document contains a Results subsection draft, complete figure captions,
4
- > and a supplementary-material framework for an NC submission. The main text is
5
- > written in English.
6
-
7
- ---
8
-
9
- ## Results Subsection Draft
10
-
11
- ### Module embeddings align with pathway semantic structure
12
-
13
- To test whether scLong's learned representations capture biologically meaningful relationships beyond gene-level co-expression, we examined whether the geometric structure of module embeddings aligns with the semantic structure of pathway natural language descriptions, an experiment that is structurally impossible for gene-level foundation models because individual gene names lack phrase-level semantics.
14
-
15
- We encoded the names and descriptions of 541 pathway-based modules (50 MSigDB Hallmarks + 491 Reactome pathways) using S-PubMedBert-MS-MARCO, a biomedical sentence encoder fine-tuned for semantic similarity. We then quantified the structural correspondence between the module embedding space and the text embedding space using Representational Similarity Analysis (RSA) with a Mantel permutation test (1,000 permutations).
16
-
17
- The module embedding space showed significant structural alignment with the pathway text space (Spearman r = 0.41, p < 0.001, z = 14.8 sigma; Fig. Xa). Bootstrap resampling confirmed the robustness of this estimate (95% CI: [0.32, 0.49]). Critically, this alignment was reproduced on an independent dataset (pancreas scIB; r = 0.40, z = 14.1 sigma, p < 0.001), demonstrating that the observed correspondence is not dataset-specific but reflects a stable property of the learned representation.
18
-
19
- **Granularity-dependent alignment.** We observed a striking granularity effect: alignment was absent at the fine-grained Reactome pathway level (491 pathways; r = 0.001, p > 0.5), moderate at the Reactome top-level category level (17 categories; r = 0.27, 95% CI: [0.06, 0.68]), and significant at the Hallmarks level (50 pathways; r = 0.16, p = 0.03). This pattern is biologically expected because fine-grained Reactome pathways share extensive gene overlap, making their gene-composition similarity orthogonal to their name similarity. At coarser granularity, pathway names better reflect the functional distinctions that the model has learned.
20
-
21
- **Category-specific alignment patterns.** Per-hallmark alignment analysis revealed that 38 of 50 hallmarks (76%) showed positive alignment scores (Fig. Xc). Grouping by biological process category revealed significant between-category differences (Kruskal-Wallis H = 20.8, p = 0.004; Fig. Xd). Signaling pathways (median r = 0.33) and proliferation-related pathways (median r = 0.32) showed the strongest alignment, while metabolic (median r = -0.05) and developmental (median r = -0.04) pathways showed weak or negative alignment. This hierarchy suggests that the model most faithfully encodes functional relationships for pathways with coherent, non-overlapping gene programs.
22
-
23
- **Nearest-neighbor overlap.** As a complementary metric, we computed k-nearest-neighbor overlap (k = 5) between the two spaces. The mean overlap was 0.20, significantly exceeding the permutation null of 0.10 (z = 4.5 sigma, p < 0.001; Extended Data Fig. Xc).
24
-
25
- **Informative negative results.** The five least-aligned hallmarks (Apoptosis, r = -0.39; Inflammatory Response, r = -0.27; EMT, r = -0.26; Fatty Acid Metabolism, r = -0.26; Spermatogenesis, r = -0.14) provide biological insight. For example, Apoptosis is embedded near Apical Surface and Bile Acid Metabolism in the model space, reflecting shared gene composition, but near Adipogenesis and Angiogenesis in text space, reflecting name-level semantics. This divergence shows that the model encodes functional gene-composition relationships rather than superficial naming conventions, which is desirable for a biological foundation model.
26
-
27
- **Independence from module size.** Alignment scores showed no correlation with the number of genes per module (Spearman r = -0.17, p = 0.23; Extended Data Fig. Xa), ruling out the trivial explanation that larger modules produce more stable embeddings and thus better alignment.
28
-
29
- ---
30
-
31
- ## Main Figure Caption
32
-
33
- ### Figure X. Module embedding space aligns with pathway semantic structure.
34
-
35
- **(a)** Representational Similarity Analysis (RSA) between module embedding distances and pathway text embedding distances for 541 pathway-based modules. Left: 2D hexbin density plot showing the correlation between pairwise cosine distances in module space (x-axis) and text space (y-axis). Spearman r = 0.41, p < 0.001 (Mantel test, 1,000 permutations). Right: null distribution from permutation test (gray histogram) with observed correlation (red dashed line) and 95% bootstrap confidence interval (red shaded region). z = 14.8 sigma.
36
-
37
- **(b)** Rank-transformed cosine similarity matrices for the 50 MSigDB Hallmark modules, ordered by hierarchical clustering (Ward's method on module embeddings). Left: module embedding similarity. Middle: text embedding similarity. Right: difference (module - text) in rank space. Color bar above indicates MSigDB biological process category. Rank transformation converts the narrow raw cosine similarity range ([0.988, 1.000] for module; [0.76, 1.00] for text) to a uniform [0, 1] scale, revealing block-diagonal structure corresponding to functionally related pathway clusters.
38
-
39
- **(c)** Per-hallmark alignment scores (Spearman correlation between each hallmark's similarity profile in module space vs. text space), displayed as a lollipop chart sorted by score. Colors indicate biological process category. 38/50 hallmarks (76%) show positive alignment. Mean alignment = 0.12 (95% CI: [0.07, 0.18]).
40
-
41
- **(d)** Alignment scores grouped by MSigDB biological process category. Box-and-strip plot with individual hallmark scores overlaid. Kruskal-Wallis H = 20.8, p = 0.004. Signaling and proliferation categories show significantly stronger alignment than metabolic and developmental categories.
42
-
43
- ---
44
-
45
- ## Extended Data Figure Caption
46
-
47
- ### Extended Data Figure X. Supplementary module-text alignment analyses.
48
-
49
- **(a)** Alignment score vs. number of genes per module. Spearman r = -0.17, p = 0.23, indicating no dependence on module size.
50
-
51
- **(b)** RSA as a function of pathway granularity. Bar chart showing Spearman r at four levels: fine-grained Reactome (n = 491), Reactome top-level categories (n = 17), MSigDB Hallmarks (n = 50), and Reactome super-categories (n = 6). Error bars: 95% bootstrap CI. Alignment peaks at intermediate granularity where pathway names best reflect functional distinctions.
52
-
53
- **(c)** Negative result analysis: top-5 and bottom-5 aligned hallmarks with their 5 nearest neighbors in module space vs. text space. Divergent neighbors show that the model encodes gene-composition similarity rather than name-level semantics.
54
-
55
- ---
56
-
57
- ## Key Statistics Summary
58
-
59
- | Metric | Value | 95% CI | p-value | Note |
60
- | --- | --- | --- | --- | --- |
61
- | RSA (all 541, primary) | 0.41 | [0.32, 0.49] | < 0.001 | z = 14.8 sigma |
62
- | RSA (all 541, secondary/pancreas) | 0.40 | [0.32, 0.48] | < 0.001 | z = 14.1 sigma |
63
- | RSA (Hallmarks 50) | 0.16 | [0.05, 0.39] | 0.026 | - |
64
- | RSA (Reactome fine 491) | 0.001 | [-0.04, 0.06] | > 0.5 | Expected null |
65
- | RSA (Reactome category 17) | 0.27 | [0.06, 0.68] | - | Wide CI due to n=17 |
66
- | kNN overlap (k=5) | 0.20 | [0.16, 0.24] | < 0.001 | null = 0.10, z = 4.5 sigma |
67
- | Per-hallmark positive rate | 38/50 (76%) | - | - | - |
68
- | Mean per-hallmark alignment | 0.12 | [0.07, 0.18] | - | - |
69
- | Category effect (Kruskal-Wallis) | H = 20.8 | - | 0.004 | 8 categories |
70
- | Alignment vs module size | r = -0.17 | - | 0.23 | No correlation |
71
-
72
- ### Top-5 Best-Aligned Hallmarks
73
-
74
- | Hallmark | Alignment r | Category |
75
- | --- | --- | --- |
76
- | G2M Checkpoint | 0.47 | proliferation |
77
- | KRAS Signaling Up | 0.46 | signaling |
78
- | KRAS Signaling Dn | 0.42 | signaling |
79
- | MYC Targets V2 | 0.42 | proliferation |
80
- | mTORC1 Signaling | 0.35 | signaling |
81
-
82
- ### Bottom-5 Worst-Aligned Hallmarks
83
-
84
- | Hallmark | Alignment r | Module NN | Text NN |
85
- | --- | --- | --- | --- |
86
- | Apoptosis | -0.39 | Apical Surface, Bile Acid, Cholesterol, Coagulation | Adipogenesis, Angiogenesis, Spermatogenesis |
87
- | Inflammatory Response | -0.27 | Hypoxia, KRAS Up, KRAS Dn, MYC V1 | Androgen, IFN gamma, IFN alpha, UPR, TNF alpha |
88
- | EMT | -0.26 | G2M, Fatty Acid, Estrogen Late, E2F | Apical Junction, Inflammatory, Estrogen Early |
89
- | Fatty Acid Metabolism | -0.26 | Estrogen Late, G2M, Hypoxia, Hedgehog | Bile Acid, Heme, Glycolysis, Xenobiotic |
90
- | Spermatogenesis | -0.14 | UV Dn, Myogenesis, Notch, Xenobiotic | Adipogenesis, Apoptosis, Peroxisome |
91
-
92
- ---
93
-
94
- ## Methods Subsection Draft
95
-
96
- ### Module embedding-pathway text alignment (RSA)
97
-
98
- To quantify the structural correspondence between scLong's module embedding space and pathway semantic space, we performed Representational Similarity Analysis (RSA) with a Mantel permutation test.
99
-
100
- **Text encoding.** For each of the 541 pathway-based modules (50 MSigDB Hallmarks and 491 Reactome pathways), we constructed a text description consisting of the pathway name and, where available, the Reactome summaryText (obtained via the Reactome Content Service API; 486/491 pathways had descriptions). Texts were encoded using S-PubMedBert-MS-MARCO (PubMedBERT fine-tuned on MS-MARCO for sentence-level semantic similarity), producing 768-dimensional text embeddings.
101
-
102
- **Module embeddings.** Cell-averaged module embeddings (1,024-dimensional) were obtained from the module geometry analysis by running the pretrained scLong encoder with mask_ratio = 0 on 50,000 cells from the pretraining corpus, then averaging across cells.
103
-
104
- **RSA and Mantel test.** We computed pairwise cosine distance matrices for both the module embeddings (541 x 541) and text embeddings (541 x 541), then measured their Spearman rank correlation. Statistical significance was assessed via a Mantel permutation test: we shuffled the row/column assignment of one distance matrix 1,000 times to construct a null distribution of correlations under the hypothesis of no structural correspondence. The p-value was computed as the fraction of permuted correlations exceeding the observed value. Effect size was reported as z = (observed - null_mean) / null_std.
105
-
106
- **Bootstrap confidence intervals.** We computed 95% confidence intervals for RSA by resampling modules with replacement (2,000 iterations) and recomputing the Spearman correlation on each bootstrap sample.
107
-
108
- **Per-hallmark alignment scores.** For each of the 50 Hallmark modules, we computed the Spearman correlation between its similarity profile in module space (cosine similarities to all other 49 Hallmarks) and its similarity profile in text space. This yields a per-pathway alignment score ranging from -1, anti-aligned, to +1, perfectly aligned.
109
-
110
- **kNN overlap.** For each module, we identified its k = 5 nearest neighbors in both spaces and computed the fraction of shared neighbors. The mean overlap was compared to a permutation null with 1,000 shuffles.
111
-
112
- **Cross-dataset validation.** We repeated the full RSA analysis using module embeddings derived from an independent dataset (pancreas scIB, about 15,000 cells) to assess generalizability.
113
-
114
- **Granularity analysis.** We computed RSA at four levels of pathway granularity: individual Reactome pathways (n = 491), Reactome top-level categories (n = 17, obtained by mapping each pathway to its root ancestor in the Reactome hierarchy), MSigDB Hallmarks (n = 50), and Reactome super-categories (n = 6, by merging related top-level categories). For category-level analysis, module embeddings and text embeddings were averaged within each category before computing pairwise distances.
115
-
116
- ---
117
-
118
- ## Manuscript Narrative Logic
119
-
120
- ### Core Claim
121
-
122
- The scLong module embedding space spontaneously aligns with pathway semantic
123
- space. This is a unique advantage of module-level design:
124
-
125
- 1. **Gene-level models cannot reproduce it** because single gene names lack phrase-level semantics and cannot support RSA with pathway descriptions.
126
- 2. **The alignment is learned**; combined with the init/shuffle controls from Experiment 1, it supports a pretraining-derived signal.
127
- 3. **Alignment appears at the right granularity**, matching biological expectation. Too fine a level is dominated by gene overlap; too coarse a level has too few samples.
128
- 4. **Negative results are informative**, because non-aligned pathways show that the model encodes function rather than names.
129
- 5. **Cross-dataset consistency** rules out dataset-specific artifacts.
130
-
131
- ### Connection to Experiment 1
132
-
133
- Experiment 1 shows that the module space self-organizes by pathway family, which
134
- is geometric structure. Experiment 2 further shows that this structure aligns
135
- with a human-readable semantic space, providing semantic interpretability. The
136
- two experiments jointly support intrinsic biological interpretability in scLong
137
- module representations.
138
-
139
- ### Differentiation from Baselines
140
-
141
- > Gene-level foundation models (scGPT, Geneformer, scFoundation) embed individual genes,
142
- > whose names are arbitrary identifiers such as `BRCA1` and `TP53` and lack compositional semantics.
143
- > Consequently, the RSA framework applied here, which requires phrase-level text descriptions
144
- > to construct a meaningful semantic distance matrix, is structurally inapplicable to gene-level
145
- > models. This represents a qualitative interpretability advantage of the module-level design.
146
-
147
- ### How to Use Negative Results in the Main Text
148
-
149
- Negative results, such as metabolic/development pathways not aligning, are not a
150
- weakness but evidence:
151
-
152
- - The model encodes **functional similarity in gene composition**, not linguistic name similarity.
153
- - This is exactly what a strong biological model should do.
154
- - If the model only learned name similarity, the result would be trivial.
155
-
156
- ---
157
-
158
- ## Figure File Mapping
159
-
160
- ### Main Figure
161
-
162
- | Panel | File | Content |
163
- | --- | --- | --- |
164
- | (a) | `fig1_rsa_overview.pdf` | Hexbin density plot and Mantel null distribution |
165
- | (b) | `fig2_hallmarks_heatmaps.pdf` | Rank-transformed three-panel heatmap and dendrogram |
166
- | (c) | `fig3_per_hallmark_alignment.pdf` | Lollipop chart colored by category |
167
- | (d) | `fig4_category_alignment.pdf` | Box + strip plot with Kruskal-Wallis test |
168
-
169
- ### Extended Data
170
-
171
- | Panel | File | Content |
172
- | --- | --- | --- |
173
- | (a) | `fig_alignment_vs_size.pdf` | Alignment vs module gene count |
174
- | (b) | `fig5_granularity_effect.pdf` | RSA across four granularity levels |
175
- | (c) | `negative_analysis.csv` | Neighbor comparison table |
176
- | Table | `hallmarks_alignment_table.csv` | Full ranking for 50 Hallmarks |
177
- | Metrics | `publication_metrics.json` | Machine-readable summary |
178
-
179
- ---
180
-
181
- ## Category Median Details for Main Text
182
-
183
- | Category | n | Median r | Interpretation |
184
- | --- | --- | --- | --- |
185
- | Signaling | 11 | 0.333 | Pathway names and functions are highly consistent |
186
- | Proliferation | 6 | 0.323 | Proliferation-related modules have clear semantics |
187
- | DNA Damage | 3 | 0.215 | Good |
188
- | Pathway | 5 | 0.182 | Moderate |
189
- | Cellular Component | 3 | 0.170 | Moderate |
190
- | Immune | 9 | 0.121 | Moderately low |
191
- | Development | 5 | -0.041 | Not aligned |
192
- | Metabolic | 8 | -0.052 | Not aligned |
193
-
194
- ---
195
-
196
- ## kNN Overlap by Category
197
-
198
- | Category | Mean Overlap | Interpretation |
199
- | --- | --- | --- |
200
- | Cellular Component | 0.40 | Highest; cellular-component modules share neighbors across both spaces |
201
- | Immune | 0.29 | High; immune pathways cluster stably |
202
- | Metabolic | 0.20 | Moderate |
203
- | Proliferation | 0.17 | Moderate |
204
- | Signaling | 0.16 | Moderate |
205
- | Development | 0.16 | Moderate |
206
- | DNA Damage | 0.13 | Low |
207
- | Pathway | 0.12 | Low |
208
- | **Null (random)** | **0.10** | Baseline |
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
BioMod/interpretability/biomod_text_alignment/__init__.py DELETED
File without changes
BioMod/interpretability/biomod_text_alignment/biomod_text_alignment.py DELETED
@@ -1,665 +0,0 @@
1
- """Experiment 2 β€” Module embedding <-> pathway text representation alignment.
2
-
3
- Question
4
- --------
5
- Do scLong's module embeddings align with the natural-language semantics of
6
- their corresponding pathway names/descriptions, If yes, this demonstrates
7
- that the model has learned biologically meaningful representations at the
8
- module level β€” something gene-level baselines cannot replicate because
9
- individual gene names lack phrase-level semantics.
10
-
11
- Pipeline
12
- --------
13
- 1. Parse pathway names from merged_biomods.json keys for Hallmarks (50) and
14
- Reactome (491) modules. Hotspot/Processed are excluded (no semantic names).
15
- 2. Encode pathway names with a biomedical sentence encoder.
16
- 3. Load cell-averaged module embeddings from experiment 1 output.
17
- 4. Measure alignment:
18
- - RSA (Representational Similarity Analysis) with Mantel test
19
- - CKA (Centered Kernel Alignment) with permutation test
20
- - Ridge regression linear mapping (LOO-CV) for retrieval
21
- 5. Visualize: RSA scatter, similarity distribution, retrieval examples.
22
-
23
- Data requirement
24
- ----------------
25
- Only needs module_embeddings.npy from experiment 1 (any dataset). No raw
26
- expression data needed β€” this experiment operates entirely in embedding space.
27
-
28
- Sentence encoder
29
- ----------------
30
- Default: pritamdeka/S-PubMedBert-MS-MARCO (a PubMedBERT fine-tuned for
31
- sentence similarity via MS-MARCO). Unlike raw BioLinkBERT (which is just a
32
- masked LM without sentence-level training), this model produces embeddings
33
- optimized for semantic similarity between sentences.
34
-
35
- Alternative: sentence-transformers/all-mpnet-base-v2 (general-domain, strong).
36
- """
37
- from __future__ import annotations
38
-
39
- import argparse
40
- import json
41
- import logging
42
- import sys
43
- from pathlib import Path
44
- from typing import Dict, List, Optional, Tuple
45
-
46
- import numpy as np
47
- import pandas as pd
48
- from scipy.spatial.distance import cosine as cosine_dist
49
- from scipy.spatial.distance import pdist, squareform
50
- from scipy.stats import spearmanr, pearsonr
51
- from sklearn.linear_model import RidgeCV
52
- from sklearn.metrics.pairwise import cosine_similarity
53
- from sklearn.preprocessing import StandardScaler
54
-
55
- import matplotlib
56
- matplotlib.use("Agg")
57
- import matplotlib.pyplot as plt
58
-
59
- HERE = Path(__file__).resolve().parent
60
- INTERP_DIR = HERE.parent
61
- BIOMOD_SOURCE = INTERP_DIR.parent
62
- REPO_ROOT = BIOMOD_SOURCE.parent
63
- for p in (str(INTERP_DIR), str(BIOMOD_SOURCE), str(REPO_ROOT)):
64
- if p not in sys.path:
65
- sys.path.insert(0, p)
66
-
67
- from interpretability.utils import (
68
- MODULE_FAMILY_MAP,
69
- load_biomod_names,
70
- parse_biomod_family,
71
- )
72
-
73
- logger = logging.getLogger(__name__)
74
-
75
-
76
- # ============================================================
77
- # 1. Pathway name parsing
78
- # ============================================================
79
-
80
- def parse_pathway_text(module_name: str) -> Optional[str]:
81
- """Extract a human-readable pathway name from a module key."""
82
- family = parse_biomod_family(module_name)
83
- if family == "Hallmarks":
84
- raw = module_name.split("__", 1)[1]
85
- raw = raw.replace("HALLMARK_", "", 1)
86
- return raw.replace("_", " ").title()
87
- elif family == "Reactome":
88
- raw = module_name.split("__", 1)[1]
89
- parts = raw.rsplit(" R-HSA-", 1)
90
- return parts[0].strip()
91
- return None
92
-
93
-
94
- def build_text_descriptions(
95
- module_names: List[str],
96
- descriptions_file: Optional[str] = None,
97
- ) -> Tuple[List[int], List[str], List[str]]:
98
- """Build text descriptions for modules that have semantic names.
99
-
100
- Returns: (indices, names, texts) β€” only for Hallmarks + Reactome modules.
101
- """
102
- ext_descriptions = {}
103
- if descriptions_file and Path(descriptions_file).exists():
104
- with open(descriptions_file, "r", encoding="utf-8") as f:
105
- ext_descriptions = json.load(f)
106
-
107
- indices = []
108
- names = []
109
- texts = []
110
- for i, pname in enumerate(module_names):
111
- text = parse_pathway_text(pname)
112
- if text is None:
113
- continue
114
- indices.append(i)
115
- names.append(pname)
116
- if pname in ext_descriptions:
117
- texts.append(f"{text}: {ext_descriptions[pname]}")
118
- else:
119
- texts.append(text)
120
- return indices, names, texts
121
-
122
-
123
- # ============================================================
124
- # 2. Text encoding
125
- # ============================================================
126
-
127
- def encode_texts(
128
- texts: List[str],
129
- model_name: str = "pritamdeka/S-PubMedBert-MS-MARCO",
130
- batch_size: int = 32,
131
- device: str = "cpu",
132
- ) -> np.ndarray:
133
- """Encode texts using a sentence-transformers model."""
134
- try:
135
- from sentence_transformers import SentenceTransformer
136
- except ImportError:
137
- raise ImportError(
138
- "sentence-transformers required. Install: pip install sentence-transformers"
139
- )
140
-
141
- logger.info("Loading sentence encoder: %s", model_name)
142
- model = SentenceTransformer(model_name, device=device)
143
- logger.info("Encoding %d texts...", len(texts))
144
- embeddings = model.encode(
145
- texts, batch_size=batch_size, show_progress_bar=True,
146
- normalize_embeddings=True,
147
- )
148
- return np.asarray(embeddings, dtype=np.float32)
149
-
150
-
151
- # ============================================================
152
- # 3. RSA (Representational Similarity Analysis)
153
- # ============================================================
154
-
155
- def compute_rsa(
156
- module_emb: np.ndarray,
157
- text_emb: np.ndarray,
158
- metric: str = "cosine",
159
- ) -> Tuple[float, float, float, float]:
160
- """Representational Similarity Analysis.
161
-
162
- Computes pairwise distance matrices in both spaces, then correlates them.
163
- This tests: "are modules that are close in model space also close in text space, "
164
-
165
- Returns: (spearman_r, spearman_p, pearson_r, pearson_p)
166
- """
167
- dist_module = pdist(module_emb, metric=metric)
168
- dist_text = pdist(text_emb, metric=metric)
169
-
170
- sp_r, sp_p = spearmanr(dist_module, dist_text)
171
- pe_r, pe_p = pearsonr(dist_module, dist_text)
172
-
173
- return float(sp_r), float(sp_p), float(pe_r), float(pe_p)
174
-
175
-
176
- def mantel_test(
177
- module_emb: np.ndarray,
178
- text_emb: np.ndarray,
179
- metric: str = "cosine",
180
- n_perm: int = 1000,
181
- seed: int = 42,
182
- ) -> Dict[str, float]:
183
- """Mantel test: permutation-based significance for RSA.
184
-
185
- Shuffles rows of one matrix and recomputes the distance-matrix correlation.
186
- More robust than parametric p-values for distance correlations.
187
- """
188
- dist_module = pdist(module_emb, metric=metric)
189
- dist_text = pdist(text_emb, metric=metric)
190
-
191
- observed_r, _ = spearmanr(dist_module, dist_text)
192
-
193
- rng = np.random.default_rng(seed)
194
- null_rs = np.zeros(n_perm)
195
- n = module_emb.shape[0]
196
- for k in range(n_perm):
197
- perm = rng.permutation(n)
198
- dist_text_perm = pdist(text_emb[perm], metric=metric)
199
- null_rs[k], _ = spearmanr(dist_module, dist_text_perm)
200
-
201
- p_value = float(np.mean(null_rs >= observed_r))
202
- return {
203
- "rsa_spearman_observed": float(observed_r),
204
- "rsa_null_mean": float(null_rs.mean()),
205
- "rsa_null_std": float(null_rs.std()),
206
- "rsa_p_value": p_value,
207
- "rsa_effect_size_z": float((observed_r - null_rs.mean()) / max(null_rs.std(), 1e-10)),
208
- }
209
-
210
-
211
- def compute_rsa_by_family(
212
- module_emb: np.ndarray,
213
- text_emb: np.ndarray,
214
- names: List[str],
215
- metric: str = "cosine",
216
- n_perm: int = 500,
217
- seed: int = 42,
218
- ) -> Dict[str, Dict[str, float]]:
219
- """RSA computed separately for each module family (Hallmarks vs Reactome)."""
220
- results = {}
221
- families = [parse_biomod_family(n) for n in names]
222
- for fam in ["Hallmarks", "Reactome"]:
223
- mask = np.array([f == fam for f in families])
224
- if mask.sum() < 10:
225
- continue
226
- sub_module = module_emb[mask]
227
- sub_text = text_emb[mask]
228
- mantel = mantel_test(sub_module, sub_text, metric=metric,
229
- n_perm=n_perm, seed=seed)
230
- results[fam] = mantel
231
- return results
232
-
233
-
234
- # ============================================================
235
- # 4. CKA
236
- # ============================================================
237
-
238
- def compute_cka(X: np.ndarray, Y: np.ndarray) -> float:
239
- """Centered Kernel Alignment (linear kernel)."""
240
- n = X.shape[0]
241
- H = np.eye(n) - np.ones((n, n)) / n
242
- Kx = H @ (X @ X.T) @ H
243
- Ky = H @ (Y @ Y.T) @ H
244
- hsic = np.sum(Kx * Ky)
245
- norm = np.sqrt(np.sum(Kx * Kx) * np.sum(Ky * Ky))
246
- if norm < 1e-10:
247
- return 0.0
248
- return float(hsic / norm)
249
-
250
-
251
- def permutation_test_cka(
252
- module_emb: np.ndarray,
253
- text_emb: np.ndarray,
254
- n_perm: int = 1000,
255
- seed: int = 42,
256
- ) -> Dict[str, float]:
257
- """CKA with permutation null distribution."""
258
- observed = compute_cka(module_emb, text_emb)
259
- rng = np.random.default_rng(seed)
260
- null_values = np.zeros(n_perm)
261
- for k in range(n_perm):
262
- perm = rng.permutation(text_emb.shape[0])
263
- null_values[k] = compute_cka(module_emb, text_emb[perm])
264
- p_value = float(np.mean(null_values >= observed))
265
- return {
266
- "cka_observed": observed,
267
- "cka_null_mean": float(null_values.mean()),
268
- "cka_null_std": float(null_values.std()),
269
- "cka_p_value": p_value,
270
- }
271
-
272
-
273
- # ============================================================
274
- # 5. Retrieval (Ridge LOO)
275
- # ============================================================
276
-
277
- def compute_retrieval_metrics(
278
- module_emb: np.ndarray,
279
- text_emb: np.ndarray,
280
- ks: List[int] = [1, 5, 10, 20],
281
- ) -> Tuple[Dict[str, float], np.ndarray]:
282
- """Cross-modal retrieval using Ridge LOO projection."""
283
- n = module_emb.shape[0]
284
- scaler_x = StandardScaler()
285
- scaler_y = StandardScaler()
286
- X_scaled = scaler_x.fit_transform(module_emb)
287
- Y_scaled = scaler_y.fit_transform(text_emb)
288
-
289
- ranks = np.zeros(n, dtype=np.int64)
290
- for i in range(n):
291
- train_mask = np.ones(n, dtype=bool)
292
- train_mask[i] = False
293
- ridge = RidgeCV(alphas=np.logspace(-2, 4, 7))
294
- ridge.fit(X_scaled[train_mask], Y_scaled[train_mask])
295
- y_pred = ridge.predict(X_scaled[i:i+1])
296
- sims = cosine_similarity(y_pred, Y_scaled)[0]
297
- sorted_idx = np.argsort(-sims)
298
- ranks[i] = int(np.where(sorted_idx == i)[0][0])
299
-
300
- metrics = {}
301
- for k in ks:
302
- metrics[f"recall@{k}"] = float(np.mean(ranks < k))
303
- metrics["mrr"] = float(np.mean(1.0 / (ranks + 1)))
304
- metrics["median_rank"] = int(np.median(ranks))
305
- metrics["mean_rank"] = float(np.mean(ranks))
306
- return metrics, ranks
307
-
308
-
309
- # ============================================================
310
- # 6. Visualization
311
- # ============================================================
312
-
313
- def plot_rsa_scatter(
314
- module_emb: np.ndarray,
315
- text_emb: np.ndarray,
316
- rsa_r: float,
317
- rsa_p: float,
318
- output_path: str,
319
- metric: str = "cosine",
320
- max_pairs: int = 5000,
321
- ):
322
- """Scatter plot of pairwise distances: module space vs text space."""
323
- dist_module = pdist(module_emb, metric=metric)
324
- dist_text = pdist(text_emb, metric=metric)
325
-
326
- if len(dist_module) > max_pairs:
327
- rng = np.random.default_rng(0)
328
- idx = rng.choice(len(dist_module), size=max_pairs, replace=False)
329
- dist_module = dist_module[idx]
330
- dist_text = dist_text[idx]
331
-
332
- fig, ax = plt.subplots(figsize=(7, 6))
333
- ax.scatter(dist_text, dist_module, alpha=0.1, s=3, color="steelblue")
334
- ax.set_xlabel("Text pairwise distance (cosine)")
335
- ax.set_ylabel("Module embedding pairwise distance (cosine)")
336
- ax.set_title(f"RSA: Spearman r = {rsa_r:.4f} (p = {rsa_p:.4f})")
337
-
338
- z = np.polyfit(dist_text, dist_module, 1)
339
- p_line = np.poly1d(z)
340
- x_range = np.linspace(dist_text.min(), dist_text.max(), 100)
341
- ax.plot(x_range, p_line(x_range), "r-", linewidth=2, alpha=0.8)
342
-
343
- plt.tight_layout()
344
- plt.savefig(output_path, dpi=150, bbox_inches="tight")
345
- plt.close()
346
- logger.info("Saved: %s", output_path)
347
-
348
-
349
- def plot_retrieval_rank_distribution(
350
- ranks: np.ndarray,
351
- n_total: int,
352
- output_path: str,
353
- ):
354
- """Histogram of retrieval ranks."""
355
- fig, ax = plt.subplots(figsize=(8, 5))
356
- ax.hist(ranks, bins=50, alpha=0.7, color="darkorange", edgecolor="white")
357
- ax.axvline(n_total / 2, color="gray", linestyle="--", linewidth=1.5,
358
- label=f"Random baseline = {n_total//2}")
359
- ax.axvline(np.median(ranks), color="blue", linestyle="-", linewidth=1.5,
360
- label=f"Median = {int(np.median(ranks))}")
361
- ax.set_xlabel("Retrieval Rank (0 = perfect)")
362
- ax.set_ylabel("Count")
363
- ax.set_title("Cross-Modal Retrieval: Rank Distribution (LOO Ridge)")
364
- ax.legend()
365
- plt.tight_layout()
366
- plt.savefig(output_path, dpi=150, bbox_inches="tight")
367
- plt.close()
368
- logger.info("Saved: %s", output_path)
369
-
370
-
371
- def plot_rsa_null_distribution(
372
- observed: float,
373
- null_values_mean: float,
374
- null_values_std: float,
375
- p_value: float,
376
- output_path: str,
377
- ):
378
- """Plot observed RSA vs null distribution."""
379
- fig, ax = plt.subplots(figsize=(7, 5))
380
- x = np.linspace(null_values_mean - 4*null_values_std,
381
- max(observed + null_values_std, null_values_mean + 4*null_values_std), 200)
382
- from scipy.stats import norm as norm_dist
383
- y = norm_dist.pdf(x, null_values_mean, null_values_std)
384
- ax.fill_between(x, y, alpha=0.3, color="gray", label="Null distribution")
385
- ax.plot(x, y, color="gray", linewidth=1.5)
386
- ax.axvline(observed, color="red", linewidth=2.5, linestyle="-",
387
- label=f"Observed = {observed:.4f}")
388
- ax.set_xlabel("Spearman r (distance matrix correlation)")
389
- ax.set_ylabel("Density")
390
- ax.set_title(f"Mantel Test: p = {p_value:.4f}")
391
- ax.legend()
392
- plt.tight_layout()
393
- plt.savefig(output_path, dpi=150, bbox_inches="tight")
394
- plt.close()
395
- logger.info("Saved: %s", output_path)
396
-
397
-
398
- def save_retrieval_examples(
399
- module_emb: np.ndarray,
400
- text_emb: np.ndarray,
401
- texts: List[str],
402
- names: List[str],
403
- output_path: str,
404
- top_k: int = 5,
405
- query_indices: Optional[List[int]] = None,
406
- ):
407
- """Save retrieval examples for selected modules."""
408
- n = module_emb.shape[0]
409
- scaler_x = StandardScaler()
410
- scaler_y = StandardScaler()
411
- X = scaler_x.fit_transform(module_emb)
412
- Y = scaler_y.fit_transform(text_emb)
413
-
414
- ridge = RidgeCV(alphas=np.logspace(-2, 4, 7))
415
- ridge.fit(X, Y)
416
-
417
- if query_indices is None:
418
- rng = np.random.default_rng(0)
419
- query_indices = sorted(rng.choice(n, size=min(20, n), replace=False))
420
-
421
- rows = []
422
- for qi in query_indices:
423
- y_pred = ridge.predict(X[qi:qi+1])
424
- sims = cosine_similarity(y_pred, Y)[0]
425
- top_idx = np.argsort(-sims)[:top_k]
426
- for rank, ti in enumerate(top_idx):
427
- rows.append({
428
- "query_module": names[qi],
429
- "query_text": texts[qi],
430
- "retrieved_rank": rank + 1,
431
- "retrieved_text": texts[ti],
432
- "similarity": float(sims[ti]),
433
- "is_self": ti == qi,
434
- })
435
-
436
- df = pd.DataFrame(rows)
437
- df.to_csv(output_path, index=False)
438
- logger.info("Saved retrieval examples: %s", output_path)
439
-
440
-
441
- # ============================================================
442
- # 7. Main
443
- # ============================================================
444
-
445
- def parse_args() -> argparse.Namespace:
446
- parser = argparse.ArgumentParser(
447
- description=__doc__,
448
- formatter_class=argparse.RawDescriptionHelpFormatter,
449
- )
450
- parser.add_argument("--biomod_embedding_path", "--module_embedding_path", type=str, required=True,
451
- help="Path to module_embeddings.npy from experiment 1.", dest='module_embedding_path')
452
- parser.add_argument("--biomod_json", "--module_json", type=str,
453
- default=str(REPO_ROOT / "model_dependencies" / "biomod_data_all" / "merged_biomods.json"),
454
- help="Path to merged_biomods.json.", dest='module_json')
455
- parser.add_argument("--descriptions_file", type=str, default=None,
456
- help="Optional JSON with extended pathway descriptions.")
457
- parser.add_argument("--sentence_model", type=str,
458
- default="pritamdeka/S-PubMedBert-MS-MARCO",
459
- help="HuggingFace sentence-transformers model.")
460
- parser.add_argument("--device", type=str, default="cpu",
461
- help="Device for sentence encoder.")
462
- parser.add_argument("--n_permutations", type=int, default=1000)
463
- parser.add_argument("--seed", type=int, default=42)
464
- parser.add_argument("--output_dir", type=str, required=True)
465
- parser.add_argument("--skip_loo", action="store_true",
466
- help="Skip LOO retrieval (slow). Use RSA/CKA only.")
467
- parser.add_argument("--rsa_metric", type=str, default="cosine",
468
- choices=["cosine", "euclidean"],
469
- help="Distance metric for RSA.")
470
- return parser.parse_args()
471
-
472
-
473
- def main():
474
- args = parse_args()
475
-
476
- output_dir = Path(args.output_dir)
477
- output_dir.mkdir(parents=True, exist_ok=True)
478
-
479
- logging.basicConfig(
480
- level=logging.INFO,
481
- format="%(asctime)s [%(levelname)s] %(message)s",
482
- handlers=[
483
- logging.StreamHandler(),
484
- logging.FileHandler(output_dir / "run.log", mode="w"),
485
- ],
486
- )
487
-
488
- # --- Load module names and embeddings ---
489
- logger.info("Loading module names from %s", args.module_json)
490
- module_names = load_biomod_names(args.module_json)
491
- logger.info("Total modules: %d", len(module_names))
492
-
493
- logger.info("Loading module embeddings from %s", args.module_embedding_path)
494
- all_embeddings = np.load(args.module_embedding_path)
495
- assert all_embeddings.shape[0] == len(module_names), (
496
- f"Embedding count {all_embeddings.shape[0]} != module count {len(module_names)}"
497
- )
498
-
499
- # --- Build text descriptions (Hallmarks + Reactome only) ---
500
- indices, names, texts = build_text_descriptions(
501
- module_names, args.descriptions_file
502
- )
503
- logger.info("Modules with text descriptions: %d / %d", len(indices), len(module_names))
504
-
505
- module_emb = all_embeddings[indices]
506
- logger.info("Module embedding shape: %s", module_emb.shape)
507
-
508
- # --- Encode texts ---
509
- text_emb = encode_texts(
510
- texts, model_name=args.sentence_model, device=args.device
511
- )
512
- logger.info("Text embedding shape: %s", text_emb.shape)
513
-
514
- # Save intermediate
515
- np.save(output_dir / "text_embeddings.npy", text_emb)
516
- np.save(output_dir / "module_embeddings_subset.npy", module_emb)
517
- with open(output_dir / "pathway_descriptions.json", "w", encoding="utf-8") as f:
518
- json.dump(
519
- [{"index": idx, "module_name": name, "text": text}
520
- for idx, name, text in zip(indices, names, texts)],
521
- f, indent=2, ensure_ascii=False,
522
- )
523
-
524
- # --- RSA / Mantel test (PRIMARY METRIC) ---
525
- logger.info("=" * 50)
526
- logger.info("RSA / Mantel Test (primary alignment metric)")
527
- logger.info("=" * 50)
528
-
529
- sp_r, sp_p, pe_r, pe_p = compute_rsa(module_emb, text_emb, metric=args.rsa_metric)
530
- logger.info("RSA (all modules): Spearman r=%.4f (p=%.2e), Pearson r=%.4f (p=%.2e)",
531
- sp_r, sp_p, pe_r, pe_p)
532
-
533
- mantel_results = mantel_test(
534
- module_emb, text_emb, metric=args.rsa_metric,
535
- n_perm=args.n_permutations, seed=args.seed,
536
- )
537
- logger.info("Mantel test: observed=%.4f, null=%.4f +/- %.4f, p=%.4f, z=%.1f",
538
- mantel_results["rsa_spearman_observed"],
539
- mantel_results["rsa_null_mean"],
540
- mantel_results["rsa_null_std"],
541
- mantel_results["rsa_p_value"],
542
- mantel_results["rsa_effect_size_z"])
543
-
544
- # RSA by family
545
- family_rsa = compute_rsa_by_family(
546
- module_emb, text_emb, names, metric=args.rsa_metric,
547
- n_perm=min(args.n_permutations, 500), seed=args.seed,
548
- )
549
- for fam, res in family_rsa.items():
550
- logger.info("RSA [%s]: r=%.4f, null=%.4f +/- %.4f, p=%.4f, z=%.1f",
551
- fam, res["rsa_spearman_observed"], res["rsa_null_mean"],
552
- res["rsa_null_std"], res["rsa_p_value"], res["rsa_effect_size_z"])
553
-
554
- # --- CKA ---
555
- logger.info("Computing CKA with %d permutations...", args.n_permutations)
556
- cka_results = permutation_test_cka(
557
- module_emb, text_emb, n_perm=args.n_permutations, seed=args.seed
558
- )
559
- logger.info("CKA: observed=%.4f, null=%.4f +/- %.4f, p=%.4f",
560
- cka_results["cka_observed"], cka_results["cka_null_mean"],
561
- cka_results["cka_null_std"], cka_results["cka_p_value"])
562
-
563
- # --- LOO retrieval (optional) ---
564
- retrieval_results = {}
565
- ranks = None
566
- if not args.skip_loo:
567
- logger.info("Computing LOO retrieval metrics (n=%d)...", len(indices))
568
- retrieval_results, ranks = compute_retrieval_metrics(module_emb, text_emb)
569
- logger.info("Retrieval: recall@1=%.3f, @5=%.3f, @10=%.3f, @20=%.3f, MRR=%.3f, median_rank=%d",
570
- retrieval_results.get("recall@1", 0),
571
- retrieval_results.get("recall@5", 0),
572
- retrieval_results.get("recall@10", 0),
573
- retrieval_results.get("recall@20", 0),
574
- retrieval_results.get("mrr", 0),
575
- retrieval_results.get("median_rank", 0))
576
-
577
- # --- Aggregate metrics ---
578
- metrics = {
579
- "n_modules_with_text": len(indices),
580
- "n_modules_total": len(module_names),
581
- "module_emb_dim": int(module_emb.shape[1]),
582
- "text_emb_dim": int(text_emb.shape[1]),
583
- "sentence_model": args.sentence_model,
584
- "rsa_metric": args.rsa_metric,
585
- "n_permutations": args.n_permutations,
586
- "rsa_spearman_r": sp_r,
587
- "rsa_spearman_p": sp_p,
588
- "rsa_pearson_r": pe_r,
589
- "rsa_pearson_p": pe_p,
590
- **mantel_results,
591
- "rsa_by_family": family_rsa,
592
- **cka_results,
593
- **retrieval_results,
594
- }
595
-
596
- with open(output_dir / "alignment_metrics.json", "w") as f:
597
- json.dump(metrics, f, indent=2)
598
- logger.info("Saved alignment_metrics.json")
599
-
600
- # --- Plots ---
601
- plot_rsa_scatter(
602
- module_emb, text_emb, rsa_r=sp_r, rsa_p=mantel_results["rsa_p_value"],
603
- output_path=str(output_dir / "rsa_scatter.png"),
604
- metric=args.rsa_metric,
605
- )
606
-
607
- plot_rsa_null_distribution(
608
- observed=mantel_results["rsa_spearman_observed"],
609
- null_values_mean=mantel_results["rsa_null_mean"],
610
- null_values_std=mantel_results["rsa_null_std"],
611
- p_value=mantel_results["rsa_p_value"],
612
- output_path=str(output_dir / "rsa_null_distribution.png"),
613
- )
614
-
615
- if ranks is not None:
616
- plot_retrieval_rank_distribution(
617
- ranks, n_total=len(indices),
618
- output_path=str(output_dir / "retrieval_ranks.png"),
619
- )
620
-
621
- # --- Retrieval examples ---
622
- representative_keywords = [
623
- "interferon", "cell cycle", "oxidative", "apoptosis",
624
- "immune", "signaling", "metabolism", "dna repair",
625
- "nfkb", "mtor", "wnt", "hedgehog", "notch",
626
- "interleukin", "toll", "mapk",
627
- ]
628
- query_indices = []
629
- for kw in representative_keywords:
630
- for i, t in enumerate(texts):
631
- if kw.lower() in t.lower() and i not in query_indices:
632
- query_indices.append(i)
633
- break
634
- if len(query_indices) < 10:
635
- rng = np.random.default_rng(args.seed)
636
- extra = rng.choice(len(texts), size=15, replace=False)
637
- query_indices = list(set(query_indices) | set(extra.tolist()))[:20]
638
-
639
- save_retrieval_examples(
640
- module_emb, text_emb, texts, names,
641
- output_path=str(output_dir / "retrieval_examples.csv"),
642
- query_indices=sorted(query_indices),
643
- )
644
-
645
- # --- Summary ---
646
- logger.info("=" * 60)
647
- logger.info("EXPERIMENT 2 SUMMARY")
648
- logger.info("=" * 60)
649
- logger.info("Modules with text: %d / %d", len(indices), len(module_names))
650
- logger.info("RSA (Mantel): Spearman r=%.4f, p=%.4f, z=%.1f sigma",
651
- mantel_results["rsa_spearman_observed"],
652
- mantel_results["rsa_p_value"],
653
- mantel_results["rsa_effect_size_z"])
654
- logger.info("CKA: %.4f (p=%.4f)", cka_results["cka_observed"], cka_results["cka_p_value"])
655
- if retrieval_results:
656
- logger.info("Retrieval: recall@1=%.3f, @5=%.3f, @10=%.3f, MRR=%.3f, median_rank=%d",
657
- retrieval_results["recall@1"], retrieval_results["recall@5"],
658
- retrieval_results["recall@10"], retrieval_results["mrr"],
659
- retrieval_results["median_rank"])
660
- logger.info("Output: %s", output_dir)
661
- logger.info("=" * 60)
662
-
663
-
664
- if __name__ == "__main__":
665
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
BioMod/interpretability/biomod_text_alignment/biomod_text_alignment_extended.py DELETED
@@ -1,537 +0,0 @@
1
- """Extended analysis for Experiment 2 β€” Module-Text Alignment.
2
-
3
- Three analyses:
4
- 1. Reactome category-level RSA (aggregate 491 pathways into ~16 top-level categories)
5
- 2. Reactome with official descriptions (via descriptions JSON)
6
- 3. Hallmarks deep dive (per-module alignment scores, heatmap)
7
-
8
- Usage (run from the BioMod repository root):
9
- python BioMod/interpretability/biomod_text_alignment/biomod_text_alignment_extended.py \
10
- --biomod_embedding_path outputs/interpretability/geometry/pretrain_subset/module_embeddings.npy \
11
- --biomod_json model_dependencies/biomod_data_all/merged_biomods.json \
12
- --sentence_model pritamdeka/S-PubMedBert-MS-MARCO \
13
- --output_dir outputs/interpretability/text_alignment/pretrain_subset_v2_extended
14
- """
15
- from __future__ import annotations
16
-
17
- import argparse
18
- import json
19
- import logging
20
- import sys
21
- from collections import Counter, defaultdict
22
- from pathlib import Path
23
- from typing import Dict, List, Optional, Tuple
24
-
25
- import numpy as np
26
- import pandas as pd
27
- from scipy.spatial.distance import pdist, squareform
28
- from scipy.stats import spearmanr
29
- from sklearn.linear_model import RidgeCV
30
- from sklearn.metrics.pairwise import cosine_similarity
31
- from sklearn.preprocessing import StandardScaler
32
-
33
- import matplotlib
34
- matplotlib.use("Agg")
35
- import matplotlib.pyplot as plt
36
-
37
- HERE = Path(__file__).resolve().parent
38
- INTERP_DIR = HERE.parent
39
- BIOMOD_SOURCE = INTERP_DIR.parent
40
- REPO_ROOT = BIOMOD_SOURCE.parent
41
- for p in (str(INTERP_DIR), str(BIOMOD_SOURCE), str(REPO_ROOT)):
42
- if p not in sys.path:
43
- sys.path.insert(0, p)
44
-
45
- from interpretability.utils import load_biomod_names, parse_biomod_family
46
- from biomod_text_alignment.biomod_text_alignment import (
47
- build_text_descriptions,
48
- compute_cka,
49
- compute_rsa,
50
- encode_texts,
51
- mantel_test,
52
- parse_pathway_text,
53
- )
54
-
55
- logger = logging.getLogger(__name__)
56
-
57
-
58
- # ============================================================
59
- # Reactome top-level category mapping
60
- # ============================================================
61
-
62
- REACTOME_CATEGORIES = {
63
- "Cell Cycle": ["cell cycle", "mitotic", "mitosis", "meiosis", "g1/s", "g2/m",
64
- "s phase", "m phase", "checkpoint", "cdc", "apc/c", "cyclin",
65
- "condensation of", "centrosome", "kinetochore", "spindle",
66
- "cohes", "pre-replicative", "separation of sister", "aurora"],
67
- "DNA Repair": ["dna repair", "base excision", "nucleotide excision",
68
- "mismatch repair", "homologous recombination", "non-homologous",
69
- "double-strand", "single-strand", "fanconi", "dna damage"],
70
- "DNA Replication": ["dna replication", "replication", "okazaki", "telomere"],
71
- "Signal Transduction": ["signaling", "signal transduction", "mapk", "ras",
72
- "raf", "mek", "erk", "pi3k", "akt", "mtor", "wnt",
73
- "notch", "hedgehog", "tgf-beta", "bmp", "fgfr",
74
- "egfr", "vegf", "pdgf", "igf", "insulin", "erbb",
75
- "met receptor", "receptor tyrosine", "gpcr",
76
- "g alpha", "rho gtpase", "rac1", "cdc42",
77
- "ca2+ pathway", "nfkb", "nf-kb", "nmda",
78
- "calcium", "pkc", "plc", "dag", "pip"],
79
- "Immune System": ["immune", "innate", "adaptive", "interferon", "interleukin",
80
- "cytokine", "complement", "toll", "antigen", "mhc", "tcr",
81
- "bcr", "fcr", "neutrophil", "phagocyt", "inflamm",
82
- "antimicrobial", "antiviral", "ifn-stimulated",
83
- "c-type lectin", "nod-like", "rig-i"],
84
- "Metabolism": ["metabolism", "metabolic", "glycolysis", "gluconeogenesis",
85
- "tca cycle", "citric acid", "oxidative phosphorylation",
86
- "fatty acid", "lipid", "cholesterol", "bile", "amino acid",
87
- "nucleotide", "purine", "pyrimidine", "pentose", "urea",
88
- "biological oxidation", "glycosylation", "biosynthesis",
89
- "synthesis of", "respiratory electron"],
90
- "Gene Expression": ["transcription", "translation", "mrna", "rrna", "trna",
91
- "splicing", "polyadenylation", "ribosom",
92
- "eukaryotic translation", "nonsense-mediated",
93
- "deadenylation", "rna polymerase", "processing of capped"],
94
- "Protein Homeostasis": ["ubiquitin", "proteasome", "sumo", "nedd",
95
- "autophagy", "chaperone", "unfolded protein",
96
- "protein folding", "hsp", "degradation of",
97
- "deubiquitin"],
98
- "Transport": ["transport", "vesicle", "endocyt", "exocyt", "golgi",
99
- "er to golgi", "membrane trafficking", "clathrin", "copi",
100
- "copii", "ion channel", "slc-mediated", "abc transporter",
101
- "nuclear pore", "nuclear import", "nuclear export", "cargo"],
102
- "Apoptosis": ["apoptosis", "apoptotic", "programmed cell death", "caspase",
103
- "death receptor", "bcl-2"],
104
- "Cell-Cell Communication": ["cell junction", "gap junction", "tight junction",
105
- "adherens", "cell-cell", "extracellular matrix",
106
- "integrin", "synapse", "neurotransmitter",
107
- "collagen", "laminin", "elastic fibre",
108
- "anchoring"],
109
- "Chromatin & Epigenetics": ["chromatin", "histone", "methylation",
110
- "acetylation", "deacetylation", "epigenetic",
111
- "polycomb", "hox"],
112
- "Hemostasis": ["hemostasis", "platelet", "coagulation", "fibrin", "thrombin"],
113
- "Developmental Biology": ["developmental", "axon", "neural", "muscle",
114
- "myogenesis", "keratinization", "differentiation",
115
- "cardiac conduction"],
116
- "Disease": ["disease", "hiv", "influenza", "sars", "hcmv", "herpes",
117
- "hepatitis", "cancer", "oncogene", "tumor", "amyloid"],
118
- "Cellular Responses": ["cellular response", "oxidative stress", "hypoxia",
119
- "heat shock", "senescence", "ferroptosis",
120
- "detoxification"],
121
- }
122
-
123
-
124
- def categorize_reactome(pathway_name: str) -> str:
125
- name_lower = pathway_name.lower()
126
- for cat, keywords in REACTOME_CATEGORIES.items():
127
- for kw in keywords:
128
- if kw in name_lower:
129
- return cat
130
- return "Other"
131
-
132
-
133
- # ============================================================
134
- # Analysis 1: Reactome category-level RSA
135
- # ============================================================
136
-
137
- def reactome_category_rsa(
138
- module_emb: np.ndarray,
139
- text_emb: np.ndarray,
140
- names: List[str],
141
- texts: List[str],
142
- output_dir: Path,
143
- sentence_model: str,
144
- device: str,
145
- n_perm: int = 1000,
146
- seed: int = 42,
147
- ) -> Dict:
148
- """Aggregate Reactome modules by top-level category, then do RSA."""
149
- logger.info("=" * 50)
150
- logger.info("Analysis 1: Reactome category-level RSA")
151
- logger.info("=" * 50)
152
-
153
- families = [parse_biomod_family(n) for n in names]
154
- reactome_mask = np.array([f == "Reactome" for f in families])
155
- reactome_idx = np.where(reactome_mask)[0]
156
-
157
- reactome_names = [names[i] for i in reactome_idx]
158
- reactome_texts = [texts[i] for i in reactome_idx]
159
- reactome_module_emb = module_emb[reactome_idx]
160
-
161
- cats = [categorize_reactome(t) for t in reactome_texts]
162
- cat_counts = Counter(cats)
163
- logger.info("Reactome category distribution:")
164
- for cat, count in sorted(cat_counts.items(), key=lambda x: -x[1]):
165
- logger.info(" %s: %d", cat, count)
166
-
167
- # Aggregate: mean embedding per category
168
- unique_cats = sorted(set(c for c in cats if c != "Other"))
169
- n_cats = len(unique_cats)
170
- logger.info("Categories (excl. Other): %d", n_cats)
171
-
172
- cat_module_emb = np.zeros((n_cats, module_emb.shape[1]))
173
- for i, cat in enumerate(unique_cats):
174
- mask = np.array([c == cat for c in cats])
175
- cat_module_emb[i] = reactome_module_emb[mask].mean(axis=0)
176
-
177
- # Encode category names as text
178
- cat_text_emb = encode_texts(unique_cats, model_name=sentence_model, device=device)
179
-
180
- # RSA on category-level
181
- sp_r, sp_p, pe_r, pe_p = compute_rsa(cat_module_emb, cat_text_emb, metric="cosine")
182
- logger.info("Category-level RSA: Spearman r=%.4f (p=%.4f), Pearson r=%.4f",
183
- sp_r, sp_p, pe_r)
184
-
185
- mantel = mantel_test(cat_module_emb, cat_text_emb, metric="cosine",
186
- n_perm=n_perm, seed=seed)
187
- logger.info("Mantel test: observed=%.4f, null=%.4f +/- %.4f, p=%.4f, z=%.1f",
188
- mantel["rsa_spearman_observed"], mantel["rsa_null_mean"],
189
- mantel["rsa_null_std"], mantel["rsa_p_value"],
190
- mantel["rsa_effect_size_z"])
191
-
192
- cka = compute_cka(cat_module_emb, cat_text_emb)
193
- logger.info("Category-level CKA: %.4f", cka)
194
-
195
- # Visualize: distance heatmaps
196
- fig, axes = plt.subplots(1, 2, figsize=(14, 6))
197
-
198
- dist_module = squareform(pdist(cat_module_emb, metric="cosine"))
199
- dist_text = squareform(pdist(cat_text_emb, metric="cosine"))
200
-
201
- im0 = axes[0].imshow(dist_module, cmap="viridis", aspect="auto")
202
- axes[0].set_xticks(range(n_cats))
203
- axes[0].set_xticklabels(unique_cats, rotation=45, ha="right", fontsize=7)
204
- axes[0].set_yticks(range(n_cats))
205
- axes[0].set_yticklabels(unique_cats, fontsize=7)
206
- axes[0].set_title("Module Embedding Distance (cosine)")
207
- plt.colorbar(im0, ax=axes[0], fraction=0.046)
208
-
209
- im1 = axes[1].imshow(dist_text, cmap="viridis", aspect="auto")
210
- axes[1].set_xticks(range(n_cats))
211
- axes[1].set_xticklabels(unique_cats, rotation=45, ha="right", fontsize=7)
212
- axes[1].set_yticks(range(n_cats))
213
- axes[1].set_yticklabels(unique_cats, fontsize=7)
214
- axes[1].set_title("Text Embedding Distance (cosine)")
215
- plt.colorbar(im1, ax=axes[1], fraction=0.046)
216
-
217
- plt.suptitle(f"Reactome Category-Level: RSA r={sp_r:.3f}, p={mantel['rsa_p_value']:.4f}")
218
- plt.tight_layout()
219
- plt.savefig(output_dir / "reactome_category_heatmaps.png", dpi=150, bbox_inches="tight")
220
- plt.close()
221
-
222
- results = {
223
- "n_categories": n_cats,
224
- "category_counts": dict(cat_counts),
225
- "rsa_spearman_r": sp_r,
226
- "rsa_spearman_p": sp_p,
227
- "rsa_pearson_r": pe_r,
228
- "mantel": mantel,
229
- "cka": cka,
230
- "categories": unique_cats,
231
- }
232
- return results
233
-
234
-
235
- # ============================================================
236
- # Analysis 2: Reactome with descriptions
237
- # ============================================================
238
-
239
- def reactome_with_descriptions(
240
- module_emb: np.ndarray,
241
- names: List[str],
242
- texts: List[str],
243
- descriptions_file: str,
244
- output_dir: Path,
245
- sentence_model: str,
246
- device: str,
247
- n_perm: int = 1000,
248
- seed: int = 42,
249
- ) -> Optional[Dict]:
250
- """Re-run RSA using extended descriptions for Reactome pathways."""
251
- if not descriptions_file or not Path(descriptions_file).exists():
252
- logger.info("No descriptions file provided, skipping Analysis 2.")
253
- return None
254
-
255
- logger.info("=" * 50)
256
- logger.info("Analysis 2: Reactome with official descriptions")
257
- logger.info("=" * 50)
258
-
259
- with open(descriptions_file, "r", encoding="utf-8") as f:
260
- descriptions = json.load(f)
261
-
262
- families = [parse_biomod_family(n) for n in names]
263
- reactome_mask = np.array([f == "Reactome" for f in families])
264
- reactome_idx = np.where(reactome_mask)[0]
265
-
266
- # Build enriched texts
267
- enriched_texts = []
268
- valid_idx = []
269
- for i in reactome_idx:
270
- pname = names[i]
271
- base_text = texts[i]
272
- if pname in descriptions and descriptions[pname]:
273
- enriched_texts.append(f"{base_text}: {descriptions[pname]}")
274
- valid_idx.append(i)
275
- else:
276
- enriched_texts.append(base_text)
277
- valid_idx.append(i)
278
-
279
- logger.info("Reactome modules with descriptions: %d / %d",
280
- sum(1 for i in reactome_idx if names[i] in descriptions),
281
- len(reactome_idx))
282
-
283
- sub_module_emb = module_emb[valid_idx]
284
- text_emb_enriched = encode_texts(enriched_texts, model_name=sentence_model, device=device)
285
-
286
- sp_r, sp_p, pe_r, pe_p = compute_rsa(sub_module_emb, text_emb_enriched, metric="cosine")
287
- logger.info("RSA with descriptions: Spearman r=%.4f (p=%.2e)", sp_r, sp_p)
288
-
289
- mantel = mantel_test(sub_module_emb, text_emb_enriched, metric="cosine",
290
- n_perm=n_perm, seed=seed)
291
- logger.info("Mantel: observed=%.4f, null=%.4f +/- %.4f, p=%.4f, z=%.1f",
292
- mantel["rsa_spearman_observed"], mantel["rsa_null_mean"],
293
- mantel["rsa_null_std"], mantel["rsa_p_value"],
294
- mantel["rsa_effect_size_z"])
295
-
296
- return {
297
- "rsa_spearman_r": sp_r,
298
- "rsa_spearman_p": sp_p,
299
- "mantel": mantel,
300
- }
301
-
302
-
303
- # ============================================================
304
- # Analysis 3: Hallmarks deep dive
305
- # ============================================================
306
-
307
- def hallmarks_deep_dive(
308
- module_emb: np.ndarray,
309
- text_emb: np.ndarray,
310
- names: List[str],
311
- texts: List[str],
312
- output_dir: Path,
313
- seed: int = 42,
314
- ) -> Dict:
315
- """Per-module alignment analysis for the 50 Hallmarks."""
316
- logger.info("=" * 50)
317
- logger.info("Analysis 3: Hallmarks deep dive")
318
- logger.info("=" * 50)
319
-
320
- families = [parse_biomod_family(n) for n in names]
321
- hallmark_mask = np.array([f == "Hallmarks" for f in families])
322
- hallmark_idx = np.where(hallmark_mask)[0]
323
-
324
- h_module = module_emb[hallmark_idx]
325
- h_text = text_emb[hallmark_idx]
326
- h_names = [names[i] for i in hallmark_idx]
327
- h_texts = [texts[i] for i in hallmark_idx]
328
- n_h = len(hallmark_idx)
329
-
330
- logger.info("Hallmarks: %d modules", n_h)
331
-
332
- # Per-module alignment: for each hallmark, compute cosine sim between
333
- # its module embedding's nearest neighbors and its text embedding's nearest neighbors
334
- # (neighborhood overlap)
335
- module_sim_matrix = cosine_similarity(h_module)
336
- text_sim_matrix = cosine_similarity(h_text)
337
-
338
- # Per-module: correlation between its similarity profile in module space vs text space
339
- per_module_corr = np.zeros(n_h)
340
- for i in range(n_h):
341
- mask = np.ones(n_h, dtype=bool)
342
- mask[i] = False
343
- r, _ = spearmanr(module_sim_matrix[i, mask], text_sim_matrix[i, mask])
344
- per_module_corr[i] = r
345
-
346
- # Sort by alignment quality
347
- sort_idx = np.argsort(-per_module_corr)
348
- logger.info("\nTop-10 best aligned Hallmarks:")
349
- for rank, i in enumerate(sort_idx[:10]):
350
- logger.info(" %2d. %s (r=%.3f)", rank+1, h_texts[i], per_module_corr[i])
351
-
352
- logger.info("\nBottom-10 worst aligned Hallmarks:")
353
- for rank, i in enumerate(sort_idx[-10:]):
354
- logger.info(" %2d. %s (r=%.3f)", rank+1, h_texts[i], per_module_corr[i])
355
-
356
- # Save per-module scores
357
- df = pd.DataFrame({
358
- "module_name": h_names,
359
- "text": h_texts,
360
- "alignment_r": per_module_corr,
361
- }).sort_values("alignment_r", ascending=False)
362
- df.to_csv(output_dir / "hallmarks_per_module_alignment.csv", index=False)
363
-
364
- # Heatmap: module similarity vs text similarity
365
- fig, axes = plt.subplots(1, 3, figsize=(18, 6))
366
-
367
- im0 = axes[0].imshow(module_sim_matrix[sort_idx][:, sort_idx], cmap="RdBu_r",
368
- vmin=-0.5, vmax=0.5, aspect="auto")
369
- axes[0].set_title("Module Embedding Similarity")
370
- axes[0].set_xticks(range(n_h))
371
- axes[0].set_xticklabels([h_texts[i][:15] for i in sort_idx], rotation=90, fontsize=5)
372
- axes[0].set_yticks(range(n_h))
373
- axes[0].set_yticklabels([h_texts[i][:15] for i in sort_idx], fontsize=5)
374
- plt.colorbar(im0, ax=axes[0], fraction=0.046)
375
-
376
- im1 = axes[1].imshow(text_sim_matrix[sort_idx][:, sort_idx], cmap="RdBu_r",
377
- vmin=-0.5, vmax=0.5, aspect="auto")
378
- axes[1].set_title("Text Embedding Similarity")
379
- axes[1].set_xticks(range(n_h))
380
- axes[1].set_xticklabels([h_texts[i][:15] for i in sort_idx], rotation=90, fontsize=5)
381
- axes[1].set_yticks([])
382
- plt.colorbar(im1, ax=axes[1], fraction=0.046)
383
-
384
- # Bar chart of per-module alignment
385
- colors = ["steelblue" if r > 0 else "salmon" for r in per_module_corr[sort_idx]]
386
- axes[2].barh(range(n_h), per_module_corr[sort_idx], color=colors)
387
- axes[2].set_yticks(range(n_h))
388
- axes[2].set_yticklabels([h_texts[i][:20] for i in sort_idx], fontsize=5)
389
- axes[2].set_xlabel("Spearman r (neighborhood alignment)")
390
- axes[2].set_title("Per-Hallmark Alignment Score")
391
- axes[2].axvline(0, color="black", linewidth=0.5)
392
-
393
- plt.tight_layout()
394
- plt.savefig(output_dir / "hallmarks_deep_dive.png", dpi=150, bbox_inches="tight")
395
- plt.close()
396
- logger.info("Saved hallmarks_deep_dive.png")
397
-
398
- # kNN overlap analysis
399
- k = 5
400
- module_knn = np.argsort(-module_sim_matrix, axis=1)[:, 1:k+1]
401
- text_knn = np.argsort(-text_sim_matrix, axis=1)[:, 1:k+1]
402
- knn_overlap = np.zeros(n_h)
403
- for i in range(n_h):
404
- overlap = len(set(module_knn[i]) & set(text_knn[i]))
405
- knn_overlap[i] = overlap / k
406
-
407
- logger.info("\nkNN overlap (k=%d): mean=%.3f, median=%.3f",
408
- k, knn_overlap.mean(), np.median(knn_overlap))
409
-
410
- # Permutation test for kNN overlap
411
- rng = np.random.default_rng(seed)
412
- n_perm = 1000
413
- null_overlaps = np.zeros(n_perm)
414
- for p in range(n_perm):
415
- perm = rng.permutation(n_h)
416
- text_knn_perm = text_knn[perm]
417
- overlaps_perm = np.zeros(n_h)
418
- for i in range(n_h):
419
- overlaps_perm[i] = len(set(module_knn[i]) & set(text_knn_perm[i])) / k
420
- null_overlaps[p] = overlaps_perm.mean()
421
-
422
- knn_p = float(np.mean(null_overlaps >= knn_overlap.mean()))
423
- logger.info("kNN overlap permutation test: observed=%.3f, null=%.3f +/- %.3f, p=%.4f",
424
- knn_overlap.mean(), null_overlaps.mean(), null_overlaps.std(), knn_p)
425
-
426
- results = {
427
- "n_hallmarks": n_h,
428
- "per_module_alignment_mean": float(per_module_corr.mean()),
429
- "per_module_alignment_median": float(np.median(per_module_corr)),
430
- "per_module_alignment_std": float(per_module_corr.std()),
431
- "n_positive_alignment": int((per_module_corr > 0).sum()),
432
- "n_negative_alignment": int((per_module_corr < 0).sum()),
433
- "knn_k": k,
434
- "knn_overlap_mean": float(knn_overlap.mean()),
435
- "knn_overlap_null_mean": float(null_overlaps.mean()),
436
- "knn_overlap_p": knn_p,
437
- }
438
- return results
439
-
440
-
441
- # ============================================================
442
- # Main
443
- # ============================================================
444
-
445
- def parse_args():
446
- parser = argparse.ArgumentParser(description=__doc__,
447
- formatter_class=argparse.RawDescriptionHelpFormatter)
448
- parser.add_argument("--biomod_embedding_path", "--module_embedding_path", type=str, required=True, dest='module_embedding_path')
449
- parser.add_argument("--biomod_json", "--module_json", type=str,
450
- default=str(REPO_ROOT / "model_dependencies" / "biomod_data_all" / "merged_biomods.json"), dest='module_json')
451
- parser.add_argument("--descriptions_file", type=str, default=None,
452
- help="JSON with Reactome summaryText descriptions.")
453
- parser.add_argument("--sentence_model", type=str,
454
- default="pritamdeka/S-PubMedBert-MS-MARCO")
455
- parser.add_argument("--device", type=str, default="cpu")
456
- parser.add_argument("--n_permutations", type=int, default=1000)
457
- parser.add_argument("--seed", type=int, default=42)
458
- parser.add_argument("--output_dir", type=str, required=True)
459
- return parser.parse_args()
460
-
461
-
462
- def main():
463
- args = parse_args()
464
- output_dir = Path(args.output_dir)
465
- output_dir.mkdir(parents=True, exist_ok=True)
466
-
467
- logging.basicConfig(
468
- level=logging.INFO,
469
- format="%(asctime)s [%(levelname)s] %(message)s",
470
- handlers=[
471
- logging.StreamHandler(),
472
- logging.FileHandler(output_dir / "run_extended.log", mode="w"),
473
- ],
474
- )
475
-
476
- # Load data
477
- module_names = load_biomod_names(args.module_json)
478
- all_embeddings = np.load(args.module_embedding_path)
479
- indices, names, texts = build_text_descriptions(module_names)
480
- module_emb = all_embeddings[indices]
481
-
482
- # Load or compute text embeddings
483
- text_emb_path = output_dir.parent / "pretrain_subset_v2" / "text_embeddings.npy"
484
- if text_emb_path.exists():
485
- logger.info("Loading cached text embeddings from %s", text_emb_path)
486
- text_emb = np.load(text_emb_path)
487
- else:
488
- text_emb = encode_texts(texts, model_name=args.sentence_model, device=args.device)
489
-
490
- all_results = {}
491
-
492
- # Analysis 1: Reactome category-level
493
- cat_results = reactome_category_rsa(
494
- module_emb, text_emb, names, texts, output_dir,
495
- sentence_model=args.sentence_model, device=args.device,
496
- n_perm=args.n_permutations, seed=args.seed,
497
- )
498
- all_results["reactome_category_rsa"] = cat_results
499
-
500
- # Analysis 2: Reactome with descriptions
501
- desc_results = reactome_with_descriptions(
502
- module_emb, names, texts, args.descriptions_file, output_dir,
503
- sentence_model=args.sentence_model, device=args.device,
504
- n_perm=args.n_permutations, seed=args.seed,
505
- )
506
- if desc_results:
507
- all_results["reactome_with_descriptions"] = desc_results
508
-
509
- # Analysis 3: Hallmarks deep dive
510
- hallmark_results = hallmarks_deep_dive(
511
- module_emb, text_emb, names, texts, output_dir, seed=args.seed,
512
- )
513
- all_results["hallmarks_deep_dive"] = hallmark_results
514
-
515
- # Save all results
516
- with open(output_dir / "extended_metrics.json", "w") as f:
517
- json.dump(all_results, f, indent=2, default=str)
518
- logger.info("Saved extended_metrics.json")
519
-
520
- # Summary
521
- logger.info("=" * 60)
522
- logger.info("EXTENDED ANALYSIS SUMMARY")
523
- logger.info("=" * 60)
524
- logger.info("1. Reactome category-level RSA: r=%.4f, p=%.4f",
525
- cat_results["rsa_spearman_r"], cat_results["mantel"]["rsa_p_value"])
526
- logger.info("2. Hallmarks per-module alignment: mean r=%.3f, %d/%d positive",
527
- hallmark_results["per_module_alignment_mean"],
528
- hallmark_results["n_positive_alignment"],
529
- hallmark_results["n_hallmarks"])
530
- logger.info("3. Hallmarks kNN overlap: %.3f (null=%.3f, p=%.4f)",
531
- hallmark_results["knn_overlap_mean"],
532
- hallmark_results["knn_overlap_null_mean"],
533
- hallmark_results["knn_overlap_p"])
534
-
535
-
536
- if __name__ == "__main__":
537
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
BioMod/interpretability/biomod_text_alignment/biomod_text_alignment_publication.py DELETED
@@ -1,1277 +0,0 @@
1
- """Publication-quality analysis for Experiment 2 β€” Module-Text Alignment.
2
-
3
- Produces 6 main figures + comprehensive metrics with bootstrap CIs for paper.
4
- Supports multi-model comparison and cross-dataset validation.
5
-
6
- Usage (run from the BioMod repository root):
7
- python BioMod/interpretability/biomod_text_alignment/biomod_text_alignment_publication.py \
8
- --biomod_embedding_path outputs/interpretability/geometry/pretrain_subset/module_embeddings.npy \
9
- --biomod_embedding_path2 outputs/interpretability/geometry/pancreas_scib/module_embeddings.npy \
10
- --biomod_json model_dependencies/biomod_data_all/merged_biomods.json \
11
- --sentence_model pritamdeka/S-PubMedBert-MS-MARCO \
12
- --sentence_models_compare "sentence-transformers/all-mpnet-base-v2" \
13
- --descriptions_file BioMod/interpretability/biomod_text_alignment/reactome_descriptions.json \
14
- --n_permutations 1000 \
15
- --n_bootstrap 2000 \
16
- --output_dir outputs/interpretability/text_alignment/publication
17
- """
18
- from __future__ import annotations
19
-
20
- import argparse
21
- import json
22
- import logging
23
- import sys
24
- from collections import Counter, defaultdict
25
- from pathlib import Path
26
- from typing import Dict, List, Optional, Tuple
27
-
28
- import numpy as np
29
- import pandas as pd
30
- from scipy.cluster.hierarchy import dendrogram, fcluster, linkage
31
- from scipy.spatial.distance import pdist, squareform
32
- from scipy.stats import kruskal, spearmanr
33
- from sklearn.metrics.pairwise import cosine_similarity
34
-
35
- import matplotlib
36
- matplotlib.use("Agg")
37
- import matplotlib.pyplot as plt
38
- from matplotlib.patches import Patch as LegendHandle
39
- from matplotlib.lines import Line2D
40
-
41
- HERE = Path(__file__).resolve().parent
42
- INTERP_DIR = HERE.parent
43
- BIOMOD_SOURCE = INTERP_DIR.parent
44
- REPO_ROOT = BIOMOD_SOURCE.parent
45
- for p in (str(HERE), str(INTERP_DIR), str(BIOMOD_SOURCE), str(REPO_ROOT)):
46
- if p not in sys.path:
47
- sys.path.insert(0, p)
48
-
49
- from interpretability.utils import (
50
- HALLMARK_PROCESS,
51
- load_biomod_names,
52
- parse_hallmark_process,
53
- parse_biomod_family,
54
- )
55
- from biomod_text_alignment.biomod_text_alignment import (
56
- build_text_descriptions,
57
- compute_cka,
58
- compute_rsa,
59
- encode_texts,
60
- mantel_test,
61
- permutation_test_cka,
62
- )
63
- from biomod_text_alignment.biomod_text_alignment_extended import (
64
- REACTOME_CATEGORIES,
65
- categorize_reactome,
66
- )
67
-
68
- logger = logging.getLogger(__name__)
69
-
70
- # ============================================================
71
- # Color palette for MSigDB hallmark process categories
72
- # ============================================================
73
-
74
- PROCESS_COLORS = {
75
- "immune": "#2196F3",
76
- "metabolic": "#4CAF50",
77
- "signaling": "#FF9800",
78
- "proliferation": "#E91E63",
79
- "development": "#9C27B0",
80
- "dna_damage": "#F44336",
81
- "pathway": "#607D8B",
82
- "cellular_component": "#795548",
83
- }
84
-
85
- PROCESS_LABELS = {
86
- "immune": "Immune",
87
- "metabolic": "Metabolic",
88
- "signaling": "Signaling",
89
- "proliferation": "Proliferation",
90
- "development": "Development",
91
- "dna_damage": "DNA Damage",
92
- "pathway": "Pathway",
93
- "cellular_component": "Cellular Component",
94
- }
95
-
96
- # Reactome super-categories (merge related categories for granularity sweep)
97
- REACTOME_SUPER_CATEGORIES = {
98
- "Information Processing": ["Gene Expression", "DNA Replication", "DNA Repair",
99
- "Chromatin & Epigenetics"],
100
- "Signal & Communication": ["Signal Transduction", "Cell-Cell Communication"],
101
- "Cell Fate": ["Cell Cycle", "Apoptosis", "Cellular Responses"],
102
- "Metabolism & Transport": ["Metabolism", "Transport"],
103
- "Immune & Defense": ["Immune System", "Hemostasis", "Disease"],
104
- "Development": ["Developmental Biology", "Protein Homeostasis"],
105
- }
106
-
107
-
108
- # ============================================================
109
- # Utility functions
110
- # ============================================================
111
-
112
- def savefig(fig, path_stem: str, dpi: int = 300):
113
- """Save figure as both PDF and PNG."""
114
- fig.savefig(f"{path_stem}.pdf", dpi=dpi, bbox_inches="tight")
115
- fig.savefig(f"{path_stem}.png", dpi=dpi, bbox_inches="tight")
116
- plt.close(fig)
117
- logger.info("Saved: %s.{pdf,png}", path_stem)
118
-
119
-
120
- def adaptive_vlim(matrix: np.ndarray, percentile: int = 98, symmetric: bool = True):
121
- """Compute adaptive vmin/vmax from data percentiles."""
122
- vals = matrix[np.triu_indices_from(matrix, k=1)]
123
- if symmetric:
124
- vlim = np.percentile(np.abs(vals), percentile)
125
- return -vlim, vlim
126
- else:
127
- return np.percentile(vals, [100 - percentile, percentile])
128
-
129
-
130
- def bootstrap_ci(
131
- values: np.ndarray,
132
- stat_fn=np.mean,
133
- n_boot: int = 2000,
134
- ci: float = 0.95,
135
- seed: int = 42,
136
- ) -> Tuple[float, float, float]:
137
- """Bootstrap confidence interval. Returns (estimate, ci_low, ci_high)."""
138
- rng = np.random.default_rng(seed)
139
- n = len(values)
140
- boot_stats = np.zeros(n_boot)
141
- for i in range(n_boot):
142
- idx = rng.integers(0, n, size=n)
143
- boot_stats[i] = stat_fn(values[idx])
144
- alpha = (1 - ci) / 2
145
- return float(stat_fn(values)), float(np.percentile(boot_stats, alpha * 100)), \
146
- float(np.percentile(boot_stats, (1 - alpha) * 100))
147
-
148
-
149
- def bootstrap_rsa(
150
- module_emb: np.ndarray,
151
- text_emb: np.ndarray,
152
- n_boot: int = 2000,
153
- seed: int = 42,
154
- ) -> Tuple[float, float, float]:
155
- """Bootstrap CI for RSA Spearman r by resampling modules."""
156
- rng = np.random.default_rng(seed)
157
- n = module_emb.shape[0]
158
- boot_rs = np.zeros(n_boot)
159
- for i in range(n_boot):
160
- idx = rng.choice(n, size=n, replace=True)
161
- dist_p = pdist(module_emb[idx], metric="cosine")
162
- dist_t = pdist(text_emb[idx], metric="cosine")
163
- boot_rs[i], _ = spearmanr(dist_p, dist_t)
164
- observed, _ = spearmanr(pdist(module_emb, "cosine"), pdist(text_emb, "cosine"))
165
- return float(observed), float(np.percentile(boot_rs, 2.5)), \
166
- float(np.percentile(boot_rs, 97.5))
167
-
168
-
169
- def get_biomod_sizes(module_json: str) -> Dict[str, int]:
170
- """Get number of genes per module from merged_biomods.json."""
171
- with open(module_json, "r", encoding="utf-8") as f:
172
- modules = json.load(f)
173
- return {name: len(genes) for name, genes in modules.items()}
174
-
175
-
176
- # ============================================================
177
- # CLI
178
- # ============================================================
179
-
180
- def parse_args() -> argparse.Namespace:
181
- parser = argparse.ArgumentParser(
182
- description=__doc__,
183
- formatter_class=argparse.RawDescriptionHelpFormatter,
184
- )
185
- parser.add_argument("--biomod_embedding_path", "--module_embedding_path", type=str, required=True,
186
- help="Primary module_embeddings.npy (e.g. pretrain_subset)", dest='module_embedding_path')
187
- parser.add_argument("--biomod_embedding_path2", "--module_embedding_path2", type=str, default=None,
188
- help="Second dataset module_embeddings.npy for cross-dataset comparison", dest='module_embedding_path2')
189
- parser.add_argument("--biomod_json", "--module_json", type=str,
190
- default=str(REPO_ROOT / "model_dependencies" / "biomod_data_all" / "merged_biomods.json"), dest='module_json')
191
- parser.add_argument("--descriptions_file", type=str, default=None)
192
- parser.add_argument("--sentence_model", type=str,
193
- default="pritamdeka/S-PubMedBert-MS-MARCO")
194
- parser.add_argument("--sentence_models_compare", type=str, default=None,
195
- help="Comma-separated additional models for comparison")
196
- parser.add_argument("--device", type=str, default="cpu")
197
- parser.add_argument("--n_permutations", type=int, default=1000)
198
- parser.add_argument("--n_bootstrap", type=int, default=2000)
199
- parser.add_argument("--seed", type=int, default=42)
200
- parser.add_argument("--output_dir", type=str, required=True)
201
- return parser.parse_args()
202
-
203
-
204
- # ============================================================
205
- # Figure 1: RSA Overview (hexbin + null distribution)
206
- # ============================================================
207
-
208
- def fig1_rsa_overview(
209
- module_emb: np.ndarray,
210
- text_emb: np.ndarray,
211
- mantel_results: Dict,
212
- output_dir: Path,
213
- metric: str = "cosine",
214
- n_bootstrap: int = 2000,
215
- seed: int = 42,
216
- ) -> Dict:
217
- """RSA overview: hexbin density + Mantel null distribution."""
218
- dist_module = pdist(module_emb, metric=metric)
219
- dist_text = pdist(text_emb, metric=metric)
220
-
221
- observed_r = mantel_results["rsa_spearman_observed"]
222
- null_mean = mantel_results["rsa_null_mean"]
223
- null_std = mantel_results["rsa_null_std"]
224
- p_value = mantel_results["rsa_p_value"]
225
- z_score = mantel_results["rsa_effect_size_z"]
226
-
227
- # Bootstrap CI for RSA
228
- rsa_obs, rsa_ci_lo, rsa_ci_hi = bootstrap_rsa(
229
- module_emb, text_emb, n_boot=n_bootstrap, seed=seed
230
- )
231
-
232
- fig, axes = plt.subplots(1, 2, figsize=(12, 5))
233
-
234
- # Panel A: Hexbin density
235
- ax = axes[0]
236
- hb = ax.hexbin(dist_text, dist_module, gridsize=40, cmap="YlOrRd",
237
- mincnt=1, linewidths=0.2)
238
- cb = plt.colorbar(hb, ax=ax, fraction=0.046, pad=0.02)
239
- cb.set_label("Count", fontsize=9)
240
-
241
- # Trend line
242
- z = np.polyfit(dist_text, dist_module, 1)
243
- p_line = np.poly1d(z)
244
- x_range = np.linspace(dist_text.min(), dist_text.max(), 100)
245
- ax.plot(x_range, p_line(x_range), "k-", linewidth=2, alpha=0.8)
246
-
247
- ax.set_xlabel("Text pairwise distance (cosine)", fontsize=10)
248
- ax.set_ylabel("Module embedding pairwise distance (cosine)", fontsize=10)
249
- ax.set_title(f"A. RSA: r = {observed_r:.3f} [{rsa_ci_lo:.3f}, {rsa_ci_hi:.3f}]",
250
- fontsize=11, fontweight="bold")
251
- ax.text(0.05, 0.95, f"n = {module_emb.shape[0]} modules\n"
252
- f"pairs = {len(dist_module):,}",
253
- transform=ax.transAxes, fontsize=8, va="top",
254
- bbox=dict(boxstyle="round", facecolor="white", alpha=0.8))
255
-
256
- # Panel B: Null distribution
257
- ax = axes[1]
258
- from scipy.stats import norm as norm_dist
259
- x = np.linspace(null_mean - 4.5 * null_std,
260
- max(observed_r + 2 * null_std, null_mean + 4.5 * null_std), 300)
261
- y = norm_dist.pdf(x, null_mean, null_std)
262
- ax.fill_between(x, y, alpha=0.25, color="gray")
263
- ax.plot(x, y, color="gray", linewidth=1.5, label="Null distribution")
264
-
265
- # Shade p-value region
266
- x_tail = x[x >= observed_r]
267
- y_tail = norm_dist.pdf(x_tail, null_mean, null_std)
268
- ax.fill_between(x_tail, y_tail, alpha=0.4, color="red", label=f"p = {p_value:.4f}")
269
-
270
- ax.axvline(observed_r, color="red", linewidth=2.5, linestyle="-",
271
- label=f"Observed r = {observed_r:.4f}")
272
- ax.axvline(null_mean, color="gray", linewidth=1, linestyle="--", alpha=0.7)
273
-
274
- ax.set_xlabel("Spearman r (distance matrix correlation)", fontsize=10)
275
- ax.set_ylabel("Density", fontsize=10)
276
- ax.set_title(f"B. Mantel Test: z = {z_score:.1f}Οƒ, p = {p_value:.4f}",
277
- fontsize=11, fontweight="bold")
278
- ax.legend(fontsize=9, loc="upper left")
279
-
280
- plt.tight_layout()
281
- savefig(fig, str(output_dir / "fig1_rsa_overview"))
282
-
283
- return {
284
- "rsa_r": rsa_obs,
285
- "rsa_ci_95": [rsa_ci_lo, rsa_ci_hi],
286
- "mantel_p": p_value,
287
- "mantel_z": z_score,
288
- }
289
-
290
-
291
- # ============================================================
292
- # Figure 2: Hallmarks Similarity Matrices with Dendrograms
293
- # ============================================================
294
-
295
- def _rank_transform_matrix(sim_matrix: np.ndarray) -> np.ndarray:
296
- """Rank-transform off-diagonal elements to [0,1] for visualization."""
297
- from scipy.stats import rankdata
298
- n = sim_matrix.shape[0]
299
- result = np.zeros_like(sim_matrix)
300
- triu_idx = np.triu_indices(n, k=1)
301
- vals = sim_matrix[triu_idx]
302
- ranks = rankdata(vals) / len(vals)
303
- result[triu_idx] = ranks
304
- result[triu_idx[1], triu_idx[0]] = ranks
305
- np.fill_diagonal(result, 1.0)
306
- return result
307
-
308
-
309
- def fig2_hallmarks_heatmaps(
310
- module_emb: np.ndarray,
311
- text_emb: np.ndarray,
312
- names: List[str],
313
- texts: List[str],
314
- output_dir: Path,
315
- ) -> Dict:
316
- """Hallmarks 50x50 similarity matrices with rank-transform and clustering."""
317
- families = [parse_biomod_family(n) for n in names]
318
- hallmark_mask = np.array([f == "Hallmarks" for f in families])
319
- hallmark_idx = np.where(hallmark_mask)[0]
320
-
321
- h_module = module_emb[hallmark_idx]
322
- h_text = text_emb[hallmark_idx]
323
- h_names = [names[i] for i in hallmark_idx]
324
- h_texts = [texts[i] for i in hallmark_idx]
325
- n_h = len(hallmark_idx)
326
-
327
- h_processes = []
328
- for n in h_names:
329
- proc = parse_hallmark_process(n)
330
- h_processes.append(proc if proc else "pathway")
331
-
332
- module_sim = cosine_similarity(h_module)
333
- text_sim = cosine_similarity(h_text)
334
-
335
- linkage_mat = linkage(pdist(h_module, "cosine"), method="ward")
336
- dendro = dendrogram(linkage_mat, no_plot=True)
337
- order = dendro["leaves"]
338
-
339
- module_sim_ord = module_sim[order][:, order]
340
- text_sim_ord = text_sim[order][:, order]
341
- texts_ord = [h_texts[i] for i in order]
342
- procs_ord = [h_processes[i] for i in order]
343
-
344
- # Rank-transform to spread narrow cosine similarity ranges
345
- module_rank_ord = _rank_transform_matrix(module_sim_ord)
346
- text_rank_ord = _rank_transform_matrix(text_sim_ord)
347
- diff_rank = module_rank_ord - text_rank_ord
348
-
349
- # Adaptive limit for difference panel
350
- d_vals = diff_rank[np.triu_indices(n_h, k=1)]
351
- vlim_d = float(np.percentile(np.abs(d_vals), 95))
352
-
353
- # Figure
354
- fig = plt.figure(figsize=(20, 8.5))
355
- gs = fig.add_gridspec(2, 3, height_ratios=[0.08, 1], hspace=0.02, wspace=0.3)
356
-
357
- # Dendrogram
358
- ax_dend = fig.add_subplot(gs[0, :])
359
- dendrogram(linkage_mat, ax=ax_dend, no_labels=True,
360
- color_threshold=0, above_threshold_color="gray")
361
- ax_dend.set_xticks([])
362
- ax_dend.set_yticks([])
363
- ax_dend.spines[:].set_visible(False)
364
-
365
- for i, proc in enumerate(procs_ord):
366
- color = PROCESS_COLORS.get(proc, "#999999")
367
- ax_dend.axvspan(i * 10 + 5 - 4, i * 10 + 5 + 4, ymin=-0.2, ymax=-0.02,
368
- color=color, clip_on=False)
369
-
370
- # Panel A: Module similarity (rank-transformed)
371
- ax0 = fig.add_subplot(gs[1, 0])
372
- im0 = ax0.imshow(module_rank_ord, cmap="YlOrRd", vmin=0, vmax=1,
373
- aspect="equal", interpolation="nearest")
374
- ax0.set_title("A. Module Embedding Similarity\n(rank-transformed)",
375
- fontsize=10, fontweight="bold")
376
- ax0.set_xticks(range(n_h))
377
- ax0.set_xticklabels(texts_ord, rotation=90, fontsize=5.5, ha="center")
378
- ax0.set_yticks(range(n_h))
379
- ax0.set_yticklabels(texts_ord, fontsize=5.5)
380
- cb0 = plt.colorbar(im0, ax=ax0, fraction=0.046, pad=0.02)
381
- cb0.set_label("Rank percentile", fontsize=8)
382
-
383
- # Panel B: Text similarity (rank-transformed)
384
- ax1 = fig.add_subplot(gs[1, 1])
385
- im1 = ax1.imshow(text_rank_ord, cmap="YlOrRd", vmin=0, vmax=1,
386
- aspect="equal", interpolation="nearest")
387
- ax1.set_title("B. Text Embedding Similarity\n(rank-transformed)",
388
- fontsize=10, fontweight="bold")
389
- ax1.set_xticks(range(n_h))
390
- ax1.set_xticklabels(texts_ord, rotation=90, fontsize=5.5, ha="center")
391
- ax1.set_yticks([])
392
- cb1 = plt.colorbar(im1, ax=ax1, fraction=0.046, pad=0.02)
393
- cb1.set_label("Rank percentile", fontsize=8)
394
-
395
- # Panel C: Difference
396
- ax2 = fig.add_subplot(gs[1, 2])
397
- im2 = ax2.imshow(diff_rank, cmap="PiYG", vmin=-vlim_d, vmax=vlim_d,
398
- aspect="equal", interpolation="nearest")
399
- ax2.set_title("C. Difference (Module βˆ’ Text)\n(rank space)",
400
- fontsize=10, fontweight="bold")
401
- ax2.set_xticks(range(n_h))
402
- ax2.set_xticklabels(texts_ord, rotation=90, fontsize=5.5, ha="center")
403
- ax2.set_yticks([])
404
- cb2 = plt.colorbar(im2, ax=ax2, fraction=0.046, pad=0.02)
405
- cb2.set_label("Rank difference", fontsize=8)
406
-
407
- legend_elements = [LegendHandle(facecolor=PROCESS_COLORS[k], label=PROCESS_LABELS[k])
408
- for k in PROCESS_COLORS]
409
- fig.legend(handles=legend_elements, loc="lower center", ncol=4,
410
- fontsize=9, frameon=True, bbox_to_anchor=(0.5, -0.01))
411
-
412
- plt.tight_layout(rect=[0, 0.03, 1, 1])
413
- savefig(fig, str(output_dir / "fig2_hallmarks_heatmaps"))
414
-
415
- return {
416
- "module_sim_range": [float(module_sim.min()), float(module_sim.max())],
417
- "text_sim_range": [float(text_sim.min()), float(text_sim.max())],
418
- "clustering_order": [int(i) for i in order],
419
- }
420
-
421
-
422
- # ============================================================
423
- # Figure 3: Per-Hallmark Alignment (lollipop chart)
424
- # ============================================================
425
-
426
- def fig3_per_hallmark_alignment(
427
- module_emb: np.ndarray,
428
- text_emb: np.ndarray,
429
- names: List[str],
430
- texts: List[str],
431
- output_dir: Path,
432
- n_bootstrap: int = 2000,
433
- seed: int = 42,
434
- ) -> Dict:
435
- """Lollipop chart of per-hallmark alignment scores, colored by process."""
436
- families = [parse_biomod_family(n) for n in names]
437
- hallmark_mask = np.array([f == "Hallmarks" for f in families])
438
- hallmark_idx = np.where(hallmark_mask)[0]
439
-
440
- h_module = module_emb[hallmark_idx]
441
- h_text = text_emb[hallmark_idx]
442
- h_names = [names[i] for i in hallmark_idx]
443
- h_texts = [texts[i] for i in hallmark_idx]
444
- n_h = len(hallmark_idx)
445
-
446
- h_processes = []
447
- for n in h_names:
448
- proc = parse_hallmark_process(n)
449
- h_processes.append(proc if proc else "pathway")
450
-
451
- # Per-module alignment: Spearman r of similarity profiles
452
- module_sim = cosine_similarity(h_module)
453
- text_sim = cosine_similarity(h_text)
454
- per_module_corr = np.zeros(n_h)
455
- for i in range(n_h):
456
- mask = np.ones(n_h, dtype=bool)
457
- mask[i] = False
458
- r, _ = spearmanr(module_sim[i, mask], text_sim[i, mask])
459
- per_module_corr[i] = r
460
-
461
- # Sort by alignment
462
- sort_idx = np.argsort(per_module_corr) # ascending for bottom-to-top lollipop
463
-
464
- # Bootstrap CI for mean alignment
465
- mean_val, ci_lo, ci_hi = bootstrap_ci(per_module_corr, np.mean, n_bootstrap, seed=seed)
466
- n_positive = int((per_module_corr > 0).sum())
467
-
468
- # Figure
469
- fig, ax = plt.subplots(figsize=(8, 12))
470
-
471
- y_pos = np.arange(n_h)
472
- for i, idx in enumerate(sort_idx):
473
- color = PROCESS_COLORS.get(h_processes[idx], "#999999")
474
- ax.hlines(y=i, xmin=0, xmax=per_module_corr[idx], color=color,
475
- linewidth=1.5, alpha=0.7)
476
- ax.scatter(per_module_corr[idx], i, color=color, s=40, zorder=3)
477
-
478
- ax.axvline(0, color="black", linewidth=0.8, linestyle="-")
479
- ax.axvline(mean_val, color="black", linewidth=1.5, linestyle="--", alpha=0.6)
480
-
481
- ax.set_yticks(y_pos)
482
- ax.set_yticklabels([h_texts[i] for i in sort_idx], fontsize=7)
483
- ax.set_xlabel("Alignment Score (Spearman r of similarity profiles)", fontsize=10)
484
- ax.set_title("Per-Hallmark Alignment Score", fontsize=12, fontweight="bold")
485
-
486
- # Summary text
487
- summary = (f"Mean r = {mean_val:.3f} [{ci_lo:.3f}, {ci_hi:.3f}]\n"
488
- f"Positive: {n_positive}/{n_h} ({100*n_positive/n_h:.0f}%)")
489
- ax.text(0.98, 0.02, summary, transform=ax.transAxes, fontsize=9,
490
- ha="right", va="bottom",
491
- bbox=dict(boxstyle="round", facecolor="lightyellow", alpha=0.9))
492
-
493
- # Legend
494
- legend_elements = [Line2D([0], [0], marker="o", color="w", markerfacecolor=c,
495
- markersize=8, label=PROCESS_LABELS[k])
496
- for k, c in PROCESS_COLORS.items()]
497
- ax.legend(handles=legend_elements, loc="upper left", fontsize=7,
498
- frameon=True, ncol=2)
499
-
500
- ax.set_xlim(min(per_module_corr) - 0.05, max(per_module_corr) + 0.05)
501
- plt.tight_layout()
502
- savefig(fig, str(output_dir / "fig3_per_hallmark_alignment"))
503
-
504
- # Save CSV
505
- df = pd.DataFrame({
506
- "hallmark": h_texts,
507
- "module_name": h_names,
508
- "process_category": h_processes,
509
- "alignment_r": per_module_corr,
510
- }).sort_values("alignment_r", ascending=False)
511
- df.to_csv(output_dir / "hallmarks_alignment_table.csv", index=False)
512
-
513
- return {
514
- "mean_alignment": mean_val,
515
- "mean_alignment_ci_95": [ci_lo, ci_hi],
516
- "n_positive": n_positive,
517
- "n_total": n_h,
518
- "per_module_scores": {h_texts[i]: float(per_module_corr[i]) for i in range(n_h)},
519
- }
520
-
521
-
522
- # ============================================================
523
- # Figure 4: Alignment by Biological Process Category
524
- # ============================================================
525
-
526
- def fig4_category_alignment(
527
- module_emb: np.ndarray,
528
- text_emb: np.ndarray,
529
- names: List[str],
530
- texts: List[str],
531
- output_dir: Path,
532
- ) -> Dict:
533
- """Violin/box plot of alignment scores grouped by MSigDB process category."""
534
- families = [parse_biomod_family(n) for n in names]
535
- hallmark_mask = np.array([f == "Hallmarks" for f in families])
536
- hallmark_idx = np.where(hallmark_mask)[0]
537
-
538
- h_module = module_emb[hallmark_idx]
539
- h_text = text_emb[hallmark_idx]
540
- h_names = [names[i] for i in hallmark_idx]
541
- n_h = len(hallmark_idx)
542
-
543
- h_processes = []
544
- for n in h_names:
545
- proc = parse_hallmark_process(n)
546
- h_processes.append(proc if proc else "pathway")
547
-
548
- # Per-module alignment
549
- module_sim = cosine_similarity(h_module)
550
- text_sim = cosine_similarity(h_text)
551
- per_module_corr = np.zeros(n_h)
552
- for i in range(n_h):
553
- mask = np.ones(n_h, dtype=bool)
554
- mask[i] = False
555
- r, _ = spearmanr(module_sim[i, mask], text_sim[i, mask])
556
- per_module_corr[i] = r
557
-
558
- # Group by category
559
- cat_scores = defaultdict(list)
560
- for i in range(n_h):
561
- cat_scores[h_processes[i]].append(per_module_corr[i])
562
-
563
- # Sort categories by median alignment
564
- cats_sorted = sorted(cat_scores.keys(),
565
- key=lambda c: np.median(cat_scores[c]), reverse=True)
566
-
567
- # Kruskal-Wallis test
568
- groups = [np.array(cat_scores[c]) for c in cats_sorted if len(cat_scores[c]) >= 2]
569
- if len(groups) >= 2:
570
- kw_stat, kw_p = kruskal(*groups)
571
- else:
572
- kw_stat, kw_p = 0.0, 1.0
573
-
574
- # Figure
575
- fig, ax = plt.subplots(figsize=(10, 6))
576
-
577
- positions = range(len(cats_sorted))
578
- data_list = [np.array(cat_scores[c]) for c in cats_sorted]
579
- colors_list = [PROCESS_COLORS.get(c, "#999999") for c in cats_sorted]
580
-
581
- # Box + strip plot
582
- bp = ax.boxplot(data_list, positions=positions, widths=0.5,
583
- patch_artist=True, showfliers=False)
584
- for module, color in zip(bp["boxes"], colors_list):
585
- module.set_facecolor(color)
586
- module.set_alpha(0.4)
587
- for median in bp["medians"]:
588
- median.set_color("black")
589
- median.set_linewidth(1.5)
590
-
591
- # Overlay individual points
592
- for i, (data, color) in enumerate(zip(data_list, colors_list)):
593
- jitter = np.random.default_rng(42).uniform(-0.15, 0.15, size=len(data))
594
- ax.scatter(np.full(len(data), i) + jitter, data, color=color,
595
- s=30, alpha=0.7, edgecolors="white", linewidths=0.5, zorder=3)
596
-
597
- ax.axhline(0, color="gray", linewidth=0.8, linestyle="--")
598
- ax.set_xticks(positions)
599
- ax.set_xticklabels([PROCESS_LABELS.get(c, c) for c in cats_sorted],
600
- rotation=30, ha="right", fontsize=9)
601
- ax.set_ylabel("Alignment Score (Spearman r)", fontsize=10)
602
- ax.set_title(f"Alignment by Biological Process Category\n"
603
- f"Kruskal-Wallis H = {kw_stat:.2f}, p = {kw_p:.4f}",
604
- fontsize=11, fontweight="bold")
605
-
606
- # Annotate counts
607
- for i, c in enumerate(cats_sorted):
608
- n = len(cat_scores[c])
609
- ax.text(i, ax.get_ylim()[1] * 0.95, f"n={n}", ha="center", fontsize=8)
610
-
611
- plt.tight_layout()
612
- savefig(fig, str(output_dir / "fig4_category_alignment"))
613
-
614
- return {
615
- "kruskal_wallis_H": float(kw_stat),
616
- "kruskal_wallis_p": float(kw_p),
617
- "category_medians": {c: float(np.median(cat_scores[c])) for c in cats_sorted},
618
- "category_counts": {c: len(cat_scores[c]) for c in cats_sorted},
619
- }
620
-
621
-
622
- # ============================================================
623
- # Figure 5: Granularity Effect (Reactome hierarchy)
624
- # ============================================================
625
-
626
- def fig5_granularity_effect(
627
- module_emb: np.ndarray,
628
- text_emb: np.ndarray,
629
- names: List[str],
630
- texts: List[str],
631
- n_permutations: int = 1000,
632
- n_bootstrap: int = 2000,
633
- seed: int = 42,
634
- output_dir: Path = None,
635
- ) -> Dict:
636
- """RSA at multiple granularity levels: fine β†’ category β†’ super-category."""
637
- families = [parse_biomod_family(n) for n in names]
638
- reactome_mask = np.array([f == "Reactome" for f in families])
639
- hallmark_mask = np.array([f == "Hallmarks" for f in families])
640
- reactome_idx = np.where(reactome_mask)[0]
641
- hallmark_idx = np.where(hallmark_mask)[0]
642
-
643
- results = {}
644
-
645
- # Level 0: Hallmarks (50 modules, already shown to align)
646
- h_module = module_emb[hallmark_idx]
647
- h_text = text_emb[hallmark_idx]
648
- r_hall, ci_lo_h, ci_hi_h = bootstrap_rsa(h_module, h_text, n_bootstrap, seed)
649
- results["hallmarks_50"] = {"r": r_hall, "ci": [ci_lo_h, ci_hi_h], "n": len(hallmark_idx)}
650
-
651
- # Level 1: Reactome fine-grained (491 pathways)
652
- r_module = module_emb[reactome_idx]
653
- r_text = text_emb[reactome_idx]
654
- r_fine, ci_lo_f, ci_hi_f = bootstrap_rsa(r_module, r_text, n_bootstrap, seed)
655
- results["reactome_fine_491"] = {"r": r_fine, "ci": [ci_lo_f, ci_hi_f], "n": len(reactome_idx)}
656
-
657
- # Level 2: Reactome categories (~16)
658
- r_names = [names[i] for i in reactome_idx]
659
- r_texts = [texts[i] for i in reactome_idx]
660
- cat_map = {}
661
- for i, n in enumerate(r_names):
662
- cat = categorize_reactome(n)
663
- if cat not in cat_map:
664
- cat_map[cat] = {"module_embs": [], "text_embs": []}
665
- cat_map[cat]["module_embs"].append(r_module[i])
666
- cat_map[cat]["text_embs"].append(r_text[i])
667
-
668
- cats_with_data = {k: v for k, v in cat_map.items() if len(v["module_embs"]) >= 3}
669
- if len(cats_with_data) >= 4:
670
- cat_module_means = np.array([np.mean(v["module_embs"], axis=0)
671
- for v in cats_with_data.values()])
672
- cat_text_means = np.array([np.mean(v["text_embs"], axis=0)
673
- for v in cats_with_data.values()])
674
- r_cat, ci_lo_c, ci_hi_c = bootstrap_rsa(cat_module_means, cat_text_means,
675
- n_bootstrap, seed)
676
- results["reactome_category"] = {
677
- "r": r_cat, "ci": [ci_lo_c, ci_hi_c], "n": len(cats_with_data)
678
- }
679
- else:
680
- results["reactome_category"] = {"r": 0.0, "ci": [0.0, 0.0], "n": 0}
681
-
682
- # Level 3: Super-categories (5-6)
683
- super_map = {}
684
- for super_name, sub_cats in REACTOME_SUPER_CATEGORIES.items():
685
- embs_p, embs_t = [], []
686
- for sub in sub_cats:
687
- if sub in cats_with_data:
688
- embs_p.extend(cats_with_data[sub]["module_embs"])
689
- embs_t.extend(cats_with_data[sub]["text_embs"])
690
- if len(embs_p) >= 3:
691
- super_map[super_name] = {
692
- "module_mean": np.mean(embs_p, axis=0),
693
- "text_mean": np.mean(embs_t, axis=0),
694
- }
695
-
696
- if len(super_map) >= 4:
697
- sup_module = np.array([v["module_mean"] for v in super_map.values()])
698
- sup_text = np.array([v["text_mean"] for v in super_map.values()])
699
- r_sup, ci_lo_s, ci_hi_s = bootstrap_rsa(sup_module, sup_text, n_bootstrap, seed)
700
- results["reactome_super_category"] = {
701
- "r": r_sup, "ci": [ci_lo_s, ci_hi_s], "n": len(super_map)
702
- }
703
- else:
704
- results["reactome_super_category"] = {"r": 0.0, "ci": [0.0, 0.0], "n": 0}
705
-
706
- # Figure
707
- fig, ax = plt.subplots(figsize=(8, 5))
708
-
709
- levels = ["Reactome\nFine (491)", "Reactome\nCategory (~16)",
710
- "Reactome\nSuper-Cat (~6)", "Hallmarks\n(50)"]
711
- keys = ["reactome_fine_491", "reactome_category",
712
- "reactome_super_category", "hallmarks_50"]
713
- rs = [results[k]["r"] for k in keys]
714
- cis_lo = [results[k]["ci"][0] for k in keys]
715
- cis_hi = [results[k]["ci"][1] for k in keys]
716
- ns = [results[k]["n"] for k in keys]
717
-
718
- colors = ["#90CAF9", "#42A5F5", "#1565C0", "#E91E63"]
719
- x = np.arange(len(levels))
720
- bars = ax.bar(x, rs, color=colors, edgecolor="black", linewidth=0.8, width=0.6)
721
-
722
- # Error bars (CI)
723
- for i in range(len(levels)):
724
- ax.plot([i, i], [cis_lo[i], cis_hi[i]], color="black", linewidth=2)
725
- ax.plot([i - 0.1, i + 0.1], [cis_lo[i], cis_lo[i]], color="black", linewidth=1.5)
726
- ax.plot([i - 0.1, i + 0.1], [cis_hi[i], cis_hi[i]], color="black", linewidth=1.5)
727
-
728
- ax.axhline(0, color="gray", linewidth=0.8, linestyle="--")
729
- ax.set_xticks(x)
730
- ax.set_xticklabels(levels, fontsize=9)
731
- ax.set_ylabel("RSA Spearman r", fontsize=10)
732
- ax.set_title("Alignment Improves at Coarser Granularity",
733
- fontsize=12, fontweight="bold")
734
-
735
- # Annotate n
736
- for i, n in enumerate(ns):
737
- ax.text(i, max(rs[i], cis_hi[i]) + 0.01, f"n={n}", ha="center", fontsize=8)
738
-
739
- plt.tight_layout()
740
- savefig(fig, str(output_dir / "fig5_granularity_effect"))
741
-
742
- return results
743
-
744
-
745
- # ============================================================
746
- # Figure 6: Multi-Model / Cross-Dataset Comparison
747
- # ============================================================
748
-
749
- def fig6_model_comparison(
750
- module_emb_primary: np.ndarray,
751
- module_emb_secondary: Optional[np.ndarray],
752
- text_emb_primary: np.ndarray,
753
- names: List[str],
754
- texts: List[str],
755
- primary_model_name: str,
756
- compare_model_names: List[str],
757
- compare_text_embs: List[np.ndarray],
758
- n_permutations: int = 1000,
759
- n_bootstrap: int = 2000,
760
- seed: int = 42,
761
- output_dir: Path = None,
762
- ) -> Dict:
763
- """Grouped bar chart comparing RSA across models and datasets."""
764
- families = [parse_biomod_family(n) for n in names]
765
- hallmark_mask = np.array([f == "Hallmarks" for f in families])
766
- hallmark_idx = np.where(hallmark_mask)[0]
767
-
768
- h_module = module_emb_primary[hallmark_idx]
769
-
770
- # Compute RSA for ALL models including primary
771
- all_models = [primary_model_name] + compare_model_names
772
- all_text_embs = [text_emb_primary] + compare_text_embs
773
-
774
- model_results = {}
775
- for model_name, text_emb_full in zip(all_models, all_text_embs):
776
- h_text = text_emb_full[hallmark_idx]
777
- r, ci_lo, ci_hi = bootstrap_rsa(h_module, h_text, n_bootstrap, seed)
778
- mantel_res = mantel_test(h_module, h_text, n_perm=n_permutations)
779
- p = mantel_res["rsa_p_value"]
780
- model_results[model_name] = {
781
- "dataset": "primary",
782
- "r": r, "ci": [ci_lo, ci_hi], "p": p
783
- }
784
-
785
- # Cross-dataset (secondary)
786
- dataset_results = {}
787
- if module_emb_secondary is not None:
788
- h_module2 = module_emb_secondary[hallmark_idx]
789
- for model_name, text_emb_full in zip(all_models, all_text_embs):
790
- h_text = text_emb_full[hallmark_idx]
791
- r, ci_lo, ci_hi = bootstrap_rsa(h_module2, h_text, n_bootstrap, seed)
792
- mantel_res = mantel_test(h_module2, h_text, n_perm=n_permutations)
793
- p = mantel_res["rsa_p_value"]
794
- dataset_results[model_name] = {
795
- "dataset": "secondary",
796
- "r": r, "ci": [ci_lo, ci_hi], "p": p
797
- }
798
-
799
- # Figure
800
- has_secondary = len(dataset_results) > 0
801
- n_models = len(model_results)
802
-
803
- if n_models == 0:
804
- logger.warning("No models to compare, skipping fig6")
805
- return {}
806
-
807
- fig, ax = plt.subplots(figsize=(max(8, n_models * 2.5), 5))
808
-
809
- model_names_short = [m.split("/")[-1][:25] for m in model_results.keys()]
810
- x = np.arange(len(model_names_short))
811
- width = 0.35 if has_secondary else 0.5
812
-
813
- # Primary dataset bars
814
- rs_primary = [model_results[m]["r"] for m in model_results]
815
- cis_primary = [model_results[m]["ci"] for m in model_results]
816
- yerr_lo = [r - ci[0] for r, ci in zip(rs_primary, cis_primary)]
817
- yerr_hi = [ci[1] - r for r, ci in zip(rs_primary, cis_primary)]
818
-
819
- # Color: highlight primary model differently
820
- colors_primary = ["#1E88E5"] + ["#90CAF9"] * (n_models - 1)
821
-
822
- offset = -width / 2 if has_secondary else 0
823
- bars1 = ax.bar(x + offset, rs_primary, width, label="Dataset 1 (pretrain)",
824
- color=colors_primary, edgecolor="black", linewidth=0.8)
825
- ax.errorbar(x + offset, rs_primary, yerr=[yerr_lo, yerr_hi],
826
- fmt="none", color="black", capsize=4)
827
-
828
- if has_secondary:
829
- rs_sec = [dataset_results.get(m, {}).get("r", 0) for m in model_results]
830
- cis_sec = [dataset_results.get(m, {}).get("ci", [0, 0]) for m in model_results]
831
- yerr_lo2 = [r - ci[0] for r, ci in zip(rs_sec, cis_sec)]
832
- yerr_hi2 = [ci[1] - r for r, ci in zip(rs_sec, cis_sec)]
833
- colors_sec = ["#E53935"] + ["#EF9A9A"] * (n_models - 1)
834
- bars2 = ax.bar(x + width / 2, rs_sec, width, label="Dataset 2 (pancreas)",
835
- color=colors_sec, edgecolor="black", linewidth=0.8)
836
- ax.errorbar(x + width / 2, rs_sec, yerr=[yerr_lo2, yerr_hi2],
837
- fmt="none", color="black", capsize=4)
838
-
839
- ax.axhline(0, color="gray", linewidth=0.8, linestyle="--")
840
- ax.set_xticks(x)
841
- ax.set_xticklabels(model_names_short, rotation=15, ha="right", fontsize=9)
842
- ax.set_ylabel("RSA Spearman r (Hallmarks)", fontsize=10)
843
- ax.set_title("Alignment Across Sentence Encoders",
844
- fontsize=12, fontweight="bold")
845
- ax.legend(fontsize=9)
846
-
847
- # Annotate p-values
848
- for i, m in enumerate(model_results):
849
- p = model_results[m]["p"]
850
- sig = "***" if p < 0.001 else "**" if p < 0.01 else "*" if p < 0.05 else "ns"
851
- y_pos = max(rs_primary[i], cis_primary[i][1]) + 0.02
852
- ax.text(i + offset, y_pos, sig, ha="center", fontsize=9, fontweight="bold")
853
-
854
- plt.tight_layout()
855
- savefig(fig, str(output_dir / "fig6_model_comparison"))
856
-
857
- return {"model_results": model_results, "dataset_results": dataset_results}
858
-
859
-
860
- # ============================================================
861
- # Deep Analysis: Alignment vs Module Size
862
- # ============================================================
863
-
864
- def analysis_alignment_vs_biomod_size(
865
- module_emb: np.ndarray,
866
- text_emb: np.ndarray,
867
- names: List[str],
868
- texts: List[str],
869
- module_json: str,
870
- output_dir: Path,
871
- ) -> Dict:
872
- """Scatter: n_genes vs alignment score. Tests if larger modules align better."""
873
- families = [parse_biomod_family(n) for n in names]
874
- hallmark_mask = np.array([f == "Hallmarks" for f in families])
875
- hallmark_idx = np.where(hallmark_mask)[0]
876
-
877
- h_module = module_emb[hallmark_idx]
878
- h_text = text_emb[hallmark_idx]
879
- h_names = [names[i] for i in hallmark_idx]
880
- h_texts = [texts[i] for i in hallmark_idx]
881
- n_h = len(hallmark_idx)
882
-
883
- # Per-module alignment
884
- module_sim = cosine_similarity(h_module)
885
- text_sim = cosine_similarity(h_text)
886
- per_module_corr = np.zeros(n_h)
887
- for i in range(n_h):
888
- mask = np.ones(n_h, dtype=bool)
889
- mask[i] = False
890
- r, _ = spearmanr(module_sim[i, mask], text_sim[i, mask])
891
- per_module_corr[i] = r
892
-
893
- # Module sizes
894
- module_sizes = get_biomod_sizes(module_json)
895
- h_sizes = np.array([module_sizes.get(n, 0) for n in h_names])
896
-
897
- # Correlation
898
- r_size, p_size = spearmanr(h_sizes, per_module_corr)
899
-
900
- # Figure
901
- fig, ax = plt.subplots(figsize=(7, 5))
902
- h_processes = [parse_hallmark_process(n) or "pathway" for n in h_names]
903
- colors = [PROCESS_COLORS.get(p, "#999999") for p in h_processes]
904
-
905
- ax.scatter(h_sizes, per_module_corr, c=colors, s=50, alpha=0.7,
906
- edgecolors="white", linewidths=0.5)
907
-
908
- # Trend line
909
- z = np.polyfit(h_sizes, per_module_corr, 1)
910
- p_line = np.poly1d(z)
911
- x_range = np.linspace(h_sizes.min(), h_sizes.max(), 100)
912
- ax.plot(x_range, p_line(x_range), "k--", linewidth=1.5, alpha=0.6)
913
-
914
- ax.axhline(0, color="gray", linewidth=0.8, linestyle=":")
915
- ax.set_xlabel("Number of Genes in Module", fontsize=10)
916
- ax.set_ylabel("Alignment Score (Spearman r)", fontsize=10)
917
- ax.set_title(f"Alignment vs Module Size\n"
918
- f"Spearman ρ = {r_size:.3f}, p = {p_size:.4f}",
919
- fontsize=11, fontweight="bold")
920
-
921
- # Label outliers
922
- for i in range(n_h):
923
- if abs(per_module_corr[i]) > 0.35 or h_sizes[i] > 250:
924
- ax.annotate(h_texts[i][:15], (h_sizes[i], per_module_corr[i]),
925
- fontsize=6, alpha=0.8, xytext=(5, 3),
926
- textcoords="offset points")
927
-
928
- plt.tight_layout()
929
- savefig(fig, str(output_dir / "fig_alignment_vs_size"))
930
-
931
- return {"spearman_r": float(r_size), "spearman_p": float(p_size)}
932
-
933
-
934
- # ============================================================
935
- # Deep Analysis: Negative Result Deep-Dive
936
- # ============================================================
937
-
938
- def analysis_negative_results(
939
- module_emb: np.ndarray,
940
- text_emb: np.ndarray,
941
- names: List[str],
942
- texts: List[str],
943
- output_dir: Path,
944
- k: int = 5,
945
- n_show: int = 5,
946
- ) -> Dict:
947
- """For poorly-aligned modules, show neighbor divergence."""
948
- families = [parse_biomod_family(n) for n in names]
949
- hallmark_mask = np.array([f == "Hallmarks" for f in families])
950
- hallmark_idx = np.where(hallmark_mask)[0]
951
-
952
- h_module = module_emb[hallmark_idx]
953
- h_text = text_emb[hallmark_idx]
954
- h_names = [names[i] for i in hallmark_idx]
955
- h_texts = [texts[i] for i in hallmark_idx]
956
- n_h = len(hallmark_idx)
957
-
958
- # Per-module alignment
959
- module_sim = cosine_similarity(h_module)
960
- text_sim = cosine_similarity(h_text)
961
- per_module_corr = np.zeros(n_h)
962
- for i in range(n_h):
963
- mask = np.ones(n_h, dtype=bool)
964
- mask[i] = False
965
- r, _ = spearmanr(module_sim[i, mask], text_sim[i, mask])
966
- per_module_corr[i] = r
967
-
968
- # Bottom n_show and top n_show
969
- bottom_idx = np.argsort(per_module_corr)[:n_show]
970
- top_idx = np.argsort(per_module_corr)[-n_show:][::-1]
971
-
972
- rows = []
973
- for idx_set, label in [(bottom_idx, "bottom"), (top_idx, "top")]:
974
- for i in idx_set:
975
- # kNN in module space
976
- module_nn = np.argsort(-module_sim[i])
977
- module_nn = [j for j in module_nn if j != i][:k]
978
- # kNN in text space
979
- text_nn = np.argsort(-text_sim[i])
980
- text_nn = [j for j in text_nn if j != i][:k]
981
- overlap = set(module_nn) & set(text_nn)
982
-
983
- rows.append({
984
- "rank": label,
985
- "hallmark": h_texts[i],
986
- "alignment_r": float(per_module_corr[i]),
987
- "module_neighbors": ", ".join([h_texts[j] for j in module_nn]),
988
- "text_neighbors": ", ".join([h_texts[j] for j in text_nn]),
989
- "knn_overlap": len(overlap),
990
- })
991
-
992
- df = pd.DataFrame(rows)
993
- df.to_csv(output_dir / "negative_analysis.csv", index=False)
994
- logger.info("Saved negative_analysis.csv (%d rows)", len(df))
995
-
996
- return {
997
- "bottom_5": [{
998
- "hallmark": r["hallmark"],
999
- "r": r["alignment_r"],
1000
- "module_nn": r["module_neighbors"],
1001
- "text_nn": r["text_neighbors"],
1002
- } for _, r in df[df["rank"] == "bottom"].iterrows()],
1003
- "top_5": [{
1004
- "hallmark": r["hallmark"],
1005
- "r": r["alignment_r"],
1006
- } for _, r in df[df["rank"] == "top"].iterrows()],
1007
- }
1008
-
1009
-
1010
- # ============================================================
1011
- # Deep Analysis: kNN Overlap with Bootstrap CI
1012
- # ============================================================
1013
-
1014
- def analysis_knn_overlap(
1015
- module_emb: np.ndarray,
1016
- text_emb: np.ndarray,
1017
- names: List[str],
1018
- n_permutations: int = 1000,
1019
- n_bootstrap: int = 2000,
1020
- k: int = 5,
1021
- seed: int = 42,
1022
- output_dir: Path = None,
1023
- ) -> Dict:
1024
- """kNN overlap with bootstrap CI and per-category breakdown."""
1025
- families = [parse_biomod_family(n) for n in names]
1026
- hallmark_mask = np.array([f == "Hallmarks" for f in families])
1027
- hallmark_idx = np.where(hallmark_mask)[0]
1028
-
1029
- h_module = module_emb[hallmark_idx]
1030
- h_text = text_emb[hallmark_idx]
1031
- h_names = [names[i] for i in hallmark_idx]
1032
- n_h = len(hallmark_idx)
1033
-
1034
- # Compute kNN overlap
1035
- module_sim = cosine_similarity(h_module)
1036
- text_sim = cosine_similarity(h_text)
1037
-
1038
- overlaps = np.zeros(n_h)
1039
- for i in range(n_h):
1040
- module_nn = set(np.argsort(-module_sim[i])[1:k + 1])
1041
- text_nn = set(np.argsort(-text_sim[i])[1:k + 1])
1042
- overlaps[i] = len(module_nn & text_nn) / k
1043
-
1044
- # Bootstrap CI
1045
- mean_obs, ci_lo, ci_hi = bootstrap_ci(overlaps, np.mean, n_bootstrap, seed=seed)
1046
-
1047
- # Null distribution (permutation)
1048
- rng = np.random.default_rng(seed)
1049
- null_means = np.zeros(n_permutations)
1050
- for p in range(n_permutations):
1051
- perm = rng.permutation(n_h)
1052
- text_sim_perm = text_sim[perm][:, perm]
1053
- ov_perm = np.zeros(n_h)
1054
- for i in range(n_h):
1055
- module_nn = set(np.argsort(-module_sim[i])[1:k + 1])
1056
- text_nn = set(np.argsort(-text_sim_perm[i])[1:k + 1])
1057
- ov_perm[i] = len(module_nn & text_nn) / k
1058
- null_means[p] = ov_perm.mean()
1059
-
1060
- p_value = float((null_means >= mean_obs).sum() / n_permutations)
1061
- null_mean = float(null_means.mean())
1062
- null_std = float(null_means.std())
1063
- z_score = (mean_obs - null_mean) / null_std if null_std > 0 else 0.0
1064
-
1065
- # Per-category breakdown
1066
- h_processes = [parse_hallmark_process(n) or "pathway" for n in h_names]
1067
- cat_overlaps = defaultdict(list)
1068
- for i in range(n_h):
1069
- cat_overlaps[h_processes[i]].append(overlaps[i])
1070
-
1071
- return {
1072
- "knn_overlap_mean": mean_obs,
1073
- "knn_overlap_ci_95": [ci_lo, ci_hi],
1074
- "null_mean": null_mean,
1075
- "null_std": null_std,
1076
- "p_value": p_value,
1077
- "z_score": z_score,
1078
- "k": k,
1079
- "category_means": {c: float(np.mean(v)) for c, v in cat_overlaps.items()},
1080
- }
1081
-
1082
-
1083
- # ============================================================
1084
- # Main
1085
- # ============================================================
1086
-
1087
- def main():
1088
- args = parse_args()
1089
- output_dir = Path(args.output_dir)
1090
- output_dir.mkdir(parents=True, exist_ok=True)
1091
-
1092
- # Setup logging
1093
- log_file = output_dir / "run_publication.log"
1094
- logging.basicConfig(
1095
- level=logging.INFO,
1096
- format="%(asctime)s [%(levelname)s] %(message)s",
1097
- handlers=[
1098
- logging.FileHandler(log_file, mode="w", encoding="utf-8"),
1099
- logging.StreamHandler(),
1100
- ],
1101
- )
1102
- logger.info("Publication analysis starting")
1103
- logger.info("Args: %s", vars(args))
1104
-
1105
- # ---- Load data ----
1106
- module_names = load_biomod_names(args.module_json)
1107
- all_module_emb = np.load(args.module_embedding_path)
1108
- logger.info("Loaded module embeddings: %s from %s",
1109
- all_module_emb.shape, args.module_embedding_path)
1110
-
1111
- # Filter to semantic modules (Hallmarks + Reactome)
1112
- families = [parse_biomod_family(n) for n in module_names]
1113
- semantic_mask = np.array([f in ("Hallmarks", "Reactome") for f in families])
1114
- semantic_idx = np.where(semantic_mask)[0]
1115
- module_emb = all_module_emb[semantic_idx]
1116
- names = [module_names[i] for i in semantic_idx]
1117
- logger.info("Semantic modules: %d (Hallmarks + Reactome)", len(names))
1118
-
1119
- # ---- Build text descriptions and encode ----
1120
- desc_file = getattr(args, "descriptions_file", None)
1121
- _, _, texts = build_text_descriptions(names, descriptions_file=desc_file)
1122
- logger.info("Encoding %d texts with primary model: %s", len(texts), args.sentence_model)
1123
- text_emb = encode_texts(texts, args.sentence_model, device=args.device)
1124
- logger.info("Text embeddings: %s", text_emb.shape)
1125
-
1126
- # ---- Core metrics (RSA + Mantel) ----
1127
- logger.info("Computing RSA + Mantel test (n_perm=%d)...", args.n_permutations)
1128
- mantel_results = mantel_test(module_emb, text_emb, n_perm=args.n_permutations)
1129
- logger.info("RSA r=%.4f, p=%.4f, z=%.1f",
1130
- mantel_results["rsa_spearman_observed"],
1131
- mantel_results["rsa_p_value"],
1132
- mantel_results["rsa_effect_size_z"])
1133
-
1134
- # ---- Figure 1: RSA Overview ----
1135
- logger.info("Generating Figure 1: RSA Overview...")
1136
- metrics_fig1 = fig1_rsa_overview(
1137
- module_emb, text_emb, mantel_results, output_dir,
1138
- n_bootstrap=args.n_bootstrap, seed=args.seed,
1139
- )
1140
-
1141
- # ---- Figure 2: Hallmarks Heatmaps ----
1142
- logger.info("Generating Figure 2: Hallmarks Heatmaps...")
1143
- metrics_fig2 = fig2_hallmarks_heatmaps(
1144
- module_emb, text_emb, names, texts, output_dir,
1145
- )
1146
-
1147
- # ---- Figure 3: Per-Hallmark Alignment ----
1148
- logger.info("Generating Figure 3: Per-Hallmark Alignment...")
1149
- metrics_fig3 = fig3_per_hallmark_alignment(
1150
- module_emb, text_emb, names, texts, output_dir,
1151
- n_bootstrap=args.n_bootstrap, seed=args.seed,
1152
- )
1153
-
1154
- # ---- Figure 4: Category Alignment ----
1155
- logger.info("Generating Figure 4: Category Alignment...")
1156
- metrics_fig4 = fig4_category_alignment(
1157
- module_emb, text_emb, names, texts, output_dir,
1158
- )
1159
-
1160
- # ---- Figure 5: Granularity Effect ----
1161
- logger.info("Generating Figure 5: Granularity Effect...")
1162
- metrics_fig5 = fig5_granularity_effect(
1163
- module_emb, text_emb, names, texts,
1164
- n_permutations=args.n_permutations,
1165
- n_bootstrap=args.n_bootstrap,
1166
- seed=args.seed,
1167
- output_dir=output_dir,
1168
- )
1169
-
1170
- # ---- Figure 6: Multi-Model Comparison (optional) ----
1171
- metrics_fig6 = {}
1172
- compare_models = []
1173
- compare_embs = []
1174
- if args.sentence_models_compare:
1175
- compare_models = [m.strip() for m in args.sentence_models_compare.split(",")
1176
- if m.strip()]
1177
- logger.info("Loading %d comparison models...", len(compare_models))
1178
- for model_name in compare_models:
1179
- logger.info(" Encoding with: %s", model_name)
1180
- try:
1181
- emb = encode_texts(texts, model_name, device=args.device)
1182
- compare_embs.append(emb)
1183
- except Exception as e:
1184
- logger.warning(" Failed to load %s: %s", model_name, e)
1185
- compare_models.remove(model_name)
1186
-
1187
- module_emb2 = None
1188
- if args.module_embedding_path2:
1189
- all_module_emb2 = np.load(args.module_embedding_path2)
1190
- module_emb2 = all_module_emb2[semantic_idx]
1191
- logger.info("Loaded secondary dataset: %s", all_module_emb2.shape)
1192
-
1193
- if compare_models:
1194
- logger.info("Generating Figure 6: Model Comparison...")
1195
- metrics_fig6 = fig6_model_comparison(
1196
- module_emb, module_emb2, text_emb, names, texts,
1197
- primary_model_name=args.sentence_model.split("/")[-1],
1198
- compare_model_names=compare_models,
1199
- compare_text_embs=compare_embs,
1200
- n_permutations=args.n_permutations,
1201
- n_bootstrap=args.n_bootstrap,
1202
- seed=args.seed,
1203
- output_dir=output_dir,
1204
- )
1205
-
1206
- # ---- Deep Analysis ----
1207
- logger.info("Running deep analysis: alignment vs module size...")
1208
- metrics_size = analysis_alignment_vs_biomod_size(
1209
- module_emb, text_emb, names, texts, args.module_json, output_dir,
1210
- )
1211
-
1212
- logger.info("Running deep analysis: negative results...")
1213
- metrics_neg = analysis_negative_results(
1214
- module_emb, text_emb, names, texts, output_dir,
1215
- )
1216
-
1217
- logger.info("Running deep analysis: kNN overlap with bootstrap...")
1218
- metrics_knn = analysis_knn_overlap(
1219
- module_emb, text_emb, names,
1220
- n_permutations=args.n_permutations,
1221
- n_bootstrap=args.n_bootstrap,
1222
- seed=args.seed,
1223
- output_dir=output_dir,
1224
- )
1225
- logger.info("kNN overlap: %.3f [%.3f, %.3f], null=%.3f, p=%.4f",
1226
- metrics_knn["knn_overlap_mean"],
1227
- metrics_knn["knn_overlap_ci_95"][0],
1228
- metrics_knn["knn_overlap_ci_95"][1],
1229
- metrics_knn["null_mean"],
1230
- metrics_knn["p_value"])
1231
-
1232
- # ---- Cross-dataset RSA (if secondary available) ----
1233
- metrics_cross = {}
1234
- if module_emb2 is not None:
1235
- logger.info("Cross-dataset RSA (secondary dataset)...")
1236
- mantel2 = mantel_test(module_emb2, text_emb, n_perm=args.n_permutations)
1237
- r2, ci2_lo, ci2_hi = bootstrap_rsa(
1238
- module_emb2, text_emb, args.n_bootstrap, args.seed
1239
- )
1240
- metrics_cross = {
1241
- "secondary_rsa_r": r2,
1242
- "secondary_rsa_ci_95": [ci2_lo, ci2_hi],
1243
- "secondary_mantel_p": mantel2["rsa_p_value"],
1244
- "secondary_mantel_z": mantel2["rsa_effect_size_z"],
1245
- }
1246
- logger.info("Secondary RSA: r=%.4f [%.4f, %.4f], p=%.4f",
1247
- r2, ci2_lo, ci2_hi, mantel2["rsa_p_value"])
1248
-
1249
- # ---- Aggregate and save metrics ----
1250
- all_metrics = {
1251
- "primary_dataset": args.module_embedding_path,
1252
- "secondary_dataset": args.module_embedding_path2,
1253
- "sentence_model": args.sentence_model,
1254
- "n_modules": len(names),
1255
- "n_permutations": args.n_permutations,
1256
- "n_bootstrap": args.n_bootstrap,
1257
- "fig1_rsa_overview": metrics_fig1,
1258
- "fig2_heatmaps": metrics_fig2,
1259
- "fig3_per_hallmark": metrics_fig3,
1260
- "fig4_category": metrics_fig4,
1261
- "fig5_granularity": metrics_fig5,
1262
- "fig6_model_comparison": metrics_fig6,
1263
- "alignment_vs_size": metrics_size,
1264
- "negative_analysis": metrics_neg,
1265
- "knn_overlap": metrics_knn,
1266
- "cross_dataset": metrics_cross,
1267
- }
1268
-
1269
- metrics_path = output_dir / "publication_metrics.json"
1270
- with open(metrics_path, "w", encoding="utf-8") as f:
1271
- json.dump(all_metrics, f, indent=2, ensure_ascii=False, default=str)
1272
- logger.info("Saved: %s", metrics_path)
1273
- logger.info("Publication analysis complete. Output: %s", output_dir)
1274
-
1275
-
1276
- if __name__ == "__main__":
1277
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
BioMod/interpretability/biomod_text_alignment/fetch_reactome_descriptions.py DELETED
@@ -1,97 +0,0 @@
1
- """Fetch Reactome pathway descriptions via the Content Service REST API.
2
-
3
- Produces a JSON file mapping module_name -> summaryText that can be passed
4
- to biomod_text_alignment_extended.py via --descriptions_file.
5
-
6
- Usage (run from the BioMod repository root):
7
- python BioMod/interpretability/biomod_text_alignment/fetch_reactome_descriptions.py \
8
- --biomod_json model_dependencies/biomod_data_all/merged_biomods.json \
9
- --output reactome_descriptions.json
10
-
11
- Requires internet access to https://reactome.org/ContentService/
12
- """
13
- import argparse
14
- import json
15
- import sys
16
- import time
17
- from pathlib import Path
18
-
19
- try:
20
- import requests
21
- except ImportError:
22
- print("ERROR: 'requests' package required. Install: pip install requests")
23
- sys.exit(1)
24
-
25
- HERE = Path(__file__).resolve().parent
26
- INTERP_DIR = HERE.parent
27
- BIOMOD_SOURCE = INTERP_DIR.parent
28
- for p in (str(INTERP_DIR), str(BIOMOD_SOURCE)):
29
- if p not in sys.path:
30
- sys.path.insert(0, p)
31
-
32
- from interpretability.utils import load_biomod_names, parse_biomod_family
33
-
34
- REACTOME_API = "https://reactome.org/ContentService/data/query"
35
-
36
-
37
- def fetch_summary(rhs_id: str, retries: int = 3) -> str:
38
- """Fetch summaryText for a Reactome pathway."""
39
- url = f"{REACTOME_API}/{rhs_id}"
40
- for attempt in range(retries):
41
- try:
42
- resp = requests.get(url, timeout=10)
43
- if resp.status_code == 200:
44
- data = resp.json()
45
- return data.get("summation", [{}])[0].get("text", "")
46
- elif resp.status_code == 404:
47
- return ""
48
- elif resp.status_code == 429:
49
- time.sleep(2 ** attempt)
50
- else:
51
- return ""
52
- except (requests.RequestException, json.JSONDecodeError):
53
- time.sleep(1)
54
- return ""
55
-
56
-
57
- def main():
58
- parser = argparse.ArgumentParser(description=__doc__)
59
- parser.add_argument("--biomod_json", "--module_json", type=str, required=True, dest='module_json')
60
- parser.add_argument("--output", type=str, default="reactome_descriptions.json")
61
- parser.add_argument("--delay", type=float, default=0.1,
62
- help="Delay between API calls (seconds)")
63
- args = parser.parse_args()
64
-
65
- module_names = load_biomod_names(args.module_json)
66
- reactome_modules = [n for n in module_names if parse_biomod_family(n) == "Reactome"]
67
- print(f"Reactome modules to fetch: {len(reactome_modules)}")
68
-
69
- descriptions = {}
70
- for i, pname in enumerate(reactome_modules):
71
- raw = pname.split("__", 1)[1]
72
- parts = raw.rsplit(" R-HSA-", 1)
73
- if len(parts) != 2:
74
- continue
75
- rhs_id = "R-HSA-" + parts[1]
76
-
77
- summary = fetch_summary(rhs_id)
78
- if summary:
79
- # Strip HTML tags
80
- import re
81
- summary = re.sub(r"<[^>]+>", "", summary).strip()
82
- descriptions[pname] = summary
83
-
84
- if (i + 1) % 50 == 0:
85
- print(f" Fetched {i+1}/{len(reactome_modules)}, "
86
- f"got descriptions for {len(descriptions)}")
87
- time.sleep(args.delay)
88
-
89
- print(f"\nDone. Got descriptions for {len(descriptions)}/{len(reactome_modules)} pathways.")
90
-
91
- with open(args.output, "w", encoding="utf-8") as f:
92
- json.dump(descriptions, f, indent=2, ensure_ascii=False)
93
- print(f"Saved: {args.output}")
94
-
95
-
96
- if __name__ == "__main__":
97
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
BioMod/interpretability/biomod_text_alignment/reactome_descriptions.json DELETED
The diff for this file is too large to render. See raw diff
 
BioMod/interpretability/utils.py DELETED
@@ -1,200 +0,0 @@
1
- """Shared helpers for module interpretability experiments.
2
-
3
- Provides:
4
- * module family / hallmark-process parsing from merged_biomods.json keys
5
- * a generic in-memory dataset that yields raw [G_input] expression rows,
6
- compatible with DataPreprocessor.reindex_tensor_to_model_space
7
- """
8
- from __future__ import annotations
9
-
10
- import json
11
- import logging
12
- import os
13
- from pathlib import Path
14
- from typing import Dict, List, Optional, Sequence
15
-
16
- import numpy as np
17
- import torch
18
- from scipy import sparse
19
- from torch.utils.data import Dataset
20
-
21
- logger = logging.getLogger(__name__)
22
-
23
-
24
- # ---------- 1. Module family parsing ----------
25
-
26
- MODULE_FAMILY_MAP: Dict[str, str] = {
27
- "module1_hallmarks": "Hallmarks",
28
- "module2_reactome_2022_filtered": "Reactome",
29
- "module3_hotspot_260k": "Hotspot",
30
- "module3_processed": "Processed",
31
- }
32
-
33
- # MSigDB hallmark official process categories. Reference:
34
- # https://www.gsea-msigdb.org/gsea/msigdb/human/genesets.jsp, collection=H
35
- HALLMARK_PROCESS: Dict[str, str] = {
36
- "ADIPOGENESIS": "metabolic",
37
- "ALLOGRAFT_REJECTION": "immune",
38
- "ANDROGEN_RESPONSE": "signaling",
39
- "ANGIOGENESIS": "development",
40
- "APICAL_JUNCTION": "cellular_component",
41
- "APICAL_SURFACE": "cellular_component",
42
- "APOPTOSIS": "pathway",
43
- "BILE_ACID_METABOLISM": "metabolic",
44
- "CHOLESTEROL_HOMEOSTASIS": "metabolic",
45
- "COAGULATION": "immune",
46
- "COMPLEMENT": "immune",
47
- "DNA_REPAIR": "dna_damage",
48
- "E2F_TARGETS": "proliferation",
49
- "EPITHELIAL_MESENCHYMAL_TRANSITION": "development",
50
- "ESTROGEN_RESPONSE_EARLY": "signaling",
51
- "ESTROGEN_RESPONSE_LATE": "signaling",
52
- "FATTY_ACID_METABOLISM": "metabolic",
53
- "G2M_CHECKPOINT": "proliferation",
54
- "GLYCOLYSIS": "metabolic",
55
- "HEDGEHOG_SIGNALING": "signaling",
56
- "HEME_METABOLISM": "metabolic",
57
- "HYPOXIA": "pathway",
58
- "IL2_STAT5_SIGNALING": "immune",
59
- "IL6_JAK_STAT3_SIGNALING": "immune",
60
- "INFLAMMATORY_RESPONSE": "immune",
61
- "INTERFERON_ALPHA_RESPONSE": "immune",
62
- "INTERFERON_GAMMA_RESPONSE": "immune",
63
- "KRAS_SIGNALING_DN": "signaling",
64
- "KRAS_SIGNALING_UP": "signaling",
65
- "MITOTIC_SPINDLE": "proliferation",
66
- "MTORC1_SIGNALING": "signaling",
67
- "MYC_TARGETS_V1": "proliferation",
68
- "MYC_TARGETS_V2": "proliferation",
69
- "MYOGENESIS": "development",
70
- "NOTCH_SIGNALING": "signaling",
71
- "OXIDATIVE_PHOSPHORYLATION": "metabolic",
72
- "P53_PATHWAY": "proliferation",
73
- "PANCREAS_BETA_CELLS": "development",
74
- "PEROXISOME": "cellular_component",
75
- "PI3K_AKT_MTOR_SIGNALING": "signaling",
76
- "PROTEIN_SECRETION": "pathway",
77
- "REACTIVE_OXYGEN_SPECIES_PATHWAY": "pathway",
78
- "SPERMATOGENESIS": "development",
79
- "TGF_BETA_SIGNALING": "signaling",
80
- "TNFA_SIGNALING_VIA_NFKB": "immune",
81
- "UNFOLDED_PROTEIN_RESPONSE": "pathway",
82
- "UV_RESPONSE_DN": "dna_damage",
83
- "UV_RESPONSE_UP": "dna_damage",
84
- "WNT_BETA_CATENIN_SIGNALING": "signaling",
85
- "XENOBIOTIC_METABOLISM": "metabolic",
86
- }
87
-
88
-
89
- def parse_biomod_family(module_name: str) -> str:
90
- head = module_name.split("__", 1)[0]
91
- return MODULE_FAMILY_MAP.get(head, "Other")
92
-
93
-
94
- def parse_hallmark_process(module_name: str) -> Optional[str]:
95
- if not module_name.startswith("module1_hallmarks__"):
96
- return None
97
- short = module_name.split("__", 1)[1].replace("HALLMARK_", "", 1)
98
- return HALLMARK_PROCESS.get(short)
99
-
100
-
101
- def build_biomod_metadata(module_names: Sequence[str]):
102
- rows = []
103
- for name in module_names:
104
- rows.append({
105
- "module_name": name,
106
- "family": parse_biomod_family(name),
107
- "hallmark_process": parse_hallmark_process(name),
108
- })
109
- return rows
110
-
111
-
112
- def load_biomod_names(module_json_path: str) -> List[str]:
113
- with open(module_json_path, "r", encoding="utf-8") as handle:
114
- return list(json.load(handle).keys())
115
-
116
-
117
- # ---------- 2. Generic in-memory dataset ----------
118
-
119
- class MatrixRowDataset(Dataset):
120
- """Yield dense float32 rows from a (sparse or dense) [N, G] matrix.
121
-
122
- Used for inference-only flows where DataPreprocessor.reindex_tensor_to_model_space
123
- will project [B, G_input] -> [B, G_model].
124
- """
125
-
126
- def __init__(self, matrix):
127
- if sparse.issparse(matrix):
128
- self.matrix = matrix.tocsr()
129
- self._sparse = True
130
- else:
131
- self.matrix = np.asarray(matrix, dtype=np.float32)
132
- self._sparse = False
133
- self.n = self.matrix.shape[0]
134
-
135
- def __len__(self) -> int:
136
- return self.n
137
-
138
- def __getitem__(self, idx: int) -> torch.Tensor:
139
- row = self.matrix[idx]
140
- if self._sparse:
141
- row = row.toarray().ravel()
142
- else:
143
- row = np.asarray(row).reshape(-1)
144
- return torch.from_numpy(row.astype(np.float32, copy=False))
145
-
146
-
147
- # ---------- 3. NPZ cell dataset (pretrain data) ----------
148
-
149
- class NpzCellDataset(Dataset):
150
- """Lazily load per-cell npz files from a directory.
151
-
152
- Supports two naming conventions:
153
- * sequential: cell0.npz, cell1.npz, ... (set ``cell_num`` to total count)
154
- * listing: scan directory for all *.npz files (slower init, no cell_num needed)
155
- """
156
-
157
- def __init__(
158
- self,
159
- data_dir: str,
160
- cell_num: Optional[int] = None,
161
- max_cells: Optional[int] = None,
162
- seed: int = 0,
163
- ):
164
- self.data_dir = data_dir
165
- if cell_num is not None and cell_num > 0:
166
- self.sequential = True
167
- total = cell_num
168
- else:
169
- self.sequential = False
170
- self._files = sorted(
171
- f for f in os.listdir(data_dir) if f.endswith(".npz")
172
- )
173
- total = len(self._files)
174
-
175
- if max_cells is not None and max_cells < total:
176
- rng = np.random.default_rng(seed)
177
- self.indices = np.sort(rng.choice(total, size=max_cells, replace=False))
178
- else:
179
- self.indices = np.arange(total)
180
-
181
- def __len__(self) -> int:
182
- return len(self.indices)
183
-
184
- def __getitem__(self, idx: int) -> torch.Tensor:
185
- real = int(self.indices[idx])
186
- if self.sequential:
187
- path = os.path.join(self.data_dir, f"cell{real}.npz")
188
- else:
189
- path = os.path.join(self.data_dir, self._files[real])
190
- sp = sparse.load_npz(path)
191
- arr = sp.toarray().squeeze() if sparse.issparse(sp) else np.asarray(sp).squeeze()
192
- return torch.from_numpy(arr.astype(np.float32, copy=False))
193
-
194
-
195
- # ---------- 4. Misc ----------
196
-
197
- def ensure_dir(path: str) -> Path:
198
- p = Path(path)
199
- p.mkdir(parents=True, exist_ok=True)
200
- return p
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
README.md CHANGED
@@ -12,8 +12,7 @@ library_name: pytorch
12
 
13
  BioMod is a single-cell transcriptomics foundation model built around gene modules
14
  and an attention-based encoder. This repository provides pretrained model assets,
15
- pretraining and fine-tuning code, downstream task tutorials, and interpretability
16
- tools.
17
 
18
  BioMod is released by **CTC-Atlas**, the joint laboratory of **Raymind Biotechnology
19
  Co., Ltd** and the **Shenzhen Research Institute of Big Data (SRIBD)**.
@@ -88,14 +87,13 @@ to end with the bundled checkpoint.
88
  | Drug-pair synergy classification | [Training script](BioMod/drug_combination/train_drug_combination_biomod.py) | [Notebook](BioMod/toturials/drug_combination.ipynb) |
89
  | Gene perturbation response prediction | [Training script](BioMod/gene_perturbation/train_biomod.py) | [Notebook](BioMod/toturials/gene_perturbation.ipynb) |
90
 
91
- ### Pretraining and analysis scripts
92
 
93
- The repository also includes the following supporting workflows.
94
 
95
  | Workflow | Implementation | Documentation |
96
  | --- | --- | --- |
97
  | Pretraining and continued pretraining | [Pretraining script](BioMod/biomod.py) | [Source guide](BioMod/README.md), [launcher reference](BioMod/command.txt) |
98
- | Module interpretability analysis | [Module geometry](BioMod/interpretability/biomod_geometry.py), [module importance](BioMod/interpretability/biomod_importance/biomod_importance.py), [text alignment](BioMod/interpretability/biomod_text_alignment/biomod_text_alignment.py) | [Overview](BioMod/interpretability/README.md), [attention](BioMod/interpretability/attention/README.md), [module importance guide](BioMod/interpretability/biomod_importance/README.md) |
99
 
100
  Bundled input defaults resolve from each script's location. Command examples
101
  state their working directory; explicit relative command-line paths are resolved
@@ -139,7 +137,7 @@ Output directories are created when the corresponding operation is enabled.
139
  | --- | --- | --- |
140
  | Drug response / drug combination | `--checkpoint_path` | `--hyper_params_path` |
141
  | Zero-shot / gene perturbation | `--scfm_ckpt_path` | `--scfm_hyper_params_path` |
142
- | Cell annotation / backbone interpretation | `--biomod_checkpoint` | `--config_pkl` |
143
  | Continued pretraining | `--model_path` | `--model_config_path` or paired-file discovery |
144
 
145
  ### Data and evaluation
@@ -173,7 +171,7 @@ readability. Folder descriptions explain where to find each component.
173
 
174
  ```text
175
  BioMod/ # Project root: source code, model dependencies and weights
176
- β”œβ”€β”€ BioMod/ # Task code: training, evaluation, interpretability and tutorials
177
  β”‚ β”œβ”€β”€ cell_annotation/ # Cell-type annotation and model fine-tuning
178
  β”‚ β”‚ β”œβ”€β”€ biomod_data/ # Hallmark and Reactome modules with loading utilities
179
  β”‚ β”‚ β”‚ β”œβ”€β”€ load_biomod.py
@@ -197,41 +195,6 @@ BioMod/ # Project root: source
197
  β”‚ β”‚ β”œβ”€β”€ scfm_utils_local.py
198
  β”‚ β”‚ β”œβ”€β”€ train_biomod.py
199
  β”‚ β”‚ └── utils.py
200
- β”‚ β”œβ”€β”€ interpretability/ # Module representations and biological interpretability
201
- β”‚ β”‚ β”œβ”€β”€ attention/ # Module crosstalk and within-module gene attention
202
- β”‚ β”‚ β”‚ β”œβ”€β”€ __init__.py
203
- β”‚ β”‚ β”‚ β”œβ”€β”€ biomod_crosstalk.py
204
- β”‚ β”‚ β”‚ β”œβ”€β”€ biomod_gene_attention.py
205
- β”‚ β”‚ β”‚ β”œβ”€β”€ README.md
206
- β”‚ β”‚ β”‚ └── README_gene_attention.md
207
- β”‚ β”‚ β”œβ”€β”€ biomod_importance/ # Module importance, ablation studies and marker-gene validation
208
- β”‚ β”‚ β”‚ β”œβ”€β”€ __init__.py
209
- β”‚ β”‚ β”‚ β”œβ”€β”€ cross_class_similarity.py
210
- β”‚ β”‚ β”‚ β”œβ”€β”€ family_ablation.py
211
- β”‚ β”‚ β”‚ β”œβ”€β”€ family_importance_analysis.py
212
- β”‚ β”‚ β”‚ β”œβ”€β”€ marker_gene_verification.py
213
- β”‚ β”‚ β”‚ β”œβ”€β”€ masking_ablation.py
214
- β”‚ β”‚ β”‚ β”œβ”€β”€ biomod_importance.py
215
- β”‚ β”‚ β”‚ β”œβ”€β”€ biomod_importance_publication.py
216
- β”‚ β”‚ β”‚ β”œβ”€β”€ pub_utils.py
217
- β”‚ β”‚ β”‚ β”œβ”€β”€ random_baseline.py
218
- β”‚ β”‚ β”‚ β”œβ”€β”€ README.md
219
- β”‚ β”‚ β”‚ β”œβ”€β”€ RESULTS.md
220
- β”‚ β”‚ β”‚ β”œβ”€β”€ tcell_reactome_analysis.py
221
- β”‚ β”‚ β”‚ └── TODO.md
222
- β”‚ β”‚ β”œβ”€β”€ biomod_text_alignment/ # Semantic alignment of module representations and pathway text
223
- β”‚ β”‚ β”‚ β”œβ”€β”€ __init__.py
224
- β”‚ β”‚ β”‚ β”œβ”€β”€ DESIGN.md
225
- β”‚ β”‚ β”‚ β”œβ”€β”€ fetch_reactome_descriptions.py
226
- β”‚ β”‚ β”‚ β”œβ”€β”€ MANUSCRIPT_SECTION.md
227
- β”‚ β”‚ β”‚ β”œβ”€β”€ biomod_text_alignment.py
228
- β”‚ β”‚ β”‚ β”œβ”€β”€ biomod_text_alignment_extended.py
229
- β”‚ β”‚ β”‚ β”œβ”€β”€ biomod_text_alignment_publication.py
230
- β”‚ β”‚ β”‚ └── reactome_descriptions.json
231
- β”‚ β”‚ β”œβ”€β”€ __init__.py
232
- β”‚ β”‚ β”œβ”€β”€ biomod_geometry.py
233
- β”‚ β”‚ β”œβ”€β”€ README.md
234
- β”‚ β”‚ └── utils.py
235
  β”‚ β”œβ”€β”€ tests/ # CPU tests for shared training utilities
236
  β”‚ β”‚ └── test_training_utils.py
237
  β”‚ β”œβ”€β”€ toturials/ # Jupyter tutorials for five downstream tasks
 
12
 
13
  BioMod is a single-cell transcriptomics foundation model built around gene modules
14
  and an attention-based encoder. This repository provides pretrained model assets,
15
+ pretraining and fine-tuning code, and downstream task tutorials.
 
16
 
17
  BioMod is released by **CTC-Atlas**, the joint laboratory of **Raymind Biotechnology
18
  Co., Ltd** and the **Shenzhen Research Institute of Big Data (SRIBD)**.
 
87
  | Drug-pair synergy classification | [Training script](BioMod/drug_combination/train_drug_combination_biomod.py) | [Notebook](BioMod/toturials/drug_combination.ipynb) |
88
  | Gene perturbation response prediction | [Training script](BioMod/gene_perturbation/train_biomod.py) | [Notebook](BioMod/toturials/gene_perturbation.ipynb) |
89
 
90
+ ### Pretraining
91
 
92
+ The repository also supports pretraining and continued pretraining.
93
 
94
  | Workflow | Implementation | Documentation |
95
  | --- | --- | --- |
96
  | Pretraining and continued pretraining | [Pretraining script](BioMod/biomod.py) | [Source guide](BioMod/README.md), [launcher reference](BioMod/command.txt) |
 
97
 
98
  Bundled input defaults resolve from each script's location. Command examples
99
  state their working directory; explicit relative command-line paths are resolved
 
137
  | --- | --- | --- |
138
  | Drug response / drug combination | `--checkpoint_path` | `--hyper_params_path` |
139
  | Zero-shot / gene perturbation | `--scfm_ckpt_path` | `--scfm_hyper_params_path` |
140
+ | Cell annotation | `--biomod_checkpoint` | `--config_pkl` |
141
  | Continued pretraining | `--model_path` | `--model_config_path` or paired-file discovery |
142
 
143
  ### Data and evaluation
 
171
 
172
  ```text
173
  BioMod/ # Project root: source code, model dependencies and weights
174
+ β”œβ”€β”€ BioMod/ # Task code: training, evaluation and tutorials
175
  β”‚ β”œβ”€β”€ cell_annotation/ # Cell-type annotation and model fine-tuning
176
  β”‚ β”‚ β”œβ”€β”€ biomod_data/ # Hallmark and Reactome modules with loading utilities
177
  β”‚ β”‚ β”‚ β”œβ”€β”€ load_biomod.py
 
195
  β”‚ β”‚ β”œβ”€β”€ scfm_utils_local.py
196
  β”‚ β”‚ β”œβ”€β”€ train_biomod.py
197
  β”‚ β”‚ └── utils.py
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
198
  β”‚ β”œβ”€β”€ tests/ # CPU tests for shared training utilities
199
  β”‚ β”‚ └── test_training_utils.py
200
  β”‚ β”œβ”€β”€ toturials/ # Jupyter tutorials for five downstream tasks