beiken1201 commited on
Commit
e1f0461
·
verified ·
1 Parent(s): 8c86905

Remove reverse perturbation code and refresh README documentation

Browse files

Remove 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 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
- Single-cell transcriptomics foundation model (BioMod / PerformerModule architecture),
14
- released by **CTC-Atlas** — the joint laboratory of **Raymind Biotechnology Co., Ltd**
15
- and the **Shenzhen Research Institute of Big Data (SRIBD)**.
 
16
 
17
- The current local checkpoint is `weight/0810model/`. Start with the
18
- [0810 quick start](#0810-quick-start) for a complete example using its paired
19
- weights and configuration. The outer `BioMod/` directory is the project root;
20
- the inner `BioMod/` directory contains the source code.
21
 
22
- ## Task navigation
 
 
23
 
24
- | Task | Entry point | Guide or tutorial |
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 integration | [Zero-shot runner](BioMod/zero_shot_batch/performer_module_zero_shot.py) | [0810 quick start](#0810-quick-start) |
28
- | Single-drug response | [Training script](BioMod/drug_response/train_drug_performer_module.py) | [Notebook](BioMod/toturials/drug_response_prediction.ipynb) |
29
- | Drug-combination synergy | [Training script](BioMod/drug_combination/train_drug_combination_performer_module.py) | [Task guide](BioMod/drug_combination/README.md), [notebook](BioMod/toturials/drug_combination.ipynb) |
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
- | Reverse perturbation prediction | [Benchmark builder](BioMod/gene_inverse_norman/build_norman_gene_inverse_benchmark.py), [training](BioMod/gene_inverse_norman/finetune_gene_inverse.py), [evaluation](BioMod/gene_inverse_norman/evaluate_gene_inverse.py) | [Task guide](BioMod/gene_inverse_norman/README.md) |
32
- | Reconstruction evaluation on new data | [Evaluation script](BioMod/eval_new_data/evaluate_new_data.py) | [Launcher examples](BioMod/eval_new_data/cmd.txtx) |
33
- | Interpretability | [Module geometry](BioMod/interpretability/module_geometry.py) | [Overview](BioMod/interpretability/README.md), [attention](BioMod/interpretability/attention/README.md), [module importance](BioMod/interpretability/module_importance/README.md), [text alignment](BioMod/interpretability/module_text_alignment/DESIGN.md) |
34
- | Pretraining and continued pretraining | [Pretraining script](BioMod/performer_module.py) | [Launcher example](BioMod/command.txt), [source guide](BioMod/README.md) |
35
-
36
- Task guides and notebooks include historical checkpoint and server paths.
37
- Use the current asset paths below and read [Notebook setup](#notebook-setup)
38
- before following a tutorial. The launcher examples are references to adapt to
39
- your environment, not additional steps in the quick start.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
 
41
  ## Repository layout
42
 
43
- Current local checkout, excluding the top-level `data/` and `.cache/` directories.
44
- Module definitions, model dependencies, and the remaining checkpoint are included.
 
 
 
 
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
- - **Raymind Biotechnology Co., Ltd** (睿迈生物) — https://en.raymind.com/
555
- - **Shenzhen Research Institute of Big Data** (SRIBD) — https://www.sribd.cn/en
 
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)