Remove interpretability directory and update README guides
Browse filesRemove 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.
- BioMod/README.md +1 -36
- BioMod/interpretability/README.md +0 -361
- BioMod/interpretability/__init__.py +0 -8
- BioMod/interpretability/attention/README.md +0 -111
- BioMod/interpretability/attention/README_gene_attention.md +0 -112
- BioMod/interpretability/attention/__init__.py +0 -0
- BioMod/interpretability/attention/biomod_crosstalk.py +0 -606
- BioMod/interpretability/attention/biomod_gene_attention.py +0 -1093
- BioMod/interpretability/biomod_geometry.py +0 -655
- BioMod/interpretability/biomod_importance/README.md +0 -319
- BioMod/interpretability/biomod_importance/RESULTS.md +0 -226
- BioMod/interpretability/biomod_importance/TODO.md +0 -68
- BioMod/interpretability/biomod_importance/__init__.py +0 -0
- BioMod/interpretability/biomod_importance/biomod_importance.py +0 -851
- BioMod/interpretability/biomod_importance/biomod_importance_publication.py +0 -382
- BioMod/interpretability/biomod_importance/cross_class_similarity.py +0 -247
- BioMod/interpretability/biomod_importance/family_ablation.py +0 -370
- BioMod/interpretability/biomod_importance/family_importance_analysis.py +0 -417
- BioMod/interpretability/biomod_importance/marker_gene_verification.py +0 -321
- BioMod/interpretability/biomod_importance/masking_ablation.py +0 -375
- BioMod/interpretability/biomod_importance/pub_utils.py +0 -206
- BioMod/interpretability/biomod_importance/random_baseline.py +0 -333
- BioMod/interpretability/biomod_importance/tcell_reactome_analysis.py +0 -466
- BioMod/interpretability/biomod_text_alignment/DESIGN.md +0 -235
- BioMod/interpretability/biomod_text_alignment/MANUSCRIPT_SECTION.md +0 -208
- BioMod/interpretability/biomod_text_alignment/__init__.py +0 -0
- BioMod/interpretability/biomod_text_alignment/biomod_text_alignment.py +0 -665
- BioMod/interpretability/biomod_text_alignment/biomod_text_alignment_extended.py +0 -537
- BioMod/interpretability/biomod_text_alignment/biomod_text_alignment_publication.py +0 -1277
- BioMod/interpretability/biomod_text_alignment/fetch_reactome_descriptions.py +0 -97
- BioMod/interpretability/biomod_text_alignment/reactome_descriptions.json +0 -0
- BioMod/interpretability/utils.py +0 -200
- 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
|
| 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
|
| 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
|
| 92 |
|
| 93 |
-
The repository also
|
| 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
|
| 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
|
| 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
|