Remove reverse perturbation code and refresh README documentation
Browse filesRemove the ten reverse perturbation implementation and protocol files. Update the project README and source README, including their repository trees. Model weights, paired configuration, datasets, and unrelated source files are unchanged.
- BioMod/README.md +0 -14
- BioMod/gene_inverse_norman/README.md +0 -217
- BioMod/gene_inverse_norman/build_norman_gene_inverse_benchmark.py +0 -300
- BioMod/gene_inverse_norman/common.py +0 -660
- BioMod/gene_inverse_norman/evaluate_gene_inverse.py +0 -405
- BioMod/gene_inverse_norman/finetune_gene_inverse.py +0 -837
- BioMod/gene_inverse_norman/protocols/cellfm/genes.txt +0 -20
- BioMod/gene_inverse_norman/protocols/cellfm/split.json +0 -59
- BioMod/gene_inverse_norman/protocols/scgpt/genes.txt +0 -20
- BioMod/gene_inverse_norman/protocols/scgpt/split.json +0 -61
- BioMod/gene_inverse_norman/utils.py +0 -166
- README.md +153 -406
BioMod/README.md
CHANGED
|
@@ -44,20 +44,6 @@ BioMod/ # Task code: training,
|
|
| 44 |
├── eval_new_data/ # Reconstruction error and generalization on new data
|
| 45 |
│ ├── cmd.txtx
|
| 46 |
│ └── evaluate_new_data.py
|
| 47 |
-
├── gene_inverse_norman/ # Norman reverse perturbation prediction and evaluation
|
| 48 |
-
│ ├── protocols/ # Preset gene sets and train/validation/test splits
|
| 49 |
-
│ │ ├── cellfm/ # Gene list and data splits for the CellFM protocol
|
| 50 |
-
│ │ │ ├── genes.txt
|
| 51 |
-
│ │ │ └── split.json
|
| 52 |
-
│ │ └── scgpt/ # Gene list and data splits for the scGPT protocol
|
| 53 |
-
│ │ ├── genes.txt
|
| 54 |
-
│ │ └── split.json
|
| 55 |
-
│ ├── build_norman_gene_inverse_benchmark.py
|
| 56 |
-
│ ├── common.py
|
| 57 |
-
│ ├── evaluate_gene_inverse.py
|
| 58 |
-
│ ├── finetune_gene_inverse.py
|
| 59 |
-
│ ├── README.md
|
| 60 |
-
│ └── utils.py
|
| 61 |
├── gene_perturbation/ # Perturbation response prediction: processing, training and inference
|
| 62 |
│ ├── data_utils_local.py
|
| 63 |
│ ├── gears_reduce_local.py
|
|
|
|
| 44 |
├── eval_new_data/ # Reconstruction error and generalization on new data
|
| 45 |
│ ├── cmd.txtx
|
| 46 |
│ └── evaluate_new_data.py
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
├── gene_perturbation/ # Perturbation response prediction: processing, training and inference
|
| 48 |
│ ├── data_utils_local.py
|
| 49 |
│ ├── gears_reduce_local.py
|
BioMod/gene_inverse_norman/README.md
DELETED
|
@@ -1,217 +0,0 @@
|
|
| 1 |
-
# Norman Reverse Perturbation Prediction (scGPT / CellFM Protocol Alignment)
|
| 2 |
-
|
| 3 |
-
This directory implements reverse perturbation prediction:
|
| 4 |
-
|
| 5 |
-
- Input: single-cell expression after perturbation.
|
| 6 |
-
- Candidate space: all single and double combinations from the selected gene pool; 20 genes produce 210 candidates.
|
| 7 |
-
- Training objective: identify the true perturbation within the candidate universe.
|
| 8 |
-
- Split strategy: split by perturbation case, not by cell.
|
| 9 |
-
- Main evaluation metrics: `condition_top1_acc` to `condition_top10_acc` for exact hits, plus `condition_relevant_top1_acc` to `condition_relevant_top10_acc` for predictions that hit at least one true gene.
|
| 10 |
-
- Paper-style supplementary metrics: `case_cellmean_top1_acc` to `case_cellmean_top10_acc`, which first compute cell-level hit rates within each condition and then average conditions equally; `case_cellmean_relevant_topk_acc` is also supported.
|
| 11 |
-
|
| 12 |
-
Two built-in protocols are supported and can be selected with `--protocol scgpt`
|
| 13 |
-
or `--protocol cellfm`.
|
| 14 |
-
|
| 15 |
-
## Protocol Alignment
|
| 16 |
-
|
| 17 |
-
Both protocols come from the exact condition lists reported in the papers.
|
| 18 |
-
Canonicalization maps `GENE+GENE`, `ctrl+GENE`, and `GENE+ctrl` single-gene
|
| 19 |
-
forms to `GENE`, matching the Norman annotations.
|
| 20 |
-
|
| 21 |
-
| Item | scGPT | CellFM |
|
| 22 |
-
| --- | --- | --- |
|
| 23 |
-
| Gene pool | 20 | 20 |
|
| 24 |
-
| Candidate universe | 210 (20 singles + 190 doubles) | 210 (20 singles + 190 doubles) |
|
| 25 |
-
| Train cases | 39 (20 doubles + 19 singles) | 33 (16 doubles + 17 singles) |
|
| 26 |
-
| Val cases | 3 doubles | 3 doubles |
|
| 27 |
-
| Test cases | 7 doubles | 11 doubles |
|
| 28 |
-
| Total observed cases used | 49 | 47 |
|
| 29 |
-
| Missing in local Norman data | 0 | 0 |
|
| 30 |
-
|
| 31 |
-
In the scGPT gene pool, FOSB is not included as a single-gene training case. In
|
| 32 |
-
the CellFM gene pool, FOSB, FOXA3, and MAP2K3 also have no single-gene training
|
| 33 |
-
entries. These singles exist in Norman and remain in the candidate universe as
|
| 34 |
-
distractors, matching the known-vs-unseen design in the papers.
|
| 35 |
-
|
| 36 |
-
The exact gene and split lists are stored in:
|
| 37 |
-
|
| 38 |
-
- `protocols/scgpt/genes.txt` and `protocols/scgpt/split.json`
|
| 39 |
-
- `protocols/cellfm/genes.txt` and `protocols/cellfm/split.json`
|
| 40 |
-
|
| 41 |
-
## 1. Build the Benchmark
|
| 42 |
-
|
| 43 |
-
```bash
|
| 44 |
-
# scGPT
|
| 45 |
-
python performer_module/gene_inverse_norman/build_norman_gene_inverse_benchmark.py \
|
| 46 |
-
--adata_path GEARS/data/norman/perturb_processed.h5ad \
|
| 47 |
-
--protocol scgpt \
|
| 48 |
-
--output_dir performer_module/gene_inverse_norman/outputs/bench_scgpt
|
| 49 |
-
|
| 50 |
-
# CellFM
|
| 51 |
-
python performer_module/gene_inverse_norman/build_norman_gene_inverse_benchmark.py \
|
| 52 |
-
--adata_path GEARS/data/norman/perturb_processed.h5ad \
|
| 53 |
-
--protocol cellfm \
|
| 54 |
-
--output_dir performer_module/gene_inverse_norman/outputs/bench_cellfm
|
| 55 |
-
```
|
| 56 |
-
|
| 57 |
-
Main outputs:
|
| 58 |
-
|
| 59 |
-
- `gene_inverse_conditions.csv`: full candidate universe with `is_observed`.
|
| 60 |
-
- `class_mapping.csv`: `class_id -> condition`.
|
| 61 |
-
- `benchmark_summary.json`: protocol, observed counts, and `protocol_conditions_missing_in_data`.
|
| 62 |
-
- `protocol_split.json`: backup copy of the original protocol split.
|
| 63 |
-
|
| 64 |
-
## 2. Training
|
| 65 |
-
|
| 66 |
-
```bash
|
| 67 |
-
# scGPT
|
| 68 |
-
python finetune_gene_inverse.py \
|
| 69 |
-
--scfm_hyper_params_path ../mae_tost_48M_cross_attn_only_pure_memory/mae_tost_48M_cross_attn_only_pure_memory.pkl \
|
| 70 |
-
--scfm_ckpt_path ../mae_tost_48M_cross_attn_only_pure_memory/mae_tost_48M_cross_attn_only_pure_memory_epoch12.pth \
|
| 71 |
-
--benchmark_csv ./outputs/bench_scgpt/gene_inverse_conditions.csv \
|
| 72 |
-
--adata_path ../../GEARS/data/norman/perturb_processed.h5ad \
|
| 73 |
-
--gene_mapping_path ../../gene2ens.csv \
|
| 74 |
-
--scfm_genes_file ../../selected_genes_27k.txt \
|
| 75 |
-
--gene2vec_path ../../selected_gene2vec_27k.npy \
|
| 76 |
-
--protocol scgpt \
|
| 77 |
-
--finetune_mode full \
|
| 78 |
-
--pool_method max \
|
| 79 |
-
--epochs 20 \
|
| 80 |
-
--batch_size 24 \
|
| 81 |
-
--lr_head 1e-3 \
|
| 82 |
-
--lr_backbone 1e-4 \
|
| 83 |
-
--algorithm_name scLong_scgpt_protocol \
|
| 84 |
-
--export_test_logits \
|
| 85 |
-
--save_best_checkpoint \
|
| 86 |
-
--output_dir performer_module/gene_inverse_norman/outputs/run_scgpt
|
| 87 |
-
|
| 88 |
-
# CellFM
|
| 89 |
-
python performer_module/gene_inverse_norman/finetune_gene_inverse.py \
|
| 90 |
-
--scfm_hyper_params_path mae_tost_48M_cross_attn_only_pure_memory/mae_tost_48M_cross_attn_only_pure_memory.pkl \
|
| 91 |
-
--scfm_ckpt_path mae_tost_48M_cross_attn_only_pure_memory/mae_tost_48M_cross_attn_only_pure_memory.pth \
|
| 92 |
-
--benchmark_csv performer_module/gene_inverse_norman/outputs/bench_cellfm/gene_inverse_conditions.csv \
|
| 93 |
-
--adata_path GEARS/data/norman/perturb_processed.h5ad \
|
| 94 |
-
--gene_mapping_path gene2ens.csv \
|
| 95 |
-
--scfm_genes_file selected_genes_27k.txt \
|
| 96 |
-
--gene2vec_path selected_gene2vec_27k.npy \
|
| 97 |
-
--protocol cellfm \
|
| 98 |
-
--finetune_mode frozen \
|
| 99 |
-
--pool_method max \
|
| 100 |
-
--epochs 5 \
|
| 101 |
-
--batch_size 4 \
|
| 102 |
-
--lr_head 1e-4 \
|
| 103 |
-
--lr_backbone 1e-5 \
|
| 104 |
-
--algorithm_name scLong_cellfm_protocol \
|
| 105 |
-
--export_test_logits \
|
| 106 |
-
--save_best_checkpoint \
|
| 107 |
-
--output_dir performer_module/gene_inverse_norman/outputs/run_cellfm
|
| 108 |
-
```
|
| 109 |
-
|
| 110 |
-
Once `--protocol` is specified, the fixed train/val/test lists from
|
| 111 |
-
`protocols/<name>/split.json` are used. Random splitting is disabled. `--seed`
|
| 112 |
-
only affects initialization and dataloader order.
|
| 113 |
-
|
| 114 |
-
DDP training on multiple GPUs:
|
| 115 |
-
|
| 116 |
-
```bash
|
| 117 |
-
torchrun --nproc_per_node=4 \
|
| 118 |
-
performer_module/gene_inverse_norman/finetune_gene_inverse.py \
|
| 119 |
-
--protocol cellfm \
|
| 120 |
-
... # Other arguments are the same as above.
|
| 121 |
-
```
|
| 122 |
-
|
| 123 |
-
Training outputs:
|
| 124 |
-
|
| 125 |
-
- `best_finetune_gene_inverse.pth`, saved only when `--save_best_checkpoint` is passed.
|
| 126 |
-
- `summary_metrics.json`, including `test_condition_top1_acc` to `test_condition_top10_acc`, `test_condition_relevant_top1_acc` to `test_condition_relevant_top10_acc`, and `case_cellmean` metrics such as `test_case_cellmean_top1_acc` and `test_case_cellmean_relevant_top1_acc`.
|
| 127 |
-
- `test_summary_metrics.json`, which keeps only the test-set view and key configuration for reporting, without all `best_val_*` fields.
|
| 128 |
-
- `metrics_table.csv`, a flattened top1-to-top10 table grouped by metric family and including the `algorithm` field.
|
| 129 |
-
- `train_history.csv`.
|
| 130 |
-
- `test_predictions.csv` for per-cell predictions.
|
| 131 |
-
- `test_logits.csv` when `--export_test_logits` is enabled.
|
| 132 |
-
- `test_condition_rankings.jsonl`, containing top-10 rankings and the true rank for each test perturbation.
|
| 133 |
-
- `condition_splits.npz`, `condition_splits.json`, and `split_indices.npz`.
|
| 134 |
-
|
| 135 |
-
Notes:
|
| 136 |
-
|
| 137 |
-
- Full finetuned checkpoints are not saved by default to save disk space. Add `--save_best_checkpoint` if later re-evaluation with `evaluate_gene_inverse.py` is needed.
|
| 138 |
-
- `--select_metric` is used only to choose the best epoch by one metric; it is not a multi-metric selector. Other metrics are still fully exported.
|
| 139 |
-
- When `--select_metric` ties, `condition_top1_acc`, `case_cellmean_top5_acc`, and `val_loss` are used as tie-breakers.
|
| 140 |
-
- Optional training stabilization: `--class_balance_power 0.5` for inverse-square-root weighting by condition sample count, and `--label_smoothing 0.05`.
|
| 141 |
-
|
| 142 |
-
## 3. Re-Evaluation
|
| 143 |
-
|
| 144 |
-
```bash
|
| 145 |
-
python performer_module/gene_inverse_norman/evaluate_gene_inverse.py \
|
| 146 |
-
--trained_head_ckpt performer_module/gene_inverse_norman/outputs/run_cellfm/best_finetune_gene_inverse.pth \
|
| 147 |
-
--scfm_hyper_params_path mae_tost_48M_cross_attn_only_pure_memory/mae_tost_48M_cross_attn_only_pure_memory.pkl \
|
| 148 |
-
--scfm_ckpt_path mae_tost_48M_cross_attn_only_pure_memory/mae_tost_48M_cross_attn_only_pure_memory.pth \
|
| 149 |
-
--benchmark_csv performer_module/gene_inverse_norman/outputs/bench_cellfm/gene_inverse_conditions.csv \
|
| 150 |
-
--condition_splits_npz performer_module/gene_inverse_norman/outputs/run_cellfm/condition_splits.npz \
|
| 151 |
-
--split test \
|
| 152 |
-
--export_logits \
|
| 153 |
-
--output_dir performer_module/gene_inverse_norman/outputs/eval_cellfm
|
| 154 |
-
```
|
| 155 |
-
|
| 156 |
-
## 4. Evaluation Definition
|
| 157 |
-
|
| 158 |
-
### Main Metrics Aligned with the Papers
|
| 159 |
-
|
| 160 |
-
`condition_top1_acc` ... `condition_top10_acc`: exact-hit metrics.
|
| 161 |
-
|
| 162 |
-
`condition_relevant_top1_acc` ... `condition_relevant_top10_acc`: relevant-hit
|
| 163 |
-
metrics, where the predicted set shares at least one gene with the true
|
| 164 |
-
perturbation.
|
| 165 |
-
|
| 166 |
-
`case_cellmean_top1_acc` ... `case_cellmean_top10_acc`: first compute cell-level
|
| 167 |
-
hit@k within each condition, then average conditions equally. This can produce
|
| 168 |
-
decimal values that are not simply `x/7` or `x/11`.
|
| 169 |
-
|
| 170 |
-
`case_cellmean_relevant_top1_acc` ... `case_cellmean_relevant_top10_acc`: same
|
| 171 |
-
idea as above, but using the relevant-hit definition.
|
| 172 |
-
|
| 173 |
-
Workflow:
|
| 174 |
-
|
| 175 |
-
1. The model outputs logits over 210 candidates for each test cell.
|
| 176 |
-
2. Logits from all cells under the same perturbation are averaged.
|
| 177 |
-
3. The true perturbation is checked against the top-k ranking.
|
| 178 |
-
|
| 179 |
-
### Auxiliary Metrics
|
| 180 |
-
|
| 181 |
-
`top1_acc` ... `top10_acc`: cell-level top-k metrics for reference only.
|
| 182 |
-
|
| 183 |
-
`--select_metric` can be one of `condition_top{k}_acc`,
|
| 184 |
-
`condition_relevant_top{k}_acc`, `case_cellmean_top{k}_acc`,
|
| 185 |
-
`case_cellmean_relevant_top{k}_acc`, or `top{k}_acc`, where `k=1..10`.
|
| 186 |
-
|
| 187 |
-
Recommendations:
|
| 188 |
-
|
| 189 |
-
- Use `condition_topk_acc` and `condition_relevant_topk_acc` for the main conclusion.
|
| 190 |
-
- Also report `case_cellmean_*` when aligning with paper wording about non-integer average hit counts.
|
| 191 |
-
- The classification head uses residual connections by default; add `--no_head_residual` to return to the old head.
|
| 192 |
-
- Use only `max` or `mean` for `pool_method`; other historical options have been removed from the current training entry point.
|
| 193 |
-
|
| 194 |
-
## 5. Points to Watch
|
| 195 |
-
|
| 196 |
-
- The candidate space is the full set of 210 singles and doubles. Paper-style Top-k hit rates are ranked over this full set by default. If a baseline ranks only a subset, report that separately because the numbers are not directly comparable.
|
| 197 |
-
- The two protocols use different gene pools: scGPT has 19 unique genes plus FEV, while CellFM has 20 unique genes. Do not directly cross-compare scGPT-train and CellFM-test without explaining this.
|
| 198 |
-
- `seed` does not affect the split because splits come from fixed protocol lists, but it still affects initialization and dataloader order. Multi-seed runs can estimate variance.
|
| 199 |
-
|
| 200 |
-
## 6. Custom Splits Without a Protocol
|
| 201 |
-
|
| 202 |
-
Custom gene pools and random splits are still supported:
|
| 203 |
-
|
| 204 |
-
```bash
|
| 205 |
-
python performer_module/gene_inverse_norman/build_norman_gene_inverse_benchmark.py \
|
| 206 |
-
--adata_path GEARS/data/norman/perturb_processed.h5ad \
|
| 207 |
-
--gene_pool_file my_genes.txt \
|
| 208 |
-
--output_dir performer_module/gene_inverse_norman/outputs/bench_custom
|
| 209 |
-
|
| 210 |
-
python performer_module/gene_inverse_norman/finetune_gene_inverse.py \
|
| 211 |
-
... \
|
| 212 |
-
--train_cases 33 --val_cases 3 --test_cases 11 \
|
| 213 |
-
--allow_observed_count_mismatch
|
| 214 |
-
```
|
| 215 |
-
|
| 216 |
-
When `--protocol` is not passed, observed conditions are randomly split by
|
| 217 |
-
`--train_cases`, `--val_cases`, `--test_cases`, and `--seed`.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
BioMod/gene_inverse_norman/build_norman_gene_inverse_benchmark.py
DELETED
|
@@ -1,300 +0,0 @@
|
|
| 1 |
-
import argparse
|
| 2 |
-
import itertools
|
| 3 |
-
import json
|
| 4 |
-
import sys
|
| 5 |
-
from pathlib import Path
|
| 6 |
-
from typing import Dict, List, Optional
|
| 7 |
-
|
| 8 |
-
import anndata as ad
|
| 9 |
-
import numpy as np
|
| 10 |
-
import pandas as pd
|
| 11 |
-
|
| 12 |
-
CURRENT_DIR = Path(__file__).resolve().parent
|
| 13 |
-
if str(CURRENT_DIR) not in sys.path:
|
| 14 |
-
sys.path.insert(0, str(CURRENT_DIR))
|
| 15 |
-
|
| 16 |
-
from utils import canonical_condition_name, default_repo_path, parse_condition, print_sys
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
PROTOCOLS_DIR = CURRENT_DIR / "protocols"
|
| 20 |
-
SUPPORTED_PROTOCOLS = ("scgpt", "cellfm")
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
def protocol_dir(protocol: str) -> Path:
|
| 24 |
-
if protocol not in SUPPORTED_PROTOCOLS:
|
| 25 |
-
raise ValueError(f"Unsupported protocol '{protocol}'. Expected one of {SUPPORTED_PROTOCOLS}.")
|
| 26 |
-
return PROTOCOLS_DIR / protocol
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
def load_protocol_genes(protocol: str) -> List[str]:
|
| 30 |
-
genes_path = protocol_dir(protocol) / "genes.txt"
|
| 31 |
-
with open(genes_path, "r", encoding="utf-8") as handle:
|
| 32 |
-
genes = [line.strip() for line in handle.readlines() if line.strip()]
|
| 33 |
-
return sorted(dict.fromkeys(genes))
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
def load_protocol_split(protocol: str) -> Dict[str, object]:
|
| 37 |
-
split_path = protocol_dir(protocol) / "split.json"
|
| 38 |
-
with open(split_path, "r", encoding="utf-8") as handle:
|
| 39 |
-
return json.load(handle)
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
def load_gene_pool(args) -> List[str]:
|
| 43 |
-
if args.protocol:
|
| 44 |
-
return load_protocol_genes(args.protocol)
|
| 45 |
-
if args.gene_pool_file:
|
| 46 |
-
with open(args.gene_pool_file, "r", encoding="utf-8") as handle:
|
| 47 |
-
genes = [line.strip() for line in handle.readlines() if line.strip()]
|
| 48 |
-
return sorted(dict.fromkeys(genes))
|
| 49 |
-
raise ValueError("Must specify --protocol or --gene_pool_file")
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
def build_candidate_conditions(genes: List[str], include_singles: bool = True, include_doubles: bool = True) -> List[str]:
|
| 53 |
-
candidates: List[str] = []
|
| 54 |
-
if include_singles:
|
| 55 |
-
candidates.extend(genes)
|
| 56 |
-
if include_doubles:
|
| 57 |
-
candidates.extend([f"{a}+{b}" for a, b in itertools.combinations(genes, 2)])
|
| 58 |
-
return sorted(candidates)
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
def summarize_observed_conditions(
|
| 62 |
-
adata,
|
| 63 |
-
condition_col: str,
|
| 64 |
-
condition_name_col: Optional[str],
|
| 65 |
-
candidate_set: set,
|
| 66 |
-
) -> Dict[str, Dict[str, object]]:
|
| 67 |
-
if condition_col not in adata.obs.columns:
|
| 68 |
-
raise KeyError(f"condition_col '{condition_col}' not found in adata.obs")
|
| 69 |
-
|
| 70 |
-
condition_series = adata.obs[condition_col].astype(str)
|
| 71 |
-
raw_condition_counts = condition_series.value_counts().to_dict()
|
| 72 |
-
|
| 73 |
-
raw_condition_name_map: Dict[str, str] = {}
|
| 74 |
-
if condition_name_col and condition_name_col in adata.obs.columns:
|
| 75 |
-
raw_condition_name_map = (
|
| 76 |
-
adata.obs[[condition_col, condition_name_col]]
|
| 77 |
-
.astype(str)
|
| 78 |
-
.drop_duplicates(subset=[condition_col])
|
| 79 |
-
.set_index(condition_col)[condition_name_col]
|
| 80 |
-
.to_dict()
|
| 81 |
-
)
|
| 82 |
-
|
| 83 |
-
candidate_to_raw: Dict[str, List[str]] = {candidate: [] for candidate in candidate_set}
|
| 84 |
-
for raw_condition in sorted(raw_condition_counts.keys()):
|
| 85 |
-
if raw_condition == "ctrl":
|
| 86 |
-
continue
|
| 87 |
-
canonical = canonical_condition_name(raw_condition)
|
| 88 |
-
if canonical in candidate_set:
|
| 89 |
-
candidate_to_raw[canonical].append(raw_condition)
|
| 90 |
-
|
| 91 |
-
candidate_summary: Dict[str, Dict[str, object]] = {}
|
| 92 |
-
for candidate in sorted(candidate_set):
|
| 93 |
-
raw_conditions = candidate_to_raw.get(candidate, [])
|
| 94 |
-
n_cells = int(sum(raw_condition_counts[raw] for raw in raw_conditions)) if raw_conditions else 0
|
| 95 |
-
candidate_summary[candidate] = {
|
| 96 |
-
"raw_conditions": raw_conditions,
|
| 97 |
-
"condition_names": [raw_condition_name_map.get(raw, "") for raw in raw_conditions],
|
| 98 |
-
"n_cells": n_cells,
|
| 99 |
-
"is_observed": int(n_cells > 0),
|
| 100 |
-
}
|
| 101 |
-
return candidate_summary
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
def main() -> None:
|
| 105 |
-
parser = argparse.ArgumentParser(
|
| 106 |
-
description="Build a CellFM-style Norman reverse-perturbation benchmark table."
|
| 107 |
-
)
|
| 108 |
-
parser.add_argument(
|
| 109 |
-
"--adata_path",
|
| 110 |
-
type=str,
|
| 111 |
-
default=default_repo_path("GEARS", "data", "norman", "perturb_processed.h5ad"),
|
| 112 |
-
help="Path to Norman perturb_processed.h5ad",
|
| 113 |
-
)
|
| 114 |
-
parser.add_argument("--condition_col", type=str, default="condition", help="Condition column in adata.obs")
|
| 115 |
-
parser.add_argument(
|
| 116 |
-
"--condition_name_col",
|
| 117 |
-
type=str,
|
| 118 |
-
default="condition_name",
|
| 119 |
-
help="Optional condition-name column in adata.obs",
|
| 120 |
-
)
|
| 121 |
-
parser.add_argument(
|
| 122 |
-
"--protocol",
|
| 123 |
-
type=str,
|
| 124 |
-
default="",
|
| 125 |
-
choices=["", "scgpt", "cellfm"],
|
| 126 |
-
help="Use a built-in protocol (scgpt or cellfm) for gene pool and split. Overrides --gene_pool_file.",
|
| 127 |
-
)
|
| 128 |
-
parser.add_argument(
|
| 129 |
-
"--gene_pool_file",
|
| 130 |
-
type=str,
|
| 131 |
-
default="",
|
| 132 |
-
help="Optional newline-delimited gene pool. Used only when --protocol is empty.",
|
| 133 |
-
)
|
| 134 |
-
parser.add_argument(
|
| 135 |
-
"--include_singles",
|
| 136 |
-
action="store_true",
|
| 137 |
-
help="Include single-gene candidates. Defaults follow the protocol when --protocol is set.",
|
| 138 |
-
)
|
| 139 |
-
parser.add_argument(
|
| 140 |
-
"--include_doubles",
|
| 141 |
-
action="store_true",
|
| 142 |
-
help="Include double-gene candidates. Defaults follow the protocol when --protocol is set.",
|
| 143 |
-
)
|
| 144 |
-
parser.add_argument("--singles_only", action="store_true", help="Only keep single-gene candidates.")
|
| 145 |
-
parser.add_argument("--doubles_only", action="store_true", help="Only keep double-gene candidates.")
|
| 146 |
-
parser.add_argument(
|
| 147 |
-
"--min_cells_per_condition",
|
| 148 |
-
type=int,
|
| 149 |
-
default=1,
|
| 150 |
-
help="Observed candidates with fewer cells than this threshold are downgraded to unobserved.",
|
| 151 |
-
)
|
| 152 |
-
parser.add_argument(
|
| 153 |
-
"--output_dir",
|
| 154 |
-
type=str,
|
| 155 |
-
default=default_repo_path(
|
| 156 |
-
"performer_module",
|
| 157 |
-
"gene_inverse_norman",
|
| 158 |
-
"outputs",
|
| 159 |
-
"norman_gene_inverse_benchmark",
|
| 160 |
-
),
|
| 161 |
-
help="Output directory",
|
| 162 |
-
)
|
| 163 |
-
args = parser.parse_args()
|
| 164 |
-
|
| 165 |
-
if args.singles_only and args.doubles_only:
|
| 166 |
-
raise ValueError("--singles_only and --doubles_only cannot be used together")
|
| 167 |
-
|
| 168 |
-
protocol_split: Optional[Dict[str, object]] = None
|
| 169 |
-
if args.protocol:
|
| 170 |
-
protocol_split = load_protocol_split(args.protocol)
|
| 171 |
-
include_singles = bool(protocol_split.get("include_singles", False))
|
| 172 |
-
include_doubles = bool(protocol_split.get("include_doubles", True))
|
| 173 |
-
else:
|
| 174 |
-
include_singles = True
|
| 175 |
-
include_doubles = True
|
| 176 |
-
|
| 177 |
-
if args.singles_only:
|
| 178 |
-
include_doubles = False
|
| 179 |
-
if args.doubles_only:
|
| 180 |
-
include_singles = False
|
| 181 |
-
if args.include_singles:
|
| 182 |
-
include_singles = True
|
| 183 |
-
if args.include_doubles:
|
| 184 |
-
include_doubles = True
|
| 185 |
-
if not include_singles and not include_doubles:
|
| 186 |
-
raise ValueError("Candidate space cannot be empty")
|
| 187 |
-
|
| 188 |
-
output_dir = Path(args.output_dir)
|
| 189 |
-
output_dir.mkdir(parents=True, exist_ok=True)
|
| 190 |
-
|
| 191 |
-
selected_genes = load_gene_pool(args)
|
| 192 |
-
candidate_conditions = build_candidate_conditions(
|
| 193 |
-
genes=selected_genes,
|
| 194 |
-
include_singles=include_singles,
|
| 195 |
-
include_doubles=include_doubles,
|
| 196 |
-
)
|
| 197 |
-
candidate_set = set(candidate_conditions)
|
| 198 |
-
|
| 199 |
-
print_sys(f"Loading Norman data from {args.adata_path}")
|
| 200 |
-
adata = ad.read_h5ad(args.adata_path, backed="r")
|
| 201 |
-
|
| 202 |
-
candidate_summary = summarize_observed_conditions(
|
| 203 |
-
adata=adata,
|
| 204 |
-
condition_col=args.condition_col,
|
| 205 |
-
condition_name_col=args.condition_name_col,
|
| 206 |
-
candidate_set=candidate_set,
|
| 207 |
-
)
|
| 208 |
-
|
| 209 |
-
rows = []
|
| 210 |
-
min_cells = int(args.min_cells_per_condition)
|
| 211 |
-
for candidate in candidate_conditions:
|
| 212 |
-
genes = parse_condition(candidate)
|
| 213 |
-
perturbation_type = "single" if len(genes) == 1 else "double"
|
| 214 |
-
gene_a = genes[0]
|
| 215 |
-
gene_b = genes[1] if len(genes) > 1 else ""
|
| 216 |
-
|
| 217 |
-
summary = candidate_summary[candidate]
|
| 218 |
-
n_cells = int(summary["n_cells"])
|
| 219 |
-
is_observed = int(summary["is_observed"] and n_cells >= min_cells)
|
| 220 |
-
if not is_observed:
|
| 221 |
-
n_cells = 0
|
| 222 |
-
|
| 223 |
-
rows.append(
|
| 224 |
-
{
|
| 225 |
-
"condition": candidate,
|
| 226 |
-
"perturbation_type": perturbation_type,
|
| 227 |
-
"gene_a": gene_a,
|
| 228 |
-
"gene_b": gene_b,
|
| 229 |
-
"perturb_gene": candidate if perturbation_type == "double" else gene_a,
|
| 230 |
-
"n_cells": n_cells,
|
| 231 |
-
"is_observed": is_observed,
|
| 232 |
-
"n_raw_conditions_merged": int(len(summary["raw_conditions"])) if is_observed else 0,
|
| 233 |
-
"raw_conditions_json": json.dumps(summary["raw_conditions"] if is_observed else [], ensure_ascii=False),
|
| 234 |
-
"condition_name_json": json.dumps(summary["condition_names"] if is_observed else [], ensure_ascii=False),
|
| 235 |
-
}
|
| 236 |
-
)
|
| 237 |
-
|
| 238 |
-
benchmark_df = pd.DataFrame(rows)
|
| 239 |
-
benchmark_df = benchmark_df.sort_values(["perturbation_type", "condition"]).reset_index(drop=True)
|
| 240 |
-
benchmark_df["class_id"] = np.arange(len(benchmark_df), dtype=np.int64)
|
| 241 |
-
benchmark_df["class_id"] = benchmark_df["class_id"].astype(int)
|
| 242 |
-
|
| 243 |
-
observed_df = benchmark_df[benchmark_df["is_observed"] == 1].copy()
|
| 244 |
-
observed_single = int((observed_df["perturbation_type"] == "single").sum())
|
| 245 |
-
observed_double = int((observed_df["perturbation_type"] == "double").sum())
|
| 246 |
-
|
| 247 |
-
summary = {
|
| 248 |
-
"n_cells_total": int(adata.n_obs),
|
| 249 |
-
"n_genes_total": int(adata.n_vars),
|
| 250 |
-
"protocol": args.protocol or None,
|
| 251 |
-
"selected_genes": selected_genes,
|
| 252 |
-
"n_selected_genes": int(len(selected_genes)),
|
| 253 |
-
"n_candidate_conditions": int(len(benchmark_df)),
|
| 254 |
-
"n_candidate_single": int((benchmark_df["perturbation_type"] == "single").sum()),
|
| 255 |
-
"n_candidate_double": int((benchmark_df["perturbation_type"] == "double").sum()),
|
| 256 |
-
"n_observed_conditions": int(len(observed_df)),
|
| 257 |
-
"n_observed_single": observed_single,
|
| 258 |
-
"n_observed_double": observed_double,
|
| 259 |
-
"min_cells_per_condition": min_cells,
|
| 260 |
-
"condition_col": args.condition_col,
|
| 261 |
-
"condition_name_col": args.condition_name_col,
|
| 262 |
-
"alignment_note": (
|
| 263 |
-
f"Built from protocol '{args.protocol}'." if args.protocol
|
| 264 |
-
else "Built from custom gene pool."
|
| 265 |
-
),
|
| 266 |
-
}
|
| 267 |
-
|
| 268 |
-
if protocol_split is not None:
|
| 269 |
-
with (output_dir / "protocol_split.json").open("w", encoding="utf-8") as handle:
|
| 270 |
-
json.dump(protocol_split, handle, indent=2, ensure_ascii=False)
|
| 271 |
-
|
| 272 |
-
protocol_conditions = set()
|
| 273 |
-
for split_name in ("train", "val", "test"):
|
| 274 |
-
for cond in protocol_split.get(split_name, []):
|
| 275 |
-
protocol_conditions.add(canonical_condition_name(cond))
|
| 276 |
-
observed_protocol = protocol_conditions & set(
|
| 277 |
-
observed_df["condition"].astype(str).tolist()
|
| 278 |
-
)
|
| 279 |
-
missing_in_data = sorted(protocol_conditions - observed_protocol)
|
| 280 |
-
summary["n_protocol_conditions"] = int(len(protocol_conditions))
|
| 281 |
-
summary["n_protocol_conditions_observed"] = int(len(observed_protocol))
|
| 282 |
-
summary["protocol_conditions_missing_in_data"] = missing_in_data
|
| 283 |
-
if missing_in_data:
|
| 284 |
-
print_sys(
|
| 285 |
-
f"WARNING: {len(missing_in_data)} protocol conditions missing in Norman data: {missing_in_data}"
|
| 286 |
-
)
|
| 287 |
-
|
| 288 |
-
benchmark_df.to_csv(output_dir / "gene_inverse_conditions.csv", index=False)
|
| 289 |
-
benchmark_df[
|
| 290 |
-
["class_id", "condition", "perturbation_type", "gene_a", "gene_b", "is_observed", "n_cells"]
|
| 291 |
-
].to_csv(output_dir / "class_mapping.csv", index=False)
|
| 292 |
-
with (output_dir / "benchmark_summary.json").open("w", encoding="utf-8") as handle:
|
| 293 |
-
json.dump(summary, handle, indent=2, ensure_ascii=False)
|
| 294 |
-
|
| 295 |
-
print_sys(json.dumps(summary, indent=2, ensure_ascii=False))
|
| 296 |
-
print_sys(f"Saved benchmark files to {output_dir}")
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
if __name__ == "__main__":
|
| 300 |
-
main()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
BioMod/gene_inverse_norman/common.py
DELETED
|
@@ -1,660 +0,0 @@
|
|
| 1 |
-
import json
|
| 2 |
-
import random
|
| 3 |
-
import sys
|
| 4 |
-
from pathlib import Path
|
| 5 |
-
from typing import Dict, List, Optional, Sequence, Tuple
|
| 6 |
-
|
| 7 |
-
import numpy as np
|
| 8 |
-
import pandas as pd
|
| 9 |
-
import torch
|
| 10 |
-
import torch.distributed as dist
|
| 11 |
-
from scipy import sparse
|
| 12 |
-
from torch import nn
|
| 13 |
-
from torch.utils.data import Dataset
|
| 14 |
-
|
| 15 |
-
CURRENT_DIR = Path(__file__).resolve().parent
|
| 16 |
-
if str(CURRENT_DIR) not in sys.path:
|
| 17 |
-
sys.path.insert(0, str(CURRENT_DIR))
|
| 18 |
-
|
| 19 |
-
from utils import canonical_condition_name, parse_condition, print_sys, reindex_tensor_universal
|
| 20 |
-
|
| 21 |
-
TOPK_METRICS: Tuple[int, ...] = tuple(range(1, 11))
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
def seed_all(seed: int) -> None:
|
| 25 |
-
random.seed(seed)
|
| 26 |
-
np.random.seed(seed)
|
| 27 |
-
torch.manual_seed(seed)
|
| 28 |
-
torch.cuda.manual_seed_all(seed)
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
def parse_raw_conditions_json(raw_value: object, fallback_condition: str) -> List[str]:
|
| 32 |
-
if isinstance(raw_value, list):
|
| 33 |
-
return [str(item) for item in raw_value]
|
| 34 |
-
|
| 35 |
-
if pd.isna(raw_value):
|
| 36 |
-
return [fallback_condition]
|
| 37 |
-
|
| 38 |
-
raw_str = str(raw_value).strip()
|
| 39 |
-
if not raw_str:
|
| 40 |
-
return [fallback_condition]
|
| 41 |
-
|
| 42 |
-
try:
|
| 43 |
-
parsed = json.loads(raw_str)
|
| 44 |
-
if isinstance(parsed, list) and parsed:
|
| 45 |
-
return [str(item) for item in parsed]
|
| 46 |
-
except json.JSONDecodeError:
|
| 47 |
-
pass
|
| 48 |
-
return [fallback_condition]
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
def normalize_benchmark_table(benchmark_df: pd.DataFrame, min_cells_per_condition: int = 1) -> pd.DataFrame:
|
| 52 |
-
if "condition" not in benchmark_df.columns:
|
| 53 |
-
raise KeyError("benchmark_csv must contain column 'condition'")
|
| 54 |
-
|
| 55 |
-
table = benchmark_df.copy()
|
| 56 |
-
table["condition"] = table["condition"].astype(str)
|
| 57 |
-
table = table.drop_duplicates(subset=["condition"]).reset_index(drop=True)
|
| 58 |
-
|
| 59 |
-
if "n_cells" not in table.columns:
|
| 60 |
-
table["n_cells"] = 0
|
| 61 |
-
table["n_cells"] = table["n_cells"].fillna(0).astype(int)
|
| 62 |
-
|
| 63 |
-
if "is_observed" not in table.columns:
|
| 64 |
-
table["is_observed"] = (table["n_cells"] > 0).astype(int)
|
| 65 |
-
table["is_observed"] = table["is_observed"].fillna(0).astype(int)
|
| 66 |
-
|
| 67 |
-
if "perturbation_type" not in table.columns:
|
| 68 |
-
table["perturbation_type"] = table["condition"].apply(
|
| 69 |
-
lambda condition: "single" if len(parse_condition(condition)) == 1 else "double"
|
| 70 |
-
)
|
| 71 |
-
table["perturbation_type"] = table["perturbation_type"].astype(str)
|
| 72 |
-
|
| 73 |
-
if "gene_a" not in table.columns:
|
| 74 |
-
table["gene_a"] = table["condition"].apply(lambda condition: parse_condition(condition)[0])
|
| 75 |
-
if "gene_b" not in table.columns:
|
| 76 |
-
table["gene_b"] = table["condition"].apply(
|
| 77 |
-
lambda condition: parse_condition(condition)[1] if len(parse_condition(condition)) > 1 else ""
|
| 78 |
-
)
|
| 79 |
-
table["gene_a"] = table["gene_a"].fillna("").astype(str)
|
| 80 |
-
table["gene_b"] = table["gene_b"].fillna("").astype(str)
|
| 81 |
-
|
| 82 |
-
if "perturb_gene" not in table.columns:
|
| 83 |
-
table["perturb_gene"] = table.apply(
|
| 84 |
-
lambda row: row["condition"] if row["perturbation_type"] == "double" else row["gene_a"],
|
| 85 |
-
axis=1,
|
| 86 |
-
)
|
| 87 |
-
table["perturb_gene"] = table["perturb_gene"].astype(str)
|
| 88 |
-
|
| 89 |
-
if min_cells_per_condition > 1:
|
| 90 |
-
observed_mask = (table["is_observed"] == 0) | (table["n_cells"] >= int(min_cells_per_condition))
|
| 91 |
-
table = table[observed_mask].copy()
|
| 92 |
-
table.loc[table["n_cells"] < int(min_cells_per_condition), "is_observed"] = 0
|
| 93 |
-
table.loc[table["n_cells"] < int(min_cells_per_condition), "n_cells"] = 0
|
| 94 |
-
|
| 95 |
-
if "class_id" in table.columns:
|
| 96 |
-
table = table.sort_values("class_id").reset_index(drop=True)
|
| 97 |
-
else:
|
| 98 |
-
table = table.sort_values(["perturbation_type", "condition"]).reset_index(drop=True)
|
| 99 |
-
table["class_id"] = np.arange(len(table), dtype=np.int64)
|
| 100 |
-
table["class_id"] = table["class_id"].astype(int)
|
| 101 |
-
|
| 102 |
-
return table
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
def build_raw_to_canonical_map(benchmark_df: pd.DataFrame) -> Dict[str, str]:
|
| 106 |
-
raw_to_canonical: Dict[str, str] = {}
|
| 107 |
-
for _, row in benchmark_df.iterrows():
|
| 108 |
-
canonical = str(row["condition"])
|
| 109 |
-
raw_conditions = parse_raw_conditions_json(row.get("raw_conditions_json", np.nan), canonical)
|
| 110 |
-
for raw in raw_conditions:
|
| 111 |
-
raw_to_canonical[str(raw)] = canonical
|
| 112 |
-
raw_to_canonical[canonical] = canonical
|
| 113 |
-
return raw_to_canonical
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
def collect_labeled_cells(
|
| 117 |
-
adata,
|
| 118 |
-
condition_col: str,
|
| 119 |
-
raw_to_canonical: Dict[str, str],
|
| 120 |
-
canonical_to_id: Dict[str, int],
|
| 121 |
-
) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
|
| 122 |
-
if condition_col not in adata.obs.columns:
|
| 123 |
-
raise KeyError(f"condition_col '{condition_col}' not found in adata.obs")
|
| 124 |
-
|
| 125 |
-
condition_series = adata.obs[condition_col].astype(str)
|
| 126 |
-
canonical_series = condition_series.map(raw_to_canonical)
|
| 127 |
-
keep_mask = canonical_series.notna().to_numpy()
|
| 128 |
-
row_indices = np.where(keep_mask)[0]
|
| 129 |
-
canonical_labels = canonical_series.iloc[row_indices].astype(str).to_numpy()
|
| 130 |
-
class_ids = np.array([canonical_to_id[label] for label in canonical_labels.tolist()], dtype=np.int64)
|
| 131 |
-
return row_indices, class_ids, canonical_labels
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
def split_observed_conditions(
|
| 135 |
-
class_df: pd.DataFrame,
|
| 136 |
-
seed: int,
|
| 137 |
-
train_cases: int,
|
| 138 |
-
val_cases: int,
|
| 139 |
-
test_cases: int,
|
| 140 |
-
allow_observed_count_mismatch: bool = False,
|
| 141 |
-
) -> Dict[str, List[str]]:
|
| 142 |
-
observed_conditions = class_df.loc[class_df["is_observed"] == 1, "condition"].astype(str).tolist()
|
| 143 |
-
expected_cases = int(train_cases) + int(val_cases) + int(test_cases)
|
| 144 |
-
observed_count = len(observed_conditions)
|
| 145 |
-
|
| 146 |
-
if observed_count < expected_cases:
|
| 147 |
-
raise ValueError(
|
| 148 |
-
f"Observed perturbation cases ({observed_count}) are fewer than requested split size ({expected_cases})."
|
| 149 |
-
)
|
| 150 |
-
if observed_count != expected_cases and not allow_observed_count_mismatch:
|
| 151 |
-
raise ValueError(
|
| 152 |
-
"Observed perturbation case count does not match the requested CellFM-style split. "
|
| 153 |
-
f"observed={observed_count}, expected={expected_cases}. "
|
| 154 |
-
"Use the exact CellFM gene pool or pass --allow_observed_count_mismatch to continue."
|
| 155 |
-
)
|
| 156 |
-
|
| 157 |
-
rng = np.random.default_rng(seed)
|
| 158 |
-
observed_conditions = observed_conditions.copy()
|
| 159 |
-
rng.shuffle(observed_conditions)
|
| 160 |
-
|
| 161 |
-
usable_conditions = observed_conditions[:expected_cases]
|
| 162 |
-
unused_conditions = observed_conditions[expected_cases:]
|
| 163 |
-
|
| 164 |
-
train_conditions = sorted(usable_conditions[:train_cases])
|
| 165 |
-
val_conditions = sorted(usable_conditions[train_cases : train_cases + val_cases])
|
| 166 |
-
test_conditions = sorted(usable_conditions[train_cases + val_cases : expected_cases])
|
| 167 |
-
|
| 168 |
-
return {
|
| 169 |
-
"train": train_conditions,
|
| 170 |
-
"val": val_conditions,
|
| 171 |
-
"test": test_conditions,
|
| 172 |
-
"unused_observed": sorted(unused_conditions),
|
| 173 |
-
}
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
def split_from_protocol(
|
| 177 |
-
class_df: pd.DataFrame,
|
| 178 |
-
protocol_split: Dict[str, object],
|
| 179 |
-
require_all_observed: bool = True,
|
| 180 |
-
) -> Dict[str, List[str]]:
|
| 181 |
-
observed_set = set(class_df.loc[class_df["is_observed"] == 1, "condition"].astype(str).tolist())
|
| 182 |
-
candidate_set = set(class_df["condition"].astype(str).tolist())
|
| 183 |
-
|
| 184 |
-
def canonicalize_list(items: Sequence[str]) -> List[str]:
|
| 185 |
-
return [canonical_condition_name(str(item)) for item in items]
|
| 186 |
-
|
| 187 |
-
train = canonicalize_list(protocol_split.get("train", []))
|
| 188 |
-
val = canonicalize_list(protocol_split.get("val", []))
|
| 189 |
-
test = canonicalize_list(protocol_split.get("test", []))
|
| 190 |
-
|
| 191 |
-
all_listed = train + val + test
|
| 192 |
-
missing_in_candidates = sorted(set(all_listed) - candidate_set)
|
| 193 |
-
if missing_in_candidates:
|
| 194 |
-
raise ValueError(
|
| 195 |
-
f"Protocol conditions missing from benchmark candidate space: {missing_in_candidates}"
|
| 196 |
-
)
|
| 197 |
-
|
| 198 |
-
missing_in_data = sorted(set(all_listed) - observed_set)
|
| 199 |
-
if missing_in_data and require_all_observed:
|
| 200 |
-
raise ValueError(
|
| 201 |
-
"Protocol conditions are not observed in the local Norman data: "
|
| 202 |
-
f"{missing_in_data}. Re-run benchmark or pass --allow_protocol_unobserved."
|
| 203 |
-
)
|
| 204 |
-
|
| 205 |
-
return {
|
| 206 |
-
"train": sorted(train),
|
| 207 |
-
"val": sorted(val),
|
| 208 |
-
"test": sorted(test),
|
| 209 |
-
"unused_observed": sorted(observed_set - set(all_listed)),
|
| 210 |
-
"missing_in_data": missing_in_data,
|
| 211 |
-
}
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
def extract_candidate_gene_names(class_df: pd.DataFrame) -> List[str]:
|
| 215 |
-
genes = set(class_df["gene_a"].astype(str).tolist())
|
| 216 |
-
genes.update(gene for gene in class_df["gene_b"].astype(str).tolist() if gene)
|
| 217 |
-
genes.discard("")
|
| 218 |
-
return sorted(genes)
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
class GeneInverseCellDataset(Dataset):
|
| 222 |
-
def __init__(self, matrix, row_indices: np.ndarray, class_ids: np.ndarray):
|
| 223 |
-
if row_indices.shape[0] != class_ids.shape[0]:
|
| 224 |
-
raise ValueError("row_indices and class_ids must have the same length")
|
| 225 |
-
self.matrix = matrix
|
| 226 |
-
self.row_indices = row_indices.astype(np.int64, copy=False)
|
| 227 |
-
self.class_ids = class_ids.astype(np.int64, copy=False)
|
| 228 |
-
|
| 229 |
-
def __len__(self) -> int:
|
| 230 |
-
return int(self.row_indices.shape[0])
|
| 231 |
-
|
| 232 |
-
def __getitem__(self, idx: int):
|
| 233 |
-
row_idx = int(self.row_indices[idx])
|
| 234 |
-
row = self.matrix[row_idx]
|
| 235 |
-
if sparse.issparse(row):
|
| 236 |
-
row = row.toarray().ravel()
|
| 237 |
-
else:
|
| 238 |
-
row = np.asarray(row).reshape(-1)
|
| 239 |
-
x = torch.from_numpy(row.astype(np.float32, copy=False))
|
| 240 |
-
y = torch.tensor(int(self.class_ids[idx]), dtype=torch.long)
|
| 241 |
-
return x, y, row_idx
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
class GeneInverseCompositionalRetrievalModel(nn.Module):
|
| 245 |
-
def __init__(
|
| 246 |
-
self,
|
| 247 |
-
backbone: nn.Module,
|
| 248 |
-
class_df: pd.DataFrame,
|
| 249 |
-
pool_method: str = "max",
|
| 250 |
-
head_hidden_dim: int = 0,
|
| 251 |
-
head_dropout: float = 0.1,
|
| 252 |
-
head_residual: bool = True,
|
| 253 |
-
temperature: float = 0.07,
|
| 254 |
-
):
|
| 255 |
-
super().__init__()
|
| 256 |
-
self.backbone = backbone
|
| 257 |
-
self.pool_method = pool_method
|
| 258 |
-
self.head_residual = bool(head_residual)
|
| 259 |
-
self.temperature = float(temperature)
|
| 260 |
-
|
| 261 |
-
embed_dim = int(backbone.base_dim)
|
| 262 |
-
hidden_dim = head_hidden_dim if head_hidden_dim > 0 else embed_dim * 4
|
| 263 |
-
self.cell_head = nn.Sequential(
|
| 264 |
-
nn.LayerNorm(embed_dim),
|
| 265 |
-
nn.Dropout(head_dropout),
|
| 266 |
-
nn.Linear(embed_dim, hidden_dim),
|
| 267 |
-
nn.GELU(),
|
| 268 |
-
nn.Dropout(head_dropout),
|
| 269 |
-
nn.Linear(hidden_dim, embed_dim),
|
| 270 |
-
)
|
| 271 |
-
|
| 272 |
-
candidate_gene_names = extract_candidate_gene_names(class_df)
|
| 273 |
-
gene_to_index = {gene: idx for idx, gene in enumerate(candidate_gene_names)}
|
| 274 |
-
candidate_index = np.full((len(class_df), 2), -1, dtype=np.int64)
|
| 275 |
-
candidate_mask = np.zeros((len(class_df), 2), dtype=np.float32)
|
| 276 |
-
for row_idx, (_, row) in enumerate(class_df.iterrows()):
|
| 277 |
-
genes = [gene for gene in [str(row["gene_a"]), str(row["gene_b"])] if gene and gene != "nan"]
|
| 278 |
-
for gene_pos, gene in enumerate(genes[:2]):
|
| 279 |
-
candidate_index[row_idx, gene_pos] = gene_to_index[gene]
|
| 280 |
-
candidate_mask[row_idx, gene_pos] = 1.0
|
| 281 |
-
|
| 282 |
-
self.candidate_gene_names = candidate_gene_names
|
| 283 |
-
self.candidate_gene_embeddings = nn.Embedding(len(candidate_gene_names), embed_dim)
|
| 284 |
-
self.register_buffer("candidate_gene_indices", torch.tensor(candidate_index, dtype=torch.long), persistent=False)
|
| 285 |
-
self.register_buffer("candidate_gene_mask", torch.tensor(candidate_mask, dtype=torch.float32), persistent=False)
|
| 286 |
-
|
| 287 |
-
def _pool_cell_embedding(self, outputs: Dict[str, torch.Tensor]) -> torch.Tensor:
|
| 288 |
-
module_encodings = outputs["module_encodings"]
|
| 289 |
-
|
| 290 |
-
if self.pool_method == "mean":
|
| 291 |
-
return module_encodings.mean(dim=1)
|
| 292 |
-
if self.pool_method == "max":
|
| 293 |
-
return module_encodings.max(dim=1)[0]
|
| 294 |
-
raise ValueError(f"Unsupported pool_method: {self.pool_method}. Expected one of ['mean', 'max'].")
|
| 295 |
-
|
| 296 |
-
def get_candidate_embeddings(self) -> torch.Tensor:
|
| 297 |
-
safe_indices = self.candidate_gene_indices.clamp_min(0)
|
| 298 |
-
gene_embeddings = self.candidate_gene_embeddings(safe_indices)
|
| 299 |
-
mask = self.candidate_gene_mask.unsqueeze(-1)
|
| 300 |
-
summed = (gene_embeddings * mask).sum(dim=1)
|
| 301 |
-
denom = mask.sum(dim=1).clamp_min(1.0)
|
| 302 |
-
candidate_embeddings = summed / denom
|
| 303 |
-
return nn.functional.normalize(candidate_embeddings, dim=-1)
|
| 304 |
-
|
| 305 |
-
def forward(self, x_model: torch.Tensor) -> torch.Tensor:
|
| 306 |
-
outputs = self.backbone(x_model, mask_ratio=0.0, return_encodings=True)
|
| 307 |
-
pooled_embedding = self._pool_cell_embedding(outputs)
|
| 308 |
-
cell_embedding = self.cell_head(pooled_embedding)
|
| 309 |
-
if self.head_residual:
|
| 310 |
-
cell_embedding = pooled_embedding + cell_embedding
|
| 311 |
-
cell_embedding = nn.functional.normalize(cell_embedding, dim=-1)
|
| 312 |
-
candidate_embeddings = self.get_candidate_embeddings()
|
| 313 |
-
logits = cell_embedding @ candidate_embeddings.T
|
| 314 |
-
return logits / self.temperature
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
def set_requires_grad(module: nn.Module, flag: bool) -> None:
|
| 318 |
-
for param in module.parameters():
|
| 319 |
-
param.requires_grad = flag
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
def configure_backbone_trainability(backbone: nn.Module, finetune_mode: str) -> Dict[str, int]:
|
| 323 |
-
set_requires_grad(backbone, False)
|
| 324 |
-
|
| 325 |
-
if finetune_mode == "full":
|
| 326 |
-
set_requires_grad(backbone.module_embed, True)
|
| 327 |
-
set_requires_grad(backbone.encoder, True)
|
| 328 |
-
set_requires_grad(backbone.encoder_norm, True)
|
| 329 |
-
backbone.pos_emb.requires_grad = True
|
| 330 |
-
if getattr(backbone, "cls_token", None) is not None:
|
| 331 |
-
backbone.cls_token.requires_grad = True
|
| 332 |
-
elif finetune_mode != "frozen":
|
| 333 |
-
raise ValueError(f"Unsupported finetune_mode: {finetune_mode}")
|
| 334 |
-
|
| 335 |
-
total_params = sum(param.numel() for param in backbone.parameters())
|
| 336 |
-
trainable_params = sum(param.numel() for param in backbone.parameters() if param.requires_grad)
|
| 337 |
-
return {"total": int(total_params), "trainable": int(trainable_params)}
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
def build_optimizer(
|
| 341 |
-
model: nn.Module,
|
| 342 |
-
lr_head: float,
|
| 343 |
-
lr_backbone: float,
|
| 344 |
-
weight_decay: float,
|
| 345 |
-
) -> torch.optim.Optimizer:
|
| 346 |
-
backbone_params = []
|
| 347 |
-
head_params = []
|
| 348 |
-
for name, param in model.named_parameters():
|
| 349 |
-
if not param.requires_grad:
|
| 350 |
-
continue
|
| 351 |
-
if name.startswith("backbone."):
|
| 352 |
-
backbone_params.append(param)
|
| 353 |
-
else:
|
| 354 |
-
head_params.append(param)
|
| 355 |
-
|
| 356 |
-
param_groups = []
|
| 357 |
-
if backbone_params:
|
| 358 |
-
param_groups.append({"params": backbone_params, "lr": lr_backbone})
|
| 359 |
-
if head_params:
|
| 360 |
-
param_groups.append({"params": head_params, "lr": lr_head})
|
| 361 |
-
if not param_groups:
|
| 362 |
-
raise RuntimeError("No trainable parameters found")
|
| 363 |
-
|
| 364 |
-
return torch.optim.AdamW(
|
| 365 |
-
param_groups,
|
| 366 |
-
weight_decay=weight_decay,
|
| 367 |
-
betas=(0.9, 0.999),
|
| 368 |
-
eps=1e-8,
|
| 369 |
-
)
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
def compute_topk_metrics(
|
| 373 |
-
logits: torch.Tensor,
|
| 374 |
-
targets: torch.Tensor,
|
| 375 |
-
topk_values: Sequence[int] = TOPK_METRICS,
|
| 376 |
-
prefix: str = "",
|
| 377 |
-
) -> Dict[str, float]:
|
| 378 |
-
if logits.numel() == 0:
|
| 379 |
-
raise ValueError("logits is empty")
|
| 380 |
-
|
| 381 |
-
num_classes = int(logits.shape[1])
|
| 382 |
-
maxk = min(max(topk_values), num_classes)
|
| 383 |
-
topk_pred = logits.topk(maxk, dim=1).indices
|
| 384 |
-
targets_expanded = targets.unsqueeze(1)
|
| 385 |
-
correct = topk_pred.eq(targets_expanded)
|
| 386 |
-
|
| 387 |
-
metrics: Dict[str, float] = {}
|
| 388 |
-
metric_prefix = f"{prefix}_" if prefix else ""
|
| 389 |
-
for k in topk_values:
|
| 390 |
-
k_eff = min(k, num_classes)
|
| 391 |
-
value = correct[:, :k_eff].any(dim=1).float().mean().item()
|
| 392 |
-
metrics[f"{metric_prefix}top{k}_acc"] = float(value)
|
| 393 |
-
return metrics
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
def compute_condition_hit_rates(
|
| 397 |
-
logits: torch.Tensor,
|
| 398 |
-
targets: torch.Tensor,
|
| 399 |
-
condition_names: Sequence[str],
|
| 400 |
-
topk_values: Sequence[int] = TOPK_METRICS,
|
| 401 |
-
) -> Tuple[Dict[str, float], torch.Tensor, torch.Tensor, List[str]]:
|
| 402 |
-
grouped_logits: Dict[str, List[torch.Tensor]] = {}
|
| 403 |
-
grouped_targets: Dict[str, List[int]] = {}
|
| 404 |
-
for logit_row, target, condition_name in zip(logits, targets.tolist(), condition_names):
|
| 405 |
-
grouped_logits.setdefault(str(condition_name), []).append(logit_row.detach().cpu())
|
| 406 |
-
grouped_targets.setdefault(str(condition_name), []).append(int(target))
|
| 407 |
-
|
| 408 |
-
aggregated_logits = []
|
| 409 |
-
aggregated_targets = []
|
| 410 |
-
ordered_conditions = sorted(grouped_logits.keys())
|
| 411 |
-
for condition_name in ordered_conditions:
|
| 412 |
-
target_ids = sorted(set(grouped_targets[condition_name]))
|
| 413 |
-
if len(target_ids) != 1:
|
| 414 |
-
raise ValueError(f"Condition {condition_name} maps to multiple targets: {target_ids}")
|
| 415 |
-
aggregated_targets.append(target_ids[0])
|
| 416 |
-
stacked = torch.stack(grouped_logits[condition_name], dim=0)
|
| 417 |
-
aggregated_logits.append(stacked.mean(dim=0))
|
| 418 |
-
|
| 419 |
-
aggregated_logits_tensor = torch.stack(aggregated_logits, dim=0)
|
| 420 |
-
aggregated_targets_tensor = torch.tensor(aggregated_targets, dtype=torch.long)
|
| 421 |
-
metrics = compute_topk_metrics(
|
| 422 |
-
aggregated_logits_tensor,
|
| 423 |
-
aggregated_targets_tensor,
|
| 424 |
-
topk_values=topk_values,
|
| 425 |
-
prefix="condition",
|
| 426 |
-
)
|
| 427 |
-
metrics["n_conditions"] = float(len(ordered_conditions))
|
| 428 |
-
return metrics, aggregated_logits_tensor, aggregated_targets_tensor, ordered_conditions
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
def compute_condition_relevant_hit_rates(
|
| 432 |
-
aggregated_logits: torch.Tensor,
|
| 433 |
-
aggregated_targets: torch.Tensor,
|
| 434 |
-
id_to_condition: Sequence[str],
|
| 435 |
-
topk_values: Sequence[int] = TOPK_METRICS,
|
| 436 |
-
prefix: str = "condition_relevant",
|
| 437 |
-
) -> Dict[str, float]:
|
| 438 |
-
if aggregated_logits.numel() == 0:
|
| 439 |
-
raise ValueError("aggregated_logits is empty")
|
| 440 |
-
if aggregated_logits.shape[0] != aggregated_targets.shape[0]:
|
| 441 |
-
raise ValueError(
|
| 442 |
-
"aggregated_logits and aggregated_targets must have matching first dimension"
|
| 443 |
-
)
|
| 444 |
-
|
| 445 |
-
num_classes = int(aggregated_logits.shape[1])
|
| 446 |
-
maxk = min(max(topk_values), num_classes)
|
| 447 |
-
topk_pred = aggregated_logits.topk(maxk, dim=1).indices.cpu().numpy()
|
| 448 |
-
target_ids = aggregated_targets.cpu().numpy()
|
| 449 |
-
|
| 450 |
-
relevant_hits = {int(k): 0 for k in topk_values}
|
| 451 |
-
for row_idx, true_id in enumerate(target_ids.tolist()):
|
| 452 |
-
true_genes = set(parse_condition(id_to_condition[int(true_id)]))
|
| 453 |
-
for k in topk_values:
|
| 454 |
-
k_eff = min(int(k), num_classes)
|
| 455 |
-
pred_ids = topk_pred[row_idx, :k_eff].tolist()
|
| 456 |
-
hit = any(
|
| 457 |
-
bool(true_genes & set(parse_condition(id_to_condition[int(pred_id)])))
|
| 458 |
-
for pred_id in pred_ids
|
| 459 |
-
)
|
| 460 |
-
if hit:
|
| 461 |
-
relevant_hits[int(k)] += 1
|
| 462 |
-
|
| 463 |
-
n_conditions = max(int(aggregated_logits.shape[0]), 1)
|
| 464 |
-
metrics: Dict[str, float] = {}
|
| 465 |
-
for k in topk_values:
|
| 466 |
-
metrics[f"{prefix}_top{int(k)}_acc"] = float(relevant_hits[int(k)] / n_conditions)
|
| 467 |
-
return metrics
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
def compute_case_cellmean_hit_rates(
|
| 471 |
-
logits: torch.Tensor,
|
| 472 |
-
targets: torch.Tensor,
|
| 473 |
-
condition_names: Sequence[str],
|
| 474 |
-
id_to_condition: Sequence[str],
|
| 475 |
-
topk_values: Sequence[int] = TOPK_METRICS,
|
| 476 |
-
prefix: str = "case_cellmean",
|
| 477 |
-
) -> Dict[str, float]:
|
| 478 |
-
if logits.numel() == 0:
|
| 479 |
-
raise ValueError("logits is empty")
|
| 480 |
-
if logits.shape[0] != targets.shape[0]:
|
| 481 |
-
raise ValueError("logits and targets must have matching first dimension")
|
| 482 |
-
if logits.shape[0] != len(condition_names):
|
| 483 |
-
raise ValueError("condition_names length must match number of logits rows")
|
| 484 |
-
|
| 485 |
-
num_classes = int(logits.shape[1])
|
| 486 |
-
maxk = min(max(topk_values), num_classes)
|
| 487 |
-
topk_pred = logits.topk(maxk, dim=1).indices.cpu().numpy()
|
| 488 |
-
target_ids = targets.cpu().numpy().tolist()
|
| 489 |
-
|
| 490 |
-
condition_counts: Dict[str, int] = {}
|
| 491 |
-
condition_exact_hits: Dict[str, Dict[int, int]] = {}
|
| 492 |
-
condition_relevant_hits: Dict[str, Dict[int, int]] = {}
|
| 493 |
-
|
| 494 |
-
for row_idx, condition_name in enumerate(condition_names):
|
| 495 |
-
condition = str(condition_name)
|
| 496 |
-
condition_counts[condition] = condition_counts.get(condition, 0) + 1
|
| 497 |
-
if condition not in condition_exact_hits:
|
| 498 |
-
condition_exact_hits[condition] = {int(k): 0 for k in topk_values}
|
| 499 |
-
condition_relevant_hits[condition] = {int(k): 0 for k in topk_values}
|
| 500 |
-
|
| 501 |
-
true_id = int(target_ids[row_idx])
|
| 502 |
-
true_genes = set(parse_condition(id_to_condition[true_id]))
|
| 503 |
-
for k in topk_values:
|
| 504 |
-
k_eff = min(int(k), num_classes)
|
| 505 |
-
pred_ids = topk_pred[row_idx, :k_eff].tolist()
|
| 506 |
-
exact_hit = true_id in pred_ids
|
| 507 |
-
relevant_hit = any(
|
| 508 |
-
bool(true_genes & set(parse_condition(id_to_condition[int(pred_id)])))
|
| 509 |
-
for pred_id in pred_ids
|
| 510 |
-
)
|
| 511 |
-
if exact_hit:
|
| 512 |
-
condition_exact_hits[condition][int(k)] += 1
|
| 513 |
-
if relevant_hit:
|
| 514 |
-
condition_relevant_hits[condition][int(k)] += 1
|
| 515 |
-
|
| 516 |
-
ordered_conditions = sorted(condition_counts.keys())
|
| 517 |
-
n_conditions = max(len(ordered_conditions), 1)
|
| 518 |
-
metrics: Dict[str, float] = {}
|
| 519 |
-
for k in topk_values:
|
| 520 |
-
exact_case_rates = [
|
| 521 |
-
float(condition_exact_hits[condition][int(k)] / condition_counts[condition])
|
| 522 |
-
for condition in ordered_conditions
|
| 523 |
-
]
|
| 524 |
-
relevant_case_rates = [
|
| 525 |
-
float(condition_relevant_hits[condition][int(k)] / condition_counts[condition])
|
| 526 |
-
for condition in ordered_conditions
|
| 527 |
-
]
|
| 528 |
-
metrics[f"{prefix}_top{int(k)}_acc"] = float(np.mean(exact_case_rates))
|
| 529 |
-
metrics[f"{prefix}_relevant_top{int(k)}_acc"] = float(np.mean(relevant_case_rates))
|
| 530 |
-
metrics[f"{prefix}_n_conditions"] = float(n_conditions)
|
| 531 |
-
return metrics
|
| 532 |
-
|
| 533 |
-
|
| 534 |
-
def build_condition_ranking_records(
|
| 535 |
-
aggregated_logits: torch.Tensor,
|
| 536 |
-
aggregated_targets: torch.Tensor,
|
| 537 |
-
ordered_conditions: Sequence[str],
|
| 538 |
-
id_to_condition: Sequence[str],
|
| 539 |
-
top_k: int = 10,
|
| 540 |
-
) -> List[Dict[str, object]]:
|
| 541 |
-
records: List[Dict[str, object]] = []
|
| 542 |
-
top_indices = aggregated_logits.topk(min(top_k, aggregated_logits.shape[1]), dim=1).indices.cpu().numpy()
|
| 543 |
-
sorted_indices = aggregated_logits.argsort(dim=1, descending=True).cpu().numpy()
|
| 544 |
-
|
| 545 |
-
for idx, condition_name in enumerate(ordered_conditions):
|
| 546 |
-
true_id = int(aggregated_targets[idx].item())
|
| 547 |
-
ranking = sorted_indices[idx]
|
| 548 |
-
true_rank = int(np.where(ranking == true_id)[0][0]) + 1
|
| 549 |
-
pred_ids = top_indices[idx].tolist()
|
| 550 |
-
records.append(
|
| 551 |
-
{
|
| 552 |
-
"condition": str(condition_name),
|
| 553 |
-
"true_class_id": true_id,
|
| 554 |
-
"true_condition": str(id_to_condition[true_id]),
|
| 555 |
-
"true_rank": true_rank,
|
| 556 |
-
"top_predictions": [str(id_to_condition[pred_id]) for pred_id in pred_ids],
|
| 557 |
-
}
|
| 558 |
-
)
|
| 559 |
-
return records
|
| 560 |
-
|
| 561 |
-
|
| 562 |
-
def evaluate_loader(
|
| 563 |
-
model: nn.Module,
|
| 564 |
-
dataloader,
|
| 565 |
-
device: torch.device,
|
| 566 |
-
scfm_index_positions: Sequence[int],
|
| 567 |
-
loss_fn: Optional[nn.Module] = None,
|
| 568 |
-
return_outputs: bool = False,
|
| 569 |
-
is_distributed: bool = False,
|
| 570 |
-
world_size: int = 1,
|
| 571 |
-
rank: int = 0,
|
| 572 |
-
) -> Tuple[Dict[str, float], Optional[torch.Tensor], Optional[torch.Tensor], Optional[np.ndarray]]:
|
| 573 |
-
model.eval()
|
| 574 |
-
total_loss = 0.0
|
| 575 |
-
total_samples = 0
|
| 576 |
-
logits_all = []
|
| 577 |
-
targets_all = []
|
| 578 |
-
row_ids_all = []
|
| 579 |
-
|
| 580 |
-
with torch.no_grad():
|
| 581 |
-
for x, y, row_ids in dataloader:
|
| 582 |
-
x = x.to(device, non_blocking=True)
|
| 583 |
-
y = y.to(device, non_blocking=True)
|
| 584 |
-
x_model = reindex_tensor_universal(x, scfm_index_positions, dim=1, filler="0", device=device)
|
| 585 |
-
logits = model(x_model)
|
| 586 |
-
|
| 587 |
-
if loss_fn is not None:
|
| 588 |
-
loss = loss_fn(logits, y)
|
| 589 |
-
total_loss += float(loss.item()) * int(x.shape[0])
|
| 590 |
-
total_samples += int(x.shape[0])
|
| 591 |
-
|
| 592 |
-
logits_all.append(logits.detach().cpu())
|
| 593 |
-
targets_all.append(y.detach().cpu())
|
| 594 |
-
row_ids_all.append(np.asarray(row_ids, dtype=np.int64))
|
| 595 |
-
|
| 596 |
-
if logits_all:
|
| 597 |
-
logits_cat = torch.cat(logits_all, dim=0)
|
| 598 |
-
targets_cat = torch.cat(targets_all, dim=0)
|
| 599 |
-
row_ids_cat = np.concatenate(row_ids_all, axis=0)
|
| 600 |
-
else:
|
| 601 |
-
logits_cat = torch.empty((0, 0), dtype=torch.float32)
|
| 602 |
-
targets_cat = torch.empty((0,), dtype=torch.long)
|
| 603 |
-
row_ids_cat = np.empty((0,), dtype=np.int64)
|
| 604 |
-
|
| 605 |
-
if is_distributed:
|
| 606 |
-
loss_tensor = torch.tensor([total_loss], device=device)
|
| 607 |
-
samples_tensor = torch.tensor([total_samples], device=device)
|
| 608 |
-
dist.all_reduce(loss_tensor, op=dist.ReduceOp.SUM)
|
| 609 |
-
dist.all_reduce(samples_tensor, op=dist.ReduceOp.SUM)
|
| 610 |
-
total_loss = float(loss_tensor.item())
|
| 611 |
-
total_samples = int(samples_tensor.item())
|
| 612 |
-
|
| 613 |
-
gather_payload = {
|
| 614 |
-
"logits": logits_cat.numpy(),
|
| 615 |
-
"targets": targets_cat.numpy(),
|
| 616 |
-
"row_ids": row_ids_cat,
|
| 617 |
-
}
|
| 618 |
-
gathered_payloads = [None for _ in range(world_size)]
|
| 619 |
-
dist.all_gather_object(gathered_payloads, gather_payload)
|
| 620 |
-
|
| 621 |
-
if rank != 0:
|
| 622 |
-
return {}, None, None, None
|
| 623 |
-
|
| 624 |
-
logits_list = [torch.from_numpy(payload["logits"]).float() for payload in gathered_payloads if payload["logits"].size > 0]
|
| 625 |
-
targets_list = [torch.from_numpy(payload["targets"]).long() for payload in gathered_payloads if payload["targets"].size > 0]
|
| 626 |
-
row_list = [payload["row_ids"].astype(np.int64) for payload in gathered_payloads if payload["row_ids"].size > 0]
|
| 627 |
-
|
| 628 |
-
if logits_list:
|
| 629 |
-
logits_cat = torch.cat(logits_list, dim=0)
|
| 630 |
-
targets_cat = torch.cat(targets_list, dim=0)
|
| 631 |
-
row_ids_cat = np.concatenate(row_list, axis=0)
|
| 632 |
-
else:
|
| 633 |
-
logits_cat = torch.empty((0, 0), dtype=torch.float32)
|
| 634 |
-
targets_cat = torch.empty((0,), dtype=torch.long)
|
| 635 |
-
row_ids_cat = np.empty((0,), dtype=np.int64)
|
| 636 |
-
|
| 637 |
-
metrics = compute_topk_metrics(logits_cat, targets_cat, topk_values=TOPK_METRICS)
|
| 638 |
-
metrics["loss"] = float(total_loss / max(total_samples, 1)) if loss_fn is not None else 0.0
|
| 639 |
-
|
| 640 |
-
if return_outputs:
|
| 641 |
-
return metrics, logits_cat, targets_cat, row_ids_cat
|
| 642 |
-
return metrics, None, None, None
|
| 643 |
-
|
| 644 |
-
|
| 645 |
-
def fallback_gene_col(adata, gene_col: str) -> str:
|
| 646 |
-
if gene_col in adata.var.columns:
|
| 647 |
-
return gene_col
|
| 648 |
-
fallback_col = "_gene_inverse_var_index"
|
| 649 |
-
adata.var[fallback_col] = adata.var.index.astype(str)
|
| 650 |
-
print_sys(
|
| 651 |
-
f"gene_col '{gene_col}' not found in adata.var.columns; "
|
| 652 |
-
f"fallback to adata.var.index via temporary column '{fallback_col}'."
|
| 653 |
-
)
|
| 654 |
-
return fallback_col
|
| 655 |
-
|
| 656 |
-
|
| 657 |
-
def rebuild_canonical_from_raw(raw_condition: str, collapse_ctrl_orientation: bool) -> str:
|
| 658 |
-
if collapse_ctrl_orientation:
|
| 659 |
-
return canonical_condition_name(raw_condition)
|
| 660 |
-
return str(raw_condition)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
BioMod/gene_inverse_norman/evaluate_gene_inverse.py
DELETED
|
@@ -1,405 +0,0 @@
|
|
| 1 |
-
import argparse
|
| 2 |
-
import json
|
| 3 |
-
import sys
|
| 4 |
-
from pathlib import Path
|
| 5 |
-
from typing import Dict, List
|
| 6 |
-
|
| 7 |
-
import anndata as ad
|
| 8 |
-
import numpy as np
|
| 9 |
-
import pandas as pd
|
| 10 |
-
import torch
|
| 11 |
-
from torch import nn
|
| 12 |
-
from torch.utils.data import DataLoader
|
| 13 |
-
|
| 14 |
-
CURRENT_DIR = Path(__file__).resolve().parent
|
| 15 |
-
if str(CURRENT_DIR) not in sys.path:
|
| 16 |
-
sys.path.insert(0, str(CURRENT_DIR))
|
| 17 |
-
|
| 18 |
-
from common import (
|
| 19 |
-
GeneInverseCellDataset,
|
| 20 |
-
GeneInverseCompositionalRetrievalModel,
|
| 21 |
-
build_condition_ranking_records,
|
| 22 |
-
build_raw_to_canonical_map,
|
| 23 |
-
collect_labeled_cells,
|
| 24 |
-
compute_case_cellmean_hit_rates,
|
| 25 |
-
compute_condition_hit_rates,
|
| 26 |
-
compute_condition_relevant_hit_rates,
|
| 27 |
-
evaluate_loader,
|
| 28 |
-
fallback_gene_col,
|
| 29 |
-
normalize_benchmark_table,
|
| 30 |
-
seed_all,
|
| 31 |
-
)
|
| 32 |
-
from utils import (
|
| 33 |
-
default_repo_path,
|
| 34 |
-
load_pretrained_backbone,
|
| 35 |
-
print_sys,
|
| 36 |
-
resolve_input_gene_ids,
|
| 37 |
-
)
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
TOPK_RANGE = list(range(1, 11))
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
def build_topk_table_rows(metrics: Dict[str, float], split_name: str, algorithm_name: str) -> pd.DataFrame:
|
| 44 |
-
families = [
|
| 45 |
-
("top", "cell_correct"),
|
| 46 |
-
("condition_top", "condition_correct"),
|
| 47 |
-
("condition_relevant_top", "condition_relevant"),
|
| 48 |
-
("case_cellmean_top", "case_cellmean_correct"),
|
| 49 |
-
("case_cellmean_relevant_top", "case_cellmean_relevant"),
|
| 50 |
-
]
|
| 51 |
-
rows = []
|
| 52 |
-
for key_prefix, family_name in families:
|
| 53 |
-
row = {
|
| 54 |
-
"algorithm": str(algorithm_name),
|
| 55 |
-
"split": str(split_name),
|
| 56 |
-
"metric_family": family_name,
|
| 57 |
-
}
|
| 58 |
-
has_value = False
|
| 59 |
-
for k in TOPK_RANGE:
|
| 60 |
-
key = f"{key_prefix}{k}_acc"
|
| 61 |
-
value = metrics.get(key)
|
| 62 |
-
if value is not None:
|
| 63 |
-
has_value = True
|
| 64 |
-
row[f"top{k}"] = float(value) if value is not None else np.nan
|
| 65 |
-
if has_value:
|
| 66 |
-
rows.append(row)
|
| 67 |
-
return pd.DataFrame(rows)
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
def print_topk_table(metrics_df: pd.DataFrame, title: str) -> None:
|
| 71 |
-
if metrics_df.empty:
|
| 72 |
-
return
|
| 73 |
-
printable_df = metrics_df.copy()
|
| 74 |
-
for k in TOPK_RANGE:
|
| 75 |
-
col = f"top{k}"
|
| 76 |
-
printable_df[col] = printable_df[col].map(lambda x: "" if pd.isna(x) else f"{x:.4f}")
|
| 77 |
-
print_sys(title)
|
| 78 |
-
print_sys(printable_df.to_string(index=False))
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
def main() -> None:
|
| 82 |
-
parser = argparse.ArgumentParser(
|
| 83 |
-
description="Evaluate a CellFM-style reverse-perturbation retrieval head and optionally export logits."
|
| 84 |
-
)
|
| 85 |
-
parser.add_argument("--trained_head_ckpt", type=str, required=True, help="Path to best_finetune_gene_inverse.pth")
|
| 86 |
-
parser.add_argument("--scfm_hyper_params_path", type=str, required=True, help="Path to pretrained PKL")
|
| 87 |
-
parser.add_argument("--scfm_ckpt_path", type=str, required=True, help="Path to pretrained checkpoint")
|
| 88 |
-
parser.add_argument(
|
| 89 |
-
"--gene2vec_path",
|
| 90 |
-
type=str,
|
| 91 |
-
default=default_repo_path("selected_gene2vec_27k.npy"),
|
| 92 |
-
help="Path to gene2vec file",
|
| 93 |
-
)
|
| 94 |
-
parser.add_argument(
|
| 95 |
-
"--gene_mapping_path",
|
| 96 |
-
type=str,
|
| 97 |
-
default=default_repo_path("gene2ens.csv"),
|
| 98 |
-
help="Path to gene symbol -> Ensembl CSV",
|
| 99 |
-
)
|
| 100 |
-
parser.add_argument(
|
| 101 |
-
"--scfm_genes_file",
|
| 102 |
-
type=str,
|
| 103 |
-
default=default_repo_path("selected_genes_27k.txt"),
|
| 104 |
-
help="Path to selected model genes",
|
| 105 |
-
)
|
| 106 |
-
parser.add_argument(
|
| 107 |
-
"--adata_path",
|
| 108 |
-
type=str,
|
| 109 |
-
default=default_repo_path("GEARS", "data", "norman", "perturb_processed.h5ad"),
|
| 110 |
-
help="Path to Norman h5ad",
|
| 111 |
-
)
|
| 112 |
-
parser.add_argument(
|
| 113 |
-
"--benchmark_csv",
|
| 114 |
-
type=str,
|
| 115 |
-
default=default_repo_path(
|
| 116 |
-
"performer_module",
|
| 117 |
-
"gene_inverse_norman",
|
| 118 |
-
"outputs",
|
| 119 |
-
"norman_gene_inverse_benchmark",
|
| 120 |
-
"gene_inverse_conditions.csv",
|
| 121 |
-
),
|
| 122 |
-
help="Benchmark CSV produced by build_norman_gene_inverse_benchmark.py",
|
| 123 |
-
)
|
| 124 |
-
parser.add_argument("--condition_col", type=str, default="condition")
|
| 125 |
-
parser.add_argument("--gene_col", type=str, default="gene_name")
|
| 126 |
-
parser.add_argument("--min_cells_per_condition", type=int, default=1)
|
| 127 |
-
parser.add_argument("--batch_size", type=int, default=128)
|
| 128 |
-
parser.add_argument("--num_workers", type=int, default=4)
|
| 129 |
-
parser.add_argument("--seed", type=int, default=42)
|
| 130 |
-
parser.add_argument(
|
| 131 |
-
"--split",
|
| 132 |
-
type=str,
|
| 133 |
-
default="test",
|
| 134 |
-
choices=["train", "val", "test", "all"],
|
| 135 |
-
help="Which split to evaluate.",
|
| 136 |
-
)
|
| 137 |
-
parser.add_argument(
|
| 138 |
-
"--split_indices_npz",
|
| 139 |
-
type=str,
|
| 140 |
-
default="",
|
| 141 |
-
help="Optional split_indices.npz from finetune run for cell rows.",
|
| 142 |
-
)
|
| 143 |
-
parser.add_argument(
|
| 144 |
-
"--condition_splits_npz",
|
| 145 |
-
type=str,
|
| 146 |
-
default="",
|
| 147 |
-
help="Optional condition_splits.npz from finetune run for perturbation-level splits.",
|
| 148 |
-
)
|
| 149 |
-
parser.add_argument(
|
| 150 |
-
"--export_logits",
|
| 151 |
-
action="store_true",
|
| 152 |
-
help="Export per-cell raw logits CSV.",
|
| 153 |
-
)
|
| 154 |
-
parser.add_argument(
|
| 155 |
-
"--output_dir",
|
| 156 |
-
type=str,
|
| 157 |
-
default=default_repo_path("performer_module", "gene_inverse_norman", "outputs", "norman_gene_inverse_eval"),
|
| 158 |
-
)
|
| 159 |
-
parser.add_argument(
|
| 160 |
-
"--algorithm_name",
|
| 161 |
-
type=str,
|
| 162 |
-
default="",
|
| 163 |
-
help="Optional algorithm label in output tables; falls back to checkpoint value or scLong.",
|
| 164 |
-
)
|
| 165 |
-
args = parser.parse_args()
|
| 166 |
-
|
| 167 |
-
seed_all(args.seed)
|
| 168 |
-
output_dir = Path(args.output_dir)
|
| 169 |
-
output_dir.mkdir(parents=True, exist_ok=True)
|
| 170 |
-
|
| 171 |
-
head_ckpt = torch.load(args.trained_head_ckpt, map_location="cpu")
|
| 172 |
-
run_config = head_ckpt.get("run_config", {})
|
| 173 |
-
pool_method = str(head_ckpt.get("pool_method", run_config.get("pool_method", "max")))
|
| 174 |
-
if pool_method not in {"mean", "max"}:
|
| 175 |
-
print_sys(f"pool_method '{pool_method}' is deprecated in current evaluator; fallback to 'max'.")
|
| 176 |
-
pool_method = "max"
|
| 177 |
-
head_hidden_dim = int(head_ckpt.get("head_hidden_dim", run_config.get("head_hidden_dim", 0)))
|
| 178 |
-
head_dropout = float(head_ckpt.get("head_dropout", run_config.get("head_dropout", 0.1)))
|
| 179 |
-
head_residual = bool(head_ckpt.get("head_residual", run_config.get("head_residual", False)))
|
| 180 |
-
temperature = float(head_ckpt.get("temperature", run_config.get("temperature", 0.07)))
|
| 181 |
-
algorithm_name = args.algorithm_name or str(run_config.get("algorithm_name", "scLong"))
|
| 182 |
-
|
| 183 |
-
class_table = head_ckpt.get("class_table")
|
| 184 |
-
if class_table:
|
| 185 |
-
class_df = normalize_benchmark_table(
|
| 186 |
-
benchmark_df=pd.DataFrame(class_table),
|
| 187 |
-
min_cells_per_condition=1,
|
| 188 |
-
)
|
| 189 |
-
print_sys("Using class_table from trained checkpoint for label mapping.")
|
| 190 |
-
else:
|
| 191 |
-
benchmark_df = pd.read_csv(args.benchmark_csv)
|
| 192 |
-
class_df = normalize_benchmark_table(
|
| 193 |
-
benchmark_df=benchmark_df,
|
| 194 |
-
min_cells_per_condition=args.min_cells_per_condition,
|
| 195 |
-
)
|
| 196 |
-
print_sys("Checkpoint has no class_table; fallback to benchmark_csv for label mapping.")
|
| 197 |
-
class_df = class_df.sort_values("class_id").reset_index(drop=True)
|
| 198 |
-
canonical_to_id: Dict[str, int] = dict(zip(class_df["condition"].tolist(), class_df["class_id"].tolist()))
|
| 199 |
-
id_to_condition: List[str] = class_df["condition"].astype(str).tolist()
|
| 200 |
-
id_to_gene: List[str] = class_df["perturb_gene"].astype(str).tolist()
|
| 201 |
-
|
| 202 |
-
print_sys(f"Loading Norman data from {args.adata_path}")
|
| 203 |
-
adata = ad.read_h5ad(args.adata_path)
|
| 204 |
-
raw_to_canonical = build_raw_to_canonical_map(class_df[class_df["is_observed"] == 1])
|
| 205 |
-
labeled_rows, labeled_class_ids, canonical_labels = collect_labeled_cells(
|
| 206 |
-
adata=adata,
|
| 207 |
-
condition_col=args.condition_col,
|
| 208 |
-
raw_to_canonical=raw_to_canonical,
|
| 209 |
-
canonical_to_id=canonical_to_id,
|
| 210 |
-
)
|
| 211 |
-
if labeled_rows.size == 0:
|
| 212 |
-
raise RuntimeError("No labeled cells found after mapping conditions to classes")
|
| 213 |
-
|
| 214 |
-
row_to_class = dict(zip(labeled_rows.tolist(), labeled_class_ids.tolist()))
|
| 215 |
-
row_to_condition = dict(zip(labeled_rows.tolist(), canonical_labels.tolist()))
|
| 216 |
-
|
| 217 |
-
split_npz = args.split_indices_npz
|
| 218 |
-
if not split_npz:
|
| 219 |
-
candidate = Path(args.trained_head_ckpt).resolve().parent / "split_indices.npz"
|
| 220 |
-
if candidate.exists():
|
| 221 |
-
split_npz = str(candidate)
|
| 222 |
-
|
| 223 |
-
condition_splits_npz = args.condition_splits_npz
|
| 224 |
-
if not condition_splits_npz:
|
| 225 |
-
candidate = Path(args.trained_head_ckpt).resolve().parent / "condition_splits.npz"
|
| 226 |
-
if candidate.exists():
|
| 227 |
-
condition_splits_npz = str(candidate)
|
| 228 |
-
|
| 229 |
-
if split_npz:
|
| 230 |
-
split_data = np.load(split_npz, allow_pickle=True)
|
| 231 |
-
train_rows = split_data["train_rows"].astype(np.int64)
|
| 232 |
-
val_rows = split_data["val_rows"].astype(np.int64)
|
| 233 |
-
test_rows = split_data["test_rows"].astype(np.int64)
|
| 234 |
-
print_sys(f"Loaded cell split indices from {split_npz}")
|
| 235 |
-
elif condition_splits_npz:
|
| 236 |
-
condition_data = np.load(condition_splits_npz, allow_pickle=True)
|
| 237 |
-
train_conditions = set(condition_data["train_conditions"].tolist())
|
| 238 |
-
val_conditions = set(condition_data["val_conditions"].tolist())
|
| 239 |
-
test_conditions = set(condition_data["test_conditions"].tolist())
|
| 240 |
-
train_rows = np.array([row for row in labeled_rows.tolist() if row_to_condition[row] in train_conditions], dtype=np.int64)
|
| 241 |
-
val_rows = np.array([row for row in labeled_rows.tolist() if row_to_condition[row] in val_conditions], dtype=np.int64)
|
| 242 |
-
test_rows = np.array([row for row in labeled_rows.tolist() if row_to_condition[row] in test_conditions], dtype=np.int64)
|
| 243 |
-
print_sys(f"Loaded perturbation splits from {condition_splits_npz}")
|
| 244 |
-
else:
|
| 245 |
-
raise RuntimeError("No split artifacts found. Provide --split_indices_npz or --condition_splits_npz.")
|
| 246 |
-
|
| 247 |
-
if args.split == "train":
|
| 248 |
-
eval_rows = train_rows
|
| 249 |
-
elif args.split == "val":
|
| 250 |
-
eval_rows = val_rows
|
| 251 |
-
elif args.split == "test":
|
| 252 |
-
eval_rows = test_rows
|
| 253 |
-
else:
|
| 254 |
-
eval_rows = np.concatenate([train_rows, val_rows, test_rows], axis=0)
|
| 255 |
-
|
| 256 |
-
eval_labels = np.array([row_to_class[int(idx)] for idx in eval_rows], dtype=np.int64)
|
| 257 |
-
eval_condition_names = [row_to_condition[int(idx)] for idx in eval_rows]
|
| 258 |
-
eval_dataset = GeneInverseCellDataset(adata.X, eval_rows, eval_labels)
|
| 259 |
-
eval_loader = DataLoader(
|
| 260 |
-
eval_dataset,
|
| 261 |
-
batch_size=args.batch_size,
|
| 262 |
-
shuffle=False,
|
| 263 |
-
num_workers=args.num_workers,
|
| 264 |
-
pin_memory=True,
|
| 265 |
-
drop_last=False,
|
| 266 |
-
)
|
| 267 |
-
|
| 268 |
-
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 269 |
-
backbone, _ = load_pretrained_backbone(
|
| 270 |
-
scfm_hyper_params_path=args.scfm_hyper_params_path,
|
| 271 |
-
scfm_ckpt_path=args.scfm_ckpt_path,
|
| 272 |
-
gene2vec_path=args.gene2vec_path,
|
| 273 |
-
device=device,
|
| 274 |
-
)
|
| 275 |
-
model = GeneInverseCompositionalRetrievalModel(
|
| 276 |
-
backbone=backbone,
|
| 277 |
-
class_df=class_df,
|
| 278 |
-
pool_method=pool_method,
|
| 279 |
-
head_hidden_dim=head_hidden_dim,
|
| 280 |
-
head_dropout=head_dropout,
|
| 281 |
-
head_residual=head_residual,
|
| 282 |
-
temperature=temperature,
|
| 283 |
-
).to(device)
|
| 284 |
-
model.load_state_dict(head_ckpt["model_state_dict"], strict=True)
|
| 285 |
-
|
| 286 |
-
gene2ens = pd.read_csv(args.gene_mapping_path, index_col=0)
|
| 287 |
-
symbol_to_ensembl = gene2ens["Ensembl gene ID"].to_dict()
|
| 288 |
-
with open(args.scfm_genes_file, "r", encoding="utf-8") as handle:
|
| 289 |
-
scfm_genes = [line.rstrip("\n") for line in handle.readlines()]
|
| 290 |
-
scfm_gene_set = set(scfm_genes)
|
| 291 |
-
|
| 292 |
-
resolved_gene_col = fallback_gene_col(adata, args.gene_col)
|
| 293 |
-
input_gene_ids = resolve_input_gene_ids(
|
| 294 |
-
adata=adata,
|
| 295 |
-
gene_col=resolved_gene_col,
|
| 296 |
-
symbol_to_ensembl=symbol_to_ensembl,
|
| 297 |
-
target_gene_set=scfm_gene_set,
|
| 298 |
-
)
|
| 299 |
-
input_mapping = {gene_id: index for index, gene_id in enumerate(input_gene_ids)}
|
| 300 |
-
input_gene_num = len(input_gene_ids)
|
| 301 |
-
scfm_genes_pad = scfm_genes + ["PAD"]
|
| 302 |
-
scfm_index_positions = [input_mapping.get(gene_id, input_gene_num) for gene_id in scfm_genes_pad]
|
| 303 |
-
|
| 304 |
-
loss_fn = nn.CrossEntropyLoss()
|
| 305 |
-
metrics, logits, targets, row_ids = evaluate_loader(
|
| 306 |
-
model=model,
|
| 307 |
-
dataloader=eval_loader,
|
| 308 |
-
device=device,
|
| 309 |
-
scfm_index_positions=scfm_index_positions,
|
| 310 |
-
loss_fn=loss_fn,
|
| 311 |
-
return_outputs=True,
|
| 312 |
-
)
|
| 313 |
-
|
| 314 |
-
pred_ids = logits.argmax(dim=1).numpy()
|
| 315 |
-
true_ids = targets.numpy()
|
| 316 |
-
pred_df = pd.DataFrame(
|
| 317 |
-
{
|
| 318 |
-
"row_index": row_ids.astype(int),
|
| 319 |
-
"cell_name": adata.obs_names[row_ids].astype(str).tolist(),
|
| 320 |
-
"true_class_id": true_ids.astype(int),
|
| 321 |
-
"pred_class_id": pred_ids.astype(int),
|
| 322 |
-
"true_condition": [id_to_condition[idx] for idx in true_ids],
|
| 323 |
-
"pred_condition": [id_to_condition[idx] for idx in pred_ids],
|
| 324 |
-
"true_gene": [id_to_gene[idx] for idx in true_ids],
|
| 325 |
-
"pred_gene": [id_to_gene[idx] for idx in pred_ids],
|
| 326 |
-
"correct": (pred_ids == true_ids).astype(int),
|
| 327 |
-
}
|
| 328 |
-
)
|
| 329 |
-
pred_df.to_csv(output_dir / f"{args.split}_predictions.csv", index=False)
|
| 330 |
-
|
| 331 |
-
if args.export_logits:
|
| 332 |
-
logits_df = pd.DataFrame(logits.numpy(), columns=id_to_condition)
|
| 333 |
-
logits_df.insert(0, "cell_name", adata.obs_names[row_ids].astype(str).tolist())
|
| 334 |
-
logits_df.insert(1, "row_index", row_ids.astype(int))
|
| 335 |
-
logits_df.to_csv(output_dir / f"{args.split}_logits.csv", index=False)
|
| 336 |
-
|
| 337 |
-
eval_condition_names_in_order = [row_to_condition[int(row)] for row in row_ids.tolist()]
|
| 338 |
-
condition_metrics, aggregated_logits, aggregated_targets, ordered_conditions = compute_condition_hit_rates(
|
| 339 |
-
logits=logits,
|
| 340 |
-
targets=targets,
|
| 341 |
-
condition_names=eval_condition_names_in_order,
|
| 342 |
-
)
|
| 343 |
-
metrics.update(condition_metrics)
|
| 344 |
-
relevant_metrics = compute_condition_relevant_hit_rates(
|
| 345 |
-
aggregated_logits=aggregated_logits,
|
| 346 |
-
aggregated_targets=aggregated_targets,
|
| 347 |
-
id_to_condition=id_to_condition,
|
| 348 |
-
)
|
| 349 |
-
metrics.update(relevant_metrics)
|
| 350 |
-
case_cellmean_metrics = compute_case_cellmean_hit_rates(
|
| 351 |
-
logits=logits,
|
| 352 |
-
targets=targets,
|
| 353 |
-
condition_names=eval_condition_names_in_order,
|
| 354 |
-
id_to_condition=id_to_condition,
|
| 355 |
-
)
|
| 356 |
-
metrics.update(case_cellmean_metrics)
|
| 357 |
-
|
| 358 |
-
ranking_records = build_condition_ranking_records(
|
| 359 |
-
aggregated_logits=aggregated_logits,
|
| 360 |
-
aggregated_targets=aggregated_targets,
|
| 361 |
-
ordered_conditions=ordered_conditions,
|
| 362 |
-
id_to_condition=id_to_condition,
|
| 363 |
-
top_k=10,
|
| 364 |
-
)
|
| 365 |
-
with (output_dir / f"{args.split}_condition_rankings.jsonl").open("w", encoding="utf-8") as handle:
|
| 366 |
-
for record in ranking_records:
|
| 367 |
-
handle.write(json.dumps(record, ensure_ascii=False) + "\n")
|
| 368 |
-
|
| 369 |
-
summary = {
|
| 370 |
-
"algorithm_name": algorithm_name,
|
| 371 |
-
"protocol": run_config.get("protocol", None),
|
| 372 |
-
"split": args.split,
|
| 373 |
-
"n_cells": int(eval_rows.size),
|
| 374 |
-
"n_conditions": int(len(ordered_conditions)),
|
| 375 |
-
"n_classes": int(len(class_df)),
|
| 376 |
-
"pool_method": pool_method,
|
| 377 |
-
"head_hidden_dim": head_hidden_dim,
|
| 378 |
-
"head_dropout": head_dropout,
|
| 379 |
-
"head_residual": head_residual,
|
| 380 |
-
"temperature": temperature,
|
| 381 |
-
**{f"{args.split}_{k}": float(v) for k, v in metrics.items()},
|
| 382 |
-
"alignment_notes": [
|
| 383 |
-
"Primary CellFM-style comparison should use condition_top1_acc through condition_top10_acc.",
|
| 384 |
-
"Metrics aggregate cell logits to perturbation-level mean logits before computing condition hit rates.",
|
| 385 |
-
"scGPT-style relevant-hit metrics are also exported as condition_relevant_topk_acc.",
|
| 386 |
-
"Paper-style non-integer hit rates can be approximated via case_cellmean_topk_acc metrics.",
|
| 387 |
-
"Exact CellFM gene-pool identity must be verified separately if strict paper reproduction is required.",
|
| 388 |
-
],
|
| 389 |
-
}
|
| 390 |
-
metrics_table_df = build_topk_table_rows(
|
| 391 |
-
metrics=metrics,
|
| 392 |
-
split_name=args.split,
|
| 393 |
-
algorithm_name=algorithm_name,
|
| 394 |
-
)
|
| 395 |
-
metrics_table_df.to_csv(output_dir / f"{args.split}_metrics_table.csv", index=False)
|
| 396 |
-
print_topk_table(metrics_table_df, title=f"Top-k Metrics Table ({args.split})")
|
| 397 |
-
with (output_dir / f"{args.split}_summary_metrics.json").open("w", encoding="utf-8") as handle:
|
| 398 |
-
json.dump(summary, handle, indent=2, ensure_ascii=False)
|
| 399 |
-
|
| 400 |
-
print_sys(json.dumps(summary, indent=2, ensure_ascii=False))
|
| 401 |
-
print_sys(f"Saved evaluation outputs to {output_dir}")
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
if __name__ == "__main__":
|
| 405 |
-
main()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
BioMod/gene_inverse_norman/finetune_gene_inverse.py
DELETED
|
@@ -1,837 +0,0 @@
|
|
| 1 |
-
import argparse
|
| 2 |
-
import json
|
| 3 |
-
import os
|
| 4 |
-
import sys
|
| 5 |
-
from pathlib import Path
|
| 6 |
-
from typing import Dict, List
|
| 7 |
-
|
| 8 |
-
import anndata as ad
|
| 9 |
-
import numpy as np
|
| 10 |
-
import pandas as pd
|
| 11 |
-
import torch
|
| 12 |
-
import torch.distributed as dist
|
| 13 |
-
from torch import nn
|
| 14 |
-
from torch.nn.parallel import DistributedDataParallel as DDP
|
| 15 |
-
from torch.utils.data import DataLoader, DistributedSampler
|
| 16 |
-
|
| 17 |
-
CURRENT_DIR = Path(__file__).resolve().parent
|
| 18 |
-
if str(CURRENT_DIR) not in sys.path:
|
| 19 |
-
sys.path.insert(0, str(CURRENT_DIR))
|
| 20 |
-
|
| 21 |
-
from common import (
|
| 22 |
-
GeneInverseCellDataset,
|
| 23 |
-
GeneInverseCompositionalRetrievalModel,
|
| 24 |
-
build_condition_ranking_records,
|
| 25 |
-
build_optimizer,
|
| 26 |
-
build_raw_to_canonical_map,
|
| 27 |
-
collect_labeled_cells,
|
| 28 |
-
compute_case_cellmean_hit_rates,
|
| 29 |
-
compute_condition_hit_rates,
|
| 30 |
-
compute_condition_relevant_hit_rates,
|
| 31 |
-
configure_backbone_trainability,
|
| 32 |
-
evaluate_loader,
|
| 33 |
-
fallback_gene_col,
|
| 34 |
-
normalize_benchmark_table,
|
| 35 |
-
seed_all,
|
| 36 |
-
split_from_protocol,
|
| 37 |
-
split_observed_conditions,
|
| 38 |
-
)
|
| 39 |
-
from utils import (
|
| 40 |
-
default_repo_path,
|
| 41 |
-
load_pretrained_backbone,
|
| 42 |
-
print_sys,
|
| 43 |
-
reindex_tensor_universal,
|
| 44 |
-
resolve_input_gene_ids,
|
| 45 |
-
)
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
TOPK_RANGE = list(range(1, 11))
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
def build_topk_table_rows(metrics: Dict[str, float], split_name: str, algorithm_name: str) -> pd.DataFrame:
|
| 52 |
-
families = [
|
| 53 |
-
("top", "cell_correct"),
|
| 54 |
-
("condition_top", "condition_correct"),
|
| 55 |
-
("condition_relevant_top", "condition_relevant"),
|
| 56 |
-
("case_cellmean_top", "case_cellmean_correct"),
|
| 57 |
-
("case_cellmean_relevant_top", "case_cellmean_relevant"),
|
| 58 |
-
]
|
| 59 |
-
rows = []
|
| 60 |
-
for key_prefix, family_name in families:
|
| 61 |
-
row = {
|
| 62 |
-
"algorithm": str(algorithm_name),
|
| 63 |
-
"split": str(split_name),
|
| 64 |
-
"metric_family": family_name,
|
| 65 |
-
}
|
| 66 |
-
has_value = False
|
| 67 |
-
for k in TOPK_RANGE:
|
| 68 |
-
key = f"{key_prefix}{k}_acc"
|
| 69 |
-
value = metrics.get(key)
|
| 70 |
-
if value is not None:
|
| 71 |
-
has_value = True
|
| 72 |
-
row[f"top{k}"] = float(value) if value is not None else np.nan
|
| 73 |
-
if has_value:
|
| 74 |
-
rows.append(row)
|
| 75 |
-
return pd.DataFrame(rows)
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
def print_topk_table(metrics_df: pd.DataFrame, title: str) -> None:
|
| 79 |
-
if metrics_df.empty:
|
| 80 |
-
return
|
| 81 |
-
printable_df = metrics_df.copy()
|
| 82 |
-
for k in TOPK_RANGE:
|
| 83 |
-
col = f"top{k}"
|
| 84 |
-
printable_df[col] = printable_df[col].map(lambda x: "" if pd.isna(x) else f"{x:.4f}")
|
| 85 |
-
print_sys(title)
|
| 86 |
-
print_sys(printable_df.to_string(index=False))
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
def main() -> None:
|
| 90 |
-
parser = argparse.ArgumentParser(
|
| 91 |
-
description="Fine-tune performer_module for CellFM-style Norman reverse perturbation retrieval."
|
| 92 |
-
)
|
| 93 |
-
parser.add_argument("--scfm_hyper_params_path", type=str, required=True, help="Path to pretrained PKL")
|
| 94 |
-
parser.add_argument("--scfm_ckpt_path", type=str, required=True, help="Path to pretrained checkpoint")
|
| 95 |
-
parser.add_argument(
|
| 96 |
-
"--gene2vec_path",
|
| 97 |
-
type=str,
|
| 98 |
-
default=default_repo_path("selected_gene2vec_27k.npy"),
|
| 99 |
-
help="Path to gene2vec file",
|
| 100 |
-
)
|
| 101 |
-
parser.add_argument(
|
| 102 |
-
"--gene_mapping_path",
|
| 103 |
-
type=str,
|
| 104 |
-
default=default_repo_path("gene2ens.csv"),
|
| 105 |
-
help="Path to gene symbol -> Ensembl CSV",
|
| 106 |
-
)
|
| 107 |
-
parser.add_argument(
|
| 108 |
-
"--scfm_genes_file",
|
| 109 |
-
type=str,
|
| 110 |
-
default=default_repo_path("selected_genes_27k.txt"),
|
| 111 |
-
help="Path to selected model genes",
|
| 112 |
-
)
|
| 113 |
-
parser.add_argument(
|
| 114 |
-
"--adata_path",
|
| 115 |
-
type=str,
|
| 116 |
-
default=default_repo_path("GEARS", "data", "norman", "perturb_processed.h5ad"),
|
| 117 |
-
help="Path to Norman h5ad",
|
| 118 |
-
)
|
| 119 |
-
parser.add_argument(
|
| 120 |
-
"--benchmark_csv",
|
| 121 |
-
type=str,
|
| 122 |
-
default=default_repo_path(
|
| 123 |
-
"performer_module",
|
| 124 |
-
"gene_inverse_norman",
|
| 125 |
-
"outputs",
|
| 126 |
-
"norman_gene_inverse_benchmark",
|
| 127 |
-
"gene_inverse_conditions.csv",
|
| 128 |
-
),
|
| 129 |
-
help="Benchmark CSV produced by build_norman_gene_inverse_benchmark.py",
|
| 130 |
-
)
|
| 131 |
-
parser.add_argument("--condition_col", type=str, default="condition", help="Condition column in adata.obs")
|
| 132 |
-
parser.add_argument("--gene_col", type=str, default="gene_name", help="Gene column in adata.var")
|
| 133 |
-
parser.add_argument("--min_cells_per_condition", type=int, default=1)
|
| 134 |
-
parser.add_argument("--epochs", type=int, default=5)
|
| 135 |
-
parser.add_argument("--batch_size", type=int, default=4)
|
| 136 |
-
parser.add_argument("--num_workers", type=int, default=6)
|
| 137 |
-
parser.add_argument("--lr_head", type=float, default=1e-4)
|
| 138 |
-
parser.add_argument("--lr_backbone", type=float, default=1e-4)
|
| 139 |
-
parser.add_argument("--weight_decay", type=float, default=1e-3)
|
| 140 |
-
parser.add_argument(
|
| 141 |
-
"--class_balance_power",
|
| 142 |
-
type=float,
|
| 143 |
-
default=0.0,
|
| 144 |
-
help="If >0, use train class weights = count^(-power) in CE loss (e.g., 0.5 for inverse-sqrt).",
|
| 145 |
-
)
|
| 146 |
-
parser.add_argument(
|
| 147 |
-
"--label_smoothing",
|
| 148 |
-
type=float,
|
| 149 |
-
default=0.0,
|
| 150 |
-
help="Label smoothing for training CE loss. 0 disables it.",
|
| 151 |
-
)
|
| 152 |
-
parser.add_argument("--head_hidden_dim", type=int, default=0, help="0 means 4x embed dim")
|
| 153 |
-
parser.add_argument(
|
| 154 |
-
"--no_head_residual",
|
| 155 |
-
dest="head_residual",
|
| 156 |
-
action="store_false",
|
| 157 |
-
help="Disable residual connection in classification head.",
|
| 158 |
-
)
|
| 159 |
-
parser.add_argument("--head_dropout", type=float, default=0.1)
|
| 160 |
-
parser.add_argument("--temperature", type=float, default=0.07)
|
| 161 |
-
parser.add_argument(
|
| 162 |
-
"--pool_method",
|
| 163 |
-
type=str,
|
| 164 |
-
default="max",
|
| 165 |
-
choices=["mean", "max"],
|
| 166 |
-
help="Cell embedding pooling over module encodings.",
|
| 167 |
-
)
|
| 168 |
-
parser.add_argument("--seed", type=int, default=42)
|
| 169 |
-
parser.add_argument("--grad_clip", type=float, default=1.0)
|
| 170 |
-
parser.add_argument("--finetune_mode", type=str, default="frozen", choices=["frozen", "full"])
|
| 171 |
-
parser.add_argument(
|
| 172 |
-
"--protocol",
|
| 173 |
-
type=str,
|
| 174 |
-
default="",
|
| 175 |
-
choices=["", "scgpt", "cellfm"],
|
| 176 |
-
help="Use a built-in protocol split (scgpt or cellfm). Overrides --train_cases/--val_cases/--test_cases.",
|
| 177 |
-
)
|
| 178 |
-
parser.add_argument("--train_cases", type=int, default=33)
|
| 179 |
-
parser.add_argument("--val_cases", type=int, default=3)
|
| 180 |
-
parser.add_argument("--test_cases", type=int, default=11)
|
| 181 |
-
parser.add_argument(
|
| 182 |
-
"--allow_observed_count_mismatch",
|
| 183 |
-
action="store_true",
|
| 184 |
-
help="Allow observed perturbation count to differ from train+val+test total when not using a protocol.",
|
| 185 |
-
)
|
| 186 |
-
parser.add_argument(
|
| 187 |
-
"--allow_protocol_unobserved",
|
| 188 |
-
action="store_true",
|
| 189 |
-
help="Allow protocol conditions to be missing in the local data (uses fewer cells).",
|
| 190 |
-
)
|
| 191 |
-
parser.add_argument(
|
| 192 |
-
"--local_rank",
|
| 193 |
-
type=int,
|
| 194 |
-
default=-1,
|
| 195 |
-
help="Local rank for distributed training. Set by torchrun automatically.",
|
| 196 |
-
)
|
| 197 |
-
parser.add_argument(
|
| 198 |
-
"--select_metric",
|
| 199 |
-
type=str,
|
| 200 |
-
default="condition_top10_acc",
|
| 201 |
-
choices=[f"condition_top{k}_acc" for k in range(1, 11)]
|
| 202 |
-
+ [f"condition_relevant_top{k}_acc" for k in range(1, 11)]
|
| 203 |
-
+ [f"case_cellmean_top{k}_acc" for k in range(1, 11)]
|
| 204 |
-
+ [f"case_cellmean_relevant_top{k}_acc" for k in range(1, 11)]
|
| 205 |
-
+ [f"top{k}_acc" for k in range(1, 11)],
|
| 206 |
-
help="Single validation metric used to select best checkpoint; all metrics are still reported.",
|
| 207 |
-
)
|
| 208 |
-
parser.add_argument("--early_stop_patience", type=int, default=0, help="0 disables early stopping")
|
| 209 |
-
parser.add_argument(
|
| 210 |
-
"--algorithm_name",
|
| 211 |
-
type=str,
|
| 212 |
-
default="scLong",
|
| 213 |
-
help="Name tag written to outputs for easier cross-algorithm comparison.",
|
| 214 |
-
)
|
| 215 |
-
parser.add_argument(
|
| 216 |
-
"--save_best_checkpoint",
|
| 217 |
-
action="store_true",
|
| 218 |
-
help="Save best_finetune_gene_inverse.pth. Disabled by default to reduce disk usage.",
|
| 219 |
-
)
|
| 220 |
-
parser.add_argument(
|
| 221 |
-
"--amp",
|
| 222 |
-
dest="amp",
|
| 223 |
-
action="store_true",
|
| 224 |
-
help="Enable AMP mixed precision (default).",
|
| 225 |
-
)
|
| 226 |
-
parser.add_argument(
|
| 227 |
-
"--no_amp",
|
| 228 |
-
dest="amp",
|
| 229 |
-
action="store_false",
|
| 230 |
-
help="Disable AMP mixed precision.",
|
| 231 |
-
)
|
| 232 |
-
parser.set_defaults(amp=True, head_residual=True)
|
| 233 |
-
parser.add_argument(
|
| 234 |
-
"--export_test_logits",
|
| 235 |
-
action="store_true",
|
| 236 |
-
help="Export per-cell raw logits CSV for the test split.",
|
| 237 |
-
)
|
| 238 |
-
parser.add_argument(
|
| 239 |
-
"--output_dir",
|
| 240 |
-
type=str,
|
| 241 |
-
default=default_repo_path("performer_module", "gene_inverse_norman", "outputs", "norman_gene_inverse_finetune"),
|
| 242 |
-
help="Output directory",
|
| 243 |
-
)
|
| 244 |
-
args = parser.parse_args()
|
| 245 |
-
|
| 246 |
-
is_distributed = args.local_rank != -1 or "LOCAL_RANK" in os.environ or "RANK" in os.environ
|
| 247 |
-
if is_distributed:
|
| 248 |
-
if "LOCAL_RANK" in os.environ:
|
| 249 |
-
local_rank = int(os.environ["LOCAL_RANK"])
|
| 250 |
-
elif "RANK" in os.environ:
|
| 251 |
-
local_rank = int(os.environ["RANK"]) % torch.cuda.device_count()
|
| 252 |
-
else:
|
| 253 |
-
local_rank = args.local_rank
|
| 254 |
-
|
| 255 |
-
if not dist.is_initialized():
|
| 256 |
-
dist.init_process_group(backend="nccl")
|
| 257 |
-
world_size = dist.get_world_size()
|
| 258 |
-
rank = dist.get_rank()
|
| 259 |
-
is_main_process = rank == 0
|
| 260 |
-
device = torch.device(f"cuda:{local_rank}")
|
| 261 |
-
torch.cuda.set_device(device)
|
| 262 |
-
print_sys(f"[DDP] Initialized: rank={rank}, local_rank={local_rank}, world_size={world_size}")
|
| 263 |
-
else:
|
| 264 |
-
world_size = 1
|
| 265 |
-
rank = 0
|
| 266 |
-
local_rank = 0
|
| 267 |
-
is_main_process = True
|
| 268 |
-
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 269 |
-
print_sys(f"[Single GPU] Running on device: {device}")
|
| 270 |
-
|
| 271 |
-
seed_all(args.seed + rank)
|
| 272 |
-
output_dir = Path(args.output_dir)
|
| 273 |
-
if is_main_process:
|
| 274 |
-
output_dir.mkdir(parents=True, exist_ok=True)
|
| 275 |
-
if is_distributed:
|
| 276 |
-
dist.barrier()
|
| 277 |
-
|
| 278 |
-
benchmark_df = pd.read_csv(args.benchmark_csv)
|
| 279 |
-
class_df = normalize_benchmark_table(
|
| 280 |
-
benchmark_df=benchmark_df,
|
| 281 |
-
min_cells_per_condition=args.min_cells_per_condition,
|
| 282 |
-
)
|
| 283 |
-
if class_df.empty:
|
| 284 |
-
raise RuntimeError("No classes remain after benchmark filtering")
|
| 285 |
-
|
| 286 |
-
class_df = class_df.sort_values("class_id").reset_index(drop=True)
|
| 287 |
-
if is_main_process:
|
| 288 |
-
class_df.to_csv(output_dir / "class_mapping.csv", index=False)
|
| 289 |
-
|
| 290 |
-
canonical_to_id: Dict[str, int] = dict(zip(class_df["condition"].tolist(), class_df["class_id"].tolist()))
|
| 291 |
-
id_to_condition: List[str] = class_df.sort_values("class_id")["condition"].astype(str).tolist()
|
| 292 |
-
id_to_gene: List[str] = class_df.sort_values("class_id")["perturb_gene"].astype(str).tolist()
|
| 293 |
-
|
| 294 |
-
if is_main_process:
|
| 295 |
-
if args.protocol:
|
| 296 |
-
protocol_split_path = CURRENT_DIR / "protocols" / args.protocol / "split.json"
|
| 297 |
-
with open(protocol_split_path, "r", encoding="utf-8") as handle:
|
| 298 |
-
protocol_split = json.load(handle)
|
| 299 |
-
split_map = split_from_protocol(
|
| 300 |
-
class_df=class_df,
|
| 301 |
-
protocol_split=protocol_split,
|
| 302 |
-
require_all_observed=not args.allow_protocol_unobserved,
|
| 303 |
-
)
|
| 304 |
-
print_sys(
|
| 305 |
-
f"Using built-in protocol '{args.protocol}': "
|
| 306 |
-
f"train={len(split_map['train'])}, val={len(split_map['val'])}, test={len(split_map['test'])}"
|
| 307 |
-
)
|
| 308 |
-
if split_map.get("missing_in_data"):
|
| 309 |
-
print_sys(
|
| 310 |
-
f"WARNING: protocol conditions missing in data: {split_map['missing_in_data']}"
|
| 311 |
-
)
|
| 312 |
-
else:
|
| 313 |
-
split_map = split_observed_conditions(
|
| 314 |
-
class_df=class_df,
|
| 315 |
-
seed=args.seed,
|
| 316 |
-
train_cases=args.train_cases,
|
| 317 |
-
val_cases=args.val_cases,
|
| 318 |
-
test_cases=args.test_cases,
|
| 319 |
-
allow_observed_count_mismatch=args.allow_observed_count_mismatch,
|
| 320 |
-
)
|
| 321 |
-
split_payload = {
|
| 322 |
-
"train_conditions": np.array(split_map["train"], dtype=object),
|
| 323 |
-
"val_conditions": np.array(split_map["val"], dtype=object),
|
| 324 |
-
"test_conditions": np.array(split_map["test"], dtype=object),
|
| 325 |
-
"unused_observed_conditions": np.array(split_map.get("unused_observed", []), dtype=object),
|
| 326 |
-
}
|
| 327 |
-
np.savez(output_dir / "condition_splits.npz", **split_payload)
|
| 328 |
-
with (output_dir / "condition_splits.json").open("w", encoding="utf-8") as handle:
|
| 329 |
-
json.dump(
|
| 330 |
-
{k: list(v) if isinstance(v, list) else v for k, v in split_map.items()},
|
| 331 |
-
handle,
|
| 332 |
-
indent=2,
|
| 333 |
-
ensure_ascii=False,
|
| 334 |
-
)
|
| 335 |
-
if is_distributed:
|
| 336 |
-
dist.barrier()
|
| 337 |
-
|
| 338 |
-
split_npz = np.load(output_dir / "condition_splits.npz", allow_pickle=True)
|
| 339 |
-
train_conditions = set(split_npz["train_conditions"].tolist())
|
| 340 |
-
val_conditions = set(split_npz["val_conditions"].tolist())
|
| 341 |
-
test_conditions = set(split_npz["test_conditions"].tolist())
|
| 342 |
-
|
| 343 |
-
print_sys(f"Loading Norman data from {args.adata_path}")
|
| 344 |
-
adata = ad.read_h5ad(args.adata_path)
|
| 345 |
-
raw_to_canonical = build_raw_to_canonical_map(class_df[class_df["is_observed"] == 1])
|
| 346 |
-
row_indices, class_ids, canonical_labels = collect_labeled_cells(
|
| 347 |
-
adata=adata,
|
| 348 |
-
condition_col=args.condition_col,
|
| 349 |
-
raw_to_canonical=raw_to_canonical,
|
| 350 |
-
canonical_to_id=canonical_to_id,
|
| 351 |
-
)
|
| 352 |
-
if row_indices.size == 0:
|
| 353 |
-
raise RuntimeError("No labeled cells found after mapping conditions to classes")
|
| 354 |
-
|
| 355 |
-
train_mask = np.array([label in train_conditions for label in canonical_labels], dtype=bool)
|
| 356 |
-
val_mask = np.array([label in val_conditions for label in canonical_labels], dtype=bool)
|
| 357 |
-
test_mask = np.array([label in test_conditions for label in canonical_labels], dtype=bool)
|
| 358 |
-
|
| 359 |
-
train_rows = row_indices[train_mask]
|
| 360 |
-
val_rows = row_indices[val_mask]
|
| 361 |
-
test_rows = row_indices[test_mask]
|
| 362 |
-
train_labels = class_ids[train_mask]
|
| 363 |
-
val_labels = class_ids[val_mask]
|
| 364 |
-
test_labels = class_ids[test_mask]
|
| 365 |
-
|
| 366 |
-
if is_main_process:
|
| 367 |
-
np.savez(
|
| 368 |
-
output_dir / "split_indices.npz",
|
| 369 |
-
train_rows=train_rows,
|
| 370 |
-
val_rows=val_rows,
|
| 371 |
-
test_rows=test_rows,
|
| 372 |
-
)
|
| 373 |
-
|
| 374 |
-
split_summary = {
|
| 375 |
-
"n_classes_total": int(len(class_df)),
|
| 376 |
-
"n_observed_classes": int((class_df["is_observed"] == 1).sum()),
|
| 377 |
-
"n_train_conditions": int(len(train_conditions)),
|
| 378 |
-
"n_val_conditions": int(len(val_conditions)),
|
| 379 |
-
"n_test_conditions": int(len(test_conditions)),
|
| 380 |
-
"n_train_cells": int(train_rows.size),
|
| 381 |
-
"n_val_cells": int(val_rows.size),
|
| 382 |
-
"n_test_cells": int(test_rows.size),
|
| 383 |
-
}
|
| 384 |
-
if is_main_process:
|
| 385 |
-
print_sys(json.dumps(split_summary, indent=2, ensure_ascii=False))
|
| 386 |
-
|
| 387 |
-
train_dataset = GeneInverseCellDataset(adata.X, train_rows, train_labels)
|
| 388 |
-
val_dataset = GeneInverseCellDataset(adata.X, val_rows, val_labels)
|
| 389 |
-
test_dataset = GeneInverseCellDataset(adata.X, test_rows, test_labels)
|
| 390 |
-
|
| 391 |
-
if is_distributed:
|
| 392 |
-
train_sampler = DistributedSampler(train_dataset, num_replicas=world_size, rank=rank, shuffle=True)
|
| 393 |
-
val_sampler = DistributedSampler(val_dataset, num_replicas=world_size, rank=rank, shuffle=False)
|
| 394 |
-
test_sampler = DistributedSampler(test_dataset, num_replicas=world_size, rank=rank, shuffle=False)
|
| 395 |
-
train_shuffle = False
|
| 396 |
-
else:
|
| 397 |
-
train_sampler = None
|
| 398 |
-
val_sampler = None
|
| 399 |
-
test_sampler = None
|
| 400 |
-
train_shuffle = True
|
| 401 |
-
|
| 402 |
-
train_loader = DataLoader(
|
| 403 |
-
train_dataset,
|
| 404 |
-
batch_size=args.batch_size,
|
| 405 |
-
shuffle=train_shuffle,
|
| 406 |
-
sampler=train_sampler,
|
| 407 |
-
num_workers=args.num_workers,
|
| 408 |
-
pin_memory=True,
|
| 409 |
-
drop_last=False,
|
| 410 |
-
)
|
| 411 |
-
val_loader = DataLoader(
|
| 412 |
-
val_dataset,
|
| 413 |
-
batch_size=args.batch_size,
|
| 414 |
-
shuffle=False,
|
| 415 |
-
sampler=val_sampler,
|
| 416 |
-
num_workers=args.num_workers,
|
| 417 |
-
pin_memory=True,
|
| 418 |
-
drop_last=False,
|
| 419 |
-
)
|
| 420 |
-
test_loader = DataLoader(
|
| 421 |
-
test_dataset,
|
| 422 |
-
batch_size=args.batch_size,
|
| 423 |
-
shuffle=False,
|
| 424 |
-
sampler=test_sampler,
|
| 425 |
-
num_workers=args.num_workers,
|
| 426 |
-
pin_memory=True,
|
| 427 |
-
drop_last=False,
|
| 428 |
-
)
|
| 429 |
-
|
| 430 |
-
backbone, _ = load_pretrained_backbone(
|
| 431 |
-
scfm_hyper_params_path=args.scfm_hyper_params_path,
|
| 432 |
-
scfm_ckpt_path=args.scfm_ckpt_path,
|
| 433 |
-
gene2vec_path=args.gene2vec_path,
|
| 434 |
-
device=device,
|
| 435 |
-
)
|
| 436 |
-
trainability = configure_backbone_trainability(backbone=backbone, finetune_mode=args.finetune_mode)
|
| 437 |
-
model = GeneInverseCompositionalRetrievalModel(
|
| 438 |
-
backbone=backbone,
|
| 439 |
-
class_df=class_df,
|
| 440 |
-
pool_method=args.pool_method,
|
| 441 |
-
head_hidden_dim=args.head_hidden_dim,
|
| 442 |
-
head_dropout=args.head_dropout,
|
| 443 |
-
head_residual=args.head_residual,
|
| 444 |
-
temperature=args.temperature,
|
| 445 |
-
).to(device)
|
| 446 |
-
|
| 447 |
-
if is_distributed:
|
| 448 |
-
model = DDP(model, device_ids=[local_rank], output_device=local_rank, find_unused_parameters=False)
|
| 449 |
-
unwrapped_model = model.module
|
| 450 |
-
else:
|
| 451 |
-
unwrapped_model = model
|
| 452 |
-
|
| 453 |
-
if is_main_process:
|
| 454 |
-
print_sys(
|
| 455 |
-
f"Backbone params: trainable={trainability['trainable']} total={trainability['total']} | "
|
| 456 |
-
f"Head params: trainable={sum(p.numel() for p in unwrapped_model.cell_head.parameters()) + sum(p.numel() for p in unwrapped_model.candidate_gene_embeddings.parameters())}"
|
| 457 |
-
)
|
| 458 |
-
|
| 459 |
-
gene2ens = pd.read_csv(args.gene_mapping_path, index_col=0)
|
| 460 |
-
symbol_to_ensembl = gene2ens["Ensembl gene ID"].to_dict()
|
| 461 |
-
with open(args.scfm_genes_file, "r", encoding="utf-8") as handle:
|
| 462 |
-
scfm_genes = [line.rstrip("\n") for line in handle.readlines()]
|
| 463 |
-
scfm_gene_set = set(scfm_genes)
|
| 464 |
-
|
| 465 |
-
resolved_gene_col = fallback_gene_col(adata, args.gene_col)
|
| 466 |
-
input_gene_ids = resolve_input_gene_ids(
|
| 467 |
-
adata=adata,
|
| 468 |
-
gene_col=resolved_gene_col,
|
| 469 |
-
symbol_to_ensembl=symbol_to_ensembl,
|
| 470 |
-
target_gene_set=scfm_gene_set,
|
| 471 |
-
)
|
| 472 |
-
input_mapping = {gene_id: index for index, gene_id in enumerate(input_gene_ids)}
|
| 473 |
-
input_gene_num = len(input_gene_ids)
|
| 474 |
-
scfm_genes_pad = scfm_genes + ["PAD"]
|
| 475 |
-
scfm_index_positions = [input_mapping.get(gene_id, input_gene_num) for gene_id in scfm_genes_pad]
|
| 476 |
-
|
| 477 |
-
optimizer = build_optimizer(
|
| 478 |
-
model=model,
|
| 479 |
-
lr_head=args.lr_head,
|
| 480 |
-
lr_backbone=args.lr_backbone,
|
| 481 |
-
weight_decay=args.weight_decay,
|
| 482 |
-
)
|
| 483 |
-
scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=max(args.epochs, 1))
|
| 484 |
-
train_class_weights = None
|
| 485 |
-
if args.class_balance_power > 0:
|
| 486 |
-
train_counts = np.bincount(train_labels, minlength=len(class_df)).astype(np.float32)
|
| 487 |
-
class_weights = np.ones(len(class_df), dtype=np.float32)
|
| 488 |
-
nonzero = train_counts > 0
|
| 489 |
-
class_weights[nonzero] = np.power(train_counts[nonzero], -float(args.class_balance_power))
|
| 490 |
-
class_weights = class_weights / max(float(class_weights[nonzero].mean()), 1e-8)
|
| 491 |
-
train_class_weights = torch.tensor(class_weights, dtype=torch.float32, device=device)
|
| 492 |
-
if is_main_process:
|
| 493 |
-
print_sys(
|
| 494 |
-
f"Using class-balanced CE loss with power={args.class_balance_power:.3f} | "
|
| 495 |
-
f"weight range [{float(class_weights[nonzero].min()):.4f}, {float(class_weights[nonzero].max()):.4f}]"
|
| 496 |
-
)
|
| 497 |
-
|
| 498 |
-
train_loss_fn = nn.CrossEntropyLoss(
|
| 499 |
-
weight=train_class_weights,
|
| 500 |
-
label_smoothing=float(args.label_smoothing),
|
| 501 |
-
)
|
| 502 |
-
eval_loss_fn = nn.CrossEntropyLoss()
|
| 503 |
-
|
| 504 |
-
use_amp = bool(args.amp and device.type == "cuda")
|
| 505 |
-
scaler = torch.amp.GradScaler("cuda", enabled=use_amp)
|
| 506 |
-
|
| 507 |
-
history = []
|
| 508 |
-
best_score = -float("inf")
|
| 509 |
-
best_rank_tuple = (-float("inf"), -float("inf"), -float("inf"), -float("inf"))
|
| 510 |
-
best_epoch = -1
|
| 511 |
-
best_state = None
|
| 512 |
-
best_val_metrics = None
|
| 513 |
-
no_improve = 0
|
| 514 |
-
|
| 515 |
-
val_row_to_condition = {int(row): str(cond) for row, cond in zip(val_rows.tolist(), canonical_labels[val_mask].tolist())}
|
| 516 |
-
test_row_to_condition = {int(row): str(cond) for row, cond in zip(test_rows.tolist(), canonical_labels[test_mask].tolist())}
|
| 517 |
-
|
| 518 |
-
for epoch in range(1, args.epochs + 1):
|
| 519 |
-
if is_distributed and train_sampler is not None:
|
| 520 |
-
train_sampler.set_epoch(epoch)
|
| 521 |
-
|
| 522 |
-
model.train()
|
| 523 |
-
running_loss = 0.0
|
| 524 |
-
running_samples = 0
|
| 525 |
-
|
| 526 |
-
for x, y, _ in train_loader:
|
| 527 |
-
x = x.to(device, non_blocking=True)
|
| 528 |
-
y = y.to(device, non_blocking=True)
|
| 529 |
-
x_model = reindex_tensor_universal(x, scfm_index_positions, dim=1, filler="0", device=device)
|
| 530 |
-
|
| 531 |
-
optimizer.zero_grad(set_to_none=True)
|
| 532 |
-
with torch.amp.autocast("cuda", enabled=use_amp):
|
| 533 |
-
logits = model(x_model)
|
| 534 |
-
loss = train_loss_fn(logits, y)
|
| 535 |
-
|
| 536 |
-
if use_amp:
|
| 537 |
-
scaler.scale(loss).backward()
|
| 538 |
-
if args.grad_clip > 0:
|
| 539 |
-
scaler.unscale_(optimizer)
|
| 540 |
-
torch.nn.utils.clip_grad_norm_(model.parameters(), args.grad_clip)
|
| 541 |
-
scaler.step(optimizer)
|
| 542 |
-
scaler.update()
|
| 543 |
-
else:
|
| 544 |
-
loss.backward()
|
| 545 |
-
if args.grad_clip > 0:
|
| 546 |
-
torch.nn.utils.clip_grad_norm_(model.parameters(), args.grad_clip)
|
| 547 |
-
optimizer.step()
|
| 548 |
-
|
| 549 |
-
batch_size = int(x.shape[0])
|
| 550 |
-
running_loss += float(loss.item()) * batch_size
|
| 551 |
-
running_samples += batch_size
|
| 552 |
-
|
| 553 |
-
train_loss = running_loss / max(running_samples, 1)
|
| 554 |
-
scheduler.step()
|
| 555 |
-
if is_distributed:
|
| 556 |
-
loss_tensor = torch.tensor([train_loss], device=device)
|
| 557 |
-
dist.all_reduce(loss_tensor, op=dist.ReduceOp.SUM)
|
| 558 |
-
train_loss = loss_tensor.item() / world_size
|
| 559 |
-
|
| 560 |
-
val_metrics, val_logits, val_targets, val_row_ids = evaluate_loader(
|
| 561 |
-
model=unwrapped_model,
|
| 562 |
-
dataloader=val_loader,
|
| 563 |
-
device=device,
|
| 564 |
-
scfm_index_positions=scfm_index_positions,
|
| 565 |
-
loss_fn=eval_loss_fn,
|
| 566 |
-
return_outputs=True,
|
| 567 |
-
is_distributed=is_distributed,
|
| 568 |
-
world_size=world_size,
|
| 569 |
-
rank=rank,
|
| 570 |
-
)
|
| 571 |
-
|
| 572 |
-
if is_main_process:
|
| 573 |
-
val_condition_names = [val_row_to_condition[int(row)] for row in val_row_ids.tolist()]
|
| 574 |
-
(
|
| 575 |
-
val_condition_metrics,
|
| 576 |
-
val_aggregated_logits,
|
| 577 |
-
val_aggregated_targets,
|
| 578 |
-
_,
|
| 579 |
-
) = compute_condition_hit_rates(
|
| 580 |
-
logits=val_logits,
|
| 581 |
-
targets=val_targets,
|
| 582 |
-
condition_names=val_condition_names,
|
| 583 |
-
)
|
| 584 |
-
val_metrics.update(val_condition_metrics)
|
| 585 |
-
val_relevant_metrics = compute_condition_relevant_hit_rates(
|
| 586 |
-
aggregated_logits=val_aggregated_logits,
|
| 587 |
-
aggregated_targets=val_aggregated_targets,
|
| 588 |
-
id_to_condition=id_to_condition,
|
| 589 |
-
)
|
| 590 |
-
val_metrics.update(val_relevant_metrics)
|
| 591 |
-
val_case_cellmean_metrics = compute_case_cellmean_hit_rates(
|
| 592 |
-
logits=val_logits,
|
| 593 |
-
targets=val_targets,
|
| 594 |
-
condition_names=val_condition_names,
|
| 595 |
-
id_to_condition=id_to_condition,
|
| 596 |
-
)
|
| 597 |
-
val_metrics.update(val_case_cellmean_metrics)
|
| 598 |
-
record = {
|
| 599 |
-
"epoch": int(epoch),
|
| 600 |
-
"train_loss": float(train_loss),
|
| 601 |
-
**{f"val_{k}": float(v) for k, v in val_metrics.items()},
|
| 602 |
-
}
|
| 603 |
-
history.append(record)
|
| 604 |
-
print_sys(
|
| 605 |
-
f"Epoch {epoch} | train loss {train_loss:.4f} | "
|
| 606 |
-
f"val cond top1 {val_metrics['condition_top1_acc']:.4f} "
|
| 607 |
-
f"top5 {val_metrics['condition_top5_acc']:.4f} "
|
| 608 |
-
f"top10 {val_metrics['condition_top10_acc']:.4f} | "
|
| 609 |
-
f"val case top1 {val_metrics['case_cellmean_top1_acc']:.4f} "
|
| 610 |
-
f"top5 {val_metrics['case_cellmean_top5_acc']:.4f} | "
|
| 611 |
-
f"val rel top1 {val_metrics['condition_relevant_top1_acc']:.4f} | "
|
| 612 |
-
f"val cell top1 {val_metrics['top1_acc']:.4f}"
|
| 613 |
-
)
|
| 614 |
-
score = float(val_metrics[args.select_metric])
|
| 615 |
-
rank_tuple = (
|
| 616 |
-
score,
|
| 617 |
-
float(val_metrics.get("condition_top1_acc", 0.0)),
|
| 618 |
-
float(val_metrics.get("case_cellmean_top5_acc", 0.0)),
|
| 619 |
-
-float(val_metrics.get("loss", 0.0)),
|
| 620 |
-
)
|
| 621 |
-
if rank_tuple > best_rank_tuple:
|
| 622 |
-
best_score = score
|
| 623 |
-
best_rank_tuple = rank_tuple
|
| 624 |
-
best_epoch = epoch
|
| 625 |
-
best_state = {key: value.detach().cpu() for key, value in unwrapped_model.state_dict().items()}
|
| 626 |
-
best_val_metrics = dict(val_metrics)
|
| 627 |
-
no_improve = 0
|
| 628 |
-
else:
|
| 629 |
-
no_improve += 1
|
| 630 |
-
if is_distributed:
|
| 631 |
-
stop_flag = torch.tensor([0], device=device)
|
| 632 |
-
if is_main_process and args.early_stop_patience > 0 and no_improve >= args.early_stop_patience:
|
| 633 |
-
print_sys(f"Early stopping at epoch {epoch} (no improve for {no_improve} epochs)")
|
| 634 |
-
stop_flag[0] = 1
|
| 635 |
-
dist.broadcast(stop_flag, src=0)
|
| 636 |
-
if stop_flag.item() == 1:
|
| 637 |
-
break
|
| 638 |
-
else:
|
| 639 |
-
if args.early_stop_patience > 0 and no_improve >= args.early_stop_patience:
|
| 640 |
-
print_sys(f"Early stopping at epoch {epoch} (no improve for {no_improve} epochs)")
|
| 641 |
-
break
|
| 642 |
-
if is_distributed:
|
| 643 |
-
dist.barrier()
|
| 644 |
-
|
| 645 |
-
checkpoint_path = output_dir / "best_finetune_gene_inverse.pth"
|
| 646 |
-
checkpoint_saved = False
|
| 647 |
-
checkpoint_payload = None
|
| 648 |
-
if is_main_process:
|
| 649 |
-
if best_state is None:
|
| 650 |
-
raise RuntimeError("Training did not produce a valid checkpoint")
|
| 651 |
-
history_df = pd.DataFrame(history)
|
| 652 |
-
history_df.to_csv(output_dir / "train_history.csv", index=False)
|
| 653 |
-
checkpoint_payload = {
|
| 654 |
-
"model_state_dict": best_state,
|
| 655 |
-
"epoch": int(best_epoch),
|
| 656 |
-
"val_metrics": best_val_metrics,
|
| 657 |
-
"class_table": class_df.to_dict(orient="records"),
|
| 658 |
-
"pool_method": args.pool_method,
|
| 659 |
-
"head_hidden_dim": int(args.head_hidden_dim),
|
| 660 |
-
"head_dropout": float(args.head_dropout),
|
| 661 |
-
"head_residual": bool(args.head_residual),
|
| 662 |
-
"temperature": float(args.temperature),
|
| 663 |
-
"finetune_mode": args.finetune_mode,
|
| 664 |
-
"run_config": vars(args),
|
| 665 |
-
}
|
| 666 |
-
if args.save_best_checkpoint or is_distributed:
|
| 667 |
-
torch.save(checkpoint_payload, checkpoint_path)
|
| 668 |
-
checkpoint_saved = True
|
| 669 |
-
if args.save_best_checkpoint:
|
| 670 |
-
print_sys(f"Saved best checkpoint to {checkpoint_path}")
|
| 671 |
-
else:
|
| 672 |
-
print_sys(
|
| 673 |
-
f"Saved temporary checkpoint to {checkpoint_path} "
|
| 674 |
-
"(required for distributed test evaluation; file will be removed after evaluation)."
|
| 675 |
-
)
|
| 676 |
-
|
| 677 |
-
if is_distributed:
|
| 678 |
-
dist.barrier()
|
| 679 |
-
saved_checkpoint = torch.load(checkpoint_path, map_location="cpu")
|
| 680 |
-
unwrapped_model.load_state_dict(saved_checkpoint["model_state_dict"], strict=True)
|
| 681 |
-
dist.barrier()
|
| 682 |
-
if is_main_process and not args.save_best_checkpoint and checkpoint_path.exists():
|
| 683 |
-
checkpoint_path.unlink()
|
| 684 |
-
print_sys(f"Removed temporary checkpoint {checkpoint_path}")
|
| 685 |
-
else:
|
| 686 |
-
unwrapped_model.load_state_dict(best_state, strict=True)
|
| 687 |
-
test_metrics, test_logits, test_targets, test_row_ids = evaluate_loader(
|
| 688 |
-
model=unwrapped_model,
|
| 689 |
-
dataloader=test_loader,
|
| 690 |
-
device=device,
|
| 691 |
-
scfm_index_positions=scfm_index_positions,
|
| 692 |
-
loss_fn=eval_loss_fn,
|
| 693 |
-
return_outputs=True,
|
| 694 |
-
is_distributed=is_distributed,
|
| 695 |
-
world_size=world_size,
|
| 696 |
-
rank=rank,
|
| 697 |
-
)
|
| 698 |
-
|
| 699 |
-
if is_main_process:
|
| 700 |
-
test_condition_names = [test_row_to_condition[int(row)] for row in test_row_ids.tolist()]
|
| 701 |
-
test_condition_metrics, aggregated_logits, aggregated_targets, ordered_conditions = compute_condition_hit_rates(
|
| 702 |
-
logits=test_logits,
|
| 703 |
-
targets=test_targets,
|
| 704 |
-
condition_names=test_condition_names,
|
| 705 |
-
)
|
| 706 |
-
test_metrics.update(test_condition_metrics)
|
| 707 |
-
test_relevant_metrics = compute_condition_relevant_hit_rates(
|
| 708 |
-
aggregated_logits=aggregated_logits,
|
| 709 |
-
aggregated_targets=aggregated_targets,
|
| 710 |
-
id_to_condition=id_to_condition,
|
| 711 |
-
)
|
| 712 |
-
test_metrics.update(test_relevant_metrics)
|
| 713 |
-
test_case_cellmean_metrics = compute_case_cellmean_hit_rates(
|
| 714 |
-
logits=test_logits,
|
| 715 |
-
targets=test_targets,
|
| 716 |
-
condition_names=test_condition_names,
|
| 717 |
-
id_to_condition=id_to_condition,
|
| 718 |
-
)
|
| 719 |
-
test_metrics.update(test_case_cellmean_metrics)
|
| 720 |
-
|
| 721 |
-
if args.export_test_logits:
|
| 722 |
-
logits_df = pd.DataFrame(test_logits.numpy(), columns=id_to_condition)
|
| 723 |
-
logits_df.insert(0, "cell_name", adata.obs_names[test_row_ids].astype(str).tolist())
|
| 724 |
-
logits_df.insert(1, "row_index", test_row_ids.astype(int))
|
| 725 |
-
logits_path = output_dir / "test_logits.csv"
|
| 726 |
-
logits_df.to_csv(logits_path, index=False)
|
| 727 |
-
print_sys(f"Saved test logits to {logits_path}")
|
| 728 |
-
|
| 729 |
-
pred_ids = test_logits.argmax(dim=1).numpy()
|
| 730 |
-
true_ids = test_targets.numpy()
|
| 731 |
-
pred_df = pd.DataFrame(
|
| 732 |
-
{
|
| 733 |
-
"row_index": test_row_ids.astype(int),
|
| 734 |
-
"cell_name": adata.obs_names[test_row_ids].astype(str).tolist(),
|
| 735 |
-
"true_class_id": true_ids.astype(int),
|
| 736 |
-
"pred_class_id": pred_ids.astype(int),
|
| 737 |
-
"true_condition": [id_to_condition[idx] for idx in true_ids],
|
| 738 |
-
"pred_condition": [id_to_condition[idx] for idx in pred_ids],
|
| 739 |
-
"true_gene": [id_to_gene[idx] for idx in true_ids],
|
| 740 |
-
"pred_gene": [id_to_gene[idx] for idx in pred_ids],
|
| 741 |
-
"correct": (true_ids == pred_ids).astype(int),
|
| 742 |
-
}
|
| 743 |
-
)
|
| 744 |
-
pred_df.to_csv(output_dir / "test_predictions.csv", index=False)
|
| 745 |
-
|
| 746 |
-
condition_ranking_records = build_condition_ranking_records(
|
| 747 |
-
aggregated_logits=aggregated_logits,
|
| 748 |
-
aggregated_targets=aggregated_targets,
|
| 749 |
-
ordered_conditions=ordered_conditions,
|
| 750 |
-
id_to_condition=id_to_condition,
|
| 751 |
-
top_k=10,
|
| 752 |
-
)
|
| 753 |
-
with (output_dir / "test_condition_rankings.jsonl").open("w", encoding="utf-8") as handle:
|
| 754 |
-
for record in condition_ranking_records:
|
| 755 |
-
handle.write(json.dumps(record, ensure_ascii=False) + "\n")
|
| 756 |
-
|
| 757 |
-
summary = {
|
| 758 |
-
"algorithm_name": args.algorithm_name,
|
| 759 |
-
"protocol": args.protocol or None,
|
| 760 |
-
"best_epoch": int(best_epoch),
|
| 761 |
-
"selection_metric": args.select_metric,
|
| 762 |
-
"best_val_score": float(best_score),
|
| 763 |
-
"selection_tiebreaker": [
|
| 764 |
-
"condition_top1_acc",
|
| 765 |
-
"case_cellmean_top5_acc",
|
| 766 |
-
"lower_val_loss",
|
| 767 |
-
],
|
| 768 |
-
"pool_method": args.pool_method,
|
| 769 |
-
"finetune_mode": args.finetune_mode,
|
| 770 |
-
"head_residual": bool(args.head_residual),
|
| 771 |
-
"checkpoint_saved": bool(args.save_best_checkpoint and checkpoint_saved),
|
| 772 |
-
"temperature": float(args.temperature),
|
| 773 |
-
"class_balance_power": float(args.class_balance_power),
|
| 774 |
-
"label_smoothing": float(args.label_smoothing),
|
| 775 |
-
"n_classes": int(len(class_df)),
|
| 776 |
-
"n_train_conditions": int(len(train_conditions)),
|
| 777 |
-
"n_val_conditions": int(len(val_conditions)),
|
| 778 |
-
"n_test_conditions": int(len(test_conditions)),
|
| 779 |
-
"n_train_cells": int(train_rows.size),
|
| 780 |
-
"n_val_cells": int(val_rows.size),
|
| 781 |
-
"n_test_cells": int(test_rows.size),
|
| 782 |
-
**{f"best_val_{k}": float(v) for k, v in best_val_metrics.items()},
|
| 783 |
-
**{f"test_{k}": float(v) for k, v in test_metrics.items()},
|
| 784 |
-
"alignment_notes": [
|
| 785 |
-
"Candidate universe includes selected single and double perturbations.",
|
| 786 |
-
"Top-k metrics are reported for k=1..10.",
|
| 787 |
-
"Primary comparison metric should use condition_topk_acc on perturbation-level aggregated test conditions.",
|
| 788 |
-
"scGPT-style relevant-hit metrics are exported as condition_relevant_topk_acc.",
|
| 789 |
-
"Paper-style non-integer hit rates can be approximated via case_cellmean_topk_acc metrics.",
|
| 790 |
-
"The exact CellFM 20-gene list is not published in code here; benchmark gene pool must be checked before claiming exact reproduction.",
|
| 791 |
-
],
|
| 792 |
-
}
|
| 793 |
-
best_val_table = build_topk_table_rows(
|
| 794 |
-
metrics=best_val_metrics,
|
| 795 |
-
split_name="best_val",
|
| 796 |
-
algorithm_name=args.algorithm_name,
|
| 797 |
-
)
|
| 798 |
-
test_table = build_topk_table_rows(
|
| 799 |
-
metrics=test_metrics,
|
| 800 |
-
split_name="test",
|
| 801 |
-
algorithm_name=args.algorithm_name,
|
| 802 |
-
)
|
| 803 |
-
metrics_table_df = pd.concat([best_val_table, test_table], axis=0, ignore_index=True)
|
| 804 |
-
metrics_table_df.to_csv(output_dir / "metrics_table.csv", index=False)
|
| 805 |
-
print_topk_table(metrics_table_df, title="Top-k Metrics Table")
|
| 806 |
-
|
| 807 |
-
with (output_dir / "summary_metrics.json").open("w", encoding="utf-8") as handle:
|
| 808 |
-
json.dump(summary, handle, indent=2, ensure_ascii=False)
|
| 809 |
-
test_only_summary = {
|
| 810 |
-
"algorithm_name": args.algorithm_name,
|
| 811 |
-
"protocol": args.protocol or None,
|
| 812 |
-
"selection_metric": args.select_metric,
|
| 813 |
-
"best_epoch": int(best_epoch),
|
| 814 |
-
"pool_method": args.pool_method,
|
| 815 |
-
"finetune_mode": args.finetune_mode,
|
| 816 |
-
"head_residual": bool(args.head_residual),
|
| 817 |
-
"temperature": float(args.temperature),
|
| 818 |
-
"class_balance_power": float(args.class_balance_power),
|
| 819 |
-
"label_smoothing": float(args.label_smoothing),
|
| 820 |
-
"n_test_conditions": int(len(test_conditions)),
|
| 821 |
-
"n_test_cells": int(test_rows.size),
|
| 822 |
-
**{k: float(v) for k, v in test_metrics.items()},
|
| 823 |
-
}
|
| 824 |
-
with (output_dir / "test_summary_metrics.json").open("w", encoding="utf-8") as handle:
|
| 825 |
-
json.dump(test_only_summary, handle, indent=2, ensure_ascii=False)
|
| 826 |
-
with (output_dir / "run_config.json").open("w", encoding="utf-8") as handle:
|
| 827 |
-
json.dump(vars(args), handle, indent=2, ensure_ascii=False)
|
| 828 |
-
print_sys("Test Summary")
|
| 829 |
-
print_sys(json.dumps(test_only_summary, indent=2, ensure_ascii=False))
|
| 830 |
-
|
| 831 |
-
if is_distributed:
|
| 832 |
-
dist.barrier()
|
| 833 |
-
dist.destroy_process_group()
|
| 834 |
-
|
| 835 |
-
|
| 836 |
-
if __name__ == "__main__":
|
| 837 |
-
main()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
BioMod/gene_inverse_norman/protocols/cellfm/genes.txt
DELETED
|
@@ -1,20 +0,0 @@
|
|
| 1 |
-
CBFA2T3
|
| 2 |
-
CEBPA
|
| 3 |
-
CEBPE
|
| 4 |
-
CNN1
|
| 5 |
-
ETS2
|
| 6 |
-
FEV
|
| 7 |
-
FOXA1
|
| 8 |
-
FOXA3
|
| 9 |
-
FOXF1
|
| 10 |
-
FOSB
|
| 11 |
-
HOXB9
|
| 12 |
-
IGDCC3
|
| 13 |
-
IKZF3
|
| 14 |
-
MAP2K3
|
| 15 |
-
MAP2K6
|
| 16 |
-
MAPK1
|
| 17 |
-
PTPN12
|
| 18 |
-
SNAI1
|
| 19 |
-
TGFBR2
|
| 20 |
-
UBASH3A
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
BioMod/gene_inverse_norman/protocols/cellfm/split.json
DELETED
|
@@ -1,59 +0,0 @@
|
|
| 1 |
-
{
|
| 2 |
-
"name": "cellfm",
|
| 3 |
-
"description": "CellFM-style reverse perturbation split on Norman (20 genes; 33 train = 16 doubles + 17 singles, 3 val doubles, 11 test doubles = 47 observed conditions).",
|
| 4 |
-
"include_singles": true,
|
| 5 |
-
"include_doubles": true,
|
| 6 |
-
"train": [
|
| 7 |
-
"CEBPA+CEBPE",
|
| 8 |
-
"CEBPE+ETS2",
|
| 9 |
-
"CEBPE+FOSB",
|
| 10 |
-
"FOXA3+FOXF1",
|
| 11 |
-
"FOXA1+HOXB9",
|
| 12 |
-
"FOXA3+HOXB9",
|
| 13 |
-
"ETS2+IKZF3",
|
| 14 |
-
"IKZF3+MAP2K3",
|
| 15 |
-
"IKZF3+MAP2K6",
|
| 16 |
-
"CNN1+MAPK1",
|
| 17 |
-
"IKZF3+MAPK1",
|
| 18 |
-
"CEBPE+PTPN12",
|
| 19 |
-
"PTPN12+SNAI1",
|
| 20 |
-
"ETS2+TGFBR2",
|
| 21 |
-
"IGDCC3+TGFBR2",
|
| 22 |
-
"MAPK1+TGFBR2",
|
| 23 |
-
"CBFA2T3",
|
| 24 |
-
"CEBPA",
|
| 25 |
-
"CEBPE",
|
| 26 |
-
"CNN1",
|
| 27 |
-
"ETS2",
|
| 28 |
-
"FEV",
|
| 29 |
-
"FOXA1",
|
| 30 |
-
"FOXF1",
|
| 31 |
-
"HOXB9",
|
| 32 |
-
"IGDCC3",
|
| 33 |
-
"IKZF3",
|
| 34 |
-
"MAP2K6",
|
| 35 |
-
"MAPK1",
|
| 36 |
-
"PTPN12",
|
| 37 |
-
"SNAI1",
|
| 38 |
-
"TGFBR2",
|
| 39 |
-
"UBASH3A"
|
| 40 |
-
],
|
| 41 |
-
"val": [
|
| 42 |
-
"FOXA1+FOXF1",
|
| 43 |
-
"ETS2+MAPK1",
|
| 44 |
-
"FOSB+PTPN12"
|
| 45 |
-
],
|
| 46 |
-
"test": [
|
| 47 |
-
"CEBPE+CNN1",
|
| 48 |
-
"CNN1+ETS2",
|
| 49 |
-
"CBFA2T3+FEV",
|
| 50 |
-
"FOXA1+FOXA3",
|
| 51 |
-
"FOXF1+HOXB9",
|
| 52 |
-
"ETS2+IGDCC3",
|
| 53 |
-
"FOSB+IKZF3",
|
| 54 |
-
"MAP2K3+MAP2K6",
|
| 55 |
-
"IGDCC3+MAPK1",
|
| 56 |
-
"CNN1+UBASH3A",
|
| 57 |
-
"PTPN12+UBASH3A"
|
| 58 |
-
]
|
| 59 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
BioMod/gene_inverse_norman/protocols/scgpt/genes.txt
DELETED
|
@@ -1,20 +0,0 @@
|
|
| 1 |
-
CEBPA
|
| 2 |
-
CEBPE
|
| 3 |
-
CNN1
|
| 4 |
-
FEV
|
| 5 |
-
FOSB
|
| 6 |
-
FOXA1
|
| 7 |
-
FOXF1
|
| 8 |
-
HOXB9
|
| 9 |
-
IGDCC3
|
| 10 |
-
IKZF3
|
| 11 |
-
KLF1
|
| 12 |
-
MAP2K6
|
| 13 |
-
MAPK1
|
| 14 |
-
OSR2
|
| 15 |
-
PRTG
|
| 16 |
-
PTPN9
|
| 17 |
-
SAMD1
|
| 18 |
-
TGFBR2
|
| 19 |
-
UBASH3B
|
| 20 |
-
ZBTB25
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
BioMod/gene_inverse_norman/protocols/scgpt/split.json
DELETED
|
@@ -1,61 +0,0 @@
|
|
| 1 |
-
{
|
| 2 |
-
"name": "scgpt",
|
| 3 |
-
"description": "scGPT-style reverse perturbation split on Norman (20 genes; 39 train = 20 doubles + 19 singles, 3 val doubles, 7 test doubles = 49 observed conditions).",
|
| 4 |
-
"include_singles": true,
|
| 5 |
-
"include_doubles": true,
|
| 6 |
-
"train": [
|
| 7 |
-
"UBASH3B+ZBTB25",
|
| 8 |
-
"SAMD1+UBASH3B",
|
| 9 |
-
"SAMD1+TGFBR2",
|
| 10 |
-
"PRTG+TGFBR2",
|
| 11 |
-
"OSR2+UBASH3B",
|
| 12 |
-
"MAPK1+TGFBR2",
|
| 13 |
-
"KLF1+TGFBR2",
|
| 14 |
-
"KLF1+MAP2K6",
|
| 15 |
-
"IKZF3+MAPK1",
|
| 16 |
-
"IKZF3+MAP2K6",
|
| 17 |
-
"IGDCC3+TGFBR2",
|
| 18 |
-
"FOXF1+HOXB9",
|
| 19 |
-
"FOXA1+KLF1",
|
| 20 |
-
"FOXA1+FOXF1",
|
| 21 |
-
"FOSB+OSR2",
|
| 22 |
-
"CEBPE+KLF1",
|
| 23 |
-
"CEBPE+FOSB",
|
| 24 |
-
"CEBPE+CNN1",
|
| 25 |
-
"CEBPA+KLF1",
|
| 26 |
-
"CEBPA+CEBPE",
|
| 27 |
-
"ZBTB25",
|
| 28 |
-
"UBASH3B",
|
| 29 |
-
"TGFBR2",
|
| 30 |
-
"SAMD1",
|
| 31 |
-
"PTPN9",
|
| 32 |
-
"PRTG",
|
| 33 |
-
"OSR2",
|
| 34 |
-
"MAPK1",
|
| 35 |
-
"MAP2K6",
|
| 36 |
-
"KLF1",
|
| 37 |
-
"IKZF3",
|
| 38 |
-
"IGDCC3",
|
| 39 |
-
"HOXB9",
|
| 40 |
-
"FOXF1",
|
| 41 |
-
"FOXA1",
|
| 42 |
-
"FEV",
|
| 43 |
-
"CNN1",
|
| 44 |
-
"CEBPE",
|
| 45 |
-
"CEBPA"
|
| 46 |
-
],
|
| 47 |
-
"val": [
|
| 48 |
-
"IGDCC3+ZBTB25",
|
| 49 |
-
"IGDCC3+PRTG",
|
| 50 |
-
"CNN1+UBASH3B"
|
| 51 |
-
],
|
| 52 |
-
"test": [
|
| 53 |
-
"PTPN9+UBASH3B",
|
| 54 |
-
"MAPK1+PRTG",
|
| 55 |
-
"IGDCC3+MAPK1",
|
| 56 |
-
"FOXA1+HOXB9",
|
| 57 |
-
"FOSB+UBASH3B",
|
| 58 |
-
"FOSB+IKZF3",
|
| 59 |
-
"CNN1+MAPK1"
|
| 60 |
-
]
|
| 61 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
BioMod/gene_inverse_norman/utils.py
DELETED
|
@@ -1,166 +0,0 @@
|
|
| 1 |
-
import pickle
|
| 2 |
-
import sys
|
| 3 |
-
from copy import deepcopy
|
| 4 |
-
from pathlib import Path
|
| 5 |
-
from typing import Dict, List, Optional, Sequence, Set, Union
|
| 6 |
-
|
| 7 |
-
import pandas as pd
|
| 8 |
-
import torch
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
def print_sys(message: str) -> None:
|
| 12 |
-
print(message, flush=True, file=sys.stderr)
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
def strip_gene_version(gene_id: str) -> str:
|
| 16 |
-
return gene_id.split(".", 1)[0]
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
def canonicalize_target_gene_id(
|
| 20 |
-
raw_gene: object,
|
| 21 |
-
symbol_to_ensembl: Dict[str, str],
|
| 22 |
-
target_gene_set: Set[str],
|
| 23 |
-
) -> Optional[str]:
|
| 24 |
-
if pd.isna(raw_gene):
|
| 25 |
-
return None
|
| 26 |
-
|
| 27 |
-
raw_gene_str = str(raw_gene).strip()
|
| 28 |
-
if not raw_gene_str:
|
| 29 |
-
return None
|
| 30 |
-
|
| 31 |
-
direct_candidates = [raw_gene_str, strip_gene_version(raw_gene_str)]
|
| 32 |
-
for candidate in direct_candidates:
|
| 33 |
-
if candidate in target_gene_set:
|
| 34 |
-
return candidate
|
| 35 |
-
|
| 36 |
-
mapped_gene = symbol_to_ensembl.get(raw_gene_str)
|
| 37 |
-
if mapped_gene is None:
|
| 38 |
-
mapped_gene = symbol_to_ensembl.get(strip_gene_version(raw_gene_str))
|
| 39 |
-
|
| 40 |
-
if mapped_gene is None or pd.isna(mapped_gene):
|
| 41 |
-
return None
|
| 42 |
-
|
| 43 |
-
mapped_gene_str = strip_gene_version(str(mapped_gene).strip())
|
| 44 |
-
return mapped_gene_str or None
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
def resolve_input_gene_ids(
|
| 48 |
-
adata,
|
| 49 |
-
gene_col: str,
|
| 50 |
-
symbol_to_ensembl: Dict[str, str],
|
| 51 |
-
target_gene_set: Set[str],
|
| 52 |
-
) -> List[str]:
|
| 53 |
-
if gene_col not in adata.var.columns:
|
| 54 |
-
raise KeyError(f"gene_col '{gene_col}' not found in adata.var.columns")
|
| 55 |
-
|
| 56 |
-
raw_genes = [str(gene) if not pd.isna(gene) else "" for gene in adata.var[gene_col].tolist()]
|
| 57 |
-
resolved_gene_ids: List[str] = []
|
| 58 |
-
|
| 59 |
-
direct_hits = 0
|
| 60 |
-
mapped_hits = 0
|
| 61 |
-
unresolved = 0
|
| 62 |
-
|
| 63 |
-
for index, raw_gene in enumerate(raw_genes):
|
| 64 |
-
resolved_gene = canonicalize_target_gene_id(raw_gene, symbol_to_ensembl, target_gene_set)
|
| 65 |
-
if resolved_gene is None:
|
| 66 |
-
resolved_gene = f"unknown{index}"
|
| 67 |
-
unresolved += 1
|
| 68 |
-
elif strip_gene_version(raw_gene) == resolved_gene:
|
| 69 |
-
direct_hits += 1
|
| 70 |
-
else:
|
| 71 |
-
mapped_hits += 1
|
| 72 |
-
resolved_gene_ids.append(resolved_gene)
|
| 73 |
-
|
| 74 |
-
print_sys(
|
| 75 |
-
"Gene ID resolution summary: "
|
| 76 |
-
f"direct_hits={direct_hits}, mapped_hits={mapped_hits}, unresolved={unresolved}"
|
| 77 |
-
)
|
| 78 |
-
return resolved_gene_ids
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
def reindex_tensor_universal(
|
| 82 |
-
tensor: torch.Tensor,
|
| 83 |
-
index_positions: Sequence[int],
|
| 84 |
-
dim: int,
|
| 85 |
-
filler: str = "0",
|
| 86 |
-
device: Union[str, torch.device] = "cpu",
|
| 87 |
-
) -> torch.Tensor:
|
| 88 |
-
index_tensor = torch.tensor(index_positions, device=device)
|
| 89 |
-
tensor_shape = list(tensor.shape)
|
| 90 |
-
tensor_shape[dim] = len(index_positions)
|
| 91 |
-
index_shape = [1] * len(tensor_shape)
|
| 92 |
-
index_shape[dim] = len(index_positions)
|
| 93 |
-
index_tensor = index_tensor.view(*index_shape).expand(*tensor_shape)
|
| 94 |
-
|
| 95 |
-
if filler == "0":
|
| 96 |
-
padder_shape = deepcopy(tensor_shape)
|
| 97 |
-
padder_shape[dim] = 1
|
| 98 |
-
padder = torch.zeros(padder_shape, dtype=tensor.dtype, device=device)
|
| 99 |
-
elif filler == "mean":
|
| 100 |
-
padder = tensor.mean(dim=dim, keepdim=True)
|
| 101 |
-
else:
|
| 102 |
-
raise ValueError("filler should be 0 or mean")
|
| 103 |
-
|
| 104 |
-
expanded_tensor = torch.cat((tensor, padder), dim=dim)
|
| 105 |
-
return torch.gather(expanded_tensor, dim, index_tensor)
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
def parse_condition(condition: str) -> List[str]:
|
| 109 |
-
return [gene for gene in str(condition).split("+") if gene and gene != "ctrl"]
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
def canonical_condition_name(condition: str) -> str:
|
| 113 |
-
genes = sorted(set(parse_condition(condition)))
|
| 114 |
-
if not genes:
|
| 115 |
-
return "ctrl"
|
| 116 |
-
return "+".join(genes)
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
def project_root() -> Path:
|
| 120 |
-
return Path(__file__).resolve().parents[2]
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
def default_repo_path(*parts: str) -> str:
|
| 124 |
-
return str(project_root().joinpath(*parts))
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
def load_pretrained_backbone(
|
| 128 |
-
scfm_hyper_params_path: Union[str, Path],
|
| 129 |
-
scfm_ckpt_path: Union[str, Path],
|
| 130 |
-
gene2vec_path: Union[str, Path],
|
| 131 |
-
device: torch.device,
|
| 132 |
-
):
|
| 133 |
-
repo_root = project_root()
|
| 134 |
-
if str(repo_root) not in sys.path:
|
| 135 |
-
sys.path.insert(0, str(repo_root))
|
| 136 |
-
|
| 137 |
-
from BioMod.models_performer_module import (
|
| 138 |
-
PerformerEncoderOnly_Module,
|
| 139 |
-
sanitize_performer_module_config,
|
| 140 |
-
)
|
| 141 |
-
|
| 142 |
-
with open(scfm_hyper_params_path, "rb") as handle:
|
| 143 |
-
scfm_hyper_params = pickle.load(handle)
|
| 144 |
-
|
| 145 |
-
scfm_hyper_params.pop("module_embed_chunk_padded_gene_budget", None)
|
| 146 |
-
scfm_hyper_params = sanitize_performer_module_config(
|
| 147 |
-
scfm_hyper_params,
|
| 148 |
-
source=str(scfm_hyper_params_path),
|
| 149 |
-
log_fn=print_sys,
|
| 150 |
-
)
|
| 151 |
-
scfm_hyper_params["gene2vec_file"] = str(gene2vec_path)
|
| 152 |
-
|
| 153 |
-
model = PerformerEncoderOnly_Module(**scfm_hyper_params).to(device)
|
| 154 |
-
ckpt = torch.load(scfm_ckpt_path, map_location=device)
|
| 155 |
-
model_state_dict = ckpt.get("model_state_dict", ckpt)
|
| 156 |
-
filtered_state_dict = {
|
| 157 |
-
key: value
|
| 158 |
-
for key, value in model_state_dict.items()
|
| 159 |
-
if "go_conv" not in key and "G_go" not in key.lower()
|
| 160 |
-
}
|
| 161 |
-
model.load_state_dict(filtered_state_dict, strict=True)
|
| 162 |
-
for parameter in model.parameters():
|
| 163 |
-
parameter.requires_grad = False
|
| 164 |
-
model.eval()
|
| 165 |
-
|
| 166 |
-
return model, scfm_hyper_params
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
README.md
CHANGED
|
@@ -10,38 +10,165 @@ library_name: pytorch
|
|
| 10 |
|
| 11 |
# BioMod
|
| 12 |
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
and
|
|
|
|
| 16 |
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
weights and configuration. The outer `BioMod/` directory is the project root;
|
| 20 |
-
the inner `BioMod/` directory contains the source code.
|
| 21 |
|
| 22 |
-
#
|
|
|
|
|
|
|
| 23 |
|
| 24 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
| --- | --- | --- |
|
| 26 |
| Cell-type annotation | [Fine-tuning script](BioMod/cell_annotation/performer_module_finetune.py) | [Notebook](BioMod/toturials/cell_annotation.ipynb) |
|
| 27 |
-
| Zero-shot batch
|
| 28 |
-
| Single-drug response | [Training script](BioMod/drug_response/train_drug_performer_module.py) | [Notebook](BioMod/toturials/drug_response_prediction.ipynb) |
|
| 29 |
-
| Drug-
|
| 30 |
-
| Gene perturbation response | [Training script](BioMod/gene_perturbation/train_module.py) | [Task guide](BioMod/gene_perturbation/README.md), [notebook](BioMod/toturials/gene_perturbation.ipynb) |
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
|
| 41 |
## Repository layout
|
| 42 |
|
| 43 |
-
|
| 44 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
|
| 46 |
```text
|
| 47 |
BioMod/ # Project root: source code, model dependencies and weights
|
|
@@ -62,20 +189,6 @@ BioMod/ # Project root: source
|
|
| 62 |
│ ├── eval_new_data/ # Reconstruction error and generalization on new data
|
| 63 |
│ │ ├── cmd.txtx
|
| 64 |
│ │ └── evaluate_new_data.py
|
| 65 |
-
│ ├── gene_inverse_norman/ # Norman reverse perturbation prediction and evaluation
|
| 66 |
-
│ │ ├── protocols/ # Preset gene sets and train/validation/test splits
|
| 67 |
-
│ │ │ ├── cellfm/ # Gene list and data splits for the CellFM protocol
|
| 68 |
-
│ │ │ │ ├── genes.txt
|
| 69 |
-
│ │ │ │ └── split.json
|
| 70 |
-
│ │ │ └── scgpt/ # Gene list and data splits for the scGPT protocol
|
| 71 |
-
│ │ │ ├── genes.txt
|
| 72 |
-
│ │ │ └── split.json
|
| 73 |
-
│ │ ├── build_norman_gene_inverse_benchmark.py
|
| 74 |
-
│ │ ├── common.py
|
| 75 |
-
│ │ ├── evaluate_gene_inverse.py
|
| 76 |
-
│ │ ├── finetune_gene_inverse.py
|
| 77 |
-
│ │ ├── README.md
|
| 78 |
-
│ │ └── utils.py
|
| 79 |
│ ├── gene_perturbation/ # Perturbation response prediction: processing, training and inference
|
| 80 |
│ │ ├── data_utils_local.py
|
| 81 |
│ │ ├── gears_reduce_local.py
|
|
@@ -181,375 +294,9 @@ BioMod/ # Project root: source
|
|
| 181 |
└── README.md
|
| 182 |
```
|
| 183 |
|
| 184 |
-
> The local `data/` directory is present but omitted from this view. Only
|
| 185 |
-
> `weight/0810model/` is currently included in the local checkpoint directory.
|
| 186 |
-
|
| 187 |
-
## Environment requirements
|
| 188 |
-
|
| 189 |
-
The supplied files describe the original CUDA server environment. They are
|
| 190 |
-
configuration specifications, not a tested compatibility matrix for this local
|
| 191 |
-
checkout.
|
| 192 |
-
|
| 193 |
-
| Component | Declared configuration |
|
| 194 |
-
| --- | --- |
|
| 195 |
-
| Conda environment | `performer_module` |
|
| 196 |
-
| Python | `3.9` |
|
| 197 |
-
| PyTorch | `2.6.*` in Conda; `2.6.0+cu124` in the pip requirements |
|
| 198 |
-
| CUDA runtime | `pytorch-cuda=12.4`; CUDA 12.4 wheel sources in the pip requirements |
|
| 199 |
-
| TorchVision / TorchAudio | `0.21.*` / `2.6.*` |
|
| 200 |
-
| PyTorch Geometric | `2.6.*` |
|
| 201 |
-
| Notebook tools | JupyterLab and ipykernel |
|
| 202 |
-
|
| 203 |
-
See [environment.yml](BioMod/environment.yml) and
|
| 204 |
-
[requirements.txt](BioMod/requirements.txt) for the full dependency lists.
|
| 205 |
-
The pip requirements also pin GPU packages; review those pins before applying
|
| 206 |
-
them to an existing environment.
|
| 207 |
-
|
| 208 |
-
Pretraining uses the local [training utilities](BioMod/training_utils.py) for
|
| 209 |
-
seeding, distributed sampling and reduction, learning-rate scheduling, and
|
| 210 |
-
checkpoint saving. See the [utility checks](BioMod/README.md#training-utility-checks)
|
| 211 |
-
for CPU tests and an entry-point import check.
|
| 212 |
-
|
| 213 |
-
The zero-shot runner selects CUDA when available and otherwise selects CPU.
|
| 214 |
-
Other tasks have different requirements: pretraining and new-data reconstruction
|
| 215 |
-
evaluation use CUDA/NCCL. Native Windows support, CPU performance, GPU memory
|
| 216 |
-
requirements, and elapsed time have not been validated here.
|
| 217 |
-
|
| 218 |
-
### Working directory and installation
|
| 219 |
-
|
| 220 |
-
All commands in the quick start run from the **outer project root**, which
|
| 221 |
-
contains `BioMod/`, `model_dependencies/`, `weight/`, and `data/`.
|
| 222 |
-
Choose the working-directory command for your shell:
|
| 223 |
-
|
| 224 |
-
```powershell
|
| 225 |
-
# PowerShell on this Windows checkout
|
| 226 |
-
Set-Location "E:/SLAI/BioMod"
|
| 227 |
-
```
|
| 228 |
-
|
| 229 |
-
```bash
|
| 230 |
-
# Bash on Linux or WSL; adjust the mount path if necessary
|
| 231 |
-
cd /mnt/e/SLAI/BioMod
|
| 232 |
-
```
|
| 233 |
-
|
| 234 |
-
With Conda available in that shell, create and activate the environment:
|
| 235 |
-
|
| 236 |
-
```bash
|
| 237 |
-
conda env create -f BioMod/environment.yml
|
| 238 |
-
conda activate performer_module
|
| 239 |
-
```
|
| 240 |
-
|
| 241 |
-
These two Conda commands use the same syntax in Bash and PowerShell. Keep the
|
| 242 |
-
working directory at the outer root. Environment installation and model inference
|
| 243 |
-
have not been executed to validate these examples.
|
| 244 |
-
|
| 245 |
-
## 0810 quick start
|
| 246 |
-
|
| 247 |
-
Use the following two files together:
|
| 248 |
-
|
| 249 |
-
| Asset | Path relative to the project root |
|
| 250 |
-
| --- | --- |
|
| 251 |
-
| Weights | `weight/0810model/mae_tost_70M_75mask_ec2_epoch0.pth` |
|
| 252 |
-
| Configuration | `weight/0810model/mae_tost_70M_75mask_ec2.pkl` |
|
| 253 |
-
|
| 254 |
-
The 0810 release is documented as a 2-layer encoder with a 27,874-gene axis
|
| 255 |
-
and 660 modules. The paired configuration defines the exact architecture and
|
| 256 |
-
module layout.
|
| 257 |
-
The loader restores those settings and uses the shared gene assets under
|
| 258 |
-
`model_dependencies/`. Do not substitute the configuration from the old 0629
|
| 259 |
-
or 0407 examples. The `epoch0` filename alone does not establish performance
|
| 260 |
-
on any downstream task.
|
| 261 |
-
|
| 262 |
-
### Small zero-shot evaluation
|
| 263 |
-
|
| 264 |
-
After activating the environment, choose **one** command below. Both commands
|
| 265 |
-
use the same local lung dataset, 512 cells, mean pooling, and a batch size of 1.
|
| 266 |
-
The dataset metadata contains 21,955 cells and the required `gene_name`, `sample`,
|
| 267 |
-
and `cell_type` fields. This metadata check does not establish that the expression
|
| 268 |
-
preprocessing or checkpoint performance is suitable for a new benchmark.
|
| 269 |
-
|
| 270 |
-
**Bash:**
|
| 271 |
-
|
| 272 |
-
```bash
|
| 273 |
-
python BioMod/zero_shot_batch/performer_module_zero_shot.py \
|
| 274 |
-
--scfm_ckpt_path weight/0810model/mae_tost_70M_75mask_ec2_epoch0.pth \
|
| 275 |
-
--scfm_hyper_params_path weight/0810model/mae_tost_70M_75mask_ec2.pkl \
|
| 276 |
-
--gene2vec_path model_dependencies/selected_gene2vec_27k.npy \
|
| 277 |
-
--scfm_genes_file model_dependencies/selected_genes_27k.txt \
|
| 278 |
-
--gene_mapping_path model_dependencies/gene2ens.csv \
|
| 279 |
-
--module_data_path model_dependencies/module_data_all/merged_modules.json \
|
| 280 |
-
--data_path data/downstream/final_data/Kim2020_Lung_noMalignant.h5ad \
|
| 281 |
-
--dataset_preset lung \
|
| 282 |
-
--gene_col gene_name \
|
| 283 |
-
--batch_key sample \
|
| 284 |
-
--label_key cell_type \
|
| 285 |
-
--n_cells 512 \
|
| 286 |
-
--batch_size 1 \
|
| 287 |
-
--metrics_mode simple \
|
| 288 |
-
--pool_method mean \
|
| 289 |
-
--output_dir outputs/0810_lung_smoke
|
| 290 |
-
```
|
| 291 |
-
|
| 292 |
-
**PowerShell:**
|
| 293 |
-
|
| 294 |
-
```powershell
|
| 295 |
-
python BioMod/zero_shot_batch/performer_module_zero_shot.py `
|
| 296 |
-
--scfm_ckpt_path weight/0810model/mae_tost_70M_75mask_ec2_epoch0.pth `
|
| 297 |
-
--scfm_hyper_params_path weight/0810model/mae_tost_70M_75mask_ec2.pkl `
|
| 298 |
-
--gene2vec_path model_dependencies/selected_gene2vec_27k.npy `
|
| 299 |
-
--scfm_genes_file model_dependencies/selected_genes_27k.txt `
|
| 300 |
-
--gene_mapping_path model_dependencies/gene2ens.csv `
|
| 301 |
-
--module_data_path model_dependencies/module_data_all/merged_modules.json `
|
| 302 |
-
--data_path data/downstream/final_data/Kim2020_Lung_noMalignant.h5ad `
|
| 303 |
-
--dataset_preset lung `
|
| 304 |
-
--gene_col gene_name `
|
| 305 |
-
--batch_key sample `
|
| 306 |
-
--label_key cell_type `
|
| 307 |
-
--n_cells 512 `
|
| 308 |
-
--batch_size 1 `
|
| 309 |
-
--metrics_mode simple `
|
| 310 |
-
--pool_method mean `
|
| 311 |
-
--output_dir outputs/0810_lung_smoke
|
| 312 |
-
```
|
| 313 |
-
|
| 314 |
-
The flags and input paths were checked against this checkout; checkpoint loading
|
| 315 |
-
and inference remain untested. The PowerShell block documents shell syntax, not
|
| 316 |
-
validated native-Windows dependency compatibility.
|
| 317 |
-
|
| 318 |
-
`--module_data_path` is a fallback when the paired configuration lacks module
|
| 319 |
-
sets; it does not override module sets already stored in that configuration.
|
| 320 |
-
For a different dataset, replace the input path and metadata fields as described
|
| 321 |
-
under [Input and output](#input-and-output).
|
| 322 |
-
|
| 323 |
-
`--n_cells 512` subsamples after loading the entire h5ad file. For another input,
|
| 324 |
-
choose a positive value no larger than the number of cells remaining after
|
| 325 |
-
filtering, or use `--n_cells all`. The limit does not reduce the initial file-loading
|
| 326 |
-
memory requirement. A small subset can lack the batch or cell-type diversity
|
| 327 |
-
needed by the metrics.
|
| 328 |
-
|
| 329 |
-
For a full evaluation, use `--n_cells all` and `--metrics_mode scgpt`, and select
|
| 330 |
-
a new output directory. The `simple` mode computes only the available batch
|
| 331 |
-
silhouette metric; its subset results are not a full benchmark.
|
| 332 |
-
|
| 333 |
-
### Check the result
|
| 334 |
-
|
| 335 |
-
A completed run exits successfully and prints `Zero-shot evaluation completed!`.
|
| 336 |
-
The two main files are listed under [Expected outputs](#expected-outputs).
|
| 337 |
-
After running the 512-cell example, this optional command checks their basic
|
| 338 |
-
structure without loading model weights:
|
| 339 |
-
|
| 340 |
-
```bash
|
| 341 |
-
python -c "from pathlib import Path; import numpy as np; import pandas as pd; p=Path('outputs/0810_lung_smoke'); x=np.load(p/'sclong_module_batch_cell_emb_mode_ce.npy', allow_pickle=False); m=pd.read_csv(p/'sclong_module_batch_cell_emb_mode.csv'); assert x.ndim==2 and x.shape[0]==512 and x.shape[1]>0; assert np.isfinite(x).all(); assert {'metric','label','value'}.issubset(m.columns); print('Embedding shape:', x.shape, 'Metric rows:', len(m))"
|
| 342 |
-
```
|
| 343 |
-
|
| 344 |
-
The same one-line check works in PowerShell. Adjust the expected cell count if
|
| 345 |
-
you change the command. Passing these checks establishes output structure, not
|
| 346 |
-
biological accuracy or a performance claim. A metric CSV with no data rows can
|
| 347 |
-
occur when the subset contains only one batch or no valid metric values.
|
| 348 |
-
|
| 349 |
-
### Checkpoint arguments for other tasks
|
| 350 |
-
|
| 351 |
-
| Task | Weights argument | Configuration argument |
|
| 352 |
-
| --- | --- | --- |
|
| 353 |
-
| Drug response / drug combination | `--checkpoint_path` | `--hyper_params_path` |
|
| 354 |
-
| Zero-shot / gene perturbation / gene inverse | `--scfm_ckpt_path` | `--scfm_hyper_params_path` |
|
| 355 |
-
| Cell annotation / backbone interpretation | `--performer_checkpoint` | `--config_pkl` |
|
| 356 |
-
| Continued pretraining | `--model_path` | `--model_config_path` or automatic paired-file discovery |
|
| 357 |
-
|
| 358 |
-
Use the 0810 pair above when adapting these task commands. New-data reconstruction
|
| 359 |
-
evaluation uses its own checkpoint-directory interface; inspect its linked
|
| 360 |
-
launcher and parser rather than applying this table to that entry point.
|
| 361 |
-
|
| 362 |
-
## Notebook setup
|
| 363 |
-
|
| 364 |
-
The notebooks in [`BioMod/toturials/`](BioMod/toturials/) retain historical
|
| 365 |
-
`0407weight`, `module_model`, and `/data/...` settings. Review the first
|
| 366 |
-
configuration cells before executing them. Updating an environment variable alone
|
| 367 |
-
does not update every notebook: the drug-combination notebook reads
|
| 368 |
-
`PERFORMER_MODULE_ROOT` and `BIOMODULEFM_ASSET_ROOT`, while the others contain
|
| 369 |
-
hard-coded paths.
|
| 370 |
-
|
| 371 |
-
| Setting | Local meaning |
|
| 372 |
-
| --- | --- |
|
| 373 |
-
| `BASE` or `ASSET_ROOT` | Outer project root, for example `E:/SLAI/BioMod` in native Windows Python |
|
| 374 |
-
| `PROJECT_ROOT` | Inner source directory, for example `E:/SLAI/BioMod/BioMod` |
|
| 375 |
-
| `MODULE_MODEL` or `LEGACY_MODULE_MODEL` | Shared assets directory, `model_dependencies/` under the outer root |
|
| 376 |
-
| Weight and configuration variables | The exact 0810 `.pth` and `.pkl` filenames listed above |
|
| 377 |
-
| `MODULE_DATA_DIR` | `BioMod/module_data_all/` under the outer root |
|
| 378 |
-
| Data and output variables | Task-specific inputs and a writable output directory |
|
| 379 |
-
| Python executable and launch commands | The active environment and the syntax supported by your shell |
|
| 380 |
-
|
| 381 |
-
The variable names for the paired model files differ by notebook:
|
| 382 |
-
|
| 383 |
-
- Cell annotation, drug response, and zero-shot: `CKPT_PATH` and `CONFIG_PKL`.
|
| 384 |
-
- Gene perturbation: `SCFM_CKPT` and `SCFM_HYPER`.
|
| 385 |
-
- Drug combination: `CHECKPOINT_PATH` and `HYPER_PARAMS_PATH`.
|
| 386 |
-
|
| 387 |
-
Also replace derived paths such as `WEIGHT_DIR`, `DATA_PATH` / `DATA_DIR` /
|
| 388 |
-
`DATA_ROOT`, and the notebook's output root. Confirm the path-check cells before
|
| 389 |
-
turning on `RUN_TRAINING` where that switch is provided. Under WSL, use Linux
|
| 390 |
-
mount paths such as `/mnt/e/SLAI/BioMod` rather than Windows drive paths.
|
| 391 |
-
|
| 392 |
-
Some plotting cells require additional adaptation. The zero-shot notebook calls
|
| 393 |
-
`module_model/draw/zero_shot_umap_scib.py`, which is not included in this checkout;
|
| 394 |
-
use the bundled CLI above for the documented 0810 run. The drug-response notebook
|
| 395 |
-
also refers to external historical plotting scripts and reference results.
|
| 396 |
-
Changing only the checkpoint paths does not supply those external files.
|
| 397 |
-
|
| 398 |
-
## Input and output
|
| 399 |
-
|
| 400 |
-
### Zero-shot input requirements
|
| 401 |
-
|
| 402 |
-
The quick start uses
|
| 403 |
-
`data/downstream/final_data/Kim2020_Lung_noMalignant.h5ad`.
|
| 404 |
-
The data directory is intentionally omitted from the repository tree above.
|
| 405 |
-
Other tasks have different input formats; follow their linked task guides.
|
| 406 |
-
|
| 407 |
-
| Input | Requirement or behavior |
|
| 408 |
-
| --- | --- |
|
| 409 |
-
| Expression matrix | An h5ad file with cells in rows and genes in columns of `adata.X`; dense and sparse matrices are handled |
|
| 410 |
-
| Expression preprocessing | Prepare the matrix according to the checkpoint/task protocol; this runner does not normalize, log-transform, scale, or select highly variable genes |
|
| 411 |
-
| Gene identifiers | Supply the column in `adata.var` with `--gene_col`; with no column selected, the runner uses `adata.var.index` |
|
| 412 |
-
| Gene mapping | Target Ensembl IDs can match directly; gene symbols are mapped using `model_dependencies/gene2ens.csv` |
|
| 413 |
-
| Batch labels | Select an existing `adata.obs` column with `--batch_key` |
|
| 414 |
-
| Cell-type labels | Select one or more existing `adata.obs` columns with `--label_key`; evaluation requires valid labels |
|
| 415 |
-
| Lung preset | Defaults to `gene_name` in `adata.var`, and `sample` / `cell_type` in `adata.obs`; explicit command-line values take precedence |
|
| 416 |
-
| Model gene order | Defined by `model_dependencies/selected_genes_27k.txt`; missing target genes are filled with zeros |
|
| 417 |
-
|
| 418 |
-
Check the printed gene-resolution summary and gene intersection count before
|
| 419 |
-
interpreting results. Resolve duplicate gene identifiers before inference: this
|
| 420 |
-
runner's index mapping keeps the last matching column rather than aggregating
|
| 421 |
-
duplicates. No minimum acceptable gene-overlap threshold is enforced by the script.
|
| 422 |
-
|
| 423 |
-
### Expected outputs
|
| 424 |
-
|
| 425 |
-
The example writes to `outputs/0810_lung_smoke/`:
|
| 426 |
-
|
| 427 |
-
| File | Contents |
|
| 428 |
-
| --- | --- |
|
| 429 |
-
| `sclong_module_batch_cell_emb_mode.csv` | Evaluation rows with columns `metric`, `label`, and `value` |
|
| 430 |
-
| `sclong_module_batch_cell_emb_mode_ce.npy` | Cell embeddings with shape `(processed_cells, embedding_dimension)` |
|
| 431 |
-
| `sclong_module_raw_module_emb.npy` | Optional module embeddings, written only with `--save_raw_module_emb` |
|
| 432 |
-
|
| 433 |
-
The legacy output prefixes are the filenames produced by the current script.
|
| 434 |
-
The runner does not export a new h5ad file, cell IDs, or UMAP figures. Embedding
|
| 435 |
-
rows follow the processed AnnData order; after subsampling, do not pair them with
|
| 436 |
-
the full original observation list. If downstream work needs cell IDs, prepare
|
| 437 |
-
and retain a documented input subset and process it with `--n_cells all`.
|
| 438 |
-
|
| 439 |
-
Metrics are evaluated before the main output files are saved. An evaluation
|
| 440 |
-
error can therefore prevent the final embedding file from being written even
|
| 441 |
-
when embedding extraction finished.
|
| 442 |
-
|
| 443 |
-
## Troubleshooting
|
| 444 |
-
|
| 445 |
-
| Symptom | What to check |
|
| 446 |
-
| --- | --- |
|
| 447 |
-
| Missing script, checkpoint, or gene asset | Confirm the outer working directory and the exact paths in the 0810 example; older guides may still refer to 0629 or 0407 assets |
|
| 448 |
-
| Configuration or state-dictionary mismatch | Use the matching `.pth` and `.pkl` pair and the current loader; do not edit serialized weights to resolve names |
|
| 449 |
-
| Missing gene, batch, or label column | Check `adata.var` / `adata.obs` and pass the correct `--gene_col`, `--batch_key`, and `--label_key` |
|
| 450 |
-
| Empty metrics or a small-subset metric error | Check batch and label coverage, then use a larger suitable subset or the full dataset |
|
| 451 |
-
| Invalid cell limit or an empty inference batch | Use `--n_cells all`, or a positive count no greater than the cells available after filtering |
|
| 452 |
-
| Insufficient memory | Keep inference batches small and assess host RAM separately; h5ad loading happens before subsampling |
|
| 453 |
-
| Notebook references an absent `draw/` script | Use the bundled CLI or adapt the external plotting workflow; renaming weight paths alone is insufficient |
|
| 454 |
-
|
| 455 |
-
## Download
|
| 456 |
-
|
| 457 |
-
Run these commands from the **parent of the destination directory**. Skip this
|
| 458 |
-
section when using the files already present locally. Authenticate with a
|
| 459 |
-
Hugging Face account that has access to `ctcatlas/BioMod`:
|
| 460 |
-
|
| 461 |
-
```bash
|
| 462 |
-
hf auth login
|
| 463 |
-
|
| 464 |
-
# Complete project: source code, model dependencies, 0810 weights and datasets
|
| 465 |
-
hf download ctcatlas/BioMod --repo-type=model --local-dir ./BioMod
|
| 466 |
-
|
| 467 |
-
# Source code, model dependencies and weights, without the data directory
|
| 468 |
-
hf download ctcatlas/BioMod --repo-type=model --local-dir ./BioMod \
|
| 469 |
-
--exclude "data/**"
|
| 470 |
-
|
| 471 |
-
# The 0810 checkpoint and its matching architecture configuration only
|
| 472 |
-
hf download ctcatlas/BioMod \
|
| 473 |
-
weight/0810model/mae_tost_70M_75mask_ec2_epoch0.pth \
|
| 474 |
-
weight/0810model/mae_tost_70M_75mask_ec2.pkl \
|
| 475 |
-
--repo-type=model --local-dir ./BioMod
|
| 476 |
-
```
|
| 477 |
-
|
| 478 |
-
The complete repository includes the `data/` directory, which is omitted from
|
| 479 |
-
[the repository layout](#repository-layout) for readability. Use the command
|
| 480 |
-
with `--exclude "data/**"` when the benchmark datasets are not needed.
|
| 481 |
-
|
| 482 |
-
This release uses the `BioMod/` source-directory name shown throughout this
|
| 483 |
-
README. The original [upstream repository](https://huggingface.co/ctcatlas/ModulaFM)
|
| 484 |
-
is retained separately; its historical layout and 0629 checkpoint are not part
|
| 485 |
-
of this release.
|
| 486 |
-
|
| 487 |
-
## Historical 0629 reference
|
| 488 |
-
|
| 489 |
-
The 0629 checkpoint and its paired PKL have been removed from this local checkout.
|
| 490 |
-
Only `weight/0810model/` remains. The 0629 examples below are upstream reference
|
| 491 |
-
commands and require restoring that checkpoint/config pair before use.
|
| 492 |
-
|
| 493 |
-
<details>
|
| 494 |
-
<summary>Original 0629 architecture and Bash examples</summary>
|
| 495 |
-
|
| 496 |
-
Start these reference blocks from the outer project root. The final pretraining
|
| 497 |
-
block changes into the inner source directory.
|
| 498 |
-
|
| 499 |
-
The primary release is the 32-layer checkpoint under `weight/0629model/`.
|
| 500 |
-
It uses 27,874 genes, 660 gene modules, a hidden dimension of 1,024, and the
|
| 501 |
-
cross-attention decoder. Use the checkpoint and its PKL as a pair:
|
| 502 |
-
|
| 503 |
-
```bash
|
| 504 |
-
PRIMARY_MODEL_DIR="$PWD/weight/0629model"
|
| 505 |
-
PRIMARY_MODEL_CKPT="$PRIMARY_MODEL_DIR/mae_tost_48M_cross_attn_only_pure_memory_epoch12.pth"
|
| 506 |
-
PRIMARY_MODEL_CONFIG="$PRIMARY_MODEL_DIR/mae_tost_48M_cross_attn_only_pure_memory.pkl"
|
| 507 |
-
```
|
| 508 |
-
|
| 509 |
-
The PKL records the exact architecture and module definitions used during
|
| 510 |
-
pretraining. Do not combine a checkpoint with a PKL from another model.
|
| 511 |
-
|
| 512 |
-
Example zero-shot invocation from the repository root:
|
| 513 |
-
|
| 514 |
-
```bash
|
| 515 |
-
python BioMod/zero_shot_batch/performer_module_zero_shot.py \
|
| 516 |
-
--data_path /path/to/input.h5ad \
|
| 517 |
-
--scfm_hyper_params_path "$PRIMARY_MODEL_CONFIG" \
|
| 518 |
-
--scfm_ckpt_path "$PRIMARY_MODEL_CKPT" \
|
| 519 |
-
--gene_mapping_path model_dependencies/gene2ens.csv \
|
| 520 |
-
--scfm_genes_file model_dependencies/selected_genes_27k.txt \
|
| 521 |
-
--gene2vec_path model_dependencies/selected_gene2vec_27k.npy \
|
| 522 |
-
--output_dir BioMod/results/zero_shot_0629
|
| 523 |
-
```
|
| 524 |
-
|
| 525 |
-
For drug-response and drug-combination scripts, pass the same pair through
|
| 526 |
-
`--checkpoint_path` and `--hyper_params_path`. For cell annotation, use
|
| 527 |
-
`--performer_checkpoint` and `--config_pkl`. The detailed task commands are in
|
| 528 |
-
[source README](BioMod/README.md).
|
| 529 |
-
|
| 530 |
-
To continue pretraining, pass the primary checkpoint through `--model_path`.
|
| 531 |
-
`performer_module.py` automatically finds the accompanying PKL, restores the
|
| 532 |
-
recorded architecture, and uses its module definitions:
|
| 533 |
-
|
| 534 |
-
```bash
|
| 535 |
-
cd BioMod
|
| 536 |
-
torchrun --standalone --nproc_per_node=1 performer_module.py \
|
| 537 |
-
--model_path ../weight/0629model/mae_tost_48M_cross_attn_only_pure_memory_epoch12.pth \
|
| 538 |
-
--gene_list_path ../model_dependencies/selected_genes_27k.txt \
|
| 539 |
-
--gene_mapping_path ../model_dependencies/gene2ens.csv \
|
| 540 |
-
--gene2vec_data_path ../model_dependencies/selected_gene2vec_27k.npy \
|
| 541 |
-
--data_path /path/to/pretraining_data \
|
| 542 |
-
--data_format h5ad
|
| 543 |
-
```
|
| 544 |
-
|
| 545 |
-
For a checkpoint whose PKL does not follow the same naming convention, pass
|
| 546 |
-
`--model_config_path /path/to/config.pkl` explicitly. The bundled
|
| 547 |
-
`model_dependencies/performer_pytorch_cont` package is discovered
|
| 548 |
-
automatically; callers no longer need to add it to `PYTHONPATH`.
|
| 549 |
-
|
| 550 |
</details>
|
| 551 |
|
| 552 |
## Partners
|
| 553 |
|
| 554 |
-
-
|
| 555 |
-
-
|
|
|
|
| 10 |
|
| 11 |
# BioMod
|
| 12 |
|
| 13 |
+
BioMod is a single-cell transcriptomics foundation model built around gene modules
|
| 14 |
+
and a Performer-based encoder. This repository provides pretrained model assets,
|
| 15 |
+
pretraining and fine-tuning code, downstream task tutorials, and interpretability
|
| 16 |
+
tools.
|
| 17 |
|
| 18 |
+
BioMod is released by **CTC-Atlas**, the joint laboratory of **Raymind Biotechnology
|
| 19 |
+
Co., Ltd** and the **Shenzhen Research Institute of Big Data (SRIBD)**.
|
|
|
|
|
|
|
| 20 |
|
| 21 |
+
[Installation](#installation) · [Pretrained model](#pretrained-model) ·
|
| 22 |
+
[Tasks and examples](#tasks-and-examples) · [Usage notes](#usage-notes) ·
|
| 23 |
+
[Repository layout](#repository-layout)
|
| 24 |
|
| 25 |
+
## Installation
|
| 26 |
+
|
| 27 |
+
Download the project from [Hugging Face](https://huggingface.co/ctcatlas/BioMod).
|
| 28 |
+
Run the following commands from the parent of your intended project directory:
|
| 29 |
+
|
| 30 |
+
```bash
|
| 31 |
+
python -m pip install --upgrade huggingface_hub
|
| 32 |
+
hf auth login
|
| 33 |
+
hf download ctcatlas/BioMod --local-dir ./BioMod
|
| 34 |
+
cd BioMod
|
| 35 |
+
```
|
| 36 |
+
|
| 37 |
+
The download includes source code, model dependencies, the pretrained checkpoint,
|
| 38 |
+
and datasets. To omit datasets, add `--exclude "data/**"` to the download command.
|
| 39 |
+
If you already have the project, start from its root directory.
|
| 40 |
+
|
| 41 |
+
Create and activate the supplied Conda environment:
|
| 42 |
+
|
| 43 |
+
```bash
|
| 44 |
+
conda env create -f BioMod/environment.yml
|
| 45 |
+
conda activate performer_module
|
| 46 |
+
```
|
| 47 |
+
|
| 48 |
+
The environment specification uses Python 3.9, PyTorch 2.6, CUDA 12.4, and PyTorch
|
| 49 |
+
Geometric 2.6. See [environment.yml](BioMod/environment.yml) and
|
| 50 |
+
[requirements.txt](BioMod/requirements.txt) for the complete dependency lists.
|
| 51 |
+
Match the GPU packages to your system when adapting the environment.
|
| 52 |
+
|
| 53 |
+
Pretraining and new-data reconstruction evaluation use CUDA/NCCL. The zero-shot
|
| 54 |
+
runner selects CUDA when available and otherwise uses CPU. Native Windows
|
| 55 |
+
execution has not been validated for the supplied environment.
|
| 56 |
+
|
| 57 |
+
## Pretrained model
|
| 58 |
+
|
| 59 |
+
The **0810 checkpoint** is included in this release. Use its weights and
|
| 60 |
+
configuration together; the configuration records the model architecture and
|
| 61 |
+
gene-module definitions.
|
| 62 |
+
|
| 63 |
+
| Asset | File |
|
| 64 |
+
| --- | --- |
|
| 65 |
+
| Model weights | [mae_tost_70M_75mask_ec2_epoch0.pth](weight/0810model/mae_tost_70M_75mask_ec2_epoch0.pth) |
|
| 66 |
+
| Model configuration | [mae_tost_70M_75mask_ec2.pkl](weight/0810model/mae_tost_70M_75mask_ec2.pkl) |
|
| 67 |
+
| Model gene order | [selected_genes_27k.txt](model_dependencies/selected_genes_27k.txt) |
|
| 68 |
+
| Gene identifier mapping | [gene2ens.csv](model_dependencies/gene2ens.csv) |
|
| 69 |
+
| Gene2Vec embeddings | [selected_gene2vec_27k.npy](model_dependencies/selected_gene2vec_27k.npy) |
|
| 70 |
+
| Shared module definitions | [merged_modules.json](model_dependencies/module_data_all/merged_modules.json) |
|
| 71 |
+
|
| 72 |
+
The model components are bundled under
|
| 73 |
+
[`model_dependencies/performer_pytorch_cont/`](model_dependencies/performer_pytorch_cont/).
|
| 74 |
+
Task-specific checkpoint arguments are listed under
|
| 75 |
+
[Usage notes](#checkpoint-arguments).
|
| 76 |
+
|
| 77 |
+
## Tasks and examples
|
| 78 |
+
|
| 79 |
+
The following tasks have implementations in this repository. The links identify
|
| 80 |
+
available source code and examples; these workflows have not been validated end
|
| 81 |
+
to end with the bundled checkpoint.
|
| 82 |
+
|
| 83 |
+
| Task | Implementation | Notebook or guide |
|
| 84 |
| --- | --- | --- |
|
| 85 |
| Cell-type annotation | [Fine-tuning script](BioMod/cell_annotation/performer_module_finetune.py) | [Notebook](BioMod/toturials/cell_annotation.ipynb) |
|
| 86 |
+
| Zero-shot cell embeddings and batch-effect evaluation | [Embedding and evaluation script](BioMod/zero_shot_batch/performer_module_zero_shot.py) | [Notebook](BioMod/toturials/zero_shot_batch_integration.ipynb) |
|
| 87 |
+
| Single-drug response regression (IC50) | [Training script](BioMod/drug_response/train_drug_performer_module.py) | [Notebook](BioMod/toturials/drug_response_prediction.ipynb) |
|
| 88 |
+
| Drug-pair synergy classification | [Training script](BioMod/drug_combination/train_drug_combination_performer_module.py) | [Task guide](BioMod/drug_combination/README.md), [notebook](BioMod/toturials/drug_combination.ipynb) |
|
| 89 |
+
| Gene perturbation response prediction | [Training script](BioMod/gene_perturbation/train_module.py) | [Task guide](BioMod/gene_perturbation/README.md), [notebook](BioMod/toturials/gene_perturbation.ipynb) |
|
| 90 |
+
|
| 91 |
+
### Pretraining, evaluation, and analysis scripts
|
| 92 |
+
|
| 93 |
+
The repository also includes the following supporting workflows.
|
| 94 |
+
|
| 95 |
+
| Workflow | Implementation | Documentation |
|
| 96 |
+
| --- | --- | --- |
|
| 97 |
+
| Pretraining and continued pretraining | [Pretraining script](BioMod/performer_module.py) | [Source guide](BioMod/README.md), [launcher reference](BioMod/command.txt) |
|
| 98 |
+
| Expression reconstruction evaluation on new data | [Evaluation script](BioMod/eval_new_data/evaluate_new_data.py) | [Launcher reference](BioMod/eval_new_data/cmd.txtx) |
|
| 99 |
+
| Module interpretability analysis | [Module geometry](BioMod/interpretability/module_geometry.py), [module importance](BioMod/interpretability/module_importance/module_importance.py), [text alignment](BioMod/interpretability/module_text_alignment/module_text_alignment.py) | [Overview](BioMod/interpretability/README.md), [attention](BioMod/interpretability/attention/README.md), [module importance guide](BioMod/interpretability/module_importance/README.md) |
|
| 100 |
+
|
| 101 |
+
Tutorials and launcher references retain some historical checkpoint paths,
|
| 102 |
+
server paths, and arguments. Adapt them to the current assets above and check
|
| 103 |
+
the selected script's `--help` before running. Some notebook plotting steps
|
| 104 |
+
also refer to external scripts or reference results that are not bundled here.
|
| 105 |
+
|
| 106 |
+
## Usage notes
|
| 107 |
+
|
| 108 |
+
### Project paths and notebooks
|
| 109 |
+
|
| 110 |
+
The project root contains `BioMod/`, `model_dependencies/`, `weight/`, and
|
| 111 |
+
`data/`. The inner `BioMod/` directory contains the source code. Task commands
|
| 112 |
+
may assume different working directories; adjust relative paths when following
|
| 113 |
+
a guide.
|
| 114 |
+
|
| 115 |
+
Before running a notebook, update its configuration cells:
|
| 116 |
+
|
| 117 |
+
| Setting | Location |
|
| 118 |
+
| --- | --- |
|
| 119 |
+
| `BASE` or `ASSET_ROOT` | Project root |
|
| 120 |
+
| `PROJECT_ROOT` | Inner `BioMod/` source directory |
|
| 121 |
+
| `MODULE_MODEL` or `LEGACY_MODULE_MODEL` | `model_dependencies/` under the project root |
|
| 122 |
+
| Weight and configuration paths | The paired 0810 files listed above |
|
| 123 |
+
| `MODULE_DATA_DIR` | `BioMod/module_data_all/` under the project root |
|
| 124 |
+
| Data and output paths | Your task inputs and a writable output directory |
|
| 125 |
+
|
| 126 |
+
Checkpoint variable names differ by notebook: `CKPT_PATH` / `CONFIG_PKL`
|
| 127 |
+
for cell annotation, drug response, and zero-shot integration;
|
| 128 |
+
`SCFM_CKPT` / `SCFM_HYPER` for gene perturbation; and
|
| 129 |
+
`CHECKPOINT_PATH` / `HYPER_PARAMS_PATH` for drug combination.
|
| 130 |
+
Updating environment variables alone does not replace hard-coded notebook paths.
|
| 131 |
+
|
| 132 |
+
### Checkpoint arguments
|
| 133 |
+
|
| 134 |
+
| Task | Weights argument | Configuration argument |
|
| 135 |
+
| --- | --- | --- |
|
| 136 |
+
| Drug response / drug combination | `--checkpoint_path` | `--hyper_params_path` |
|
| 137 |
+
| Zero-shot / gene perturbation | `--scfm_ckpt_path` | `--scfm_hyper_params_path` |
|
| 138 |
+
| Cell annotation / backbone interpretation | `--performer_checkpoint` | `--config_pkl` |
|
| 139 |
+
| Continued pretraining | `--model_path` | `--model_config_path` or paired-file discovery |
|
| 140 |
+
|
| 141 |
+
New-data reconstruction evaluation has a separate checkpoint-directory interface;
|
| 142 |
+
refer to its script for the corresponding arguments.
|
| 143 |
+
|
| 144 |
+
### Data and evaluation
|
| 145 |
+
|
| 146 |
+
Datasets are stored under `data/`. Input formats, metadata fields, preprocessing,
|
| 147 |
+
and train/validation splits depend on the task; follow the linked implementation
|
| 148 |
+
and tutorial.
|
| 149 |
+
|
| 150 |
+
For zero-shot embeddings, provide an h5ad expression matrix and specify the gene,
|
| 151 |
+
batch, and cell-type metadata fields. The runner aligns genes to the model gene
|
| 152 |
+
list and fills missing genes with zeros. It does not normalize or log-transform
|
| 153 |
+
the input matrix, so prepare expression values according to the intended protocol.
|
| 154 |
+
Resolve duplicate gene identifiers and inspect gene overlap before evaluating
|
| 155 |
+
embeddings.
|
| 156 |
+
|
| 157 |
+
The drug-response and drug-combination workflows use validation metrics for
|
| 158 |
+
checkpoint selection. Their validation results should not be presented as
|
| 159 |
+
performance on an independent final test set.
|
| 160 |
+
|
| 161 |
+
For checks of the shared training utilities, see
|
| 162 |
+
[Training utility checks](BioMod/README.md#training-utility-checks).
|
| 163 |
|
| 164 |
## Repository layout
|
| 165 |
|
| 166 |
+
The tree below includes source files, model dependencies, and the pretrained
|
| 167 |
+
checkpoint. The top-level `data/` and `.cache/` directories are omitted for
|
| 168 |
+
readability. Folder descriptions explain where to find each component.
|
| 169 |
+
|
| 170 |
+
<details>
|
| 171 |
+
<summary>Expand the full repository structure</summary>
|
| 172 |
|
| 173 |
```text
|
| 174 |
BioMod/ # Project root: source code, model dependencies and weights
|
|
|
|
| 189 |
│ ├── eval_new_data/ # Reconstruction error and generalization on new data
|
| 190 |
│ │ ├── cmd.txtx
|
| 191 |
│ │ └── evaluate_new_data.py
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 192 |
│ ├── gene_perturbation/ # Perturbation response prediction: processing, training and inference
|
| 193 |
│ │ ├── data_utils_local.py
|
| 194 |
│ │ ├── gears_reduce_local.py
|
|
|
|
| 294 |
└── README.md
|
| 295 |
```
|
| 296 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 297 |
</details>
|
| 298 |
|
| 299 |
## Partners
|
| 300 |
|
| 301 |
+
- [Raymind Biotechnology Co., Ltd](https://en.raymind.com/)
|
| 302 |
+
- [Shenzhen Research Institute of Big Data (SRIBD)](https://www.sribd.cn/en)
|