Add Cas9 task
Browse filesAdds the Cas9 vertical alongside gfp and peptidomimetics: the pCoMole
editor, its three objectives (Cas9-likeness, deletion count, PAM
matching), constraints, data-prep scripts, run scripts, and config.
- cas9/model/ and cas9/logic/ are vendored because the Cas9 Edit Flow
keeps the reparameterized models inside base_models.py, whereas the
repo root splits them into reparam_models.py. The two are not
interchangeable, so Cas9 imports its own copies.
- objectives.py is trimmed to the three Cas9 classes; the ADMET oracles
already ship in peptidomimetics/objectives.py.
- Oracle and checkpoint locations resolve through paths.py
(PROTEIN2PAM_ROOT, CAS_PREDICTOR_ROOT, CAS9_CLASSIFIER_CKPT,
CAS9_CLASSIFIER_CONFIG, CAS9_HMM_DIR) instead of absolute paths.
- Ten Cas9 ortholog seeds ship as cas9/data/orthologs.fasta.
Checkpoints and oracle packages are not included in this commit;
cas9/ckpt/, cas9/classifier_ckpt/ and cas9/hmm/ are placeholders.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015JoDLXiG2Vc8rwe9shw78R
- README.md +76 -1
- cas9/__init__.py +0 -0
- cas9/ckpt/README.md +3 -0
- cas9/classifier_ckpt/README.md +3 -0
- cas9/constraints.py +705 -0
- cas9/data/__init__.py +9 -0
- cas9/data/cas9_pre_batching.py +160 -0
- cas9/data/cas9_pre_batching_csv.py +138 -0
- cas9/data/data.py +309 -0
- cas9/data/match_cas9_length_distribution.py +297 -0
- cas9/data/match_cas9_length_distribution_filtered.py +506 -0
- cas9/data/orthologs.fasta +218 -0
- cas9/data/tokenizer.py +42 -0
- cas9/data/uniref_pre_batching.py +220 -0
- cas9/data/utils.py +64 -0
- cas9/domain_detector.py +226 -0
- cas9/evaluate_val.py +1066 -0
- cas9/evaluate_val_multi_sample.py +187 -0
- cas9/generate.py +78 -0
- cas9/generate_cas9_csvs.py +348 -0
- cas9/hmm/README.md +3 -0
- cas9/logic/__init__.py +5 -0
- cas9/logic/evaluate.py +183 -0
- cas9/logic/flow.py +437 -0
- cas9/logic/generate.py +103 -0
- cas9/logic/state.py +90 -0
- cas9/logic/training.py +141 -0
- cas9/model/__init__.py +13 -0
- cas9/model/base_models.py +935 -0
- cas9/model/rotary.py +115 -0
- cas9/model/transformer.py +478 -0
- cas9/model/utils.py +949 -0
- cas9/objectives.py +1248 -0
- cas9/pam_detector_hmm.py +456 -0
- cas9/pcomole.py +0 -0
- cas9/predict_pam_distribution.py +206 -0
- cas9/scripts/evaluate.sh +12 -0
- cas9/scripts/generate.sh +12 -0
- cas9/scripts/pcomole.sh +36 -0
- cas9/scripts/train.sh +5 -0
- cas9/train.py +275 -0
- configs/config_cas9.yaml +77 -0
- paths.py +64 -0
- requirements.txt +9 -0
|
@@ -26,8 +26,26 @@ pCoMole/
|
|
| 26 |
ckpt/SMILES_BindEvaluator.ckpt
|
| 27 |
ckpt/admetica/ # LD50, solubility, Caco-2, half-life
|
| 28 |
ckpt/deepdtagen/
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
```
|
| 30 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
## 1. Environment
|
| 32 |
|
| 33 |
A CUDA GPU is strongly recommended.
|
|
@@ -76,6 +94,21 @@ export MAFFT_PATH=/path/to/mafft
|
|
| 76 |
|
| 77 |
Confirm with `mafft --version` before running GFP editing.
|
| 78 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
## 2. Train, evaluate, and sample Edit Flows
|
| 80 |
|
| 81 |
Run every command from the **repository root**.
|
|
@@ -172,6 +205,40 @@ bash peptidomimetics/scripts/pcomole.sh
|
|
| 172 |
|
| 173 |
`--target` and `--motifs` are required for affinity and motif oracles. `--specificity` adds the seventh objective.
|
| 174 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 175 |
## 4. Included checkpoints
|
| 176 |
|
| 177 |
| Path | Role |
|
|
@@ -183,6 +250,9 @@ bash peptidomimetics/scripts/pcomole.sh
|
|
| 183 |
| `peptidomimetics/ckpt/SMILES_BindEvaluator.ckpt` | motif / specificity |
|
| 184 |
| `peptidomimetics/ckpt/admetica/*.ckpt` | Admetica blending oracles |
|
| 185 |
| `peptidomimetics/ckpt/deepdtagen/` | DeepDTAGen affinity |
|
|
|
|
|
|
|
|
|
|
| 186 |
|
| 187 |
Together these are about 10 GB. HuggingFace uploads should use Git LFS for the `.ckpt` / `.pt` / `.pth` files.
|
| 188 |
|
|
@@ -193,13 +263,18 @@ Together these are about 10 GB. HuggingFace uploads should use Git LFS for the `
|
|
| 193 |
| `PEPTIVERSE_ROOT` | PeptiVerse repo root (manifest + `training_classifiers/`) |
|
| 194 |
| `MAFFT_PATH` | MAFFT binary, if it is not on `PATH` |
|
| 195 |
| `GFP_CLASSIFIER_CKPT` | optional override for the GFP classifier |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 196 |
|
| 197 |
## 6. Notes
|
| 198 |
|
| 199 |
- Run scripts from the repo root, or use the wrappers under `*/scripts/`.
|
| 200 |
- GFP FPredX will fail immediately if `mafft` is missing; that is expected.
|
| 201 |
- Peptidomimetic pCoMole will fail at oracle init if PeptiVerse is missing or its weights were not downloaded.
|
| 202 |
-
- Cas9
|
| 203 |
- Training writes to `outputs/` by default.
|
| 204 |
|
| 205 |
## Citation
|
|
|
|
| 26 |
ckpt/SMILES_BindEvaluator.ckpt
|
| 27 |
ckpt/admetica/ # LD50, solubility, Caco-2, half-life
|
| 28 |
ckpt/deepdtagen/
|
| 29 |
+
cas9/ # Cas9 generation + pCoMole
|
| 30 |
+
pcomole.py # official Cas9 editor
|
| 31 |
+
generate_cas9_csvs.py
|
| 32 |
+
objectives.py # Cas9Classification, DeletionCount, PAMMatching
|
| 33 |
+
constraints.py
|
| 34 |
+
domain_detector.py # HNH / RuvC completeness (HMMER)
|
| 35 |
+
pam_detector_hmm.py # PAM-interacting domain masking
|
| 36 |
+
model/ logic/ # Cas9 Edit Flow variant (see note below)
|
| 37 |
+
data/orthologs.fasta # 10 Cas9 ortholog seeds
|
| 38 |
+
ckpt/last.ckpt
|
| 39 |
+
classifier_ckpt/ # Cas9-likeness classifier
|
| 40 |
+
hmm/ # HMM databases
|
| 41 |
```
|
| 42 |
|
| 43 |
+
> **Note.** `cas9/model/` and `cas9/logic/` are a *fork* of the repo-root
|
| 44 |
+
> packages, not duplicates: the Cas9 stack keeps the reparameterized Edit Flow
|
| 45 |
+
> models inside `base_models.py`, whereas the root splits them into
|
| 46 |
+
> `reparam_models.py`. The two are not interchangeable, so the Cas9 task
|
| 47 |
+
> imports its own copies (`from cas9.model...`).
|
| 48 |
+
|
| 49 |
## 1. Environment
|
| 50 |
|
| 51 |
A CUDA GPU is strongly recommended.
|
|
|
|
| 94 |
|
| 95 |
Confirm with `mafft --version` before running GFP editing.
|
| 96 |
|
| 97 |
+
### HMMER and protein2pam (required for Cas9 pCoMole)
|
| 98 |
+
|
| 99 |
+
The Cas9 domain-completeness and PAM-masking oracles shell out to `hmmscan`:
|
| 100 |
+
|
| 101 |
+
```bash
|
| 102 |
+
conda install -c bioconda hmmer
|
| 103 |
+
```
|
| 104 |
+
|
| 105 |
+
The PAM objective needs the `protein2pam` model class (weights download from the
|
| 106 |
+
Hub as `Profluent-Bio/protein2pam-cas9_full`):
|
| 107 |
+
|
| 108 |
+
```bash
|
| 109 |
+
pip install protein2pam==0.2.0
|
| 110 |
+
```
|
| 111 |
+
|
| 112 |
## 2. Train, evaluate, and sample Edit Flows
|
| 113 |
|
| 114 |
Run every command from the **repository root**.
|
|
|
|
| 205 |
|
| 206 |
`--target` and `--motifs` are required for affinity and motif oracles. `--specificity` adds the seventh objective.
|
| 207 |
|
| 208 |
+
### Cas9
|
| 209 |
+
|
| 210 |
+
Official editor: `cas9/pcomole.py`. Three objectives — Cas9-likeness, deletion
|
| 211 |
+
count, and PAM matching — under Cas9-score, PAM-matching and length constraints.
|
| 212 |
+
|
| 213 |
+
Cas9 code is a package, so run it from the repository root with `-m`:
|
| 214 |
+
|
| 215 |
+
```bash
|
| 216 |
+
ORTHOLOG=cjcas9 bash cas9/scripts/pcomole.sh
|
| 217 |
+
```
|
| 218 |
+
|
| 219 |
+
Equivalent command:
|
| 220 |
+
|
| 221 |
+
```bash
|
| 222 |
+
python -m cas9.pcomole \
|
| 223 |
+
--pcomol_config configs/config_cas9.yaml \
|
| 224 |
+
--ckpt cas9/ckpt/last.ckpt \
|
| 225 |
+
--input "$(awk -v n='>cjcas9' '$1==n{f=1;next}/^>/{f=0}f{printf "%s",$0}' cas9/data/orthologs.fasta)" \
|
| 226 |
+
--cas9_objective --deletion_count_obj --pam_distr_objective \
|
| 227 |
+
--objective_weights 1 1 1 \
|
| 228 |
+
--target_pam matching \
|
| 229 |
+
--num_steps 5 --num_rollouts 5 --num_candidates 10 --num_sequences 50 \
|
| 230 |
+
--max_deletion_percentage 0.2 --deletion_rate_scale 2500 \
|
| 231 |
+
--pam_min_confidence 0.7 --cas9_score_threshold 0.5 \
|
| 232 |
+
--max_target_length 930 --min_target_length 890 \
|
| 233 |
+
--use_ce_loss --allow_equal_length --zero_lam_ins \
|
| 234 |
+
--output_file outputs/cas9_cjcas9_pcomole.csv
|
| 235 |
+
```
|
| 236 |
+
|
| 237 |
+
Ten ortholog seeds ship in `cas9/data/orthologs.fasta` (SpCas9, SaCas9, CjCas9,
|
| 238 |
+
St1/St3Cas9, GeoCas9, NmCas9, iSpyMac, SpCas9++, SpRYc). Set `ORTHOLOG` to any
|
| 239 |
+
of them. `--target_pam matching` optimizes toward the seed's own PAM; pass an
|
| 240 |
+
explicit 10-nt `ACGTN` string to retarget.
|
| 241 |
+
|
| 242 |
## 4. Included checkpoints
|
| 243 |
|
| 244 |
| Path | Role |
|
|
|
|
| 250 |
| `peptidomimetics/ckpt/SMILES_BindEvaluator.ckpt` | motif / specificity |
|
| 251 |
| `peptidomimetics/ckpt/admetica/*.ckpt` | Admetica blending oracles |
|
| 252 |
| `peptidomimetics/ckpt/deepdtagen/` | DeepDTAGen affinity |
|
| 253 |
+
| `cas9/ckpt/last.ckpt` | Cas9 Edit Flow |
|
| 254 |
+
| `cas9/classifier_ckpt/` | Cas9-likeness classifier (objective + hard constraint) |
|
| 255 |
+
| `cas9/hmm/` | HNH / RuvC and PAM-interacting HMM databases |
|
| 256 |
|
| 257 |
Together these are about 10 GB. HuggingFace uploads should use Git LFS for the `.ckpt` / `.pt` / `.pth` files.
|
| 258 |
|
|
|
|
| 263 |
| `PEPTIVERSE_ROOT` | PeptiVerse repo root (manifest + `training_classifiers/`) |
|
| 264 |
| `MAFFT_PATH` | MAFFT binary, if it is not on `PATH` |
|
| 265 |
| `GFP_CLASSIFIER_CKPT` | optional override for the GFP classifier |
|
| 266 |
+
| `PROTEIN2PAM_ROOT` | protein2pam package root (PAM oracle model class) |
|
| 267 |
+
| `CAS_PREDICTOR_ROOT` | cas_predictor package root (Cas9 classifier code) |
|
| 268 |
+
| `CAS9_CLASSIFIER_CKPT` | override for the Cas9 classifier checkpoint |
|
| 269 |
+
| `CAS9_CLASSIFIER_CONFIG` | optional Cas9 classifier config |
|
| 270 |
+
| `CAS9_HMM_DIR` | directory holding the Cas9 HMM databases |
|
| 271 |
|
| 272 |
## 6. Notes
|
| 273 |
|
| 274 |
- Run scripts from the repo root, or use the wrappers under `*/scripts/`.
|
| 275 |
- GFP FPredX will fail immediately if `mafft` is missing; that is expected.
|
| 276 |
- Peptidomimetic pCoMole will fail at oracle init if PeptiVerse is missing or its weights were not downloaded.
|
| 277 |
+
- Cas9 objectives fail at init if `hmmscan`, `protein2pam`, or `cas_predictor` are missing.
|
| 278 |
- Training writes to `outputs/` by default.
|
| 279 |
|
| 280 |
## Citation
|
|
File without changes
|
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Tier B artifact slot — not yet populated.
|
| 2 |
+
|
| 3 |
+
See the repository README, section 4, for what belongs here.
|
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Tier B artifact slot — not yet populated.
|
| 2 |
+
|
| 3 |
+
See the repository README, section 4, for what belongs here.
|
|
@@ -0,0 +1,705 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# constraints.py
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
import hashlib
|
| 5 |
+
from dataclasses import dataclass
|
| 6 |
+
from typing import Dict, List, Optional, Sequence, Tuple
|
| 7 |
+
|
| 8 |
+
import torch
|
| 9 |
+
from rdkit import Chem
|
| 10 |
+
from rdkit.Chem import Descriptors, rdMolDescriptors
|
| 11 |
+
# Make selfies optional (only needed for SMILES constraints, not protein constraints)
|
| 12 |
+
try:
|
| 13 |
+
import selfies as sf
|
| 14 |
+
except ImportError:
|
| 15 |
+
sf = None
|
| 16 |
+
|
| 17 |
+
# from .is_peptidomimetic import is_peptidomimetic_not_natural
|
| 18 |
+
import pdb
|
| 19 |
+
|
| 20 |
+
# ---- Optional import: Cas9 domain detector (HNH + RuvC) ----
|
| 21 |
+
# Expects domain_detector.py to live in the same package as this constraints.py
|
| 22 |
+
try:
|
| 23 |
+
from .domain_detector import DomainDetector, DomainDetectorConfig, HitCriteria
|
| 24 |
+
except Exception as e:
|
| 25 |
+
import traceback
|
| 26 |
+
print(f"Warning: Failed to import DomainDetector: {e}")
|
| 27 |
+
traceback.print_exc()
|
| 28 |
+
DomainDetector = None
|
| 29 |
+
DomainDetectorConfig = None
|
| 30 |
+
HitCriteria = None
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def detokenize_output(x, cfg, tokenizer, bos_id, eos_id, pad_id):
|
| 34 |
+
"""
|
| 35 |
+
Convert a single generated sequence (1, L) back to string.
|
| 36 |
+
"""
|
| 37 |
+
seq = x[0].tolist()
|
| 38 |
+
# strip padding
|
| 39 |
+
seq = [tok for tok in seq if tok != pad_id]
|
| 40 |
+
# strip BOS/EOS
|
| 41 |
+
if len(seq) > 0 and seq[0] == bos_id:
|
| 42 |
+
seq = seq[1:]
|
| 43 |
+
if len(seq) > 0 and seq[-1] == eos_id:
|
| 44 |
+
seq = seq[:-1]
|
| 45 |
+
|
| 46 |
+
if cfg.task == 'protein':
|
| 47 |
+
# esm tokenizer has batch_decode
|
| 48 |
+
return tokenizer.batch_decode([seq], skip_special_tokens=True)[0]
|
| 49 |
+
elif cfg.task in ('smiles', 'selfies'):
|
| 50 |
+
return tokenizer.decode(seq)
|
| 51 |
+
else:
|
| 52 |
+
return " ".join(map(str, seq))
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
class Peptidomimetic:
|
| 56 |
+
def __init__(self):
|
| 57 |
+
pass
|
| 58 |
+
|
| 59 |
+
def __call__(self, smiles_tokens, smiles_seq):
|
| 60 |
+
flag, audit = is_peptidomimetic_not_natural(smiles_seq)
|
| 61 |
+
return 1 if flag else 0
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
# SMARTS
|
| 65 |
+
_AMIDE = Chem.MolFromSmarts("[CX3](=[OX1])[NX3]") # amide C(=O)-N (includes carbamates/ureas as centers)
|
| 66 |
+
_SULFONAMIDE = Chem.MolFromSmarts("[SX4](=[OX1])(=[OX1])[NX3]") # sulfonamide S(=O)2-N (optional isostere)
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def _centers_and_N(mol, include_sulfonamide=True):
|
| 70 |
+
centers, n2c = set(), {}
|
| 71 |
+
for c_idx, _, n_idx in mol.GetSubstructMatches(_AMIDE):
|
| 72 |
+
centers.add(c_idx)
|
| 73 |
+
n2c.setdefault(n_idx, set()).add(c_idx)
|
| 74 |
+
if include_sulfonamide:
|
| 75 |
+
for s_idx, _, _, n_idx in mol.GetSubstructMatches(_SULFONAMIDE):
|
| 76 |
+
centers.add(s_idx)
|
| 77 |
+
n2c.setdefault(n_idx, set()).add(s_idx)
|
| 78 |
+
return centers, n2c
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def _is_sp3_carbon(atom):
|
| 82 |
+
return atom.GetAtomicNum() == 6 and atom.GetHybridization() == Chem.HybridizationType.SP3
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def _backbone_edges(mol, centers, n2c):
|
| 86 |
+
"""
|
| 87 |
+
Edge between centers i--j exists if there is a single-bond path:
|
| 88 |
+
center_i — N — C(sp3) — center_j (length 3)
|
| 89 |
+
or center_i — N — (O|S|N) — C(sp3) — center_j (length 4)
|
| 90 |
+
"""
|
| 91 |
+
edges = set()
|
| 92 |
+
for n_idx, left_centers in n2c.items():
|
| 93 |
+
n = mol.GetAtomWithIdx(n_idx)
|
| 94 |
+
for b1 in n.GetBonds():
|
| 95 |
+
if b1.GetBondType() != Chem.BondType.SINGLE:
|
| 96 |
+
continue
|
| 97 |
+
a1 = b1.GetOtherAtom(n)
|
| 98 |
+
|
| 99 |
+
# case A: N—C(sp3)
|
| 100 |
+
if _is_sp3_carbon(a1):
|
| 101 |
+
for b2 in a1.GetBonds():
|
| 102 |
+
if b2.GetBondType() != Chem.BondType.SINGLE:
|
| 103 |
+
continue
|
| 104 |
+
other = b2.GetOtherAtom(a1)
|
| 105 |
+
j = other.GetIdx()
|
| 106 |
+
if j in centers:
|
| 107 |
+
for i in left_centers:
|
| 108 |
+
if i != j:
|
| 109 |
+
edges.add(tuple(sorted((i, j))))
|
| 110 |
+
|
| 111 |
+
# case B: N—(O|S|N)—C(sp3)
|
| 112 |
+
if a1.GetAtomicNum() in (8, 16, 7): # O, S, N
|
| 113 |
+
for b2 in a1.GetBonds():
|
| 114 |
+
if b2.GetBondType() != Chem.BondType.SINGLE:
|
| 115 |
+
continue
|
| 116 |
+
a2 = b2.GetOtherAtom(a1)
|
| 117 |
+
if _is_sp3_carbon(a2):
|
| 118 |
+
for b3 in a2.GetBonds():
|
| 119 |
+
if b3.GetBondType() != Chem.BondType.SINGLE:
|
| 120 |
+
continue
|
| 121 |
+
other = b3.GetOtherAtom(a2)
|
| 122 |
+
j = other.GetIdx()
|
| 123 |
+
if j in centers:
|
| 124 |
+
for i in left_centers:
|
| 125 |
+
if i != j:
|
| 126 |
+
edges.add(tuple(sorted((i, j))))
|
| 127 |
+
return edges
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def _largest_component(centers, edges):
|
| 131 |
+
if not centers:
|
| 132 |
+
return set()
|
| 133 |
+
adj = {i: set() for i in centers}
|
| 134 |
+
for i, j in edges:
|
| 135 |
+
if i in adj and j in adj:
|
| 136 |
+
adj[i].add(j)
|
| 137 |
+
adj[j].add(i)
|
| 138 |
+
seen, best = set(), set()
|
| 139 |
+
for s in adj:
|
| 140 |
+
if s in seen:
|
| 141 |
+
continue
|
| 142 |
+
stack, comp = [s], set()
|
| 143 |
+
while stack:
|
| 144 |
+
u = stack.pop()
|
| 145 |
+
if u in comp:
|
| 146 |
+
continue
|
| 147 |
+
comp.add(u)
|
| 148 |
+
seen.add(u)
|
| 149 |
+
stack.extend(v for v in adj[u] if v not in comp)
|
| 150 |
+
if len(comp) > len(best):
|
| 151 |
+
best = comp
|
| 152 |
+
return best
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def backbone_metrics(smiles: str, include_sulfonamide=True):
|
| 156 |
+
mol = Chem.MolFromSmiles(smiles)
|
| 157 |
+
if mol is None:
|
| 158 |
+
raise ValueError(f"Invalid SMILES: {smiles}")
|
| 159 |
+
centers, n2c = _centers_and_N(mol, include_sulfonamide)
|
| 160 |
+
edges = _backbone_edges(mol, centers, n2c)
|
| 161 |
+
main_nodes = _largest_component(centers, edges)
|
| 162 |
+
main_edges = {e for e in edges if e[0] in main_nodes and e[1] in main_nodes}
|
| 163 |
+
return {
|
| 164 |
+
"backbone_centers_main": len(main_nodes),
|
| 165 |
+
"backbone_links_main": len(main_edges),
|
| 166 |
+
"MW": Descriptors.MolWt(mol),
|
| 167 |
+
"HAC": mol.GetNumAtoms(),
|
| 168 |
+
"TPSA": rdMolDescriptors.CalcTPSA(mol),
|
| 169 |
+
"rotatable_bonds": rdMolDescriptors.CalcNumRotatableBonds(mol),
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def compare_shorter_smaller(orig_smiles: str, cand_smiles: str, include_sulfonamide=True,
|
| 174 |
+
mw_margin: float = 0.0, hac_margin: int = 0):
|
| 175 |
+
o = backbone_metrics(orig_smiles, include_sulfonamide)
|
| 176 |
+
c = backbone_metrics(cand_smiles, include_sulfonamide)
|
| 177 |
+
shorter_backbone = (c["backbone_centers_main"] < o["backbone_centers_main"]) or \
|
| 178 |
+
(c["backbone_links_main"] < o["backbone_links_main"])
|
| 179 |
+
smaller_bulk = (c["MW"] <= o["MW"] - mw_margin) and (c["HAC"] <= o["HAC"] - hac_margin)
|
| 180 |
+
return {"original": o, "candidate": c, "decisions": {
|
| 181 |
+
"shorter_backbone": shorter_backbone,
|
| 182 |
+
"smaller_bulk": smaller_bulk,
|
| 183 |
+
"overall_shorter_or_smaller": (shorter_backbone or smaller_bulk),
|
| 184 |
+
"require_both_shorter_and_smaller": (shorter_backbone and smaller_bulk),
|
| 185 |
+
}}
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
class Length:
|
| 189 |
+
def __init__(self, orig_smiles, cfg, smiles_tokenizer):
|
| 190 |
+
self.cfg = cfg
|
| 191 |
+
self.smiles_tokenizer = smiles_tokenizer
|
| 192 |
+
|
| 193 |
+
self.orig_smiles = sf.decoder(sf.encoder(orig_smiles))
|
| 194 |
+
self.L0 = smiles_tokenizer(orig_smiles, return_tensors='pt')['input_ids'].shape[1]
|
| 195 |
+
print("Initial Length: ", self.L0)
|
| 196 |
+
|
| 197 |
+
def __call__(self, smiles_tokens, smiles_seq):
|
| 198 |
+
res = compare_shorter_smaller(self.orig_smiles, smiles_seq)
|
| 199 |
+
return int(res["decisions"]["overall_shorter_or_smaller"] and smiles_tokens.shape[1] >= self.L0 // 2)
|
| 200 |
+
# return int(smiles_tokens.shape[1] <= self.L0 and smiles_tokens.shape[1] >= self.L0 // 2)
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
class ProteinLength:
|
| 204 |
+
"""
|
| 205 |
+
Length constraint for protein sequences.
|
| 206 |
+
|
| 207 |
+
Identical logic to the SMILES Length constraint, but using protein sequence length.
|
| 208 |
+
By default requires candidate to be strictly shorter than original AND at least half
|
| 209 |
+
the original length. If allow_equal_length is True, candidates equal to the original
|
| 210 |
+
length also pass the upper bound (still must be at least L0 // 2).
|
| 211 |
+
"""
|
| 212 |
+
def __init__(self, orig_protein_seq, cfg, tokenizer, allow_equal_length: bool = False):
|
| 213 |
+
self.cfg = cfg
|
| 214 |
+
self.tokenizer = tokenizer
|
| 215 |
+
self.allow_equal_length = bool(allow_equal_length)
|
| 216 |
+
|
| 217 |
+
self.orig_protein_seq = orig_protein_seq.strip().replace(" ", "").replace("\n", "")
|
| 218 |
+
# Store length in amino acids (equivalent to tokenized length for proteins)
|
| 219 |
+
self.L0 = len(self.orig_protein_seq)
|
| 220 |
+
print("Initial Length: ", self.L0)
|
| 221 |
+
if self.allow_equal_length:
|
| 222 |
+
print("ProteinLength: allowing equal length (candidates may have length == L0)")
|
| 223 |
+
|
| 224 |
+
def __call__(self, protein_tokens, protein_seq):
|
| 225 |
+
"""
|
| 226 |
+
Check protein sequence length against original.
|
| 227 |
+
|
| 228 |
+
- Candidate must be at least half the original length (L0 // 2).
|
| 229 |
+
- Upper bound: strictly shorter than L0 by default; if allow_equal_length, L_cand <= L0.
|
| 230 |
+
|
| 231 |
+
Args:
|
| 232 |
+
protein_tokens: Unused (kept for interface compatibility, could use for tokenized length)
|
| 233 |
+
protein_seq: Protein sequence string (single sequence)
|
| 234 |
+
|
| 235 |
+
Returns:
|
| 236 |
+
int: 1 if constraints pass, 0 otherwise
|
| 237 |
+
"""
|
| 238 |
+
seq_clean = (protein_seq or "").strip().replace(" ", "").replace("\n", "")
|
| 239 |
+
L_cand = len(seq_clean)
|
| 240 |
+
|
| 241 |
+
if self.allow_equal_length:
|
| 242 |
+
is_within_upper = L_cand <= self.L0
|
| 243 |
+
else:
|
| 244 |
+
is_within_upper = L_cand < self.L0
|
| 245 |
+
is_at_least_half = L_cand >= self.L0 // 2
|
| 246 |
+
|
| 247 |
+
return int(is_within_upper and is_at_least_half)
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
class TargetLength:
|
| 251 |
+
"""
|
| 252 |
+
Target length constraint for protein sequences.
|
| 253 |
+
|
| 254 |
+
Requires candidate sequence length to exactly match the target length.
|
| 255 |
+
"""
|
| 256 |
+
def __init__(self, target_length: int):
|
| 257 |
+
"""
|
| 258 |
+
Args:
|
| 259 |
+
target_length: The exact target length in amino acids
|
| 260 |
+
"""
|
| 261 |
+
if target_length <= 0:
|
| 262 |
+
raise ValueError(f"target_length must be positive, got {target_length}")
|
| 263 |
+
self.target_length = int(target_length)
|
| 264 |
+
print(f"Target Length: {self.target_length}")
|
| 265 |
+
|
| 266 |
+
def __call__(self, protein_tokens, protein_seq):
|
| 267 |
+
"""
|
| 268 |
+
Check if protein sequence length exactly matches the target length.
|
| 269 |
+
|
| 270 |
+
Args:
|
| 271 |
+
protein_tokens: Unused (kept for interface compatibility)
|
| 272 |
+
protein_seq: Protein sequence string (single sequence)
|
| 273 |
+
|
| 274 |
+
Returns:
|
| 275 |
+
int: 1 if length matches target, 0 otherwise
|
| 276 |
+
"""
|
| 277 |
+
seq_clean = (protein_seq or "").strip().replace(" ", "").replace("\n", "")
|
| 278 |
+
L_cand = len(seq_clean)
|
| 279 |
+
|
| 280 |
+
return int(L_cand == self.target_length)
|
| 281 |
+
|
| 282 |
+
def predict_batch(self, seqs: Sequence[str]) -> List[bool]:
|
| 283 |
+
"""
|
| 284 |
+
Batch prediction helper for evaluation / faster integration.
|
| 285 |
+
|
| 286 |
+
Args:
|
| 287 |
+
seqs: List of protein sequence strings
|
| 288 |
+
|
| 289 |
+
Returns:
|
| 290 |
+
List of bool: True if length matches target, False otherwise
|
| 291 |
+
"""
|
| 292 |
+
clean = [(s or "").strip().replace(" ", "").replace("\n", "") for s in seqs]
|
| 293 |
+
return [len(s) == self.target_length for s in clean]
|
| 294 |
+
|
| 295 |
+
|
| 296 |
+
class MinTargetLength:
|
| 297 |
+
"""
|
| 298 |
+
Minimum target length constraint for protein sequences.
|
| 299 |
+
|
| 300 |
+
Requires candidate sequence length to be greater than or equal to the minimum target length.
|
| 301 |
+
"""
|
| 302 |
+
def __init__(self, min_target_length: int):
|
| 303 |
+
"""
|
| 304 |
+
Args:
|
| 305 |
+
min_target_length: The minimum allowed length in amino acids (inclusive)
|
| 306 |
+
"""
|
| 307 |
+
if min_target_length <= 0:
|
| 308 |
+
raise ValueError(f"min_target_length must be positive, got {min_target_length}")
|
| 309 |
+
self.min_target_length = int(min_target_length)
|
| 310 |
+
print(f"Min Target Length: {self.min_target_length}")
|
| 311 |
+
|
| 312 |
+
def __call__(self, protein_tokens, protein_seq):
|
| 313 |
+
"""
|
| 314 |
+
Check if protein sequence length is greater than or equal to the minimum target length.
|
| 315 |
+
|
| 316 |
+
Args:
|
| 317 |
+
protein_tokens: Unused (kept for interface compatibility)
|
| 318 |
+
protein_seq: Protein sequence string (single sequence)
|
| 319 |
+
|
| 320 |
+
Returns:
|
| 321 |
+
int: 1 if length >= min_target_length, 0 otherwise
|
| 322 |
+
"""
|
| 323 |
+
seq_clean = (protein_seq or "").strip().replace(" ", "").replace("\n", "")
|
| 324 |
+
L_cand = len(seq_clean)
|
| 325 |
+
|
| 326 |
+
return int(L_cand >= self.min_target_length)
|
| 327 |
+
|
| 328 |
+
def predict_batch(self, seqs: Sequence[str]) -> List[bool]:
|
| 329 |
+
"""
|
| 330 |
+
Batch prediction helper for evaluation / faster integration.
|
| 331 |
+
|
| 332 |
+
Args:
|
| 333 |
+
seqs: List of protein sequence strings
|
| 334 |
+
|
| 335 |
+
Returns:
|
| 336 |
+
List of bool: True if length >= min_target_length, False otherwise
|
| 337 |
+
"""
|
| 338 |
+
clean = [(s or "").strip().replace(" ", "").replace("\n", "") for s in seqs]
|
| 339 |
+
return [len(s) >= self.min_target_length for s in clean]
|
| 340 |
+
|
| 341 |
+
|
| 342 |
+
class MaxTargetLength:
|
| 343 |
+
"""
|
| 344 |
+
Maximum target length constraint for protein sequences.
|
| 345 |
+
|
| 346 |
+
Requires candidate sequence length to be less than or equal to the maximum target length.
|
| 347 |
+
"""
|
| 348 |
+
def __init__(self, max_target_length: int):
|
| 349 |
+
"""
|
| 350 |
+
Args:
|
| 351 |
+
max_target_length: The maximum allowed length in amino acids (inclusive)
|
| 352 |
+
"""
|
| 353 |
+
if max_target_length <= 0:
|
| 354 |
+
raise ValueError(f"max_target_length must be positive, got {max_target_length}")
|
| 355 |
+
self.max_target_length = int(max_target_length)
|
| 356 |
+
print(f"Max Target Length: {self.max_target_length}")
|
| 357 |
+
|
| 358 |
+
def __call__(self, protein_tokens, protein_seq):
|
| 359 |
+
"""
|
| 360 |
+
Check if protein sequence length is less than or equal to the maximum target length.
|
| 361 |
+
|
| 362 |
+
Args:
|
| 363 |
+
protein_tokens: Unused (kept for interface compatibility)
|
| 364 |
+
protein_seq: Protein sequence string (single sequence)
|
| 365 |
+
|
| 366 |
+
Returns:
|
| 367 |
+
int: 1 if length <= max_target_length, 0 otherwise
|
| 368 |
+
"""
|
| 369 |
+
seq_clean = (protein_seq or "").strip().replace(" ", "").replace("\n", "")
|
| 370 |
+
L_cand = len(seq_clean)
|
| 371 |
+
|
| 372 |
+
return int(L_cand <= self.max_target_length)
|
| 373 |
+
|
| 374 |
+
def predict_batch(self, seqs: Sequence[str]) -> List[bool]:
|
| 375 |
+
"""
|
| 376 |
+
Batch prediction helper for evaluation / faster integration.
|
| 377 |
+
|
| 378 |
+
Args:
|
| 379 |
+
seqs: List of protein sequence strings
|
| 380 |
+
|
| 381 |
+
Returns:
|
| 382 |
+
List of bool: True if length <= max_target_length, False otherwise
|
| 383 |
+
"""
|
| 384 |
+
clean = [(s or "").strip().replace(" ", "").replace("\n", "") for s in seqs]
|
| 385 |
+
return [len(s) <= self.max_target_length for s in clean]
|
| 386 |
+
|
| 387 |
+
|
| 388 |
+
class PAMMatchingConstraint:
|
| 389 |
+
"""
|
| 390 |
+
Terminal constraint for protein sequences: predicted PAM must match target PAM.
|
| 391 |
+
|
| 392 |
+
Requires that the predicted PAM of the generated sequence exactly matches the target PAM.
|
| 393 |
+
For positions where target PAM is 'N', any nucleotide is acceptable.
|
| 394 |
+
"""
|
| 395 |
+
def __init__(self, pam_matching_obj):
|
| 396 |
+
"""
|
| 397 |
+
Args:
|
| 398 |
+
pam_matching_obj: PAMMatching objective object that has predict_pam method and target_pam attribute
|
| 399 |
+
"""
|
| 400 |
+
if not hasattr(pam_matching_obj, 'predict_pam'):
|
| 401 |
+
raise ValueError("pam_matching_obj must have a predict_pam method")
|
| 402 |
+
if not hasattr(pam_matching_obj, 'target_pam'):
|
| 403 |
+
raise ValueError("pam_matching_obj must have a target_pam attribute")
|
| 404 |
+
self.pam_matching_obj = pam_matching_obj
|
| 405 |
+
self.target_pam = pam_matching_obj.target_pam.upper()
|
| 406 |
+
print(f"PAM Matching Constraint: sequences must have predicted PAM matching target: {self.target_pam}")
|
| 407 |
+
|
| 408 |
+
def _pam_matches(self, predicted_pam: str, target_pam: str) -> bool:
|
| 409 |
+
"""
|
| 410 |
+
Check if predicted PAM matches target PAM.
|
| 411 |
+
For positions where target PAM is 'N', any nucleotide is acceptable.
|
| 412 |
+
|
| 413 |
+
Args:
|
| 414 |
+
predicted_pam: Predicted PAM string (10 nucleotides)
|
| 415 |
+
target_pam: Target PAM string (10 nucleotides, may contain 'N')
|
| 416 |
+
|
| 417 |
+
Returns:
|
| 418 |
+
bool: True if predicted PAM matches target PAM, False otherwise
|
| 419 |
+
"""
|
| 420 |
+
# if len(predicted_pam) != len(target_pam):
|
| 421 |
+
# return False
|
| 422 |
+
|
| 423 |
+
# for pred_nuc, target_nuc in zip(predicted_pam, target_pam):
|
| 424 |
+
# if target_nuc == 'N':
|
| 425 |
+
# # N in target means any nucleotide is acceptable
|
| 426 |
+
# continue
|
| 427 |
+
# if pred_nuc != target_nuc:
|
| 428 |
+
# return False
|
| 429 |
+
|
| 430 |
+
# return True
|
| 431 |
+
return predicted_pam.upper() == target_pam.upper()
|
| 432 |
+
|
| 433 |
+
def __call__(self, protein_tokens, protein_seq):
|
| 434 |
+
"""
|
| 435 |
+
Check if protein sequence's predicted PAM matches the target PAM.
|
| 436 |
+
|
| 437 |
+
Args:
|
| 438 |
+
protein_tokens: Unused (kept for interface compatibility)
|
| 439 |
+
protein_seq: Protein sequence string (single sequence)
|
| 440 |
+
|
| 441 |
+
Returns:
|
| 442 |
+
int: 1 if predicted PAM matches target PAM, 0 otherwise
|
| 443 |
+
"""
|
| 444 |
+
try:
|
| 445 |
+
# Check if the model is available
|
| 446 |
+
if not hasattr(self.pam_matching_obj, 'model') or self.pam_matching_obj.model is None:
|
| 447 |
+
print(f"[WARNING] PAMMatchingConstraint: model is not initialized. Cannot predict PAM.")
|
| 448 |
+
return 0
|
| 449 |
+
|
| 450 |
+
if not hasattr(self.pam_matching_obj, 'tokenizer') or self.pam_matching_obj.tokenizer is None:
|
| 451 |
+
print(f"[WARNING] PAMMatchingConstraint: tokenizer is not initialized. Cannot predict PAM.")
|
| 452 |
+
return 0
|
| 453 |
+
|
| 454 |
+
predicted_pams = self.pam_matching_obj.predict_pam([protein_seq])
|
| 455 |
+
if not predicted_pams:
|
| 456 |
+
print(f"[WARNING] PAMMatchingConstraint: predict_pam returned empty list for sequence.")
|
| 457 |
+
return 0
|
| 458 |
+
|
| 459 |
+
predicted_pam = predicted_pams[0]
|
| 460 |
+
matches = self._pam_matches(predicted_pam, self.target_pam)
|
| 461 |
+
if not matches:
|
| 462 |
+
# Debug output: show what was predicted vs expected
|
| 463 |
+
print(f"[DEBUG] PAMMatchingConstraint: predicted '{predicted_pam}' != target '{self.target_pam}'")
|
| 464 |
+
return int(matches)
|
| 465 |
+
except Exception as e:
|
| 466 |
+
import traceback
|
| 467 |
+
print(f"[ERROR] PAMMatchingConstraint failed: {e}")
|
| 468 |
+
traceback.print_exc()
|
| 469 |
+
return 0
|
| 470 |
+
|
| 471 |
+
def predict_batch(self, seqs: Sequence[str]) -> List[bool]:
|
| 472 |
+
"""
|
| 473 |
+
Batch prediction helper for evaluation / faster integration.
|
| 474 |
+
|
| 475 |
+
Args:
|
| 476 |
+
seqs: List of protein sequence strings
|
| 477 |
+
|
| 478 |
+
Returns:
|
| 479 |
+
List of bool: True if predicted PAM matches target PAM, False otherwise
|
| 480 |
+
"""
|
| 481 |
+
try:
|
| 482 |
+
# Check if the model is available
|
| 483 |
+
if not hasattr(self.pam_matching_obj, 'model') or self.pam_matching_obj.model is None:
|
| 484 |
+
print(f"[WARNING] PAMMatchingConstraint.predict_batch: model is not initialized. Cannot predict PAM.")
|
| 485 |
+
return [False] * len(seqs)
|
| 486 |
+
|
| 487 |
+
if not hasattr(self.pam_matching_obj, 'tokenizer') or self.pam_matching_obj.tokenizer is None:
|
| 488 |
+
print(f"[WARNING] PAMMatchingConstraint.predict_batch: tokenizer is not initialized. Cannot predict PAM.")
|
| 489 |
+
return [False] * len(seqs)
|
| 490 |
+
|
| 491 |
+
predicted_pams = self.pam_matching_obj.predict_pam(seqs)
|
| 492 |
+
results = []
|
| 493 |
+
for i, predicted_pam in enumerate(predicted_pams):
|
| 494 |
+
if not predicted_pam:
|
| 495 |
+
print(f"[WARNING] PAMMatchingConstraint.predict_batch: empty prediction for sequence {i}")
|
| 496 |
+
results.append(False)
|
| 497 |
+
else:
|
| 498 |
+
results.append(self._pam_matches(predicted_pam, self.target_pam))
|
| 499 |
+
return results
|
| 500 |
+
except Exception as e:
|
| 501 |
+
import traceback
|
| 502 |
+
print(f"[ERROR] PAMMatchingConstraint.predict_batch failed: {e}")
|
| 503 |
+
traceback.print_exc()
|
| 504 |
+
return [False] * len(seqs)
|
| 505 |
+
|
| 506 |
+
|
| 507 |
+
class Cas9ScoreThreshold:
|
| 508 |
+
"""
|
| 509 |
+
Terminal constraint for protein sequences: Cas9 classifier score must be above a threshold.
|
| 510 |
+
|
| 511 |
+
Requires that the Cas9 classifier score (probability) is strictly greater than the threshold.
|
| 512 |
+
"""
|
| 513 |
+
def __init__(self, cas9_classifier_obj, threshold: float):
|
| 514 |
+
"""
|
| 515 |
+
Args:
|
| 516 |
+
cas9_classifier_obj: Cas9Classification objective object that has get_scores method
|
| 517 |
+
threshold: Minimum Cas9 score threshold (0-1). Sequences must have score > threshold.
|
| 518 |
+
"""
|
| 519 |
+
if not hasattr(cas9_classifier_obj, 'get_scores'):
|
| 520 |
+
raise ValueError("cas9_classifier_obj must have a get_scores method")
|
| 521 |
+
if not (0.0 <= threshold <= 1.0):
|
| 522 |
+
raise ValueError(f"threshold must be in [0, 1], got {threshold}")
|
| 523 |
+
self.cas9_classifier_obj = cas9_classifier_obj
|
| 524 |
+
self.threshold = float(threshold)
|
| 525 |
+
print(f"Cas9 Score Threshold Constraint: sequences must have Cas9 score > {self.threshold}")
|
| 526 |
+
|
| 527 |
+
def __call__(self, protein_tokens, protein_seq):
|
| 528 |
+
"""
|
| 529 |
+
Check if protein sequence's Cas9 score is above the threshold.
|
| 530 |
+
|
| 531 |
+
Args:
|
| 532 |
+
protein_tokens: Unused (kept for interface compatibility)
|
| 533 |
+
protein_seq: Protein sequence string (single sequence)
|
| 534 |
+
|
| 535 |
+
Returns:
|
| 536 |
+
int: 1 if Cas9 score > threshold, 0 otherwise
|
| 537 |
+
"""
|
| 538 |
+
scores = self.cas9_classifier_obj.get_scores([protein_seq])
|
| 539 |
+
if not scores:
|
| 540 |
+
return 0
|
| 541 |
+
|
| 542 |
+
score = scores[0]
|
| 543 |
+
return int(score > self.threshold)
|
| 544 |
+
|
| 545 |
+
def predict_batch(self, seqs: Sequence[str]) -> List[bool]:
|
| 546 |
+
"""
|
| 547 |
+
Batch prediction helper for evaluation / faster integration.
|
| 548 |
+
|
| 549 |
+
Args:
|
| 550 |
+
seqs: List of protein sequence strings
|
| 551 |
+
|
| 552 |
+
Returns:
|
| 553 |
+
List of bool: True if Cas9 score > threshold, False otherwise
|
| 554 |
+
"""
|
| 555 |
+
scores = self.cas9_classifier_obj.get_scores(seqs)
|
| 556 |
+
return [score > self.threshold for score in scores]
|
| 557 |
+
|
| 558 |
+
|
| 559 |
+
class PAMMatchingProbabilityThreshold:
|
| 560 |
+
"""
|
| 561 |
+
Terminal constraint for protein sequences: PAM matching probability must be above a threshold.
|
| 562 |
+
|
| 563 |
+
Uses the PAMMatching objective model to compute probability for its configured target PAM and
|
| 564 |
+
requires probability > threshold.
|
| 565 |
+
"""
|
| 566 |
+
def __init__(self, pam_matching_obj, threshold: float):
|
| 567 |
+
"""
|
| 568 |
+
Args:
|
| 569 |
+
pam_matching_obj: PAMMatching-like object with target_pam and get_score_for_pam methods
|
| 570 |
+
threshold: Minimum PAM matching probability threshold (0-1). Sequences must have score > threshold.
|
| 571 |
+
"""
|
| 572 |
+
if not hasattr(pam_matching_obj, "get_score_for_pam"):
|
| 573 |
+
raise ValueError("pam_matching_obj must have a get_score_for_pam method")
|
| 574 |
+
if not hasattr(pam_matching_obj, "target_pam"):
|
| 575 |
+
raise ValueError("pam_matching_obj must have a target_pam attribute")
|
| 576 |
+
if not (0.0 <= threshold <= 1.0):
|
| 577 |
+
raise ValueError(f"threshold must be in [0, 1], got {threshold}")
|
| 578 |
+
|
| 579 |
+
self.pam_matching_obj = pam_matching_obj
|
| 580 |
+
self.threshold = float(threshold)
|
| 581 |
+
self.target_pam = pam_matching_obj.target_pam
|
| 582 |
+
print(
|
| 583 |
+
"PAM Matching Probability Threshold Constraint: sequences must have "
|
| 584 |
+
f"PAM matching probability > {self.threshold} for target PAM {self.target_pam}"
|
| 585 |
+
)
|
| 586 |
+
|
| 587 |
+
def __call__(self, protein_tokens, protein_seq):
|
| 588 |
+
"""
|
| 589 |
+
Check if protein sequence's PAM matching probability is above the threshold.
|
| 590 |
+
|
| 591 |
+
Args:
|
| 592 |
+
protein_tokens: Unused (kept for interface compatibility)
|
| 593 |
+
protein_seq: Protein sequence string (single sequence)
|
| 594 |
+
|
| 595 |
+
Returns:
|
| 596 |
+
int: 1 if PAM matching probability > threshold, 0 otherwise
|
| 597 |
+
"""
|
| 598 |
+
scores = self.pam_matching_obj.get_score_for_pam(
|
| 599 |
+
[protein_seq], self.target_pam, use_temperature_scaling=False
|
| 600 |
+
)
|
| 601 |
+
if not scores:
|
| 602 |
+
return 0
|
| 603 |
+
score = scores[0]
|
| 604 |
+
return int(score > self.threshold)
|
| 605 |
+
|
| 606 |
+
def predict_batch(self, seqs: Sequence[str]) -> List[bool]:
|
| 607 |
+
"""
|
| 608 |
+
Batch prediction helper for evaluation / faster integration.
|
| 609 |
+
|
| 610 |
+
Args:
|
| 611 |
+
seqs: List of protein sequence strings
|
| 612 |
+
|
| 613 |
+
Returns:
|
| 614 |
+
List of bool: True if PAM matching probability > threshold, False otherwise
|
| 615 |
+
"""
|
| 616 |
+
scores = self.pam_matching_obj.get_score_for_pam(
|
| 617 |
+
seqs, self.target_pam, use_temperature_scaling=False
|
| 618 |
+
)
|
| 619 |
+
return [score > self.threshold for score in scores]
|
| 620 |
+
|
| 621 |
+
|
| 622 |
+
# -------------------------------------------------------------------------
|
| 623 |
+
# Cas9 nuclease domain completeness detector (HNH + RuvC), recall-first
|
| 624 |
+
# -------------------------------------------------------------------------
|
| 625 |
+
|
| 626 |
+
def _sha1(s: str) -> str:
|
| 627 |
+
return hashlib.sha1(s.encode("utf-8")).hexdigest()
|
| 628 |
+
|
| 629 |
+
|
| 630 |
+
class Cas9DomainCompleteness:
|
| 631 |
+
"""
|
| 632 |
+
Hard constraint for protein sequences:
|
| 633 |
+
pass iff sequence contains BOTH HNH and RuvC domains (domain presence),
|
| 634 |
+
using the Option-B detector (Stage1 fast scan + Stage2 --max rescue).
|
| 635 |
+
|
| 636 |
+
Intended use:
|
| 637 |
+
- As a *domain presence* gate (high recall)
|
| 638 |
+
- Combine with an MLP classifier to enforce "is Cas9" / other plausibility checks.
|
| 639 |
+
"""
|
| 640 |
+
def __init__(
|
| 641 |
+
self,
|
| 642 |
+
hmm_path: str,
|
| 643 |
+
cpu: int = 16,
|
| 644 |
+
# Stage1 acceptance
|
| 645 |
+
ev1: float = 1e-2,
|
| 646 |
+
minlen1: int = 35,
|
| 647 |
+
# Stage2 acceptance (recall-first)
|
| 648 |
+
ev2: float = 10.0,
|
| 649 |
+
minlen2: int = 20,
|
| 650 |
+
# Optional: relax Stage1 HMMER filters (usually leave False)
|
| 651 |
+
stage1_relax_filters: bool = False,
|
| 652 |
+
# Cache results by sequence content to avoid repeated hmmscan calls
|
| 653 |
+
enable_cache: bool = True,
|
| 654 |
+
):
|
| 655 |
+
if DomainDetector is None:
|
| 656 |
+
raise ImportError(
|
| 657 |
+
"Could not import DomainDetector. Ensure domain_detector.py is in the same package as constraints.py "
|
| 658 |
+
"and that its imports succeed."
|
| 659 |
+
)
|
| 660 |
+
self.hmm_path = hmm_path
|
| 661 |
+
self.enable_cache = enable_cache
|
| 662 |
+
self._cache: Dict[str, int] = {}
|
| 663 |
+
|
| 664 |
+
cfg = DomainDetectorConfig(
|
| 665 |
+
hmm_path=hmm_path,
|
| 666 |
+
cpu=cpu,
|
| 667 |
+
stage1=HitCriteria(ievalue_max=ev1, min_dom_len=minlen1),
|
| 668 |
+
stage2=HitCriteria(ievalue_max=ev2, min_dom_len=minlen2),
|
| 669 |
+
)
|
| 670 |
+
|
| 671 |
+
if stage1_relax_filters:
|
| 672 |
+
cfg = DomainDetectorConfig(
|
| 673 |
+
hmm_path=hmm_path,
|
| 674 |
+
cpu=cpu,
|
| 675 |
+
stage1=HitCriteria(ievalue_max=ev1, min_dom_len=minlen1),
|
| 676 |
+
stage2=HitCriteria(ievalue_max=ev2, min_dom_len=minlen2),
|
| 677 |
+
stage1_F1=1.0, stage1_F2=1.0, stage1_F3=1.0,
|
| 678 |
+
stage1_report_E=1e6, stage1_report_domE=1e6,
|
| 679 |
+
)
|
| 680 |
+
|
| 681 |
+
self.detector = DomainDetector(cfg)
|
| 682 |
+
|
| 683 |
+
def __call__(self, protein_tokens, protein_seq: str) -> int:
|
| 684 |
+
# protein_tokens is unused; kept to match existing constraint signature
|
| 685 |
+
seq = (protein_seq or "").strip().replace(" ", "").replace("\n", "")
|
| 686 |
+
if not seq:
|
| 687 |
+
return 0
|
| 688 |
+
|
| 689 |
+
if self.enable_cache:
|
| 690 |
+
key = _sha1(seq)
|
| 691 |
+
if key in self._cache:
|
| 692 |
+
return self._cache[key]
|
| 693 |
+
|
| 694 |
+
passed = int(self.detector.predict_one(seq, id_="query"))
|
| 695 |
+
|
| 696 |
+
if self.enable_cache:
|
| 697 |
+
self._cache[_sha1(seq)] = passed
|
| 698 |
+
return passed
|
| 699 |
+
|
| 700 |
+
def predict_batch(self, seqs: Sequence[str], ids: Optional[Sequence[str]] = None) -> List[bool]:
|
| 701 |
+
"""
|
| 702 |
+
Batch prediction helper for evaluation / faster integration.
|
| 703 |
+
"""
|
| 704 |
+
clean = [(s or "").strip().replace(" ", "").replace("\n", "") for s in seqs]
|
| 705 |
+
return self.detector.predict_batch(clean, ids=ids)
|
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copyright (c) Meta Platforms, Inc. and affiliates.
|
| 2 |
+
# All rights reserved.
|
| 3 |
+
#
|
| 4 |
+
# This source code is licensed under the CC-by-NC license found in the
|
| 5 |
+
# LICENSE file in the root directory of this source tree.
|
| 6 |
+
|
| 7 |
+
from .data import DataState
|
| 8 |
+
|
| 9 |
+
__all__ = ["DataState"]
|
|
@@ -0,0 +1,160 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import random
|
| 3 |
+
import pandas as pd
|
| 4 |
+
from datasets import Dataset, DatasetDict
|
| 5 |
+
|
| 6 |
+
# import selfies as sf
|
| 7 |
+
# from rdkit import Chem
|
| 8 |
+
# from rdkit import RDLogger
|
| 9 |
+
# RDLogger.DisableLog('rdApp.*')
|
| 10 |
+
|
| 11 |
+
import sys
|
| 12 |
+
|
| 13 |
+
from transformers import EsmTokenizer
|
| 14 |
+
|
| 15 |
+
# ---------------------------------------------------------------------
|
| 16 |
+
# CONFIG
|
| 17 |
+
# ---------------------------------------------------------------------
|
| 18 |
+
# Set PROJECT_ROOT or edit paths below
|
| 19 |
+
PROJECT_ROOT = os.environ.get('PROJECT_ROOT', 'path/to/project')
|
| 20 |
+
FASTA_PATH = os.path.join(PROJECT_ROOT, 'path/to/protein2pam_db_v1.0/cas9_full.fasta')
|
| 21 |
+
OUTPUT_DIR = os.path.join(PROJECT_ROOT, 'data/cas9/cas9_dataset_esm2_tokenized_under_1125')
|
| 22 |
+
|
| 23 |
+
MAX_TOKENS_PER_BATCH = 8192
|
| 24 |
+
TRAIN_RATIO, VAL_RATIO, TEST_RATIO = 0.8, 0.1, 0.1
|
| 25 |
+
|
| 26 |
+
# ------------------------------------------------------------
|
| 27 |
+
# 1) Load sequences from FASTA file
|
| 28 |
+
# ------------------------------------------------------------
|
| 29 |
+
from Bio import SeqIO
|
| 30 |
+
|
| 31 |
+
sequences = []
|
| 32 |
+
print("Loading sequences from FASTA and validating…")
|
| 33 |
+
for record in SeqIO.parse(FASTA_PATH, "fasta"):
|
| 34 |
+
s = str(record.seq)
|
| 35 |
+
# skip empty sequences
|
| 36 |
+
if not s:
|
| 37 |
+
continue
|
| 38 |
+
sequences.append(s)
|
| 39 |
+
|
| 40 |
+
# remove all duplicates
|
| 41 |
+
sequences = list(set(sequences))
|
| 42 |
+
|
| 43 |
+
## filter out any sequence that contains non-natural amino acids
|
| 44 |
+
print(f"len(sequences) before filtering: {len(sequences)}")
|
| 45 |
+
sequences = [
|
| 46 |
+
seq for seq in sequences
|
| 47 |
+
if all(aa in "ACDEFGHIKLMNPQRSTVWY" for aa in seq) and len(seq) <= 1125
|
| 48 |
+
]
|
| 49 |
+
print(f"len(sequences) after filtering: {len(sequences)}")
|
| 50 |
+
print(f"Average length of sequences: {sum(len(seq) for seq in sequences) / len(sequences)}")
|
| 51 |
+
|
| 52 |
+
# ---- Initialize ESM tokenizer ----
|
| 53 |
+
tokenizer = EsmTokenizer.from_pretrained("facebook/esm2_t33_650M_UR50D")
|
| 54 |
+
print(f"Vocabulary Size: {tokenizer.vocab_size}")
|
| 55 |
+
|
| 56 |
+
# shuffle before splitting
|
| 57 |
+
random.shuffle(sequences)
|
| 58 |
+
n = len(sequences)
|
| 59 |
+
n_train = int(n * TRAIN_RATIO)
|
| 60 |
+
n_val = int(n * VAL_RATIO)
|
| 61 |
+
|
| 62 |
+
train_sequences = sequences[:n_train]
|
| 63 |
+
val_sequences = sequences[n_train:n_train + n_val]
|
| 64 |
+
test_sequences = sequences[n_train + n_val:]
|
| 65 |
+
|
| 66 |
+
# ------------------------------------------------------------
|
| 67 |
+
# 2) helper: tokenize sequences using the ESM tokenizer
|
| 68 |
+
# ------------------------------------------------------------
|
| 69 |
+
def tokenize_sequences(sequences):
|
| 70 |
+
enc = {"input_ids": [], "attention_mask": []}
|
| 71 |
+
for s in sequences:
|
| 72 |
+
res = tokenizer(s, add_special_tokens=True)
|
| 73 |
+
enc["input_ids"].append(res["input_ids"])
|
| 74 |
+
enc["attention_mask"].append(res["attention_mask"])
|
| 75 |
+
return enc
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
# ------------------------------------------------------------
|
| 79 |
+
# 3) build batched dataset (token-based batching: groups sequences by length, packs up to max_tokens)
|
| 80 |
+
# ------------------------------------------------------------
|
| 81 |
+
from tqdm import tqdm
|
| 82 |
+
|
| 83 |
+
def build_batched_dataset(sequences, max_tokens=8192):
|
| 84 |
+
# tokenize all first
|
| 85 |
+
toks = tokenize_sequences(sequences)
|
| 86 |
+
input_ids_list = toks["input_ids"]
|
| 87 |
+
attn_mask_list = toks["attention_mask"]
|
| 88 |
+
|
| 89 |
+
# add lengths
|
| 90 |
+
items = []
|
| 91 |
+
for ids, mask in tqdm(zip(input_ids_list, attn_mask_list), total=len(input_ids_list), desc="Collecting items with length"):
|
| 92 |
+
items.append({
|
| 93 |
+
"input_ids": ids,
|
| 94 |
+
"attention_mask": mask,
|
| 95 |
+
"length": len(ids),
|
| 96 |
+
})
|
| 97 |
+
|
| 98 |
+
# sort by length
|
| 99 |
+
items.sort(key=lambda x: x["length"])
|
| 100 |
+
|
| 101 |
+
batched_input_ids = []
|
| 102 |
+
batched_attention_masks = []
|
| 103 |
+
batched_lengths = []
|
| 104 |
+
batched_batch_sizes = []
|
| 105 |
+
|
| 106 |
+
i = 0
|
| 107 |
+
n = len(items)
|
| 108 |
+
while i < n:
|
| 109 |
+
L = items[i]["length"]
|
| 110 |
+
# how many of length L can we pack?
|
| 111 |
+
max_bs = max_tokens // L
|
| 112 |
+
if max_bs < 1:
|
| 113 |
+
max_bs = 1
|
| 114 |
+
|
| 115 |
+
# collect up to max_bs items with same length
|
| 116 |
+
cur_ids = []
|
| 117 |
+
cur_masks = []
|
| 118 |
+
taken = 0
|
| 119 |
+
j = i
|
| 120 |
+
while j < n and items[j]["length"] == L and taken < max_bs:
|
| 121 |
+
cur_ids.append(items[j]["input_ids"]) # length L
|
| 122 |
+
cur_masks.append(items[j]["attention_mask"]) # length L
|
| 123 |
+
j += 1
|
| 124 |
+
taken += 1
|
| 125 |
+
|
| 126 |
+
# now this is one batch: shape (B, L)
|
| 127 |
+
batched_input_ids.append(cur_ids)
|
| 128 |
+
batched_attention_masks.append(cur_masks)
|
| 129 |
+
batched_lengths.append(L)
|
| 130 |
+
batched_batch_sizes.append(taken)
|
| 131 |
+
|
| 132 |
+
i = j
|
| 133 |
+
|
| 134 |
+
ds = Dataset.from_dict({
|
| 135 |
+
"input_ids": batched_input_ids, # list of (B, L)
|
| 136 |
+
"attention_mask": batched_attention_masks,
|
| 137 |
+
"seq_length": batched_lengths,
|
| 138 |
+
"batch_size": batched_batch_sizes,
|
| 139 |
+
})
|
| 140 |
+
return ds
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
# ------------------------------------------------------------
|
| 144 |
+
# 4) build batched datasets for each split
|
| 145 |
+
# ------------------------------------------------------------
|
| 146 |
+
train_ds = build_batched_dataset(train_sequences, max_tokens=MAX_TOKENS_PER_BATCH)
|
| 147 |
+
val_ds = build_batched_dataset(val_sequences, max_tokens=MAX_TOKENS_PER_BATCH)
|
| 148 |
+
test_ds = build_batched_dataset(test_sequences, max_tokens=MAX_TOKENS_PER_BATCH)
|
| 149 |
+
|
| 150 |
+
dsdict = DatasetDict(
|
| 151 |
+
{
|
| 152 |
+
"train": train_ds,
|
| 153 |
+
"validation": val_ds,
|
| 154 |
+
"test": test_ds,
|
| 155 |
+
}
|
| 156 |
+
)
|
| 157 |
+
|
| 158 |
+
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
| 159 |
+
dsdict.save_to_disk(OUTPUT_DIR)
|
| 160 |
+
print(f"saved to {OUTPUT_DIR}")
|
|
@@ -0,0 +1,138 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import random
|
| 3 |
+
import pandas as pd
|
| 4 |
+
from datasets import Dataset, DatasetDict
|
| 5 |
+
from transformers import EsmTokenizer
|
| 6 |
+
|
| 7 |
+
import selfies as sf
|
| 8 |
+
from rdkit import Chem
|
| 9 |
+
from rdkit import RDLogger
|
| 10 |
+
RDLogger.DisableLog('rdApp.*')
|
| 11 |
+
|
| 12 |
+
import sys
|
| 13 |
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 14 |
+
from smiles_tokenizer.my_tokenizers import SMILES_SPE_Tokenizer
|
| 15 |
+
from smiles_tokenizer.selfies_tokenizers import SelfiesTokenizer
|
| 16 |
+
|
| 17 |
+
# ---------------------------------------------------------------------
|
| 18 |
+
# CONFIG
|
| 19 |
+
# ---------------------------------------------------------------------
|
| 20 |
+
PROJECT_ROOT = os.environ.get('PROJECT_ROOT', 'path/to/project')
|
| 21 |
+
CSV_PATH = os.path.join(PROJECT_ROOT, "data/cas9/cas9.csv")
|
| 22 |
+
OUTPUT_DIR = os.path.join(PROJECT_ROOT, "data/cas9")
|
| 23 |
+
MAX_TOKENS_PER_BATCH = 8192
|
| 24 |
+
TRAIN_RATIO, VAL_RATIO, TEST_RATIO = 0.8, 0.1, 0.1
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
# ------------------------------------------------------------
|
| 28 |
+
# 1) Load CSV and split 8:1:1
|
| 29 |
+
# ------------------------------------------------------------
|
| 30 |
+
df = pd.read_csv(CSV_PATH)
|
| 31 |
+
sequences = df["Sequence"].tolist()
|
| 32 |
+
|
| 33 |
+
# ---- Build vocab ----
|
| 34 |
+
tokenizer = EsmTokenizer.from_pretrained("facebook/esm2_t33_650M_UR50D")
|
| 35 |
+
|
| 36 |
+
random.shuffle(sequences)
|
| 37 |
+
n = len(sequences)
|
| 38 |
+
n_train = int(n * TRAIN_RATIO)
|
| 39 |
+
n_val = int(n * VAL_RATIO)
|
| 40 |
+
|
| 41 |
+
train_df = sequences[:n_train]
|
| 42 |
+
val_df = sequences[n_train:n_train + n_val]
|
| 43 |
+
test_df = sequences[n_train + n_val:]
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
# ------------------------------------------------------------
|
| 47 |
+
# 2) Tokenizer
|
| 48 |
+
# ------------------------------------------------------------
|
| 49 |
+
|
| 50 |
+
def tokenize_selfies(sequences):
|
| 51 |
+
enc = {"input_ids": [], "attention_mask": []}
|
| 52 |
+
for s in sequences:
|
| 53 |
+
res = tokenizer(s)
|
| 54 |
+
enc["input_ids"].append(res["input_ids"])
|
| 55 |
+
enc["attention_mask"].append(res["attention_mask"])
|
| 56 |
+
# enc["input_ids"] is a list of list[int]
|
| 57 |
+
return enc
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
# ------------------------------------------------------------
|
| 61 |
+
# helper: build batches (one row = one batch)
|
| 62 |
+
# ------------------------------------------------------------
|
| 63 |
+
def build_batched_dataset(sequences, max_tokens=9182):
|
| 64 |
+
# tokenize all first
|
| 65 |
+
toks = tokenize_selfies(sequences)
|
| 66 |
+
input_ids_list = toks["input_ids"]
|
| 67 |
+
attn_mask_list = toks["attention_mask"]
|
| 68 |
+
|
| 69 |
+
# add lengths
|
| 70 |
+
items = []
|
| 71 |
+
for ids, mask in zip(input_ids_list, attn_mask_list):
|
| 72 |
+
items.append({
|
| 73 |
+
"input_ids": ids,
|
| 74 |
+
"attention_mask": mask,
|
| 75 |
+
"length": len(ids),
|
| 76 |
+
})
|
| 77 |
+
|
| 78 |
+
# sort by length
|
| 79 |
+
items.sort(key=lambda x: x["length"])
|
| 80 |
+
|
| 81 |
+
batched_input_ids = []
|
| 82 |
+
batched_attention_masks = []
|
| 83 |
+
batched_lengths = []
|
| 84 |
+
batched_batch_sizes = []
|
| 85 |
+
|
| 86 |
+
i = 0
|
| 87 |
+
n = len(items)
|
| 88 |
+
while i < n:
|
| 89 |
+
L = items[i]["length"]
|
| 90 |
+
# how many of length L can we pack?
|
| 91 |
+
max_bs = max_tokens // L
|
| 92 |
+
if max_bs < 1:
|
| 93 |
+
max_bs = 1
|
| 94 |
+
|
| 95 |
+
# collect up to max_bs items with same length
|
| 96 |
+
cur_ids = []
|
| 97 |
+
cur_masks = []
|
| 98 |
+
taken = 0
|
| 99 |
+
j = i
|
| 100 |
+
while j < n and items[j]["length"] == L and taken < max_bs:
|
| 101 |
+
cur_ids.append(items[j]["input_ids"]) # length L
|
| 102 |
+
cur_masks.append(items[j]["attention_mask"]) # length L
|
| 103 |
+
j += 1
|
| 104 |
+
taken += 1
|
| 105 |
+
|
| 106 |
+
# now this is one batch: shape (B, L)
|
| 107 |
+
batched_input_ids.append(cur_ids)
|
| 108 |
+
batched_attention_masks.append(cur_masks)
|
| 109 |
+
batched_lengths.append(L)
|
| 110 |
+
batched_batch_sizes.append(taken)
|
| 111 |
+
|
| 112 |
+
i = j
|
| 113 |
+
|
| 114 |
+
ds = Dataset.from_dict({
|
| 115 |
+
"input_ids": batched_input_ids, # list of (B, L)
|
| 116 |
+
"attention_mask": batched_attention_masks,
|
| 117 |
+
"seq_length": batched_lengths,
|
| 118 |
+
"batch_size": batched_batch_sizes,
|
| 119 |
+
})
|
| 120 |
+
return ds
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
# ------------------------------------------------------------
|
| 124 |
+
# 3) build batched datasets for each split
|
| 125 |
+
# ------------------------------------------------------------
|
| 126 |
+
train_ds = build_batched_dataset(train_df, MAX_TOKENS_PER_BATCH)
|
| 127 |
+
val_ds = build_batched_dataset(val_df, MAX_TOKENS_PER_BATCH)
|
| 128 |
+
test_ds = build_batched_dataset(test_df, MAX_TOKENS_PER_BATCH)
|
| 129 |
+
|
| 130 |
+
dsdict = DatasetDict({
|
| 131 |
+
"train": train_ds,
|
| 132 |
+
"validation": val_ds,
|
| 133 |
+
"test": test_ds,
|
| 134 |
+
})
|
| 135 |
+
|
| 136 |
+
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
| 137 |
+
dsdict.save_to_disk(OUTPUT_DIR)
|
| 138 |
+
print(f"saved to {OUTPUT_DIR}")
|
|
@@ -0,0 +1,309 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copyright (c) Meta Platforms, Inc.
|
| 2 |
+
# All rights reserved.
|
| 3 |
+
#
|
| 4 |
+
# This source code is licensed under the CC-by-NC license found in the
|
| 5 |
+
# LICENSE file in the root directory of this source tree.
|
| 6 |
+
# Part of this implementation is adapted from https://github.com/louaaron/Score-Entropy-Discrete-Diffusion
|
| 7 |
+
# which is released under MIT license
|
| 8 |
+
|
| 9 |
+
from dataclasses import dataclass, field
|
| 10 |
+
from typing import Dict, Iterable, List, Tuple, Optional
|
| 11 |
+
|
| 12 |
+
import os
|
| 13 |
+
|
| 14 |
+
from datasets import Dataset as HFDataset, DatasetDict, load_dataset
|
| 15 |
+
from omegaconf import OmegaConf
|
| 16 |
+
|
| 17 |
+
import torch
|
| 18 |
+
from torch.utils.data import DataLoader, Dataset
|
| 19 |
+
from transformers import AutoTokenizer
|
| 20 |
+
try:
|
| 21 |
+
from transformers import EsmTokenizer
|
| 22 |
+
_HAVE_ESM = True
|
| 23 |
+
except Exception:
|
| 24 |
+
_HAVE_ESM = False
|
| 25 |
+
|
| 26 |
+
from cas9.data.tokenizer import wt_detokenizer
|
| 27 |
+
from cas9.data.utils import cycle_loader, StatefulDistributedSampler
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
# -------------------------
|
| 31 |
+
# Tokenizer helper
|
| 32 |
+
# -------------------------
|
| 33 |
+
|
| 34 |
+
def _get_tokenizer(name: Optional[str]):
|
| 35 |
+
"""
|
| 36 |
+
Returns a HF tokenizer by name. Supports ESM when available.
|
| 37 |
+
"""
|
| 38 |
+
tok_name = name or "gpt2"
|
| 39 |
+
if _HAVE_ESM and tok_name.startswith("facebook/esm"):
|
| 40 |
+
return EsmTokenizer.from_pretrained(tok_name)
|
| 41 |
+
return AutoTokenizer.from_pretrained(tok_name)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
# -------------------------
|
| 45 |
+
# HuggingFace datasets (ragged)
|
| 46 |
+
# -------------------------
|
| 47 |
+
|
| 48 |
+
def _get_hf_dataset_ragged(
|
| 49 |
+
name: str,
|
| 50 |
+
mode: str,
|
| 51 |
+
cache_dir: Optional[str],
|
| 52 |
+
num_proc: int,
|
| 53 |
+
tokenizer_name: Optional[str],
|
| 54 |
+
) -> HFDataset:
|
| 55 |
+
"""
|
| 56 |
+
Loads an HF dataset split and tokenizes into variable-length sequences.
|
| 57 |
+
No chunking to fixed block_size; each example is one tokenized text (+ EOS if available).
|
| 58 |
+
Returns a HuggingFace Dataset with a single column "input_ids" (list[int]) per row.
|
| 59 |
+
"""
|
| 60 |
+
detokenizer = None
|
| 61 |
+
if name == "wikitext103":
|
| 62 |
+
ds = load_dataset("wikitext", name="wikitext-103-raw-v1", cache_dir=cache_dir)[mode]
|
| 63 |
+
detokenizer = wt_detokenizer
|
| 64 |
+
elif name == "fineweb-edu":
|
| 65 |
+
ds = load_dataset("HuggingFaceFW/fineweb-edu", name="CC-MAIN-2024-10", cache_dir=cache_dir)[mode]
|
| 66 |
+
else:
|
| 67 |
+
ds = load_dataset(name, cache_dir=cache_dir)[mode]
|
| 68 |
+
|
| 69 |
+
tok = _get_tokenizer(tokenizer_name)
|
| 70 |
+
eos_id = None
|
| 71 |
+
if hasattr(tok, "eos_token") and tok.eos_token is not None:
|
| 72 |
+
eos_id = tok.convert_tokens_to_ids(tok.eos_token)
|
| 73 |
+
if isinstance(eos_id, list):
|
| 74 |
+
eos_id = eos_id[0]
|
| 75 |
+
|
| 76 |
+
def _apply_detok(batch: Dict):
|
| 77 |
+
texts = batch["text"]
|
| 78 |
+
if detokenizer is not None:
|
| 79 |
+
texts = [detokenizer(t) for t in texts]
|
| 80 |
+
return {"text": texts}
|
| 81 |
+
|
| 82 |
+
if "text" in ds.column_names:
|
| 83 |
+
ds = ds.map(_apply_detok, batched=True, num_proc=num_proc, load_from_cache_file=True)
|
| 84 |
+
|
| 85 |
+
def _tokenize(batch: Dict):
|
| 86 |
+
# Use fast tokenizer; do not return attention masks
|
| 87 |
+
ids = tok(batch["text"], add_special_tokens=True, return_attention_mask=False)["input_ids"]
|
| 88 |
+
# if eos_id is not None:
|
| 89 |
+
# for arr in ids:
|
| 90 |
+
# arr.append(eos_id)
|
| 91 |
+
return {"input_ids": ids}
|
| 92 |
+
|
| 93 |
+
# Tokenize
|
| 94 |
+
tokenized = ds.map(
|
| 95 |
+
_tokenize,
|
| 96 |
+
batched=True,
|
| 97 |
+
num_proc=num_proc,
|
| 98 |
+
remove_columns=[c for c in ds.column_names if c != "text"],
|
| 99 |
+
load_from_cache_file=True,
|
| 100 |
+
)
|
| 101 |
+
|
| 102 |
+
# Keep only input_ids
|
| 103 |
+
if "text" in tokenized.column_names:
|
| 104 |
+
tokenized = tokenized.remove_columns("text")
|
| 105 |
+
|
| 106 |
+
# For fineweb there can be extra meta columns; keep only input_ids
|
| 107 |
+
extra_cols = [c for c in tokenized.column_names if c != "input_ids"]
|
| 108 |
+
if extra_cols:
|
| 109 |
+
tokenized = tokenized.remove_columns(extra_cols)
|
| 110 |
+
|
| 111 |
+
tokenized = tokenized.with_format("python") # return plain python objects; collate will tensorize
|
| 112 |
+
return tokenized
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
# -------------------------
|
| 116 |
+
# Local FASTA dataset (ragged)
|
| 117 |
+
# -------------------------
|
| 118 |
+
|
| 119 |
+
class FASTADataset(Dataset):
|
| 120 |
+
"""
|
| 121 |
+
Loads a local FASTA file and tokenizes each sequence to variable-length ids.
|
| 122 |
+
One HF-like item per sequence: {"input_ids": List[int]}.
|
| 123 |
+
"""
|
| 124 |
+
|
| 125 |
+
def __init__(self, fasta_path: str, tokenizer_name: Optional[str]):
|
| 126 |
+
assert os.path.isfile(fasta_path), f"FASTA file not found: {fasta_path}"
|
| 127 |
+
self.fasta_path = fasta_path
|
| 128 |
+
self.tok = _get_tokenizer(tokenizer_name)
|
| 129 |
+
self.eos_id = None
|
| 130 |
+
if hasattr(self.tok, "eos_token") and self.tok.eos_token is not None:
|
| 131 |
+
eid = self.tok.convert_tokens_to_ids(self.tok.eos_token)
|
| 132 |
+
self.eos_id = eid[0] if isinstance(eid, list) else eid
|
| 133 |
+
|
| 134 |
+
# Parse FASTA (simple parser; no Biopython dependency)
|
| 135 |
+
self._seqs: List[str] = []
|
| 136 |
+
with open(fasta_path, "r") as f:
|
| 137 |
+
cur = []
|
| 138 |
+
for line in f:
|
| 139 |
+
line = line.strip()
|
| 140 |
+
if not line:
|
| 141 |
+
continue
|
| 142 |
+
if line.startswith(">"):
|
| 143 |
+
if cur:
|
| 144 |
+
self._seqs.append("".join(cur))
|
| 145 |
+
cur = []
|
| 146 |
+
else:
|
| 147 |
+
cur.append(line)
|
| 148 |
+
if cur:
|
| 149 |
+
self._seqs.append("".join(cur))
|
| 150 |
+
|
| 151 |
+
def __len__(self) -> int:
|
| 152 |
+
return len(self._seqs)
|
| 153 |
+
|
| 154 |
+
def __getitem__(self, idx: int) -> Dict[str, List[int]]:
|
| 155 |
+
seq = self._seqs[idx]
|
| 156 |
+
ids = self.tok(seq, add_special_tokens=True, return_attention_mask=False)["input_ids"]
|
| 157 |
+
if isinstance(ids, list) and len(ids) > 0 and isinstance(ids[0], list):
|
| 158 |
+
# Some tokenizers may return nested lists for per-char tokenization; flatten
|
| 159 |
+
ids = [t for sub in ids for t in (sub if isinstance(sub, list) else [sub])]
|
| 160 |
+
# if self.eos_id is not None:
|
| 161 |
+
# ids.append(self.eos_id)
|
| 162 |
+
return {"input_ids": ids}
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
# -------------------------
|
| 166 |
+
# Ragged collate
|
| 167 |
+
# -------------------------
|
| 168 |
+
|
| 169 |
+
def ragged_collate(batch: List[Dict[str, List[int]]]) -> Dict[str, List[torch.Tensor]]:
|
| 170 |
+
"""
|
| 171 |
+
Collate a list of {"input_ids": List[int]} into {"input_ids": List[LongTensor]}.
|
| 172 |
+
No padding; tensorization only.
|
| 173 |
+
"""
|
| 174 |
+
out: List[torch.Tensor] = []
|
| 175 |
+
for item in batch:
|
| 176 |
+
ids = item["input_ids"]
|
| 177 |
+
out.append(torch.tensor(ids, dtype=torch.long))
|
| 178 |
+
return {"input_ids": out}
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
# -------------------------
|
| 182 |
+
# Public dataclasses
|
| 183 |
+
# -------------------------
|
| 184 |
+
|
| 185 |
+
@dataclass
|
| 186 |
+
class DatasetWrap:
|
| 187 |
+
dataset: Dataset # HF Dataset (python format) OR torch Dataset
|
| 188 |
+
sampler: StatefulDistributedSampler
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
@dataclass
|
| 192 |
+
class DataState:
|
| 193 |
+
train: DatasetWrap
|
| 194 |
+
test: DatasetWrap
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
# -------------------------
|
| 198 |
+
# Builders
|
| 199 |
+
# -------------------------
|
| 200 |
+
|
| 201 |
+
def _get_dataset(
|
| 202 |
+
name: str,
|
| 203 |
+
mode: str,
|
| 204 |
+
cache_dir: Optional[str],
|
| 205 |
+
num_proc: int,
|
| 206 |
+
batch_size: int,
|
| 207 |
+
ngpus: int,
|
| 208 |
+
tokenizer_name: Optional[str],
|
| 209 |
+
fasta_path: Optional[str] = None,
|
| 210 |
+
) -> DatasetWrap:
|
| 211 |
+
"""
|
| 212 |
+
Build either an HF dataset (ragged) or a local FASTA dataset.
|
| 213 |
+
- name == "fasta" -> requires fasta_path
|
| 214 |
+
- otherwise -> HF dataset by name
|
| 215 |
+
"""
|
| 216 |
+
assert batch_size % ngpus == 0, f"{mode} batch size must be divisible by number of gpus."
|
| 217 |
+
|
| 218 |
+
if name.lower() == "fasta":
|
| 219 |
+
assert fasta_path is not None, "For name='fasta', you must set data.fasta_path in the config."
|
| 220 |
+
dataset = FASTADataset(fasta_path=fasta_path, tokenizer_name=tokenizer_name)
|
| 221 |
+
else:
|
| 222 |
+
dataset = _get_hf_dataset_ragged(
|
| 223 |
+
name=name,
|
| 224 |
+
mode=mode,
|
| 225 |
+
cache_dir=cache_dir,
|
| 226 |
+
num_proc=num_proc,
|
| 227 |
+
tokenizer_name=tokenizer_name,
|
| 228 |
+
)
|
| 229 |
+
|
| 230 |
+
sampler = StatefulDistributedSampler(dataset=dataset)
|
| 231 |
+
return DatasetWrap(dataset=dataset, sampler=sampler)
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
def get_data_state(config: OmegaConf) -> DataState:
|
| 235 |
+
"""
|
| 236 |
+
Expects (typical):
|
| 237 |
+
config.data.train / config.data.valid : dataset name (e.g., "wikitext103", "fineweb-edu", "fasta", or any HF hub dataset id)
|
| 238 |
+
config.data.cache_dir
|
| 239 |
+
config.data.num_workers
|
| 240 |
+
config.data.tokenizer (string, e.g., "gpt2" or "facebook/esm2_t33_650M_UR50S")
|
| 241 |
+
config.data.fasta_path (optional, used when name == "fasta")
|
| 242 |
+
config.training.batch_size
|
| 243 |
+
config.eval.batch_size
|
| 244 |
+
config.compute.ngpus
|
| 245 |
+
"""
|
| 246 |
+
tok_name = getattr(config.data, "tokenizer", None)
|
| 247 |
+
|
| 248 |
+
train = _get_dataset(
|
| 249 |
+
name=config.data.train,
|
| 250 |
+
mode="train",
|
| 251 |
+
cache_dir=config.data.cache_dir,
|
| 252 |
+
num_proc=config.data.num_workers,
|
| 253 |
+
batch_size=config.training.batch_size,
|
| 254 |
+
ngpus=config.compute.ngpus,
|
| 255 |
+
tokenizer_name=tok_name,
|
| 256 |
+
fasta_path=getattr(config.data, "fasta_path", None),
|
| 257 |
+
)
|
| 258 |
+
|
| 259 |
+
valid = _get_dataset(
|
| 260 |
+
name=config.data.valid,
|
| 261 |
+
mode="validation",
|
| 262 |
+
cache_dir=config.data.cache_dir,
|
| 263 |
+
num_proc=config.data.num_workers,
|
| 264 |
+
batch_size=config.eval.batch_size,
|
| 265 |
+
ngpus=config.compute.ngpus,
|
| 266 |
+
tokenizer_name=tok_name,
|
| 267 |
+
fasta_path=getattr(config.data, "fasta_path", None),
|
| 268 |
+
)
|
| 269 |
+
|
| 270 |
+
return DataState(train=train, test=valid)
|
| 271 |
+
|
| 272 |
+
|
| 273 |
+
def get_data_loaders(
|
| 274 |
+
config: OmegaConf,
|
| 275 |
+
data_state: DataState,
|
| 276 |
+
) -> Tuple[Iterable, Iterable]:
|
| 277 |
+
"""
|
| 278 |
+
Returns infinite iterators over train/valid using ragged_collate.
|
| 279 |
+
Each batch item is a dict: {"input_ids": List[LongTensor]}.
|
| 280 |
+
"""
|
| 281 |
+
per_gpu_train_bs = config.training.batch_size // config.compute.ngpus
|
| 282 |
+
per_gpu_valid_bs = config.eval.batch_size // config.compute.ngpus
|
| 283 |
+
|
| 284 |
+
train_loader = cycle_loader(
|
| 285 |
+
DataLoader(
|
| 286 |
+
data_state.train.dataset,
|
| 287 |
+
batch_size=per_gpu_train_bs,
|
| 288 |
+
sampler=data_state.train.sampler,
|
| 289 |
+
num_workers=config.data.num_workers,
|
| 290 |
+
pin_memory=True,
|
| 291 |
+
shuffle=(data_state.train.sampler is None),
|
| 292 |
+
persistent_workers=True,
|
| 293 |
+
collate_fn=ragged_collate,
|
| 294 |
+
)
|
| 295 |
+
)
|
| 296 |
+
|
| 297 |
+
valid_loader = cycle_loader(
|
| 298 |
+
DataLoader(
|
| 299 |
+
data_state.test.dataset,
|
| 300 |
+
batch_size=per_gpu_valid_bs,
|
| 301 |
+
sampler=data_state.test.sampler,
|
| 302 |
+
num_workers=config.data.num_workers,
|
| 303 |
+
pin_memory=True,
|
| 304 |
+
shuffle=(data_state.test.sampler is None),
|
| 305 |
+
collate_fn=ragged_collate,
|
| 306 |
+
)
|
| 307 |
+
)
|
| 308 |
+
|
| 309 |
+
return iter(train_loader), iter(valid_loader)
|
|
@@ -0,0 +1,297 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Script to construct a dataset of uniref50 sequences that matches the
|
| 4 |
+
sequence length distribution found in a cas9 FASTA file.
|
| 5 |
+
|
| 6 |
+
The script prioritizes matching the length distribution over matching
|
| 7 |
+
a specified dataset size exactly.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
import argparse
|
| 11 |
+
import sys
|
| 12 |
+
from collections import Counter, defaultdict
|
| 13 |
+
from Bio import SeqIO
|
| 14 |
+
import numpy as np
|
| 15 |
+
from tqdm import tqdm
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def load_sequences(fasta_path, description=""):
|
| 19 |
+
"""Load sequences from a FASTA file."""
|
| 20 |
+
sequences = []
|
| 21 |
+
lengths = []
|
| 22 |
+
print(f"Loading {description} sequences from {fasta_path}...")
|
| 23 |
+
|
| 24 |
+
for record in tqdm(SeqIO.parse(fasta_path, "fasta"), desc=f"Reading {description}"):
|
| 25 |
+
seq = str(record.seq).upper()
|
| 26 |
+
# Skip empty sequences
|
| 27 |
+
if not seq:
|
| 28 |
+
continue
|
| 29 |
+
# Filter out sequences with non-natural amino acids
|
| 30 |
+
if all(aa in "ACDEFGHIKLMNPQRSTVWY" for aa in seq):
|
| 31 |
+
sequences.append(seq)
|
| 32 |
+
lengths.append(len(seq))
|
| 33 |
+
|
| 34 |
+
print(f"Loaded {len(sequences)} {description} sequences")
|
| 35 |
+
if sequences:
|
| 36 |
+
print(f" Length range: {min(lengths)} - {max(lengths)}")
|
| 37 |
+
print(f" Average length: {sum(lengths) / len(lengths):.1f}")
|
| 38 |
+
|
| 39 |
+
return sequences, lengths
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def compute_length_distribution(lengths, bins=None):
|
| 43 |
+
"""
|
| 44 |
+
Compute the distribution of sequence lengths.
|
| 45 |
+
|
| 46 |
+
Args:
|
| 47 |
+
lengths: List of sequence lengths
|
| 48 |
+
bins: Optional list of bin edges. If None, uses histogram bins.
|
| 49 |
+
|
| 50 |
+
Returns:
|
| 51 |
+
bin_edges: List of bin edges
|
| 52 |
+
counts: List of counts for each bin
|
| 53 |
+
normalized_dist: Normalized distribution (probabilities)
|
| 54 |
+
"""
|
| 55 |
+
if bins is None:
|
| 56 |
+
# Use histogram to automatically determine bins
|
| 57 |
+
counts, bin_edges = np.histogram(lengths, bins='auto')
|
| 58 |
+
else:
|
| 59 |
+
counts, bin_edges = np.histogram(lengths, bins=bins)
|
| 60 |
+
|
| 61 |
+
# Normalize to get probability distribution
|
| 62 |
+
total = sum(counts)
|
| 63 |
+
normalized_dist = counts / total if total > 0 else counts
|
| 64 |
+
|
| 65 |
+
return bin_edges, counts, normalized_dist
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def group_sequences_by_length(sequences):
|
| 69 |
+
"""Group sequences by their length."""
|
| 70 |
+
length_groups = defaultdict(list)
|
| 71 |
+
for seq in sequences:
|
| 72 |
+
length_groups[len(seq)].append(seq)
|
| 73 |
+
return length_groups
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def sample_to_match_distribution(
|
| 77 |
+
source_sequences,
|
| 78 |
+
source_lengths,
|
| 79 |
+
target_lengths,
|
| 80 |
+
target_size=None,
|
| 81 |
+
bin_edges=None
|
| 82 |
+
):
|
| 83 |
+
"""
|
| 84 |
+
Sample sequences from source_sequences to match the length distribution
|
| 85 |
+
of target_lengths.
|
| 86 |
+
|
| 87 |
+
Args:
|
| 88 |
+
source_sequences: List of sequences to sample from
|
| 89 |
+
source_lengths: List of lengths corresponding to source_sequences
|
| 90 |
+
target_lengths: List of lengths to match the distribution of
|
| 91 |
+
target_size: Optional target number of sequences (approximate)
|
| 92 |
+
bin_edges: Optional bin edges for histogram (if None, computed from target)
|
| 93 |
+
|
| 94 |
+
Returns:
|
| 95 |
+
sampled_sequences: List of sampled sequences
|
| 96 |
+
sampled_lengths: List of lengths of sampled sequences
|
| 97 |
+
"""
|
| 98 |
+
# Compute target distribution
|
| 99 |
+
if bin_edges is None:
|
| 100 |
+
target_bin_edges, target_counts, target_dist = compute_length_distribution(target_lengths)
|
| 101 |
+
else:
|
| 102 |
+
_, target_counts, target_dist = compute_length_distribution(target_lengths, bins=bin_edges)
|
| 103 |
+
target_bin_edges = bin_edges
|
| 104 |
+
|
| 105 |
+
print(f"\nTarget distribution:")
|
| 106 |
+
print(f" Number of bins: {len(target_dist)}")
|
| 107 |
+
print(f" Bin edges: {target_bin_edges[:5]}...{target_bin_edges[-5:]}")
|
| 108 |
+
|
| 109 |
+
# Group source sequences by length
|
| 110 |
+
print("\nGrouping source sequences by length...")
|
| 111 |
+
length_groups = group_sequences_by_length(source_sequences)
|
| 112 |
+
print(f" Found {len(length_groups)} unique lengths")
|
| 113 |
+
|
| 114 |
+
# Determine how many sequences to sample per bin
|
| 115 |
+
if target_size is None:
|
| 116 |
+
# Use the same counts as target
|
| 117 |
+
target_counts_scaled = target_counts.copy()
|
| 118 |
+
else:
|
| 119 |
+
# Scale the distribution to approximately match target_size
|
| 120 |
+
total_target = sum(target_counts)
|
| 121 |
+
scale_factor = target_size / total_target if total_target > 0 else 1.0
|
| 122 |
+
target_counts_scaled = (target_counts * scale_factor).astype(int)
|
| 123 |
+
# Ensure we have at least 1 sequence per non-zero bin
|
| 124 |
+
target_counts_scaled = np.maximum(target_counts_scaled, (target_counts > 0).astype(int))
|
| 125 |
+
|
| 126 |
+
print(f"\nTarget sample size: {sum(target_counts_scaled)} sequences")
|
| 127 |
+
|
| 128 |
+
# Sample sequences for each bin
|
| 129 |
+
sampled_sequences = []
|
| 130 |
+
sampled_lengths = []
|
| 131 |
+
|
| 132 |
+
print("\nSampling sequences to match distribution...")
|
| 133 |
+
for i in tqdm(range(len(target_dist)), desc="Processing bins"):
|
| 134 |
+
bin_start = int(target_bin_edges[i])
|
| 135 |
+
bin_end = int(target_bin_edges[i + 1])
|
| 136 |
+
n_samples = int(target_counts_scaled[i])
|
| 137 |
+
|
| 138 |
+
if n_samples == 0:
|
| 139 |
+
continue
|
| 140 |
+
|
| 141 |
+
# Find all source sequences in this length range
|
| 142 |
+
candidates = []
|
| 143 |
+
for length in range(bin_start, bin_end + 1):
|
| 144 |
+
if length in length_groups:
|
| 145 |
+
candidates.extend(length_groups[length])
|
| 146 |
+
|
| 147 |
+
if not candidates:
|
| 148 |
+
# If no exact match, try to find sequences close to this bin
|
| 149 |
+
# Find the closest available lengths
|
| 150 |
+
available_lengths = sorted(length_groups.keys())
|
| 151 |
+
for length in available_lengths:
|
| 152 |
+
if bin_start <= length <= bin_end:
|
| 153 |
+
candidates.extend(length_groups[length])
|
| 154 |
+
break
|
| 155 |
+
|
| 156 |
+
if not candidates:
|
| 157 |
+
# Still no candidates, try to find sequences in nearby bins
|
| 158 |
+
bin_center = (bin_start + bin_end) / 2
|
| 159 |
+
closest_length = min(available_lengths, key=lambda x: abs(x - bin_center))
|
| 160 |
+
candidates.extend(length_groups[closest_length])
|
| 161 |
+
|
| 162 |
+
# Sample from candidates
|
| 163 |
+
if len(candidates) >= n_samples:
|
| 164 |
+
sampled = np.random.choice(candidates, size=n_samples, replace=False).tolist()
|
| 165 |
+
else:
|
| 166 |
+
# Not enough candidates, sample with replacement
|
| 167 |
+
sampled = np.random.choice(candidates, size=n_samples, replace=True).tolist()
|
| 168 |
+
|
| 169 |
+
sampled_sequences.extend(sampled)
|
| 170 |
+
sampled_lengths.extend([len(seq) for seq in sampled])
|
| 171 |
+
|
| 172 |
+
return sampled_sequences, sampled_lengths
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
def write_fasta(sequences, output_path, description="sequences"):
|
| 176 |
+
"""Write sequences to a FASTA file."""
|
| 177 |
+
print(f"\nWriting {len(sequences)} {description} to {output_path}...")
|
| 178 |
+
with open(output_path, 'w') as f:
|
| 179 |
+
for i, seq in enumerate(sequences):
|
| 180 |
+
f.write(f">sequence_{i+1}\n")
|
| 181 |
+
f.write(f"{seq}\n")
|
| 182 |
+
print(f"Saved to {output_path}")
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
def main():
|
| 186 |
+
parser = argparse.ArgumentParser(
|
| 187 |
+
description="Match uniref50 sequence length distribution to cas9 distribution"
|
| 188 |
+
)
|
| 189 |
+
parser.add_argument(
|
| 190 |
+
'cas9_fasta',
|
| 191 |
+
type=str,
|
| 192 |
+
help='Path to FASTA file containing cas9 sequences'
|
| 193 |
+
)
|
| 194 |
+
parser.add_argument(
|
| 195 |
+
'uniref50_fasta',
|
| 196 |
+
type=str,
|
| 197 |
+
help='Path to FASTA file containing uniref50 sequences'
|
| 198 |
+
)
|
| 199 |
+
parser.add_argument(
|
| 200 |
+
'output_fasta',
|
| 201 |
+
type=str,
|
| 202 |
+
help='Path to output FASTA file'
|
| 203 |
+
)
|
| 204 |
+
parser.add_argument(
|
| 205 |
+
'--target-size',
|
| 206 |
+
type=int,
|
| 207 |
+
default=None,
|
| 208 |
+
help='Approximate target dataset size (distribution matching takes precedence)'
|
| 209 |
+
)
|
| 210 |
+
parser.add_argument(
|
| 211 |
+
'--seed',
|
| 212 |
+
type=int,
|
| 213 |
+
default=42,
|
| 214 |
+
help='Random seed for reproducibility (default: 42)'
|
| 215 |
+
)
|
| 216 |
+
|
| 217 |
+
args = parser.parse_args()
|
| 218 |
+
|
| 219 |
+
# Set random seed
|
| 220 |
+
np.random.seed(args.seed)
|
| 221 |
+
|
| 222 |
+
# Load cas9 sequences
|
| 223 |
+
cas9_sequences, cas9_lengths = load_sequences(args.cas9_fasta, "cas9")
|
| 224 |
+
if not cas9_sequences:
|
| 225 |
+
print("Error: No valid cas9 sequences found!", file=sys.stderr)
|
| 226 |
+
sys.exit(1)
|
| 227 |
+
|
| 228 |
+
# Load uniref50 sequences
|
| 229 |
+
uniref50_sequences, uniref50_lengths = load_sequences(args.uniref50_fasta, "uniref50")
|
| 230 |
+
if not uniref50_sequences:
|
| 231 |
+
print("Error: No valid uniref50 sequences found!", file=sys.stderr)
|
| 232 |
+
sys.exit(1)
|
| 233 |
+
|
| 234 |
+
# Compute cas9 length distribution
|
| 235 |
+
print("\n" + "="*60)
|
| 236 |
+
print("Analyzing cas9 length distribution...")
|
| 237 |
+
print("="*60)
|
| 238 |
+
cas9_bin_edges, cas9_counts, cas9_dist = compute_length_distribution(cas9_lengths)
|
| 239 |
+
print(f"Cas9 distribution statistics:")
|
| 240 |
+
print(f" Total sequences: {len(cas9_sequences)}")
|
| 241 |
+
print(f" Number of bins: {len(cas9_dist)}")
|
| 242 |
+
print(f" Length range: {min(cas9_lengths)} - {max(cas9_lengths)}")
|
| 243 |
+
|
| 244 |
+
# Sample uniref50 sequences to match cas9 distribution
|
| 245 |
+
print("\n" + "="*60)
|
| 246 |
+
print("Sampling uniref50 sequences to match cas9 distribution...")
|
| 247 |
+
print("="*60)
|
| 248 |
+
sampled_sequences, sampled_lengths = sample_to_match_distribution(
|
| 249 |
+
uniref50_sequences,
|
| 250 |
+
uniref50_lengths,
|
| 251 |
+
cas9_lengths,
|
| 252 |
+
target_size=args.target_size,
|
| 253 |
+
bin_edges=cas9_bin_edges
|
| 254 |
+
)
|
| 255 |
+
|
| 256 |
+
# Compare distributions
|
| 257 |
+
print("\n" + "="*60)
|
| 258 |
+
print("Distribution comparison:")
|
| 259 |
+
print("="*60)
|
| 260 |
+
sampled_bin_edges, sampled_counts, sampled_dist = compute_length_distribution(
|
| 261 |
+
sampled_lengths, bins=cas9_bin_edges
|
| 262 |
+
)
|
| 263 |
+
|
| 264 |
+
print(f"\nCas9 distribution:")
|
| 265 |
+
print(f" Total sequences: {len(cas9_sequences)}")
|
| 266 |
+
print(f" Mean length: {np.mean(cas9_lengths):.1f}")
|
| 267 |
+
print(f" Std length: {np.std(cas9_lengths):.1f}")
|
| 268 |
+
|
| 269 |
+
print(f"\nSampled uniref50 distribution:")
|
| 270 |
+
print(f" Total sequences: {len(sampled_sequences)}")
|
| 271 |
+
print(f" Mean length: {np.mean(sampled_lengths):.1f}")
|
| 272 |
+
print(f" Std length: {np.std(sampled_lengths):.1f}")
|
| 273 |
+
|
| 274 |
+
# Compute distribution similarity (KL divergence or correlation)
|
| 275 |
+
# Remove zero bins for comparison
|
| 276 |
+
mask = (cas9_counts > 0) & (sampled_counts > 0)
|
| 277 |
+
if mask.sum() > 0:
|
| 278 |
+
cas9_dist_filtered = cas9_dist[mask]
|
| 279 |
+
sampled_dist_filtered = sampled_dist[mask]
|
| 280 |
+
# Normalize filtered distributions
|
| 281 |
+
cas9_dist_filtered = cas9_dist_filtered / cas9_dist_filtered.sum()
|
| 282 |
+
sampled_dist_filtered = sampled_dist_filtered / sampled_dist_filtered.sum()
|
| 283 |
+
|
| 284 |
+
# Compute correlation
|
| 285 |
+
correlation = np.corrcoef(cas9_dist_filtered, sampled_dist_filtered)[0, 1]
|
| 286 |
+
print(f"\nDistribution correlation: {correlation:.4f}")
|
| 287 |
+
|
| 288 |
+
# Write output
|
| 289 |
+
write_fasta(sampled_sequences, args.output_fasta, "sampled uniref50")
|
| 290 |
+
|
| 291 |
+
print("\n" + "="*60)
|
| 292 |
+
print("Done!")
|
| 293 |
+
print("="*60)
|
| 294 |
+
|
| 295 |
+
|
| 296 |
+
if __name__ == "__main__":
|
| 297 |
+
main()
|
|
@@ -0,0 +1,506 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Script to construct a dataset of uniref50 sequences that matches the
|
| 4 |
+
sequence length distribution found in a cas9 FASTA file.
|
| 5 |
+
|
| 6 |
+
This variation:
|
| 7 |
+
1. Filters uniref sequences to only include those between cas9 min and max length
|
| 8 |
+
2. Removes redundancy within the filtered sequences at 80% identity using mmseqs2
|
| 9 |
+
3. Uses the remaining sequences to match the cas9 length distribution
|
| 10 |
+
|
| 11 |
+
The script prioritizes matching the length distribution over matching
|
| 12 |
+
a specified dataset size exactly.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
import argparse
|
| 16 |
+
import sys
|
| 17 |
+
import os
|
| 18 |
+
import subprocess
|
| 19 |
+
import tempfile
|
| 20 |
+
import shutil
|
| 21 |
+
from collections import Counter, defaultdict
|
| 22 |
+
from Bio import SeqIO
|
| 23 |
+
import numpy as np
|
| 24 |
+
from tqdm import tqdm
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def load_sequences(fasta_path, description=""):
|
| 28 |
+
"""Load sequences from a FASTA file."""
|
| 29 |
+
sequences = []
|
| 30 |
+
lengths = []
|
| 31 |
+
print(f"Loading {description} sequences from {fasta_path}...")
|
| 32 |
+
|
| 33 |
+
for record in tqdm(SeqIO.parse(fasta_path, "fasta"), desc=f"Reading {description}"):
|
| 34 |
+
seq = str(record.seq).upper()
|
| 35 |
+
# Skip empty sequences
|
| 36 |
+
if not seq:
|
| 37 |
+
continue
|
| 38 |
+
# Filter out sequences with non-natural amino acids
|
| 39 |
+
if all(aa in "ACDEFGHIKLMNPQRSTVWY" for aa in seq):
|
| 40 |
+
sequences.append(seq)
|
| 41 |
+
lengths.append(len(seq))
|
| 42 |
+
|
| 43 |
+
print(f"Loaded {len(sequences)} {description} sequences")
|
| 44 |
+
if sequences:
|
| 45 |
+
print(f" Length range: {min(lengths)} - {max(lengths)}")
|
| 46 |
+
print(f" Average length: {sum(lengths) / len(lengths):.1f}")
|
| 47 |
+
|
| 48 |
+
return sequences, lengths
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def filter_and_write_fasta(input_fasta, output_fasta, min_length, max_length, description=""):
|
| 52 |
+
"""
|
| 53 |
+
Filter sequences by length range and write to output FASTA file.
|
| 54 |
+
|
| 55 |
+
Args:
|
| 56 |
+
input_fasta: Input FASTA file path
|
| 57 |
+
output_fasta: Output FASTA file path
|
| 58 |
+
min_length: Minimum sequence length (inclusive)
|
| 59 |
+
max_length: Maximum sequence length (inclusive)
|
| 60 |
+
description: Description for progress messages
|
| 61 |
+
|
| 62 |
+
Returns:
|
| 63 |
+
Number of sequences written
|
| 64 |
+
"""
|
| 65 |
+
print(f"\nFiltering {description} sequences to length range [{min_length}, {max_length}]...")
|
| 66 |
+
|
| 67 |
+
filtered_count = 0
|
| 68 |
+
with open(output_fasta, 'w') as out_handle:
|
| 69 |
+
for record in tqdm(SeqIO.parse(input_fasta, "fasta"), desc=f"Filtering {description}"):
|
| 70 |
+
seq = str(record.seq).upper()
|
| 71 |
+
# Skip empty sequences
|
| 72 |
+
if not seq:
|
| 73 |
+
continue
|
| 74 |
+
# Filter out sequences with non-natural amino acids
|
| 75 |
+
if not all(aa in "ACDEFGHIKLMNPQRSTVWY" for aa in seq):
|
| 76 |
+
continue
|
| 77 |
+
# Filter by length
|
| 78 |
+
seq_len = len(seq)
|
| 79 |
+
if min_length <= seq_len <= max_length:
|
| 80 |
+
SeqIO.write(record, out_handle, "fasta")
|
| 81 |
+
filtered_count += 1
|
| 82 |
+
|
| 83 |
+
print(f"Filtered {filtered_count} sequences to length range [{min_length}, {max_length}]")
|
| 84 |
+
return filtered_count
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def remove_redundancy_mmseqs(input_fasta, output_fasta, identity_threshold=0.8, tmp_dir=None):
|
| 88 |
+
"""
|
| 89 |
+
Remove redundancy using mmseqs2 clustering.
|
| 90 |
+
|
| 91 |
+
Args:
|
| 92 |
+
input_fasta: Input FASTA file
|
| 93 |
+
output_fasta: Output FASTA file (representative sequences)
|
| 94 |
+
identity_threshold: Sequence identity threshold (0.0-1.0)
|
| 95 |
+
tmp_dir: Temporary directory for mmseqs2 files (if None, uses tempfile)
|
| 96 |
+
|
| 97 |
+
Returns:
|
| 98 |
+
Number of sequences before and after clustering
|
| 99 |
+
"""
|
| 100 |
+
print(f"\n{'='*60}")
|
| 101 |
+
print(f"Removing redundancy using mmseqs2 at {identity_threshold*100:.0f}% identity...")
|
| 102 |
+
print(f"{'='*60}")
|
| 103 |
+
|
| 104 |
+
# Count input sequences
|
| 105 |
+
input_count = sum(1 for _ in SeqIO.parse(input_fasta, "fasta"))
|
| 106 |
+
print(f"Input sequences: {input_count}")
|
| 107 |
+
|
| 108 |
+
# Create temporary directory if not provided
|
| 109 |
+
if tmp_dir is None:
|
| 110 |
+
tmp_dir = tempfile.mkdtemp(prefix="mmseqs_")
|
| 111 |
+
cleanup_tmp = True
|
| 112 |
+
else:
|
| 113 |
+
cleanup_tmp = False
|
| 114 |
+
os.makedirs(tmp_dir, exist_ok=True)
|
| 115 |
+
|
| 116 |
+
try:
|
| 117 |
+
db_path = os.path.join(tmp_dir, "db")
|
| 118 |
+
cluster_path = os.path.join(tmp_dir, "cluster")
|
| 119 |
+
cluster_rep_path = os.path.join(tmp_dir, "cluster_rep")
|
| 120 |
+
|
| 121 |
+
# Step 1: Create database
|
| 122 |
+
print("\nStep 1: Creating mmseqs2 database...")
|
| 123 |
+
cmd = ['mmseqs', 'createdb', input_fasta, db_path]
|
| 124 |
+
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
| 125 |
+
|
| 126 |
+
# Step 2: Cluster sequences
|
| 127 |
+
print(f"\nStep 2: Clustering at {identity_threshold*100:.0f}% identity...")
|
| 128 |
+
cmd = [
|
| 129 |
+
'mmseqs', 'cluster',
|
| 130 |
+
db_path,
|
| 131 |
+
cluster_path,
|
| 132 |
+
tmp_dir,
|
| 133 |
+
'--min-seq-id', str(identity_threshold)
|
| 134 |
+
# Note: mmseqs2 uses all available threads by default
|
| 135 |
+
]
|
| 136 |
+
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
| 137 |
+
|
| 138 |
+
# Step 3: Extract representative sequences
|
| 139 |
+
print("\nStep 3: Extracting representative sequences...")
|
| 140 |
+
cmd = ['mmseqs', 'createsubdb', cluster_path, db_path, cluster_rep_path]
|
| 141 |
+
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
| 142 |
+
|
| 143 |
+
# Step 4: Convert to FASTA
|
| 144 |
+
print("\nStep 4: Converting to FASTA...")
|
| 145 |
+
cmd = ['mmseqs', 'convert2fasta', cluster_rep_path, output_fasta]
|
| 146 |
+
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
| 147 |
+
|
| 148 |
+
# Count output sequences
|
| 149 |
+
output_count = sum(1 for _ in SeqIO.parse(output_fasta, "fasta"))
|
| 150 |
+
print(f"\nOutput sequences: {output_count}")
|
| 151 |
+
print(f"Redundancy removed: {input_count - output_count} sequences ({100*(input_count-output_count)/input_count:.2f}%)")
|
| 152 |
+
|
| 153 |
+
return input_count, output_count
|
| 154 |
+
|
| 155 |
+
except subprocess.CalledProcessError as e:
|
| 156 |
+
print(f"Error running mmseqs2: {e}", file=sys.stderr)
|
| 157 |
+
print(f"stdout: {e.stdout}", file=sys.stderr)
|
| 158 |
+
print(f"stderr: {e.stderr}", file=sys.stderr)
|
| 159 |
+
raise
|
| 160 |
+
except FileNotFoundError:
|
| 161 |
+
print("Error: mmseqs2 not found in PATH. Please install mmseqs2.", file=sys.stderr)
|
| 162 |
+
raise
|
| 163 |
+
finally:
|
| 164 |
+
# Cleanup temporary directory
|
| 165 |
+
if cleanup_tmp and os.path.exists(tmp_dir):
|
| 166 |
+
shutil.rmtree(tmp_dir)
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
def compute_length_distribution(lengths, bins=None):
|
| 170 |
+
"""
|
| 171 |
+
Compute the distribution of sequence lengths.
|
| 172 |
+
|
| 173 |
+
Args:
|
| 174 |
+
lengths: List of sequence lengths
|
| 175 |
+
bins: Optional list of bin edges. If None, uses histogram bins.
|
| 176 |
+
|
| 177 |
+
Returns:
|
| 178 |
+
bin_edges: List of bin edges
|
| 179 |
+
counts: List of counts for each bin
|
| 180 |
+
normalized_dist: Normalized distribution (probabilities)
|
| 181 |
+
"""
|
| 182 |
+
if bins is None:
|
| 183 |
+
# Use histogram to automatically determine bins
|
| 184 |
+
counts, bin_edges = np.histogram(lengths, bins='auto')
|
| 185 |
+
else:
|
| 186 |
+
counts, bin_edges = np.histogram(lengths, bins=bins)
|
| 187 |
+
|
| 188 |
+
# Normalize to get probability distribution
|
| 189 |
+
total = sum(counts)
|
| 190 |
+
normalized_dist = counts / total if total > 0 else counts
|
| 191 |
+
|
| 192 |
+
return bin_edges, counts, normalized_dist
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
def group_sequences_by_length(sequences):
|
| 196 |
+
"""Group sequences by their length."""
|
| 197 |
+
length_groups = defaultdict(list)
|
| 198 |
+
for seq in sequences:
|
| 199 |
+
length_groups[len(seq)].append(seq)
|
| 200 |
+
return length_groups
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
def sample_to_match_distribution(
|
| 204 |
+
source_sequences,
|
| 205 |
+
source_lengths,
|
| 206 |
+
target_lengths,
|
| 207 |
+
target_size=None,
|
| 208 |
+
bin_edges=None
|
| 209 |
+
):
|
| 210 |
+
"""
|
| 211 |
+
Sample sequences from source_sequences to match the length distribution
|
| 212 |
+
of target_lengths.
|
| 213 |
+
|
| 214 |
+
Args:
|
| 215 |
+
source_sequences: List of sequences to sample from
|
| 216 |
+
source_lengths: List of lengths corresponding to source_sequences
|
| 217 |
+
target_lengths: List of lengths to match the distribution of
|
| 218 |
+
target_size: Optional target number of sequences (approximate)
|
| 219 |
+
bin_edges: Optional bin edges for histogram (if None, computed from target)
|
| 220 |
+
|
| 221 |
+
Returns:
|
| 222 |
+
sampled_sequences: List of sampled sequences
|
| 223 |
+
sampled_lengths: List of lengths of sampled sequences
|
| 224 |
+
"""
|
| 225 |
+
# Compute target distribution
|
| 226 |
+
if bin_edges is None:
|
| 227 |
+
target_bin_edges, target_counts, target_dist = compute_length_distribution(target_lengths)
|
| 228 |
+
else:
|
| 229 |
+
_, target_counts, target_dist = compute_length_distribution(target_lengths, bins=bin_edges)
|
| 230 |
+
target_bin_edges = bin_edges
|
| 231 |
+
|
| 232 |
+
print(f"\nTarget distribution:")
|
| 233 |
+
print(f" Number of bins: {len(target_dist)}")
|
| 234 |
+
print(f" Bin edges: {target_bin_edges[:5]}...{target_bin_edges[-5:]}")
|
| 235 |
+
|
| 236 |
+
# Group source sequences by length
|
| 237 |
+
print("\nGrouping source sequences by length...")
|
| 238 |
+
length_groups = group_sequences_by_length(source_sequences)
|
| 239 |
+
print(f" Found {len(length_groups)} unique lengths")
|
| 240 |
+
|
| 241 |
+
# Determine how many sequences to sample per bin
|
| 242 |
+
if target_size is None:
|
| 243 |
+
# Use the same counts as target
|
| 244 |
+
target_counts_scaled = target_counts.copy()
|
| 245 |
+
else:
|
| 246 |
+
# Scale the distribution to approximately match target_size
|
| 247 |
+
total_target = sum(target_counts)
|
| 248 |
+
scale_factor = target_size / total_target if total_target > 0 else 1.0
|
| 249 |
+
target_counts_scaled = (target_counts * scale_factor).astype(int)
|
| 250 |
+
# Ensure we have at least 1 sequence per non-zero bin
|
| 251 |
+
target_counts_scaled = np.maximum(target_counts_scaled, (target_counts > 0).astype(int))
|
| 252 |
+
|
| 253 |
+
print(f"\nTarget sample size: {sum(target_counts_scaled)} sequences")
|
| 254 |
+
|
| 255 |
+
# Sample sequences for each bin
|
| 256 |
+
sampled_sequences = []
|
| 257 |
+
sampled_lengths = []
|
| 258 |
+
|
| 259 |
+
print("\nSampling sequences to match distribution...")
|
| 260 |
+
for i in tqdm(range(len(target_dist)), desc="Processing bins"):
|
| 261 |
+
bin_start = int(target_bin_edges[i])
|
| 262 |
+
bin_end = int(target_bin_edges[i + 1])
|
| 263 |
+
n_samples = int(target_counts_scaled[i])
|
| 264 |
+
|
| 265 |
+
if n_samples == 0:
|
| 266 |
+
continue
|
| 267 |
+
|
| 268 |
+
# Find all source sequences in this length range
|
| 269 |
+
candidates = []
|
| 270 |
+
for length in range(bin_start, bin_end + 1):
|
| 271 |
+
if length in length_groups:
|
| 272 |
+
candidates.extend(length_groups[length])
|
| 273 |
+
|
| 274 |
+
if not candidates:
|
| 275 |
+
# If no exact match, try to find sequences close to this bin
|
| 276 |
+
# Find the closest available lengths
|
| 277 |
+
available_lengths = sorted(length_groups.keys())
|
| 278 |
+
for length in available_lengths:
|
| 279 |
+
if bin_start <= length <= bin_end:
|
| 280 |
+
candidates.extend(length_groups[length])
|
| 281 |
+
break
|
| 282 |
+
|
| 283 |
+
if not candidates:
|
| 284 |
+
# Still no candidates, try to find sequences in nearby bins
|
| 285 |
+
bin_center = (bin_start + bin_end) / 2
|
| 286 |
+
closest_length = min(available_lengths, key=lambda x: abs(x - bin_center))
|
| 287 |
+
candidates.extend(length_groups[closest_length])
|
| 288 |
+
|
| 289 |
+
# Sample from candidates
|
| 290 |
+
if len(candidates) >= n_samples:
|
| 291 |
+
sampled = np.random.choice(candidates, size=n_samples, replace=False).tolist()
|
| 292 |
+
else:
|
| 293 |
+
# Not enough candidates, sample with replacement
|
| 294 |
+
sampled = np.random.choice(candidates, size=n_samples, replace=True).tolist()
|
| 295 |
+
|
| 296 |
+
sampled_sequences.extend(sampled)
|
| 297 |
+
sampled_lengths.extend([len(seq) for seq in sampled])
|
| 298 |
+
|
| 299 |
+
return sampled_sequences, sampled_lengths
|
| 300 |
+
|
| 301 |
+
|
| 302 |
+
def write_fasta(sequences, output_path, description="sequences"):
|
| 303 |
+
"""Write sequences to a FASTA file."""
|
| 304 |
+
print(f"\nWriting {len(sequences)} {description} to {output_path}...")
|
| 305 |
+
with open(output_path, 'w') as f:
|
| 306 |
+
for i, seq in enumerate(sequences):
|
| 307 |
+
f.write(f">sequence_{i+1}\n")
|
| 308 |
+
f.write(f"{seq}\n")
|
| 309 |
+
print(f"Saved to {output_path}")
|
| 310 |
+
|
| 311 |
+
|
| 312 |
+
def main():
|
| 313 |
+
parser = argparse.ArgumentParser(
|
| 314 |
+
description="Match uniref50 sequence length distribution to cas9 distribution (with filtering and redundancy removal)"
|
| 315 |
+
)
|
| 316 |
+
parser.add_argument(
|
| 317 |
+
'cas9_fasta',
|
| 318 |
+
type=str,
|
| 319 |
+
help='Path to FASTA file containing cas9 sequences'
|
| 320 |
+
)
|
| 321 |
+
parser.add_argument(
|
| 322 |
+
'uniref50_fasta',
|
| 323 |
+
type=str,
|
| 324 |
+
help='Path to FASTA file containing uniref50 sequences'
|
| 325 |
+
)
|
| 326 |
+
parser.add_argument(
|
| 327 |
+
'output_fasta',
|
| 328 |
+
type=str,
|
| 329 |
+
help='Path to output FASTA file'
|
| 330 |
+
)
|
| 331 |
+
parser.add_argument(
|
| 332 |
+
'--target-size',
|
| 333 |
+
type=int,
|
| 334 |
+
default=None,
|
| 335 |
+
help='Approximate target dataset size (distribution matching takes precedence)'
|
| 336 |
+
)
|
| 337 |
+
parser.add_argument(
|
| 338 |
+
'--identity',
|
| 339 |
+
type=float,
|
| 340 |
+
default=0.8,
|
| 341 |
+
help='Sequence identity threshold for redundancy removal (default: 0.8)'
|
| 342 |
+
)
|
| 343 |
+
parser.add_argument(
|
| 344 |
+
'--tmp-dir',
|
| 345 |
+
type=str,
|
| 346 |
+
default=None,
|
| 347 |
+
help='Temporary directory for intermediate files (default: auto)'
|
| 348 |
+
)
|
| 349 |
+
parser.add_argument(
|
| 350 |
+
'--seed',
|
| 351 |
+
type=int,
|
| 352 |
+
default=42,
|
| 353 |
+
help='Random seed for reproducibility (default: 42)'
|
| 354 |
+
)
|
| 355 |
+
|
| 356 |
+
args = parser.parse_args()
|
| 357 |
+
|
| 358 |
+
# Set random seed
|
| 359 |
+
np.random.seed(args.seed)
|
| 360 |
+
|
| 361 |
+
# Create temporary directory for intermediate files
|
| 362 |
+
if args.tmp_dir is None:
|
| 363 |
+
tmp_dir = tempfile.mkdtemp(prefix="match_cas9_")
|
| 364 |
+
cleanup_tmp = True
|
| 365 |
+
else:
|
| 366 |
+
tmp_dir = args.tmp_dir
|
| 367 |
+
cleanup_tmp = False
|
| 368 |
+
os.makedirs(tmp_dir, exist_ok=True)
|
| 369 |
+
|
| 370 |
+
try:
|
| 371 |
+
# Load cas9 sequences
|
| 372 |
+
print("\n" + "="*60)
|
| 373 |
+
print("Step 1: Loading cas9 sequences...")
|
| 374 |
+
print("="*60)
|
| 375 |
+
cas9_sequences, cas9_lengths = load_sequences(args.cas9_fasta, "cas9")
|
| 376 |
+
if not cas9_sequences:
|
| 377 |
+
print("Error: No valid cas9 sequences found!", file=sys.stderr)
|
| 378 |
+
sys.exit(1)
|
| 379 |
+
|
| 380 |
+
# Get cas9 length range
|
| 381 |
+
cas9_min_length = min(cas9_lengths)
|
| 382 |
+
cas9_max_length = max(cas9_lengths)
|
| 383 |
+
print(f"\nCas9 length range: {cas9_min_length} - {cas9_max_length}")
|
| 384 |
+
|
| 385 |
+
# Filter uniref sequences by length range
|
| 386 |
+
print("\n" + "="*60)
|
| 387 |
+
print("Step 2: Filtering uniref sequences by length range...")
|
| 388 |
+
print("="*60)
|
| 389 |
+
filtered_fasta = os.path.join(tmp_dir, "uniref_filtered.fasta")
|
| 390 |
+
filtered_count = filter_and_write_fasta(
|
| 391 |
+
args.uniref50_fasta,
|
| 392 |
+
filtered_fasta,
|
| 393 |
+
cas9_min_length,
|
| 394 |
+
cas9_max_length,
|
| 395 |
+
"uniref50"
|
| 396 |
+
)
|
| 397 |
+
|
| 398 |
+
if filtered_count == 0:
|
| 399 |
+
print("Error: No uniref sequences found in the specified length range!", file=sys.stderr)
|
| 400 |
+
sys.exit(1)
|
| 401 |
+
|
| 402 |
+
# Remove redundancy from filtered sequences
|
| 403 |
+
print("\n" + "="*60)
|
| 404 |
+
print("Step 3: Removing redundancy from filtered sequences...")
|
| 405 |
+
print("="*60)
|
| 406 |
+
dedup_fasta = os.path.join(tmp_dir, "uniref_dedup.fasta")
|
| 407 |
+
input_count, output_count = remove_redundancy_mmseqs(
|
| 408 |
+
filtered_fasta,
|
| 409 |
+
dedup_fasta,
|
| 410 |
+
identity_threshold=args.identity,
|
| 411 |
+
tmp_dir=os.path.join(tmp_dir, "mmseqs_tmp")
|
| 412 |
+
)
|
| 413 |
+
|
| 414 |
+
# Load deduplicated sequences
|
| 415 |
+
print("\n" + "="*60)
|
| 416 |
+
print("Step 4: Loading deduplicated sequences...")
|
| 417 |
+
print("="*60)
|
| 418 |
+
uniref50_sequences, uniref50_lengths = load_sequences(dedup_fasta, "uniref50 (deduplicated)")
|
| 419 |
+
if not uniref50_sequences:
|
| 420 |
+
print("Error: No valid deduplicated uniref50 sequences found!", file=sys.stderr)
|
| 421 |
+
sys.exit(1)
|
| 422 |
+
|
| 423 |
+
# Compute cas9 length distribution
|
| 424 |
+
print("\n" + "="*60)
|
| 425 |
+
print("Step 5: Analyzing cas9 length distribution...")
|
| 426 |
+
print("="*60)
|
| 427 |
+
cas9_bin_edges, cas9_counts, cas9_dist = compute_length_distribution(cas9_lengths)
|
| 428 |
+
print(f"Cas9 distribution statistics:")
|
| 429 |
+
print(f" Total sequences: {len(cas9_sequences)}")
|
| 430 |
+
print(f" Number of bins: {len(cas9_dist)}")
|
| 431 |
+
print(f" Length range: {min(cas9_lengths)} - {max(cas9_lengths)}")
|
| 432 |
+
|
| 433 |
+
# Sample uniref50 sequences to match cas9 distribution
|
| 434 |
+
print("\n" + "="*60)
|
| 435 |
+
print("Step 6: Sampling uniref50 sequences to match cas9 distribution...")
|
| 436 |
+
print("="*60)
|
| 437 |
+
sampled_sequences, sampled_lengths = sample_to_match_distribution(
|
| 438 |
+
uniref50_sequences,
|
| 439 |
+
uniref50_lengths,
|
| 440 |
+
cas9_lengths,
|
| 441 |
+
target_size=args.target_size,
|
| 442 |
+
bin_edges=cas9_bin_edges
|
| 443 |
+
)
|
| 444 |
+
|
| 445 |
+
# Compare distributions
|
| 446 |
+
print("\n" + "="*60)
|
| 447 |
+
print("Distribution comparison:")
|
| 448 |
+
print("="*60)
|
| 449 |
+
sampled_bin_edges, sampled_counts, sampled_dist = compute_length_distribution(
|
| 450 |
+
sampled_lengths, bins=cas9_bin_edges
|
| 451 |
+
)
|
| 452 |
+
|
| 453 |
+
print(f"\nCas9 distribution:")
|
| 454 |
+
print(f" Total sequences: {len(cas9_sequences)}")
|
| 455 |
+
print(f" Mean length: {np.mean(cas9_lengths):.1f}")
|
| 456 |
+
print(f" Std length: {np.std(cas9_lengths):.1f}")
|
| 457 |
+
|
| 458 |
+
print(f"\nSampled uniref50 distribution:")
|
| 459 |
+
print(f" Total sequences: {len(sampled_sequences)}")
|
| 460 |
+
print(f" Mean length: {np.mean(sampled_lengths):.1f}")
|
| 461 |
+
print(f" Std length: {np.std(sampled_lengths):.1f}")
|
| 462 |
+
|
| 463 |
+
# Compute distribution similarity (correlation)
|
| 464 |
+
# Remove zero bins for comparison
|
| 465 |
+
mask = (cas9_counts > 0) & (sampled_counts > 0)
|
| 466 |
+
if mask.sum() > 0:
|
| 467 |
+
cas9_dist_filtered = cas9_dist[mask]
|
| 468 |
+
sampled_dist_filtered = sampled_dist[mask]
|
| 469 |
+
# Normalize filtered distributions
|
| 470 |
+
cas9_dist_filtered = cas9_dist_filtered / cas9_dist_filtered.sum()
|
| 471 |
+
sampled_dist_filtered = sampled_dist_filtered / sampled_dist_filtered.sum()
|
| 472 |
+
|
| 473 |
+
# Compute correlation
|
| 474 |
+
correlation = np.corrcoef(cas9_dist_filtered, sampled_dist_filtered)[0, 1]
|
| 475 |
+
print(f"\nDistribution correlation: {correlation:.4f}")
|
| 476 |
+
|
| 477 |
+
# Write output
|
| 478 |
+
print("\n" + "="*60)
|
| 479 |
+
print("Step 7: Writing final output...")
|
| 480 |
+
print("="*60)
|
| 481 |
+
write_fasta(sampled_sequences, args.output_fasta, "sampled uniref50")
|
| 482 |
+
|
| 483 |
+
# Summary
|
| 484 |
+
print("\n" + "="*60)
|
| 485 |
+
print("SUMMARY")
|
| 486 |
+
print("="*60)
|
| 487 |
+
print(f"Cas9 sequences: {len(cas9_sequences)}")
|
| 488 |
+
print(f"Cas9 length range: {cas9_min_length} - {cas9_max_length}")
|
| 489 |
+
print(f"Uniref sequences after length filtering: {filtered_count}")
|
| 490 |
+
print(f"Uniref sequences after redundancy removal ({args.identity*100:.0f}%): {output_count}")
|
| 491 |
+
print(f"Final sampled sequences: {len(sampled_sequences)}")
|
| 492 |
+
print("="*60)
|
| 493 |
+
|
| 494 |
+
finally:
|
| 495 |
+
# Cleanup temporary directory
|
| 496 |
+
if cleanup_tmp and os.path.exists(tmp_dir):
|
| 497 |
+
print(f"\nCleaning up temporary directory: {tmp_dir}")
|
| 498 |
+
shutil.rmtree(tmp_dir)
|
| 499 |
+
|
| 500 |
+
print("\n" + "="*60)
|
| 501 |
+
print("Done!")
|
| 502 |
+
print("="*60)
|
| 503 |
+
|
| 504 |
+
|
| 505 |
+
if __name__ == "__main__":
|
| 506 |
+
main()
|
|
@@ -0,0 +1,218 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
>sacas9 len=1053
|
| 2 |
+
MKRNYILGLDIGITSVGYGIIDYETRDVIDAGVRLFKEANVENNEGRRSKRGARRLKRRR
|
| 3 |
+
RHRIQRVKKLLFDYNLLTDHSELSGINPYEARVKGLSQKLSEEEFSAALLHLAKRRGVHN
|
| 4 |
+
VNEVEEDTGNELSTKEQISRNSKALEEKYVAELQLERLKKDGEVRGSINRFKTSDYVKEA
|
| 5 |
+
KQLLKVQKAYHQLDQSFIDTYIDLLETRRTYYEGPGEGSPFGWKDIKEWYEMLMGHCTYF
|
| 6 |
+
PEELRSVKYAYNADLYNALNDLNNLVITRDENEKLEYYEKFQIIENVFKQKKKPTLKQIA
|
| 7 |
+
KEILVNEEDIKGYRVTSTGKPEFTNLKVYHDIKDITARKEIIENAELLDQIAKILTIYQS
|
| 8 |
+
SEDIQEELTNLNSELTQEEIEQISNLKGYTGTHNLSLKAINLILDELWHTNDNQIAIFNR
|
| 9 |
+
LKLVPKKVDLSQQKEIPTTLVDDFILSPVVKRSFIQSIKVINAIIKKYGLPNDIIIELAR
|
| 10 |
+
EKNSKDAQKMINEMQKRNRQTNERIEEIIRTTGKENAKYLIEKIKLHDMQEGKCLYSLEA
|
| 11 |
+
IPLEDLLNNPFNYEVDHIIPRSVSFDNSFNNKVLVKQEENSKKGNRTPFQYLSSSDSKIS
|
| 12 |
+
YETFKKHILNLAKGKGRISKTKKEYLLEERDINRFSVQKDFINRNLVDTRYATRGLMNLL
|
| 13 |
+
RSYFRVNNLDVKVKSINGGFTSFLRRKWKFKKERNKGYKHHAEDALIIANADFIFKEWKK
|
| 14 |
+
LDKAKKVMENQMFEEKQAESMPEIETEQEYKEIFITPHQIKHIKDFKDYKYSHRVDKKPN
|
| 15 |
+
RELINDTLYSTRKDDKGNTLIVNNLNGLYDKDNDKLKKLINKSPEKLLMYHHDPQTYQKL
|
| 16 |
+
KLIMEQYGDEKNPLYKYYEETGNYLTKYSKKDNGPVIKKIKYYGNKLNAHLDITDDYPNS
|
| 17 |
+
RNKVVKLSLKPYRFDVYLDNGVYKFVTVKNLDVIKKENYYEVNSKCYEEAKKLKKISNQA
|
| 18 |
+
EFIASFYNNDLIKINGELYRVIGVNNDLLNRIEVNMIDITYREYLENMNDKRPPRIIKTI
|
| 19 |
+
ASKTQSIKKYSTDILGNLYEVKSKKHPQIIKKG
|
| 20 |
+
>cjcas9 len=984
|
| 21 |
+
MARILAFDIGISSIGWAFSENDELKDCGVRIFTKVENPKTGESLALPRRLARSARKRLAR
|
| 22 |
+
RKARLNHLKHLIANEFKLNYEDYQSFDESLAKAYKGSLISPYELRFRALNELLSKQDFAR
|
| 23 |
+
VILHIAKRRGYDDIKNSDDKEKGAILKAIKQNEEKLANYQSVGEYLYKEYFQKFKENSKE
|
| 24 |
+
FTNVRNKKESYERCIAQSFLKDELKLIFKKQREFGFSFSKKFEEEVLSVAFYKRALKDFS
|
| 25 |
+
HLVGNCSFFTDEKRAPKNSPLAFMFVALTRIINLLNNLKNTEGILYTKDDLNALLNEVLK
|
| 26 |
+
NGTLTYKQTKKLLGLSDDYEFKGEKGTYFIEFKKYKEFIKALGEHNLSQDDLNEIAKDIT
|
| 27 |
+
LIKDEIKLKKALAKYDLNQNQIDSLSKLEFKDHLNISFKALKLVTPLMLEGKKYDEACNE
|
| 28 |
+
LNLKVAINEDKKDFLPAFNETYYKDEVTNPVVLRAIKEYRKVLNALLKKYGKVHKINIEL
|
| 29 |
+
AREVGKNHSQRAKIEKEQNENYKAKKDAELECEKLGLKINSKNILKLRLFKEQKEFCAYS
|
| 30 |
+
GEKIKISDLQDEKMLEIDHIYPYSRSFDDSYMNKVLVFTKQNQEKLNQTPFEAFGNDSAK
|
| 31 |
+
WQKIEVLAKNLPTKKQKRILDKNYKDKEQKNFKDRNLNDTRYIARLVLNYTKDYLDFLPL
|
| 32 |
+
SDDENTKLNDTQKGSKVHVEAKSGMLTSALRHTWGFSAKDRNNHLHHAIDAVIIAYANNS
|
| 33 |
+
IVKAFSDFKKEQESNSAELYAKKISELDYKNKRKFFEPFSGFRQKVLDKIDEIFVSKPER
|
| 34 |
+
KKPSGALHEETFRKEEEFYQSYGGKEGVLKALELGKIRKVNGKIVKNGDMFRVDIFKHKK
|
| 35 |
+
TNKFYAVPIYTMDFALKVLPNKAVARSKKGEIKDWILMDENYEFCFSLYKDSLILIQTKD
|
| 36 |
+
MQEPEFVYYNAFTSSTVSLIVSKHDNKFETLSKNQKILFKNANEKEVIAKSIGIQNLKVF
|
| 37 |
+
EKYIVSALGEVTKAEFRQREDFKK
|
| 38 |
+
>spcas9 len=1368
|
| 39 |
+
MDKKYSIGLDIGTNSVGWAVITDEYKVPSKKFKVLGNTDRHSIKKNLIGALLFDSGETAE
|
| 40 |
+
ATRLKRTARRRYTRRKNRICYLQEIFSNEMAKVDDSFFHRLEESFLVEEDKKHERHPIFG
|
| 41 |
+
NIVDEVAYHEKYPTIYHLRKKLVDSTDKADLRLIYLALAHMIKFRGHFLIEGDLNPDNSD
|
| 42 |
+
VDKLFIQLVQTYNQLFEENPINASGVDAKAILSARLSKSRRLENLIAQLPGEKKNGLFGN
|
| 43 |
+
LIALSLGLTPNFKSNFDLAEDAKLQLSKDTYDDDLDNLLAQIGDQYADLFLAAKNLSDAI
|
| 44 |
+
LLSDILRVNTEITKAPLSASMIKRYDEHHQDLTLLKALVRQQLPEKYKEIFFDQSKNGYA
|
| 45 |
+
GYIDGGASQEEFYKFIKPILEKMDGTEELLVKLNREDLLRKQRTFDNGSIPHQIHLGELH
|
| 46 |
+
AILRRQEDFYPFLKDNREKIEKILTFRIPYYVGPLARGNSRFAWMTRKSEETITPWNFEE
|
| 47 |
+
VVDKGASAQSFIERMTNFDKNLPNEKVLPKHSLLYEYFTVYNELTKVKYVTEGMRKPAFL
|
| 48 |
+
SGEQKKAIVDLLFKTNRKVTVKQLKEDYFKKIECFDSVEISGVEDRFNASLGTYHDLLKI
|
| 49 |
+
IKDKDFLDNEENEDILEDIVLTLTLFEDREMIEERLKTYAHLFDDKVMKQLKRRRYTGWG
|
| 50 |
+
RLSRKLINGIRDKQSGKTILDFLKSDGFANRNFMQLIHDDSLTFKEDIQKAQVSGQGDSL
|
| 51 |
+
HEHIANLAGSPAIKKGILQTVKVVDELVKVMGRHKPENIVIEMARENQTTQKGQKNSRER
|
| 52 |
+
MKRIEEGIKELGSQILKEHPVENTQLQNEKLYLYYLQNGRDMYVDQELDINRLSDYDVDH
|
| 53 |
+
IVPQSFLKDDSIDNKVLTRSDKNRGKSDNVPSEEVVKKMKNYWRQLLNAKLITQRKFDNL
|
| 54 |
+
TKAERGGLSELDKAGFIKRQLVETRQITKHVAQILDSRMNTKYDENDKLIREVKVITLKS
|
| 55 |
+
KLVSDFRKDFQFYKVREINNYHHAHDAYLNAVVGTALIKKYPKLESEFVYGDYKVYDVRK
|
| 56 |
+
MIAKSEQEIGKATAKYFFYSNIMNFFKTEITLANGEIRKRPLIETNGETGEIVWDKGRDF
|
| 57 |
+
ATVRKVLSMPQVNIVKKTEVQTGGFSKESILPKRNSDKLIARKKDWDPKKYGGFDSPTVA
|
| 58 |
+
YSVLVVAKVEKGKSKKLKSVKELLGITIMERSSFEKNPIDFLEAKGYKEVKKDLIIKLPK
|
| 59 |
+
YSLFELENGRKRMLASAGELQKGNELALPSKYVNFLYLASHYEKLKGSPEDNEQKQLFVE
|
| 60 |
+
QHKHYLDEIIEQISEFSKRVILADANLDKVLSAYNKHRDKPIREQAENIIHLFTLTNLGA
|
| 61 |
+
PAAFKYFDTTIDRKRYTSTKEVLDATLIHQSITGLYETRIDLSQLGGD
|
| 62 |
+
>st3cas9 len=1388
|
| 63 |
+
MTKPYSIGLDIGTNSVGWAVTTDNYKVPSKKMKVLGNTSKKYIKKNLLGVLLFDSGITAE
|
| 64 |
+
GRRLKRTARRRYTRRRNRILYLQEIFSTEMATLDDAFFQRLDDSFLVPDDKRDSKYPIFG
|
| 65 |
+
NLVEEKAYHDEFPTIYHLRKYLADSTKKADLRLVYLALAHMIKYRGHFLIEGEFNSKNND
|
| 66 |
+
IQKNFQDFLDTYNAIFESDLSLENSKQLEEIVKDKISKLEKKDRILKLFPGEKNSGIFSE
|
| 67 |
+
FLKLIVGNQADFRKCFNLDEKASLHFSKESYDEDLETLLGYIGDDYSDVFLKAKKLYDAI
|
| 68 |
+
LLSGFLTVTDNETEAPLSSAMIKRYNEHKEDLALLKEYIRNISLKTYNEVFKDDTKNGYA
|
| 69 |
+
GYIDGKTNQEDFYVYLKKLLAEFEGADYFLEKIDREDFLRKQRTFDNGSIPYQIHLQEMR
|
| 70 |
+
AILDKQAKFYPFLAKNKERIEKILTFRIPYYVGPLARGNSDFAWSIRKRNEKITPWNFED
|
| 71 |
+
VIDKESSAEAFINRMTSFDLYLPEEKVLPKHSLLYETFNVYNELTKVRFIAESMRDYQFL
|
| 72 |
+
DSKQKKDIVRLYFKDKRKVTDKDIIEYLHAIYGYDGIELKGIEKQFNSSLSTYHDLLNII
|
| 73 |
+
NDKEFLDDSSNEAIIEEIIHTLTIFEDREMIKQRLSKFENIFDKSVLKKLSRRHYTGWGK
|
| 74 |
+
LSAKLINGIRDEKSGNTILDYLIDDGISNRNFMQLIHDDALSFKKKIQKAQIIGDEDKGN
|
| 75 |
+
IKEVVKSLPGSPAIKKGILQSIKIVDELVKVMGGRKPESIVVEMARENQYTNQGKSNSQQ
|
| 76 |
+
RLKRLEKSLKELGSKILKENIPAKLSKIDNNALQNDRLYLYYLQNGKDMYTGDDLDIDRL
|
| 77 |
+
SNYDIDHIIPQAFLKDNSIDNKVLVSSASNRGKSDDVPSLEVVKKRKTFWYQLLKSKLIS
|
| 78 |
+
QRKFDNLTKAERGGLSPEDKAGFIQRQLVETRQITKHVARLLDEKFNNKKDENNRAVRTV
|
| 79 |
+
KIITLKSTLVSQFRKDFELYKVREINDFHHAHDAYLNAVVASALLKKYPKLEPEFVYGDY
|
| 80 |
+
PKYNSFRERKSATEKVYFYSNIMNIFKKSISLADGRVIERPLIEVNEETGESVWNKESDL
|
| 81 |
+
ATVRRVLSYPQVNVVKKVEEQNHGLDRGKPKGLFNANLSSKPKPNSNENLVGAKEYLDPK
|
| 82 |
+
KYGGYAGISNSFTVLVKGTIEKGAKKKITNVLEFQGISILDRINYRKDKLNFLLEKGYKD
|
| 83 |
+
IELIIELPKYSLFELSDGSRRMLASILSTNNKRGEIHKGNQIFLSQKFVKLLYHAKRISN
|
| 84 |
+
TINENHRKYVENHKKEFEELFYYILEFNENYVGAKKNGKLLNSAFQSWQNHSIDELCSSF
|
| 85 |
+
IGPTGSERKGLFELTSRGSAADFEFLGVKIPRYRDYTPSSLLKDATLIHQSVTGLYETRI
|
| 86 |
+
DLAKLGEG
|
| 87 |
+
>geocas9 len=1087
|
| 88 |
+
MRYKIGLDIGITSVGWAVMNLDIPRIEDLGVRIFDRAENPQTGESLALPRRLARSARRRL
|
| 89 |
+
RRRKHRLERIRRLVIREGILTKEELDKLFEEKHEIDVWQLRVEALDRKLNNDELARVLLH
|
| 90 |
+
LAKRRGFKSNRKSERSNKENSTMLKHIEENRAILSSYRTVGEMIVKDPKFALHKRNKGEN
|
| 91 |
+
YTNTIARDDLEREIRLIFSKQREFGNMSCTEEFENEYITIWASQRPVASKDDIEKKVGFC
|
| 92 |
+
TFEPKEKRAPKATYTFQSFIAWEHINKLRLISPSGARGLTDEERRLLYEQAFQKNKITYH
|
| 93 |
+
DIRTLLHLPDDTYFKGIVYDRGESRKQNENIRFLELDAYHQIRKAVDKVYGKGKSSSFLP
|
| 94 |
+
IDFDTFGYALTLFKDDADIHSYLRNEYEQNGKRMPNLANKVYDNELIEELLNLSFTKFGH
|
| 95 |
+
LSLKALRSILPYMEQGEVYSSACERAGYTFTGPKKKQKTMLLPNIPPIANPVVMRALTQA
|
| 96 |
+
RKVVNAIIKKYGSPVSIHIELARDLSQTFDERRKTKKEQDENRKKNETAIRQLMEYGLTL
|
| 97 |
+
NPTGHDIVKFKLWSEQNGRCAYSLQPIEIERLLEPGYVEVDHVIPYSRSLDDSYTNKVLV
|
| 98 |
+
LTRENREKGNRIPAEYLGVGTERWQQFETFVLTNKQFSKKKRDRLLRLHYDENEETEFKN
|
| 99 |
+
RNLNDTRYISRFFANFIREHLKFAESDDKQKVYTVNGRVTAHLRSRWEFNKNREESDLHH
|
| 100 |
+
AVDAVIVACTTPSDIAKVTAFYQRREQNKELAKKTEPHFPQPWPHFADELRARLSKHPKE
|
| 101 |
+
SIKALNLGNYDDQKLESLQPVFVSRMPKRSVTGAAHQETLRRYVGIDERSGKIQTVVKTK
|
| 102 |
+
LSEIKLDASGHFPMYGKESDPRTYEAIRQRLLEHNNDPKKAFQEPLYKPKKNGEPGPVIR
|
| 103 |
+
TVKIIDTKNQVIPLNDGKTVAYNSNIVRVDVFEKDGKYYCVPVYTMDIMKGILPNKAIEP
|
| 104 |
+
NKPYSEWKEMTEDYTFRFSLYPNDLIRIELPREKTVKTAAGEEINVKDVFVYYKTIDSAN
|
| 105 |
+
GGLELISHDHRFSLRGVGSRTLKRFEKYQVDVLGNIYKVRGEKRVGLASSAHSKPGKTIR
|
| 106 |
+
PLQSTRD
|
| 107 |
+
>nmcas9 len=1082
|
| 108 |
+
MAAFKPNPINYILGLDIGIASVGWAMVEIDEEENPIRLIDLGVRVFERAEVPKTGDSLAM
|
| 109 |
+
VRRLARSVRRLTRRRAHRLLRARRLLKREGVLQAADFDENGLIKSLPNTPWQLRAAALDR
|
| 110 |
+
KLTPLEWSAVLLHLIKHRGYLSQRKNEGETADKELGALLKGVADNAHALQTGDFRTPAEL
|
| 111 |
+
ALNKFEKESGHIRNQRGDYSHTFSRKDLQAELILLFEKQKEFGNPHISGGLKEGIETLLM
|
| 112 |
+
TQRPALSGDAVQKMLGHCTFEPAEPKAAKNTYTAERFIWLTKLNNLRILEQGSERPLTDT
|
| 113 |
+
ERATLMDEPYRKSKLTYAQARKLLGLEDTAFFKGLRYGKDNAEASTLMEMKAYHAISRAL
|
| 114 |
+
EKEGLKDKKSPLNLSPELQDEIGTAFSLFKTDEDITGRLKDRIQPEILEALLKHISFDKF
|
| 115 |
+
VQISLKALRRIVPLMEQGKRYDEACAEIYGDHYGKKNTEEKIYLPPIPADEIRNPVVLRA
|
| 116 |
+
LSQARKVINGVVRRYGSPARIHIETAREVGKSFKDRKEIEKRQEENRKDREKAAAKFREY
|
| 117 |
+
FPNFVGEPKSKDILKLRLYEQQHGKCLYSGKEINLGRLNEKGYVEIDHALPFSRTWDDSF
|
| 118 |
+
NNKVLVLGSENQNKGNQTPYEYFNGKDNSREWQEFKARVETSRFPRSKKQRILLQKFDED
|
| 119 |
+
GFKERNLNDTRYVNRFLCQFVADRMRLTGKGKKRVFASNGQITNLLRGFWGLRKVRAEND
|
| 120 |
+
RHHALDAVVVACSTVAMQQKITRFVRYKEMNAFDGKTIDKETGEVLHQKTHFPQPWEFFA
|
| 121 |
+
QEVMIRVFGKPDGKPEFEEADTPEKLRTLLAEKLSSRPEAVHEYVTPLFVSRAPNRKMSG
|
| 122 |
+
QGHMETVKSAKRLDEGVSVLRVPLTQLKLKDLEKMVNREREPKLYEALKARLEAHKDDPA
|
| 123 |
+
KAFAEPFYKYDKAGNRTQQVKAVRVEQVQKTGVWVRNHNGIADNATMVRVDVFEKGDKYY
|
| 124 |
+
LVPIYSWQVAKGILPDRAVVQGKDEEDWQLIDDSFNFKFSLHPNDLVEVITKKARMFGYF
|
| 125 |
+
ASCHRGTGNINIRIHDLDHKIGKNGILEGIGVKTALSFQKYQIDELGKEIRPCRLKKRPP
|
| 126 |
+
VR
|
| 127 |
+
>st1cas9 len=1121
|
| 128 |
+
MSDLVLGLDIGIGSVGVGILNKVTGEIIHKNSRIFPAAQAENNLVRRTNRQGRRLARRKK
|
| 129 |
+
HRRVRLNRLFEESGLITDFTKISINLNPYQLRVKGLTDELSNEELFIALKNMVKHRGISY
|
| 130 |
+
LDDASDDGNSSVGDYAQIVKENSKQLETKTPGQIQLERYQTYGQLRGDFTVEKDGKKHRL
|
| 131 |
+
INVFPTSAYRSEALRILQTQQEFNPQITDEFINRYLEILTGKRKYYHGPGNEKSRTDYGR
|
| 132 |
+
YRTSGETLDNIFGILIGKCTFYPDEFRAAKASYTAQEFNLLNDLNNLTVPTETKKLSKEQ
|
| 133 |
+
KNQIINYVKNEKAMGPAKLFKYIAKLLSCDVADIKGYRIDKSGKAEIHTFEAYRKMKTLE
|
| 134 |
+
TLDIEQMDRETLDKLAYVLTLNTEREGIQEALEHEFADGSFSQKQVDELVQFRKANSSIF
|
| 135 |
+
GKGWHNFSVKLMMELIPELYETSEEQMTILTRLGKQKTTSSSNKTKYIDEKLLTEEIYNP
|
| 136 |
+
VVAKSVRQAIKIVNAAIKEYGDFDNIVIEMARETNEDDEKKAIQKIQKANKDEKDAAMLK
|
| 137 |
+
AANQYNGKAELPHSVFHGHKQLATKIRLWHQQGERCLYTGKTISIHDLINNSNQFEVDHI
|
| 138 |
+
LPLSITFDDSLANKVLVYATANQEKGQRTPYQALDSMDDAWSFRELKAFVRESKTLSNKK
|
| 139 |
+
KEYLLTEEDISKFDVRKKFIERNLVDTRYASRVVLNALQEHFRAHKIDTKVSVVRGQFTS
|
| 140 |
+
QLRRHWGIEKTRDTYHHHAVDALIIAASSQLNLWKKQKNTLVSYSEDQLLDIETGELISD
|
| 141 |
+
DEYKESVFKAPYQHFVDTLKSKEFEDSILFSYQVDSKFNRKISDATIYATRQAKVGKDKA
|
| 142 |
+
DETYVLGKIKDIYTQDGYDAFMKIYKKDKSKFLMYRHDPQTFEKVIEPILENYPNKQINE
|
| 143 |
+
KGKEVPCNPFLKYKEEHGYIRKYSKKGNGPEIKSLKYYDSKLGNHIDITPKDSNNKVVLQ
|
| 144 |
+
SVSPWRADVYFNKTTGKYEILGLKYADLQFEKGTGTYKISQEKYNDIKKKEGVDSDSEFK
|
| 145 |
+
FTLYKNDLLLVKDTETKEQQLFRFLSRTMPKQKHYVELKPYDKQKFEGGEALIKVLGNVA
|
| 146 |
+
NSGQCKKGLGKSNISIYKVRTDVLGNQHIIKNEGDKPKLDF
|
| 147 |
+
>iSpyMac len=1360
|
| 148 |
+
MDKKYSIGLDIGTNSVGWAVITDEYKVPSKKFKVLGNTDRHSIKKNLIGALLFDSGETAE
|
| 149 |
+
ATRLKRTARRRYTRRKNRICYLQEIFSNEMAKVDDSFFHRLEESFLVEEDKKHERHPIFG
|
| 150 |
+
NIVDEVAYHEKYPTIYHLRKKLVDSTDKADLRLIYLALAHMIKFRGHFLIEGDLNPDNSD
|
| 151 |
+
VDKLFIQLVQTYNQLFEENPINASGVDAKAILSARLSKSRKLENLIAQLPGEKKNGLFGN
|
| 152 |
+
LIALSLGLTPNFKSNFDLAEDAKLQLSKDTYDDDLDNLLAQIGDQYADLFLAAKNLSDAI
|
| 153 |
+
LLSDILRVNTEITKAPLSASMIKRYDEHHQDLTLLKALVRQQLPEKYKEIFFDQSKNGYA
|
| 154 |
+
GYIDGGASQEEFYKFIKPILEKMDGTEELLVKLKREDLLRKQRTFDNGSIPHQIHLGELH
|
| 155 |
+
AILRRQEDFYPFLKDNREKIEKILTFRIPYYVGPLARGNSRFAWMTRKSEETITPWNFEE
|
| 156 |
+
VVDKGASAQSFIERMTNFDKNLPNEKVLPKHSLLYEYFTVYNELTKVKYVTEGMRKPAFL
|
| 157 |
+
SGEQKKAIVDLLFKTNRKVTVKQLKEDYFKKIECFDSVEISGVEDRFNASLGTYHDLLKI
|
| 158 |
+
IKDKDFLDNEENEDILEDIVLTLTLFEDREMIEERLKTYAHLFDDKVMKQLKRRRYTGWG
|
| 159 |
+
RLSRKLINGIRDKQSGKTILDFLKSDGFANANFMQLIHDDSLTFKEDIQKAQVSGQGDSL
|
| 160 |
+
HEHIANLAGSPAIKKGILQTVKVVDELVKVMGRHKPENIVIEMARENQTTQKGQKNSRER
|
| 161 |
+
MKRIEEGIKELGSQILKEHPVENTQLQNEKLYLYYLQNGRDMYVDQELDINRLSDYDVDH
|
| 162 |
+
IVPQSFLKDDSIDNKVLTRSDKNRGKSDNVPSEEVVKKMKNYWRQLLNAKLITQRKFDNL
|
| 163 |
+
TKAERGGLSELDKAGFIKRQLVETRQITKHVAQILDSRMNTKYDENDKLIREVKVITLKS
|
| 164 |
+
KLVSDFRKDFQFYKVREINNYHHAHDAYLNAVVGTALIKKYPKLESEFVYGDYKVYDVRK
|
| 165 |
+
MIAKSEQEIGKATAKYFFYSNIMNFFKTEITLANGEIRKRPLIETNGETGEIVWDKGRDF
|
| 166 |
+
ATVRKVLSMPQVNIVKKTEIQTVGQNGGLFDDNPKSPLEVTPSKLVPLKKELNPKKYGGY
|
| 167 |
+
QKPTTAYPVLLITDTKQLIPISVMNKKQFEQNPVKFLRDRGYQQVGKNDFIKLPKYTLVD
|
| 168 |
+
IGDGIKRLWASSKEIHKGNQLVVSKKSQILLYHAHHLDSDLSNDYLQNHNQQFDVLFNEI
|
| 169 |
+
ISFSKKCKLGKEHIQKIENVYSNKKNSASIEELAESFIKLLGFTQLGATSPFNFLGVKLN
|
| 170 |
+
QKQYKGKKDYILPCTEGTLIRQSITGLYETRVDLSKIGED
|
| 171 |
+
>Scplusplus len=1375
|
| 172 |
+
MEKKYSIGLDIGTNSVGWAVITDDYKVPSKKFKVLGNTNRKSIKKNLMGALLFDSGETAE
|
| 173 |
+
ATRLKRTARRRYTRRKNRIRYLQEIFANEMAKLDDSFFQRLEESFLVEEDKKNERHPIFG
|
| 174 |
+
NLADEVAYHRNYPTIYHLRKKLADSPEKADLRLIYLALAHIIKFRGHFLIEGKLNAENSD
|
| 175 |
+
VAKLFYQLIQTYNQLFEESPLDEIEVDAKGILSARLSKSKRLEKLIAVFPNEKKNGLFGN
|
| 176 |
+
IIALALGLTPNFKSNFDLTEDAKLQLSKDTYDDDLDELLGQIGDQYADLFSAAKNLSDAI
|
| 177 |
+
LLSDILRSNSEVTKAPLSASMVKRYDEHHQDLALLKTLVRQQFPEKYAEIFKDDTKNGYA
|
| 178 |
+
GYVGADKKLRKRSGKLATEEEFYKFIKPILEKMDGAEELLAKLNRDDLLRKQRTFDNGSI
|
| 179 |
+
PHQIHLKELHAILRRQEEFYPFLKENREKIEKILTFRIPYYVGPLARGNSRFAWLTRKSE
|
| 180 |
+
EAITPWNFEEVVDKGASAQSFIERMTNFDEQLPNKKVLPKHSLLYEYFTVYNELTKVKYV
|
| 181 |
+
TERMRKPEFLSGEQKKAIVDLLFKTNRKVTVKQLKEDYFKKIECFDSVEIIGVEDRFNAS
|
| 182 |
+
LGTYHDLLKIIKDKDFLDNEENEDILEDIVLTLTLFEDREMIEERLKTYAHLFDDKVMKQ
|
| 183 |
+
LKRRHYTGWGRLSRKMINGIRDKQSGKTILDFLKSDGFSNRNFMQLIHDDSLTFKEEIEK
|
| 184 |
+
AQVSGQGDSLHEQIADLAGSPAIKKGILQTVKIVDELVKVMGHKPENIVIEMARENQTTT
|
| 185 |
+
KGLQQSRERKKRIEEGIKELESQILKENPVENTQLQNEKLYLYYLQNGRDMYVDQELDIN
|
| 186 |
+
RLSDYDVDHIVPQSFIKDDSIDNKVLTRSVENRGKSDNVPSEEVVKKMKNYWRQLLNAKL
|
| 187 |
+
ITQRKFDNLTKAERGGLSEADKAGFIKRQLVETRQITKHVARILDSRMNTKRDKNDKPIR
|
| 188 |
+
EVKVITLKSKLVSDFRKDFQLYKVRDINNYHHAHDAYLNAVVGTALIKKYPKLESEFVYG
|
| 189 |
+
DYKVYDVRKMIAKSEQEIGKATAKRFFYSNIMNFFKTEVKLANGEIRKRPLIETNGETGE
|
| 190 |
+
VVWNKEKDFATVRKVLAMPQVNIVKKTEVQTGGFSKESILSKRESAKLIPRKKGWDTRKY
|
| 191 |
+
GGFGSPTVAYSILVVAKVEKGKAKKLKSVKVLVGITIMEKGSYEKDPIGFLEAKGYKDIK
|
| 192 |
+
KELIFKLPKYSLFELENGRRRMLASAKELQKANELVLPQHLVRLLYYTQNISATTGSNNL
|
| 193 |
+
GYIEQHREEFKEIFEKIIDFSEKYILKNKVNSNLKSSFDEQFAVSDSILLSNSFVSLLKY
|
| 194 |
+
TSFGASGGFTFLDLDVKQGRLRYQTVTEVLDATLIYQSITGLYETRTDLSQLGGD
|
| 195 |
+
>SpRYc len=1377
|
| 196 |
+
MEKKYSIGLDIGTNSVGWAVITDDYKVPSKKFKVLGNTNRKSIKKNLMGALLFDSGETAE
|
| 197 |
+
ATRLKRTARRRYTRRKNRIRYLQEIFANEMAKLDDSFFQRLEESFLVEEDKKNERHPIFG
|
| 198 |
+
NLADEVAYHRNYPTIYHLRKKLADSPEKADLRLIYLALAHIIKFRGHFLIEGKLNAENSD
|
| 199 |
+
VAKLFYQLIQTYNQLFEESPLDEIEVDAKGILSARLSKSKRLEKLIAVFPNEKKNGLFGN
|
| 200 |
+
IIALALGLTPNFKSNFDLTEDAKLQLSKDTYDDDLDELLGQIGDQYADLFSAAKNLSDAI
|
| 201 |
+
LLSDILRSNSEVTKAPLSASMVKRYDEHHQDLALLKTLVRQQFPEKYAEIFKDDTKNGYA
|
| 202 |
+
GYVGADKKLRKRSGKLATEEEFYKFIKPILEKMDGAEELLAKLNRDDLLRKQRTFDNGSI
|
| 203 |
+
PHQIHLKELHAILRRQEEFYPFLKENREKIEKILTFRIPYYVGPLARGNSRFAWLTRKSE
|
| 204 |
+
EAITPWNFEEVVDKGASAQSFIERMTNFDEQLPNKKVLPKHSLLYEYFTVYNELTKVKYV
|
| 205 |
+
TERMRKPEFLSGEQKKAIVDLLFKTNRKVTVKQLKEDYFKKIECFDSVEIIGVEDRFNAS
|
| 206 |
+
LGTYHDLLKIIKDKDFLDNEENEDILEDIVLTLTLFEDREMIEERLKTYAHLFDDKVMKQ
|
| 207 |
+
LKRRHYTGWGRLSRKMINGIRDKQSGKTILDFLKSDGFSNRNFMQLIHDDSLTFKEEIEK
|
| 208 |
+
AQVSGQGDSLHEQIADLAGSPAIKKGILQTVKIVDELVKVMGHKPENIVIEMARENQTTT
|
| 209 |
+
KGLQQSRERKKRIEEGIKELESQILKENPVENTQLQNEKLYLYYLQNGRDMYVDQELDIN
|
| 210 |
+
RLSDYDVDHIVPQSFIKDDSIDNKVLTRSVENRGKSDNVPSEEVVKKMKNYWRQLLNAKL
|
| 211 |
+
ITQRKFDNLTKAERGGLSEADKAGFIKRQLVETRQITKHVARILDSRMNTKRDKNDKPIR
|
| 212 |
+
EVKVITLKSKLVSDFRKDFQLYKVRDINNYHHAHDAYLNAVVGTALIKKYPKLESEFVYG
|
| 213 |
+
DYKVYDVRKMIAKSEQEIGKATAKRFFYSNIMNFFKTEVKLANGEIRKRPLIETNGETGE
|
| 214 |
+
VVWNKEKDFATVRKVLAMPQVNIVKKTEVQTGGFSKESIRPKRNSDKLIARKKDWDPKKY
|
| 215 |
+
GGFLWPTVAYSVLVVAKVEKGKSKKLKSVKELLGITIMERSSFEKNPIDFLEAKGYKEVK
|
| 216 |
+
KDLIIKLPKYSLFELENGRKRMLASAKQLQKGNELALPSKYVNFLYLASHYEKLKGSPED
|
| 217 |
+
NEQKQLFVEQHKHYLDEIIEQISEFSKRVILADANLDKVLSAYNKHRDKPIREQAENIIH
|
| 218 |
+
LFTLTRLGAPRAFKYFDTTIDPKQYRSTKEVLDATLIHQSITGLYETRIDLSQLGGD
|
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copyright (c) Meta Platforms, Inc. and affiliates.
|
| 2 |
+
# All rights reserved.
|
| 3 |
+
#
|
| 4 |
+
# This source code is licensed under the CC-by-NC license found in the
|
| 5 |
+
# LICENSE file in the root directory of this source tree.
|
| 6 |
+
# This implementation is adapted from https://github.com/louaaron/Score-Entropy-Discrete-Diffusion
|
| 7 |
+
# which is released under MIT license
|
| 8 |
+
|
| 9 |
+
import re
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def wt_detokenizer(string):
|
| 13 |
+
# contractions
|
| 14 |
+
string = string.replace("s '", "s'")
|
| 15 |
+
string = re.sub(r"/' [0-9]/", r"/'[0-9]/", string)
|
| 16 |
+
# number separators
|
| 17 |
+
string = string.replace(" @-@ ", "-")
|
| 18 |
+
string = string.replace(" @,@ ", ",")
|
| 19 |
+
string = string.replace(" @.@ ", ".")
|
| 20 |
+
# punctuation
|
| 21 |
+
string = string.replace(" : ", ": ")
|
| 22 |
+
string = string.replace(" ; ", "; ")
|
| 23 |
+
string = string.replace(" . ", ". ")
|
| 24 |
+
string = string.replace(" ! ", "! ")
|
| 25 |
+
string = string.replace(" ? ", "? ")
|
| 26 |
+
string = string.replace(" , ", ", ")
|
| 27 |
+
# double brackets
|
| 28 |
+
string = re.sub(r"\(\s*([^\)]*?)\s*\)", r"(\1)", string)
|
| 29 |
+
string = re.sub(r"\[\s*([^\]]*?)\s*\]", r"[\1]", string)
|
| 30 |
+
string = re.sub(r"{\s*([^}]*?)\s*}", r"{\1}", string)
|
| 31 |
+
string = re.sub(r"\"\s*([^\"]*?)\s*\"", r'"\1"', string)
|
| 32 |
+
string = re.sub(r"'\s*([^']*?)\s*'", r"'\1'", string)
|
| 33 |
+
# miscellaneous
|
| 34 |
+
string = string.replace("= = = =", "====")
|
| 35 |
+
string = string.replace("= = =", "===")
|
| 36 |
+
string = string.replace("= =", "==")
|
| 37 |
+
string = string.replace(" " + chr(176) + " ", chr(176))
|
| 38 |
+
string = string.replace(" \n", "\n")
|
| 39 |
+
string = string.replace("\n ", "\n")
|
| 40 |
+
string = string.replace(" N ", " 1 ")
|
| 41 |
+
string = string.replace(" 's", "'s")
|
| 42 |
+
return string
|
|
@@ -0,0 +1,220 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import random
|
| 3 |
+
import pandas as pd
|
| 4 |
+
from datasets import Dataset, DatasetDict
|
| 5 |
+
|
| 6 |
+
# import selfies as sf
|
| 7 |
+
# from rdkit import Chem
|
| 8 |
+
# from rdkit import RDLogger
|
| 9 |
+
# RDLogger.DisableLog('rdApp.*')
|
| 10 |
+
|
| 11 |
+
import sys
|
| 12 |
+
|
| 13 |
+
from transformers import EsmTokenizer
|
| 14 |
+
|
| 15 |
+
# ---------------------------------------------------------------------
|
| 16 |
+
# CONFIG
|
| 17 |
+
# ---------------------------------------------------------------------
|
| 18 |
+
|
| 19 |
+
MAX_TOKENS_PER_BATCH = 4096
|
| 20 |
+
SOURCE_DATASET = 'uniprot_sprot'
|
| 21 |
+
TRAIN_RATIO, VAL_RATIO, TEST_RATIO = 0.8, 0.1, 0.1
|
| 22 |
+
MIN_LENGTH = 800
|
| 23 |
+
MAX_LENGTH = 1600
|
| 24 |
+
MAX_DATA_SIZE = None
|
| 25 |
+
NUM_REPEATS = None
|
| 26 |
+
|
| 27 |
+
# Set PROJECT_ROOT or edit paths below to your data locations
|
| 28 |
+
PROJECT_ROOT = os.environ.get('PROJECT_ROOT', 'path/to/project')
|
| 29 |
+
if SOURCE_DATASET == 'uniprot_sprot':
|
| 30 |
+
FASTA_PATH = os.path.join(PROJECT_ROOT, 'data/uniref/uniref50_filtered_matched_30k.fasta')
|
| 31 |
+
elif SOURCE_DATASET == 'crispr_cas_atlas':
|
| 32 |
+
FASTA_PATH = os.path.join(PROJECT_ROOT, f'data/crispr_cas_atlas/cas9_sequences_reduced_30k.fasta')
|
| 33 |
+
elif SOURCE_DATASET == 'gfp':
|
| 34 |
+
FASTA_PATH = os.path.join(PROJECT_ROOT, 'data/gfp/fpbase_valid_gfps.fasta')
|
| 35 |
+
else:
|
| 36 |
+
raise ValueError(f"Invalid source dataset: {SOURCE_DATASET}")
|
| 37 |
+
|
| 38 |
+
if MAX_DATA_SIZE is not None:
|
| 39 |
+
if MAX_DATA_SIZE // 1000 > 0:
|
| 40 |
+
if SOURCE_DATASET == 'uniprot_sprot':
|
| 41 |
+
OUTPUT_DIR = os.path.join(PROJECT_ROOT, f'data/uniref/uniref_dataset_esm2_tokenized_mt{MAX_TOKENS_PER_BATCH}_leq{MAX_LENGTH}_n{MAX_DATA_SIZE//1000}k_30k')
|
| 42 |
+
elif SOURCE_DATASET == 'crispr_cas_atlas':
|
| 43 |
+
OUTPUT_DIR = os.path.join(PROJECT_ROOT, f'data/crispr_cas_atlas/dataset/{SOURCE_DATASET}_dataset_esm2_tokenized_mt{MAX_TOKENS_PER_BATCH}_leq{MAX_LENGTH}_n{MAX_DATA_SIZE//1000}k')
|
| 44 |
+
else:
|
| 45 |
+
raise ValueError(f"Invalid source dataset: {SOURCE_DATASET}")
|
| 46 |
+
else:
|
| 47 |
+
if SOURCE_DATASET == 'uniprot_sprot':
|
| 48 |
+
OUTPUT_DIR = os.path.join(PROJECT_ROOT, f'data/uniref/uniref_dataset_esm2_tokenized_mt{MAX_TOKENS_PER_BATCH}_leq{MAX_LENGTH}_n{MAX_DATA_SIZE}_30k')
|
| 49 |
+
elif SOURCE_DATASET == 'crispr_cas_atlas':
|
| 50 |
+
OUTPUT_DIR = os.path.join(PROJECT_ROOT, f'data/crispr_cas_atlas/dataset/{SOURCE_DATASET}_dataset_esm2_tokenized_mt{MAX_TOKENS_PER_BATCH}_leq{MAX_LENGTH}_n{MAX_DATA_SIZE}')
|
| 51 |
+
else:
|
| 52 |
+
raise ValueError(f"Invalid source dataset: {SOURCE_DATASET}")
|
| 53 |
+
else:
|
| 54 |
+
if SOURCE_DATASET == 'uniprot_sprot':
|
| 55 |
+
OUTPUT_DIR = os.path.join(PROJECT_ROOT, f'data/uniref/uniref_dataset_esm2_tokenized_mt{MAX_TOKENS_PER_BATCH}_leq{MAX_LENGTH}_30k')
|
| 56 |
+
elif SOURCE_DATASET == 'crispr_cas_atlas':
|
| 57 |
+
OUTPUT_DIR = os.path.join(PROJECT_ROOT, f'data/crispr_cas_atlas/dataset/{SOURCE_DATASET}_dataset_esm2_tokenized_mt{MAX_TOKENS_PER_BATCH}_leq{MAX_LENGTH}')
|
| 58 |
+
else:
|
| 59 |
+
raise ValueError(f"Invalid source dataset: {SOURCE_DATASET}")
|
| 60 |
+
# ------------------------------------------------------------
|
| 61 |
+
# 1) Load sequences from FASTA file
|
| 62 |
+
# ------------------------------------------------------------
|
| 63 |
+
from Bio import SeqIO
|
| 64 |
+
|
| 65 |
+
sequences = []
|
| 66 |
+
print("Loading sequences from FASTA and validating…")
|
| 67 |
+
for record in SeqIO.parse(FASTA_PATH, "fasta"):
|
| 68 |
+
s = str(record.seq)
|
| 69 |
+
# skip empty sequences
|
| 70 |
+
if not s:
|
| 71 |
+
continue
|
| 72 |
+
sequences.append(s)
|
| 73 |
+
|
| 74 |
+
# remove all duplicates
|
| 75 |
+
sequences = list(set(sequences))
|
| 76 |
+
|
| 77 |
+
## filter out any sequence that contains non-natural amino acids
|
| 78 |
+
print(f"len(sequences) before filtering: {len(sequences)}")
|
| 79 |
+
sequences = [
|
| 80 |
+
seq for seq in sequences
|
| 81 |
+
if all(aa in "ACDEFGHIKLMNPQRSTVWY" for aa in seq)
|
| 82 |
+
and (MAX_LENGTH is None or len(seq) <= MAX_LENGTH)
|
| 83 |
+
and (MIN_LENGTH is None or len(seq) >= MIN_LENGTH)
|
| 84 |
+
]
|
| 85 |
+
print(f"len(sequences) after filtering: {len(sequences)}")
|
| 86 |
+
print(f"Average length of sequences: {sum(len(seq) for seq in sequences) / len(sequences)}")
|
| 87 |
+
|
| 88 |
+
# ---- Initialize ESM tokenizer ----
|
| 89 |
+
tokenizer = EsmTokenizer.from_pretrained("facebook/esm2_t33_650M_UR50D")
|
| 90 |
+
print(f"Vocabulary Size: {tokenizer.vocab_size}")
|
| 91 |
+
|
| 92 |
+
# shuffle before splitting
|
| 93 |
+
random.shuffle(sequences)
|
| 94 |
+
|
| 95 |
+
if MAX_DATA_SIZE is not None:
|
| 96 |
+
print(f"Truncating to {MAX_DATA_SIZE} sequences")
|
| 97 |
+
sequences = sequences[:MAX_DATA_SIZE]
|
| 98 |
+
print(f"len(sequences) after truncation: {len(sequences)}")
|
| 99 |
+
|
| 100 |
+
n = len(sequences)
|
| 101 |
+
n_train = int(n * TRAIN_RATIO)
|
| 102 |
+
n_val = int(n * VAL_RATIO)
|
| 103 |
+
|
| 104 |
+
train_sequences = sequences[:n_train]
|
| 105 |
+
val_sequences = sequences[n_train:n_train + n_val]
|
| 106 |
+
test_sequences = sequences[n_train + n_val:]
|
| 107 |
+
|
| 108 |
+
print(f"len(train_sequences) before repetition: {len(train_sequences)}")
|
| 109 |
+
print(f"len(val_sequences) before repetition: {len(val_sequences)}")
|
| 110 |
+
print(f"len(test_sequences) before repetition: {len(test_sequences)}")
|
| 111 |
+
|
| 112 |
+
if NUM_REPEATS is not None:
|
| 113 |
+
train_sequences = train_sequences * NUM_REPEATS
|
| 114 |
+
val_sequences = val_sequences * NUM_REPEATS
|
| 115 |
+
test_sequences = test_sequences * NUM_REPEATS
|
| 116 |
+
|
| 117 |
+
print(f"len(train_sequences) after repetition: {len(train_sequences)}")
|
| 118 |
+
print(f"len(val_sequences) after repetition: {len(val_sequences)}")
|
| 119 |
+
print(f"len(test_sequences) after repetition: {len(test_sequences)}")
|
| 120 |
+
# ------------------------------------------------------------
|
| 121 |
+
# 2) helper: tokenize sequences using the ESM tokenizer
|
| 122 |
+
# ------------------------------------------------------------
|
| 123 |
+
def tokenize_sequences(sequences):
|
| 124 |
+
enc = {"input_ids": [], "attention_mask": []}
|
| 125 |
+
for s in sequences:
|
| 126 |
+
res = tokenizer(s, add_special_tokens=True)
|
| 127 |
+
enc["input_ids"].append(res["input_ids"])
|
| 128 |
+
enc["attention_mask"].append(res["attention_mask"])
|
| 129 |
+
return enc
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
# ------------------------------------------------------------
|
| 133 |
+
# 3) build batched dataset (token-based batching: groups sequences by length, packs up to max_tokens)
|
| 134 |
+
# ------------------------------------------------------------
|
| 135 |
+
from tqdm import tqdm
|
| 136 |
+
|
| 137 |
+
def build_batched_dataset(sequences, max_tokens=8192):
|
| 138 |
+
# tokenize all first
|
| 139 |
+
toks = tokenize_sequences(sequences)
|
| 140 |
+
input_ids_list = toks["input_ids"]
|
| 141 |
+
attn_mask_list = toks["attention_mask"]
|
| 142 |
+
|
| 143 |
+
# add lengths
|
| 144 |
+
items = []
|
| 145 |
+
for ids, mask in tqdm(zip(input_ids_list, attn_mask_list), total=len(input_ids_list), desc="Collecting items with length"):
|
| 146 |
+
items.append({
|
| 147 |
+
"input_ids": ids,
|
| 148 |
+
"attention_mask": mask,
|
| 149 |
+
"length": len(ids),
|
| 150 |
+
})
|
| 151 |
+
|
| 152 |
+
# sort by length
|
| 153 |
+
items.sort(key=lambda x: x["length"])
|
| 154 |
+
|
| 155 |
+
batched_input_ids = []
|
| 156 |
+
batched_attention_masks = []
|
| 157 |
+
batched_lengths = []
|
| 158 |
+
batched_batch_sizes = []
|
| 159 |
+
|
| 160 |
+
i = 0
|
| 161 |
+
n = len(items)
|
| 162 |
+
while i < n:
|
| 163 |
+
L = items[i]["length"]
|
| 164 |
+
# how many of length L can we pack?
|
| 165 |
+
max_bs = max_tokens // L
|
| 166 |
+
if max_bs < 1:
|
| 167 |
+
max_bs = 1
|
| 168 |
+
|
| 169 |
+
# collect up to max_bs items with same length
|
| 170 |
+
cur_ids = []
|
| 171 |
+
cur_masks = []
|
| 172 |
+
taken = 0
|
| 173 |
+
j = i
|
| 174 |
+
while j < n and items[j]["length"] == L and taken < max_bs:
|
| 175 |
+
cur_ids.append(items[j]["input_ids"]) # length L
|
| 176 |
+
cur_masks.append(items[j]["attention_mask"]) # length L
|
| 177 |
+
j += 1
|
| 178 |
+
taken += 1
|
| 179 |
+
|
| 180 |
+
# now this is one batch: shape (B, L)
|
| 181 |
+
batched_input_ids.append(cur_ids)
|
| 182 |
+
batched_attention_masks.append(cur_masks)
|
| 183 |
+
batched_lengths.append(L)
|
| 184 |
+
batched_batch_sizes.append(taken)
|
| 185 |
+
|
| 186 |
+
i = j
|
| 187 |
+
|
| 188 |
+
ds = Dataset.from_dict({
|
| 189 |
+
"input_ids": batched_input_ids, # list of (B, L)
|
| 190 |
+
"attention_mask": batched_attention_masks,
|
| 191 |
+
"seq_length": batched_lengths,
|
| 192 |
+
"batch_size": batched_batch_sizes,
|
| 193 |
+
})
|
| 194 |
+
return ds
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
# ------------------------------------------------------------
|
| 198 |
+
# 4) build batched datasets for each split
|
| 199 |
+
# ------------------------------------------------------------
|
| 200 |
+
train_ds = build_batched_dataset(train_sequences, max_tokens=MAX_TOKENS_PER_BATCH)
|
| 201 |
+
val_ds = build_batched_dataset(val_sequences, max_tokens=MAX_TOKENS_PER_BATCH)
|
| 202 |
+
test_ds = build_batched_dataset(test_sequences, max_tokens=MAX_TOKENS_PER_BATCH)
|
| 203 |
+
|
| 204 |
+
for ds in [train_ds, val_ds, test_ds]:
|
| 205 |
+
num_seqs = 0
|
| 206 |
+
for item in ds:
|
| 207 |
+
num_seqs += len(item["input_ids"])
|
| 208 |
+
print(f"Number of sequences in {ds}: {num_seqs}")
|
| 209 |
+
|
| 210 |
+
dsdict = DatasetDict(
|
| 211 |
+
{
|
| 212 |
+
"train": train_ds,
|
| 213 |
+
"validation": val_ds,
|
| 214 |
+
"test": test_ds,
|
| 215 |
+
}
|
| 216 |
+
)
|
| 217 |
+
|
| 218 |
+
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
| 219 |
+
dsdict.save_to_disk(OUTPUT_DIR)
|
| 220 |
+
print(f"saved to {OUTPUT_DIR}")
|
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copyright (c) Meta Platforms, Inc. and affiliates.
|
| 2 |
+
# All rights reserved.
|
| 3 |
+
#
|
| 4 |
+
# This source code is licensed under the CC-by-NC license found in the
|
| 5 |
+
# LICENSE file in the root directory of this source tree.
|
| 6 |
+
# This implementation is adapted from https://github.com/pytorch/data/blob/main/torchdata/stateful_dataloader/sampler.py#L132
|
| 7 |
+
# which is released under BSD-3 license
|
| 8 |
+
|
| 9 |
+
import itertools
|
| 10 |
+
from typing import Any, Dict, Optional
|
| 11 |
+
|
| 12 |
+
import numpy as np
|
| 13 |
+
import torch
|
| 14 |
+
from torch import Tensor
|
| 15 |
+
from torch.utils.data import DataLoader, Dataset, Sampler
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def cycle_loader(dataloader: DataLoader, sampler: Sampler = None) -> Tensor:
|
| 19 |
+
while 1:
|
| 20 |
+
if sampler is not None:
|
| 21 |
+
sampler.set_epoch(np.random.randint(0, 100000))
|
| 22 |
+
for data in dataloader:
|
| 23 |
+
yield data
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class StatefulDistributedSampler(torch.utils.data.distributed.DistributedSampler):
|
| 27 |
+
"""
|
| 28 |
+
From: https://github.com/pytorch/data/blob/main/torchdata/stateful_dataloader/sampler.py#L132
|
| 29 |
+
"""
|
| 30 |
+
|
| 31 |
+
_YIELDED = "yielded"
|
| 32 |
+
|
| 33 |
+
def __init__(
|
| 34 |
+
self,
|
| 35 |
+
dataset: Dataset,
|
| 36 |
+
num_replicas: Optional[int] = None,
|
| 37 |
+
rank: Optional[int] = None,
|
| 38 |
+
shuffle: bool = True,
|
| 39 |
+
seed: int = 0,
|
| 40 |
+
drop_last: bool = False,
|
| 41 |
+
) -> None:
|
| 42 |
+
super().__init__(dataset, num_replicas, rank, shuffle, seed, drop_last)
|
| 43 |
+
self.yielded = 0
|
| 44 |
+
self.next_yielded = None
|
| 45 |
+
|
| 46 |
+
def __iter__(self):
|
| 47 |
+
self.yielded = 0
|
| 48 |
+
if self.next_yielded is not None:
|
| 49 |
+
self.yielded = self.next_yielded
|
| 50 |
+
self.next_yielded = None
|
| 51 |
+
it = super().__iter__()
|
| 52 |
+
for idx in itertools.islice(it, self.yielded, None):
|
| 53 |
+
self.yielded += 1
|
| 54 |
+
yield idx
|
| 55 |
+
|
| 56 |
+
def state_dict(self) -> Dict[str, Any]:
|
| 57 |
+
return {self._YIELDED: self.yielded}
|
| 58 |
+
|
| 59 |
+
def load_state_dict(self, state_dict: Dict[str, Any]) -> None:
|
| 60 |
+
if self._YIELDED not in state_dict:
|
| 61 |
+
raise ValueError("Invalid state_dict")
|
| 62 |
+
if state_dict[self._YIELDED] < 0:
|
| 63 |
+
raise ValueError("Cannot load state_dict with negative yielded value")
|
| 64 |
+
self.next_yielded = state_dict[self._YIELDED]
|
|
@@ -0,0 +1,226 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# domain_detector.py
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import os
|
| 5 |
+
import shutil
|
| 6 |
+
import subprocess
|
| 7 |
+
import tempfile
|
| 8 |
+
from dataclasses import dataclass
|
| 9 |
+
from typing import Dict, Iterable, List, Optional, Sequence, Tuple
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
@dataclass(frozen=True)
|
| 13 |
+
class HitCriteria:
|
| 14 |
+
ievalue_max: float
|
| 15 |
+
min_dom_len: int
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
@dataclass(frozen=True)
|
| 19 |
+
class DomainDetectorConfig:
|
| 20 |
+
hmm_path: str
|
| 21 |
+
cpu: int = 8
|
| 22 |
+
|
| 23 |
+
# Stage1 acceptance
|
| 24 |
+
stage1: HitCriteria = HitCriteria(ievalue_max=1e-2, min_dom_len=35)
|
| 25 |
+
|
| 26 |
+
# Stage2 acceptance (recall-first)
|
| 27 |
+
stage2: HitCriteria = HitCriteria(ievalue_max=10.0, min_dom_len=20)
|
| 28 |
+
|
| 29 |
+
# Stage2 hmmscan reporting knobs (big so rescue sees weak hits)
|
| 30 |
+
stage2_report_E: float = 1e6
|
| 31 |
+
stage2_report_domE: float = 1e6
|
| 32 |
+
stage2_incE: float = 1e6
|
| 33 |
+
stage2_incdomE: float = 1e6
|
| 34 |
+
|
| 35 |
+
# Optional Stage1 sensitivity knobs (usually leave None)
|
| 36 |
+
stage1_report_E: Optional[float] = None
|
| 37 |
+
stage1_report_domE: Optional[float] = None
|
| 38 |
+
stage1_F1: Optional[float] = None
|
| 39 |
+
stage1_F2: Optional[float] = None
|
| 40 |
+
stage1_F3: Optional[float] = None
|
| 41 |
+
|
| 42 |
+
# Model name prefixes in your HMM library
|
| 43 |
+
hnh_prefix: str = "HNH_"
|
| 44 |
+
ruvc_prefix: str = "RuvC_"
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
class DomainDetector:
|
| 48 |
+
"""
|
| 49 |
+
Cas9 nuclease domain presence detector (HNH + RuvC), Option-B (recall-first).
|
| 50 |
+
|
| 51 |
+
Uses hmmscan:
|
| 52 |
+
- Stage1: fast scan over batch
|
| 53 |
+
- Stage2: --max scan only on Stage1 misses (rescue)
|
| 54 |
+
"""
|
| 55 |
+
|
| 56 |
+
def __init__(self, config: DomainDetectorConfig):
|
| 57 |
+
self.cfg = config
|
| 58 |
+
self._hmmscan = self._check_exe("hmmscan")
|
| 59 |
+
self._validate_hmm(self.cfg.hmm_path)
|
| 60 |
+
|
| 61 |
+
@staticmethod
|
| 62 |
+
def _check_exe(name: str) -> str:
|
| 63 |
+
exe = shutil.which(name)
|
| 64 |
+
if not exe:
|
| 65 |
+
raise RuntimeError(f"Required executable not found on PATH: {name}")
|
| 66 |
+
return exe
|
| 67 |
+
|
| 68 |
+
@staticmethod
|
| 69 |
+
def _validate_hmm(hmm_path: str) -> None:
|
| 70 |
+
if not os.path.exists(hmm_path):
|
| 71 |
+
raise FileNotFoundError(f"HMM file not found: {hmm_path}")
|
| 72 |
+
if os.path.getsize(hmm_path) == 0:
|
| 73 |
+
raise ValueError(f"HMM file is empty: {hmm_path}")
|
| 74 |
+
|
| 75 |
+
@staticmethod
|
| 76 |
+
def _write_fasta_from_pairs(pairs: Sequence[Tuple[str, str]], path: str) -> None:
|
| 77 |
+
with open(path, "w") as out:
|
| 78 |
+
for sid, seq in pairs:
|
| 79 |
+
out.write(f">{sid}\n")
|
| 80 |
+
seq = seq.strip().replace(" ", "").replace("\n", "")
|
| 81 |
+
for i in range(0, len(seq), 80):
|
| 82 |
+
out.write(seq[i:i+80] + "\n")
|
| 83 |
+
|
| 84 |
+
def _run_hmmscan(
|
| 85 |
+
self,
|
| 86 |
+
fasta_path: str,
|
| 87 |
+
domtblout_path: str,
|
| 88 |
+
*,
|
| 89 |
+
max_mode: bool,
|
| 90 |
+
report_E: Optional[float] = None,
|
| 91 |
+
report_domE: Optional[float] = None,
|
| 92 |
+
incE: Optional[float] = None,
|
| 93 |
+
incdomE: Optional[float] = None,
|
| 94 |
+
F1: Optional[float] = None,
|
| 95 |
+
F2: Optional[float] = None,
|
| 96 |
+
F3: Optional[float] = None,
|
| 97 |
+
) -> None:
|
| 98 |
+
cmd: List[str] = [
|
| 99 |
+
self._hmmscan,
|
| 100 |
+
"--cpu", str(self.cfg.cpu),
|
| 101 |
+
"--domtblout", domtblout_path,
|
| 102 |
+
]
|
| 103 |
+
if max_mode:
|
| 104 |
+
cmd.append("--max")
|
| 105 |
+
|
| 106 |
+
if F1 is not None:
|
| 107 |
+
cmd += ["--F1", str(F1)]
|
| 108 |
+
if F2 is not None:
|
| 109 |
+
cmd += ["--F2", str(F2)]
|
| 110 |
+
if F3 is not None:
|
| 111 |
+
cmd += ["--F3", str(F3)]
|
| 112 |
+
|
| 113 |
+
if report_E is not None:
|
| 114 |
+
cmd += ["-E", str(report_E)]
|
| 115 |
+
if report_domE is not None:
|
| 116 |
+
cmd += ["--domE", str(report_domE)]
|
| 117 |
+
if incE is not None:
|
| 118 |
+
cmd += ["--incE", str(incE)]
|
| 119 |
+
if incdomE is not None:
|
| 120 |
+
cmd += ["--incdomE", str(incdomE)]
|
| 121 |
+
|
| 122 |
+
cmd += [self.cfg.hmm_path, fasta_path]
|
| 123 |
+
|
| 124 |
+
# discard stdout; errors go to CalledProcessError
|
| 125 |
+
with open(os.devnull, "w") as devnull:
|
| 126 |
+
subprocess.run(cmd, check=True, stdout=devnull, stderr=subprocess.DEVNULL)
|
| 127 |
+
|
| 128 |
+
def _parse_domtblout_presence(self, domtblout_path: str, crit: HitCriteria) -> Tuple[Dict[str, bool], Dict[str, bool]]:
|
| 129 |
+
"""
|
| 130 |
+
Returns (has_hnh, has_ruvc) dicts keyed by query ID.
|
| 131 |
+
domtblout columns (1-based):
|
| 132 |
+
1=model, 4=query, 13=i-Evalue, 20=env_from, 21=env_to
|
| 133 |
+
"""
|
| 134 |
+
has_hnh: Dict[str, bool] = {}
|
| 135 |
+
has_ruvc: Dict[str, bool] = {}
|
| 136 |
+
|
| 137 |
+
with open(domtblout_path, "r") as f:
|
| 138 |
+
for line in f:
|
| 139 |
+
if not line.strip() or line.startswith("#"):
|
| 140 |
+
continue
|
| 141 |
+
parts = line.split()
|
| 142 |
+
if len(parts) < 21:
|
| 143 |
+
continue
|
| 144 |
+
model = parts[0]
|
| 145 |
+
qid = parts[3]
|
| 146 |
+
try:
|
| 147 |
+
ie = float(parts[12])
|
| 148 |
+
env_from = int(parts[19])
|
| 149 |
+
env_to = int(parts[20])
|
| 150 |
+
except ValueError:
|
| 151 |
+
continue
|
| 152 |
+
dom_len = env_to - env_from + 1
|
| 153 |
+
if ie > crit.ievalue_max or dom_len < crit.min_dom_len:
|
| 154 |
+
continue
|
| 155 |
+
|
| 156 |
+
if model.startswith(self.cfg.hnh_prefix):
|
| 157 |
+
has_hnh[qid] = True
|
| 158 |
+
elif model.startswith(self.cfg.ruvc_prefix):
|
| 159 |
+
has_ruvc[qid] = True
|
| 160 |
+
|
| 161 |
+
return has_hnh, has_ruvc
|
| 162 |
+
|
| 163 |
+
def predict_batch(
|
| 164 |
+
self,
|
| 165 |
+
seqs: Sequence[str],
|
| 166 |
+
ids: Optional[Sequence[str]] = None,
|
| 167 |
+
) -> List[bool]:
|
| 168 |
+
"""
|
| 169 |
+
Classify sequences as pass/fail (domains present: HNH AND RuvC).
|
| 170 |
+
- seqs: list of amino acid sequences
|
| 171 |
+
- ids: optional list of IDs (same length). If omitted, uses seq0..seqN.
|
| 172 |
+
|
| 173 |
+
Returns: List[bool] aligned with input order.
|
| 174 |
+
"""
|
| 175 |
+
if ids is None:
|
| 176 |
+
ids = [f"seq{i}" for i in range(len(seqs))]
|
| 177 |
+
if len(ids) != len(seqs):
|
| 178 |
+
raise ValueError("ids and seqs must have same length")
|
| 179 |
+
|
| 180 |
+
pairs = list(zip(ids, seqs))
|
| 181 |
+
|
| 182 |
+
with tempfile.TemporaryDirectory() as td:
|
| 183 |
+
fa1 = os.path.join(td, "stage1.faa")
|
| 184 |
+
dom1 = os.path.join(td, "stage1.domtblout")
|
| 185 |
+
|
| 186 |
+
self._write_fasta_from_pairs(pairs, fa1)
|
| 187 |
+
self._run_hmmscan(
|
| 188 |
+
fasta_path=fa1,
|
| 189 |
+
domtblout_path=dom1,
|
| 190 |
+
max_mode=False,
|
| 191 |
+
report_E=self.cfg.stage1_report_E,
|
| 192 |
+
report_domE=self.cfg.stage1_report_domE,
|
| 193 |
+
F1=self.cfg.stage1_F1,
|
| 194 |
+
F2=self.cfg.stage1_F2,
|
| 195 |
+
F3=self.cfg.stage1_F3,
|
| 196 |
+
)
|
| 197 |
+
|
| 198 |
+
h1, r1 = self._parse_domtblout_presence(dom1, self.cfg.stage1)
|
| 199 |
+
|
| 200 |
+
stage1_pass = {sid for sid in ids if h1.get(sid, False) and r1.get(sid, False)}
|
| 201 |
+
miss = [sid for sid in ids if sid not in stage1_pass]
|
| 202 |
+
|
| 203 |
+
stage2_pass: set[str] = set()
|
| 204 |
+
if miss:
|
| 205 |
+
miss_pairs = [(sid, seqs[ids.index(sid)]) for sid in miss] # stable small sizes
|
| 206 |
+
fa2 = os.path.join(td, "stage2.faa")
|
| 207 |
+
dom2 = os.path.join(td, "stage2.domtblout")
|
| 208 |
+
|
| 209 |
+
self._write_fasta_from_pairs(miss_pairs, fa2)
|
| 210 |
+
self._run_hmmscan(
|
| 211 |
+
fasta_path=fa2,
|
| 212 |
+
domtblout_path=dom2,
|
| 213 |
+
max_mode=True,
|
| 214 |
+
report_E=self.cfg.stage2_report_E,
|
| 215 |
+
report_domE=self.cfg.stage2_report_domE,
|
| 216 |
+
incE=self.cfg.stage2_incE,
|
| 217 |
+
incdomE=self.cfg.stage2_incdomE,
|
| 218 |
+
)
|
| 219 |
+
h2, r2 = self._parse_domtblout_presence(dom2, self.cfg.stage2)
|
| 220 |
+
stage2_pass = {sid for sid in miss if h2.get(sid, False) and r2.get(sid, False)}
|
| 221 |
+
|
| 222 |
+
final_pass = stage1_pass | stage2_pass
|
| 223 |
+
return [sid in final_pass for sid in ids]
|
| 224 |
+
|
| 225 |
+
def predict_one(self, seq: str, id_: str = "seq0") -> bool:
|
| 226 |
+
return self.predict_batch([seq], ids=[id_])[0]
|
|
@@ -0,0 +1,1066 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Script to evaluate Editflows models by generating sequences and evaluating them.
|
| 4 |
+
1. Decodes test set into actual string sequences
|
| 5 |
+
2. Calculates diversity, sequence loss (val_unweighted_total_loss), and optionally Cas9 scores for decoded sequences
|
| 6 |
+
3. Uses multi-step generation (generate_from_x0_multi_edit) to sample new sequences
|
| 7 |
+
4. Evaluates generated sequences on diversity, sequence loss, and optionally Cas9 scores
|
| 8 |
+
5. Calculates and plots pLDDT scores for both decoded and generated sequences (as separate subplots)
|
| 9 |
+
|
| 10 |
+
If Cas9 classifier is not provided, only diversity and sequence loss metrics will be calculated.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
import os
|
| 14 |
+
import argparse
|
| 15 |
+
import torch
|
| 16 |
+
from datasets import load_from_disk
|
| 17 |
+
import yaml
|
| 18 |
+
from easydict import EasyDict as edict
|
| 19 |
+
from tqdm import tqdm
|
| 20 |
+
import numpy as np
|
| 21 |
+
import random
|
| 22 |
+
from collections import Counter
|
| 23 |
+
import matplotlib.pyplot as plt
|
| 24 |
+
from transformers import AutoTokenizer, EsmForProteinFolding
|
| 25 |
+
|
| 26 |
+
from cas9.generate import build_model_and_stuff, tokenize_input_str, detokenize_output
|
| 27 |
+
from cas9.model.utils import generate_from_x0_multi_edit
|
| 28 |
+
from cas9.objectives import Cas9Classification
|
| 29 |
+
|
| 30 |
+
# Try to import rapidfuzz for Levenshtein diversity (optional)
|
| 31 |
+
HAS_RAPIDFUZZ = False
|
| 32 |
+
HAS_LEVENSHTEIN = False
|
| 33 |
+
try:
|
| 34 |
+
from rapidfuzz.distance import Levenshtein as RapidLevenshtein
|
| 35 |
+
HAS_RAPIDFUZZ = True
|
| 36 |
+
except ImportError:
|
| 37 |
+
try:
|
| 38 |
+
from Levenshtein import distance as levenshtein_distance_fallback
|
| 39 |
+
HAS_LEVENSHTEIN = True
|
| 40 |
+
except ImportError:
|
| 41 |
+
print("Note: rapidfuzz and python-Levenshtein not available. Levenshtein diversity will be skipped.")
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def decode_validation_set(val_dataset, tokenizer, pad_id, bos_id, eos_id, num_samples=1000):
|
| 45 |
+
"""
|
| 46 |
+
Decode validation dataset into actual string sequences.
|
| 47 |
+
|
| 48 |
+
Returns:
|
| 49 |
+
Tuple of (all_sequences, sampled_sequences)
|
| 50 |
+
"""
|
| 51 |
+
all_sequences = []
|
| 52 |
+
|
| 53 |
+
print(f"Decoding sequences from validation dataset...")
|
| 54 |
+
for batch_item in tqdm(val_dataset, desc="Decoding"):
|
| 55 |
+
input_ids_batch = batch_item["input_ids"]
|
| 56 |
+
|
| 57 |
+
# Convert to tensor if needed
|
| 58 |
+
if isinstance(input_ids_batch[0], list):
|
| 59 |
+
batch_tensor = torch.tensor(input_ids_batch, dtype=torch.long)
|
| 60 |
+
elif isinstance(input_ids_batch, torch.Tensor):
|
| 61 |
+
batch_tensor = input_ids_batch
|
| 62 |
+
else:
|
| 63 |
+
batch_tensor = torch.tensor(input_ids_batch, dtype=torch.long)
|
| 64 |
+
|
| 65 |
+
# Decode each sequence to string
|
| 66 |
+
for seq_tensor in batch_tensor:
|
| 67 |
+
# Find valid length (non-padding tokens)
|
| 68 |
+
seq_list = seq_tensor.tolist()
|
| 69 |
+
valid_length = 0
|
| 70 |
+
for i, tok in enumerate(seq_list):
|
| 71 |
+
if tok == pad_id:
|
| 72 |
+
break
|
| 73 |
+
valid_length = i + 1
|
| 74 |
+
|
| 75 |
+
seq_list = seq_list[:valid_length]
|
| 76 |
+
|
| 77 |
+
# Decode to string - tokenizer will handle BOS/EOS automatically
|
| 78 |
+
if len(seq_list) > 0:
|
| 79 |
+
try:
|
| 80 |
+
# For ESM tokenizer (protein), use batch_decode
|
| 81 |
+
if hasattr(tokenizer, 'batch_decode'):
|
| 82 |
+
seq_str = tokenizer.batch_decode([seq_list], skip_special_tokens=True)[0]
|
| 83 |
+
else:
|
| 84 |
+
seq_str = tokenizer.decode(seq_list, skip_special_tokens=True)
|
| 85 |
+
|
| 86 |
+
# Remove spaces (ESM tokenizer adds spaces between tokens)
|
| 87 |
+
seq_str = seq_str.replace(" ", "")
|
| 88 |
+
|
| 89 |
+
# Verify we got a valid sequence string
|
| 90 |
+
if seq_str and len(seq_str) > 0:
|
| 91 |
+
# Check if it contains valid amino acids
|
| 92 |
+
valid_chars = sum(1 for c in seq_str if c in "ACDEFGHIKLMNPQRSTVWY")
|
| 93 |
+
if valid_chars > len(seq_str) * 0.9: # At least 90% valid amino acids
|
| 94 |
+
all_sequences.append(seq_str)
|
| 95 |
+
except Exception as e:
|
| 96 |
+
pass
|
| 97 |
+
|
| 98 |
+
# Sample random subset if needed
|
| 99 |
+
if len(all_sequences) > num_samples:
|
| 100 |
+
sampled = random.sample(all_sequences, num_samples)
|
| 101 |
+
print(f"Sampled {num_samples} sequences from {len(all_sequences)} total")
|
| 102 |
+
else:
|
| 103 |
+
sampled = all_sequences
|
| 104 |
+
print(f"Using all {len(all_sequences)} sequences from validation set")
|
| 105 |
+
|
| 106 |
+
# Debug: Print first few sequences to verify they're decoded correctly
|
| 107 |
+
if len(sampled) > 0:
|
| 108 |
+
print(f"\nSample of decoded sequences (first 3):")
|
| 109 |
+
for i, seq in enumerate(sampled[:3]):
|
| 110 |
+
print(f" Sequence {i+1}: Length={len(seq)}, First 50 chars: {seq[:50]}")
|
| 111 |
+
|
| 112 |
+
return all_sequences, sampled
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def calculate_plddt_from_sequence_string(sequence_string, esmfold_tokenizer, esm_model, device):
|
| 116 |
+
"""
|
| 117 |
+
Calculate pLDDT score for a sequence string using ESMFold.
|
| 118 |
+
Based on generate_and_analyze_plddt.py
|
| 119 |
+
"""
|
| 120 |
+
try:
|
| 121 |
+
tok = esmfold_tokenizer([sequence_string], return_tensors="pt", add_special_tokens=False).to(device)
|
| 122 |
+
with torch.no_grad():
|
| 123 |
+
out = esm_model(**tok)
|
| 124 |
+
plddt = out.plddt.mean(-1).mean(-1) # Average across both confidence and sequence length
|
| 125 |
+
# Handle scalar or tensor output
|
| 126 |
+
if plddt.dim() > 0:
|
| 127 |
+
plddt = plddt[0] # Take first element if batch dimension exists
|
| 128 |
+
return plddt.cpu().item()
|
| 129 |
+
except Exception as e:
|
| 130 |
+
print(f"Error calculating pLDDT for sequence (length {len(sequence_string)}): {e}")
|
| 131 |
+
return None
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def calculate_plddt_scores(sequences, esmfold_tokenizer, esm_model, device, batch_size=1):
|
| 135 |
+
"""
|
| 136 |
+
Calculate pLDDT scores for a list of sequences.
|
| 137 |
+
|
| 138 |
+
Args:
|
| 139 |
+
sequences: List of sequence strings
|
| 140 |
+
esmfold_tokenizer: ESMFold tokenizer
|
| 141 |
+
esm_model: ESMFold model
|
| 142 |
+
device: torch device
|
| 143 |
+
batch_size: Batch size for processing (default 1 for ESMFold)
|
| 144 |
+
|
| 145 |
+
Returns:
|
| 146 |
+
List of pLDDT scores (None for failed calculations)
|
| 147 |
+
"""
|
| 148 |
+
plddt_scores = []
|
| 149 |
+
|
| 150 |
+
for i in tqdm(range(0, len(sequences), batch_size), desc="Computing pLDDT scores"):
|
| 151 |
+
batch_seqs = sequences[i:i+batch_size]
|
| 152 |
+
for seq in batch_seqs:
|
| 153 |
+
score = calculate_plddt_from_sequence_string(seq, esmfold_tokenizer, esm_model, device)
|
| 154 |
+
plddt_scores.append(score)
|
| 155 |
+
|
| 156 |
+
return plddt_scores
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
def plot_plddt_histogram(decoded_plddt, generated_plddt, output_dir, num_bins=50, dataset_type="test"):
|
| 160 |
+
"""
|
| 161 |
+
Plot histogram comparing pLDDT scores between decoded and generated sequences.
|
| 162 |
+
Uses two subplots (one above the other) instead of overlapping distributions.
|
| 163 |
+
|
| 164 |
+
Args:
|
| 165 |
+
decoded_plddt: List of pLDDT scores for decoded sequences
|
| 166 |
+
generated_plddt: List of pLDDT scores for generated sequences
|
| 167 |
+
output_dir: Directory to save the plot
|
| 168 |
+
num_bins: Number of bins for histogram
|
| 169 |
+
dataset_type: Type of dataset ("test", "validation", etc.) for labeling
|
| 170 |
+
"""
|
| 171 |
+
# Filter out None values
|
| 172 |
+
decoded_plddt_valid = [s for s in decoded_plddt if s is not None]
|
| 173 |
+
generated_plddt_valid = [g for g in generated_plddt if g is not None]
|
| 174 |
+
|
| 175 |
+
if len(decoded_plddt_valid) == 0 or len(generated_plddt_valid) == 0:
|
| 176 |
+
print("Warning: No valid pLDDT scores to plot. Skipping histogram.")
|
| 177 |
+
return
|
| 178 |
+
|
| 179 |
+
# Create output directory if it doesn't exist
|
| 180 |
+
os.makedirs(output_dir, exist_ok=True)
|
| 181 |
+
|
| 182 |
+
# Professional color scheme - clear, distinct colors
|
| 183 |
+
color_decoded = '#2E86AB' # Professional blue
|
| 184 |
+
color_generated = '#E63946' # Clear red/coral
|
| 185 |
+
|
| 186 |
+
# Determine bin edges based on all scores
|
| 187 |
+
all_scores = decoded_plddt_valid + generated_plddt_valid
|
| 188 |
+
min_score = min(all_scores)
|
| 189 |
+
max_score = max(all_scores)
|
| 190 |
+
bin_edges = np.linspace(min_score, max_score, num_bins + 1)
|
| 191 |
+
|
| 192 |
+
# Create figure with two subplots stacked vertically
|
| 193 |
+
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(10, 8), sharex=True)
|
| 194 |
+
|
| 195 |
+
# Top subplot: Decoded sequences (true data)
|
| 196 |
+
ax1.hist(decoded_plddt_valid, bins=bin_edges, alpha=0.7, color=color_decoded,
|
| 197 |
+
density=True, edgecolor=color_decoded, linewidth=1.2)
|
| 198 |
+
ax1.set_ylabel('Density', fontsize=14, fontweight='medium')
|
| 199 |
+
ax1.set_title(f'Decoded sequences ({dataset_type} set)', fontsize=13, fontweight='medium', pad=10)
|
| 200 |
+
ax1.grid(True, alpha=0.2, linestyle='--', linewidth=0.5)
|
| 201 |
+
ax1.spines['top'].set_visible(False)
|
| 202 |
+
ax1.spines['right'].set_visible(False)
|
| 203 |
+
ax1.spines['left'].set_linewidth(0.8)
|
| 204 |
+
ax1.spines['bottom'].set_linewidth(0.8)
|
| 205 |
+
ax1.tick_params(axis='both', which='major', labelsize=12, length=4, width=0.8)
|
| 206 |
+
|
| 207 |
+
# Bottom subplot: Generated sequences
|
| 208 |
+
ax2.hist(generated_plddt_valid, bins=bin_edges, alpha=0.7, color=color_generated,
|
| 209 |
+
density=True, edgecolor=color_generated, linewidth=1.2)
|
| 210 |
+
ax2.set_xlabel('pLDDT Score', fontsize=14, fontweight='medium')
|
| 211 |
+
ax2.set_ylabel('Density', fontsize=14, fontweight='medium')
|
| 212 |
+
ax2.set_title('Generated sequences', fontsize=13, fontweight='medium', pad=10)
|
| 213 |
+
ax2.grid(True, alpha=0.2, linestyle='--', linewidth=0.5)
|
| 214 |
+
ax2.spines['top'].set_visible(False)
|
| 215 |
+
ax2.spines['right'].set_visible(False)
|
| 216 |
+
ax2.spines['left'].set_linewidth(0.8)
|
| 217 |
+
ax2.spines['bottom'].set_linewidth(0.8)
|
| 218 |
+
ax2.tick_params(axis='both', which='major', labelsize=12, length=4, width=0.8)
|
| 219 |
+
|
| 220 |
+
# Adjust spacing between subplots
|
| 221 |
+
plt.tight_layout()
|
| 222 |
+
|
| 223 |
+
# Save plot
|
| 224 |
+
filename = os.path.join(output_dir, 'plddt_histogram_comparison.png')
|
| 225 |
+
plt.savefig(filename, dpi=300, bbox_inches='tight', facecolor='white')
|
| 226 |
+
print(f"\nSaved pLDDT histogram to {filename}")
|
| 227 |
+
plt.close()
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
def calculate_sequence_loss(editflow, sequences, tokenizer, pad_id, bos_id, eos_id, device, cfg, batch_size=32):
|
| 232 |
+
"""
|
| 233 |
+
Calculate sequence loss (val_unweighted_total_loss) for sequences.
|
| 234 |
+
Uses the same loss calculation as validation_step in base_models.py.
|
| 235 |
+
|
| 236 |
+
Args:
|
| 237 |
+
editflow: EditFlow LightningModule
|
| 238 |
+
sequences: List of sequence strings
|
| 239 |
+
tokenizer: Tokenizer
|
| 240 |
+
pad_id, bos_id, eos_id: Special token IDs
|
| 241 |
+
device: torch device
|
| 242 |
+
cfg: Config object
|
| 243 |
+
batch_size: Batch size for processing
|
| 244 |
+
|
| 245 |
+
Returns:
|
| 246 |
+
List of loss values (one per sequence)
|
| 247 |
+
"""
|
| 248 |
+
editflow.eval()
|
| 249 |
+
all_losses = []
|
| 250 |
+
|
| 251 |
+
print(f"Calculating sequence loss for {len(sequences)} sequences...")
|
| 252 |
+
|
| 253 |
+
# Process in batches
|
| 254 |
+
for i in tqdm(range(0, len(sequences), batch_size), desc="Computing losses"):
|
| 255 |
+
batch_seqs = sequences[i:i+batch_size]
|
| 256 |
+
|
| 257 |
+
# Tokenize batch
|
| 258 |
+
x1_batch = []
|
| 259 |
+
for seq_str in batch_seqs:
|
| 260 |
+
x1 = tokenize_input_str(seq_str, cfg, tokenizer, bos_id, eos_id, pad_id, device)
|
| 261 |
+
x1_batch.append(x1.squeeze(0))
|
| 262 |
+
|
| 263 |
+
# Pad to same length
|
| 264 |
+
max_len = max(x.shape[0] for x in x1_batch)
|
| 265 |
+
padded_batch = []
|
| 266 |
+
for x in x1_batch:
|
| 267 |
+
padding = torch.full((max_len - x.shape[0],), pad_id, dtype=torch.long, device=device)
|
| 268 |
+
padded_batch.append(torch.cat([x, padding]))
|
| 269 |
+
x1_tensor = torch.stack(padded_batch).to(device)
|
| 270 |
+
|
| 271 |
+
# Calculate loss for this batch
|
| 272 |
+
with torch.no_grad():
|
| 273 |
+
# Call preparation to get necessary tensors
|
| 274 |
+
if editflow.reparameterize:
|
| 275 |
+
lam_total, logits_type, logits_ins, logits_sub, z_t, z_1, x_t, mask, weight, M_t = editflow.preparation(x1_tensor)
|
| 276 |
+
|
| 277 |
+
if editflow.loc_prop_path:
|
| 278 |
+
loss, loss_components = editflow.loss_fn.reparameterized_forward_localized(
|
| 279 |
+
lam_total, logits_type, logits_ins, logits_sub,
|
| 280 |
+
z_t, z_1, x_t, mask, weight, M_t, editflow.lam_prop,
|
| 281 |
+
editflow.eps_id, editflow.bos_id, editflow.eos_id,
|
| 282 |
+
editflow.gamma_rate, editflow.gamma_edit,
|
| 283 |
+
editflow.use_aux_ce, editflow.aux_ce_weight
|
| 284 |
+
)
|
| 285 |
+
else:
|
| 286 |
+
loss, loss_components = editflow.loss_fn.reparameterized_forward(
|
| 287 |
+
lam_total, logits_type, logits_ins, logits_sub,
|
| 288 |
+
z_t, z_1, x_t, mask, weight, editflow.eps_id,
|
| 289 |
+
editflow.bos_id, editflow.eos_id, editflow.gamma_rate,
|
| 290 |
+
editflow.gamma_edit, editflow.use_aux_ce, editflow.aux_ce_weight
|
| 291 |
+
)
|
| 292 |
+
else:
|
| 293 |
+
lam_ins, logits_ins, lam_del, lam_sub, logits_sub, z_t, z_1, x_t, mask, weight, M_t = editflow.preparation(x1_tensor)
|
| 294 |
+
|
| 295 |
+
if editflow.loc_prop_path:
|
| 296 |
+
loss, loss_components = editflow.loss_fn.forward_localized(
|
| 297 |
+
lam_ins, logits_ins, lam_del, lam_sub, logits_sub,
|
| 298 |
+
z_t, z_1, x_t, mask, weight, M_t, editflow.lam_prop,
|
| 299 |
+
editflow.eps_id, editflow.bos_id, editflow.eos_id,
|
| 300 |
+
editflow.use_aux_ce, editflow.aux_ce_weight
|
| 301 |
+
)
|
| 302 |
+
else:
|
| 303 |
+
loss, loss_components = editflow.loss_fn.forward(
|
| 304 |
+
lam_ins, logits_ins, lam_del, lam_sub, logits_sub,
|
| 305 |
+
z_t, z_1, x_t, mask, weight, editflow.eps_id,
|
| 306 |
+
editflow.bos_id, editflow.eos_id,
|
| 307 |
+
editflow.use_aux_ce, editflow.aux_ce_weight
|
| 308 |
+
)
|
| 309 |
+
|
| 310 |
+
# Extract unweighted total loss (matching val_unweighted_total_loss)
|
| 311 |
+
if "loss_total_unweighted" in loss_components:
|
| 312 |
+
# For reparameterized models
|
| 313 |
+
unweighted_loss = loss_components["loss_total_unweighted"]
|
| 314 |
+
elif "loss_base" in loss_components:
|
| 315 |
+
# For non-reparameterized models, loss_base is rate + edit (unweighted)
|
| 316 |
+
unweighted_loss = loss_components["loss_base"]
|
| 317 |
+
else:
|
| 318 |
+
# Fallback to total loss
|
| 319 |
+
unweighted_loss = loss
|
| 320 |
+
|
| 321 |
+
# Get per-sequence losses (loss is averaged over batch, so we need to compute per-sequence)
|
| 322 |
+
# Since loss is batch-averaged, we'll use the batch loss for all sequences in the batch
|
| 323 |
+
# For more accurate per-sequence loss, we'd need to compute individually
|
| 324 |
+
batch_loss_value = unweighted_loss.item()
|
| 325 |
+
all_losses.extend([batch_loss_value] * len(batch_seqs))
|
| 326 |
+
|
| 327 |
+
return all_losses
|
| 328 |
+
|
| 329 |
+
|
| 330 |
+
def kgrams(s: str, k: int):
|
| 331 |
+
"""Extract k-grams from a string."""
|
| 332 |
+
s = s.strip()
|
| 333 |
+
if len(s) < k:
|
| 334 |
+
return {s} if s else set()
|
| 335 |
+
return {s[i:i+k] for i in range(len(s) - k + 1)}
|
| 336 |
+
|
| 337 |
+
|
| 338 |
+
def jaccard(a: set, b: set) -> float:
|
| 339 |
+
"""Calculate Jaccard similarity between two sets."""
|
| 340 |
+
if not a and not b:
|
| 341 |
+
return 1.0
|
| 342 |
+
inter = len(a & b)
|
| 343 |
+
union = len(a | b)
|
| 344 |
+
return inter / union if union else 1.0
|
| 345 |
+
|
| 346 |
+
|
| 347 |
+
def diversity_kmer_jaccard(seqs, k=3, pairs=50000, seed=0):
|
| 348 |
+
"""
|
| 349 |
+
Diversity = 1 - average Jaccard similarity over random pairs.
|
| 350 |
+
Works for variable-length strings.
|
| 351 |
+
|
| 352 |
+
Returns:
|
| 353 |
+
diversity (1 - avg_sim), avg_similarity
|
| 354 |
+
"""
|
| 355 |
+
if len(seqs) < 2:
|
| 356 |
+
return 0.0, 1.0
|
| 357 |
+
|
| 358 |
+
rng = random.Random(seed)
|
| 359 |
+
grams = [kgrams(s, k) for s in seqs]
|
| 360 |
+
n = len(seqs)
|
| 361 |
+
|
| 362 |
+
# Limit pairs to avoid excessive computation
|
| 363 |
+
max_pairs = min(pairs, n * (n - 1) // 2)
|
| 364 |
+
if max_pairs == 0:
|
| 365 |
+
return 0.0, 1.0
|
| 366 |
+
|
| 367 |
+
total_sim = 0.0
|
| 368 |
+
for _ in range(max_pairs):
|
| 369 |
+
i = rng.randrange(n)
|
| 370 |
+
j = rng.randrange(n - 1)
|
| 371 |
+
if j >= i:
|
| 372 |
+
j += 1
|
| 373 |
+
total_sim += jaccard(grams[i], grams[j])
|
| 374 |
+
|
| 375 |
+
avg_sim = total_sim / max_pairs
|
| 376 |
+
return 1.0 - avg_sim, avg_sim
|
| 377 |
+
|
| 378 |
+
|
| 379 |
+
def diversity_levenshtein(seqs, pairs=20000, seed=0):
|
| 380 |
+
"""
|
| 381 |
+
Diversity = 1 - average normalized Levenshtein similarity over random pairs.
|
| 382 |
+
|
| 383 |
+
Returns:
|
| 384 |
+
diversity (1 - avg_sim), avg_similarity
|
| 385 |
+
"""
|
| 386 |
+
if len(seqs) < 2:
|
| 387 |
+
return 0.0, 1.0
|
| 388 |
+
|
| 389 |
+
if not (HAS_RAPIDFUZZ or HAS_LEVENSHTEIN):
|
| 390 |
+
# No Levenshtein implementation available
|
| 391 |
+
return 0.0, 1.0
|
| 392 |
+
|
| 393 |
+
rng = random.Random(seed)
|
| 394 |
+
n = len(seqs)
|
| 395 |
+
|
| 396 |
+
# Limit pairs to avoid excessive computation
|
| 397 |
+
max_pairs = min(pairs, n * (n - 1) // 2)
|
| 398 |
+
if max_pairs == 0:
|
| 399 |
+
return 0.0, 1.0
|
| 400 |
+
|
| 401 |
+
total_sim = 0.0
|
| 402 |
+
valid_pairs = 0
|
| 403 |
+
for _ in range(max_pairs):
|
| 404 |
+
i = rng.randrange(n)
|
| 405 |
+
j = rng.randrange(n - 1)
|
| 406 |
+
if j >= i:
|
| 407 |
+
j += 1
|
| 408 |
+
|
| 409 |
+
if HAS_RAPIDFUZZ:
|
| 410 |
+
# Use rapidfuzz for fast normalized similarity
|
| 411 |
+
sim = RapidLevenshtein.normalized_similarity(seqs[i], seqs[j])
|
| 412 |
+
elif HAS_LEVENSHTEIN:
|
| 413 |
+
# Fallback: use python-Levenshtein distance and normalize
|
| 414 |
+
# Simple normalization: 1 - (distance / max_length)
|
| 415 |
+
dist = levenshtein_distance_fallback(seqs[i], seqs[j])
|
| 416 |
+
max_len = max(len(seqs[i]), len(seqs[j]))
|
| 417 |
+
sim = 1.0 - (dist / max_len) if max_len > 0 else 1.0
|
| 418 |
+
else:
|
| 419 |
+
# Should not reach here, but skip if somehow we do
|
| 420 |
+
continue
|
| 421 |
+
|
| 422 |
+
total_sim += sim
|
| 423 |
+
valid_pairs += 1
|
| 424 |
+
|
| 425 |
+
if valid_pairs == 0:
|
| 426 |
+
return 0.0, 1.0
|
| 427 |
+
|
| 428 |
+
avg_sim = total_sim / valid_pairs
|
| 429 |
+
return 1.0 - avg_sim, avg_sim
|
| 430 |
+
|
| 431 |
+
|
| 432 |
+
def calculate_diversity(sequences):
|
| 433 |
+
"""
|
| 434 |
+
Calculate diversity metrics for a set of sequences.
|
| 435 |
+
Uses k-mer Jaccard diversity and optionally Levenshtein diversity.
|
| 436 |
+
|
| 437 |
+
Args:
|
| 438 |
+
sequences: List of sequence strings
|
| 439 |
+
|
| 440 |
+
Returns:
|
| 441 |
+
Dictionary with diversity metrics:
|
| 442 |
+
- unique_count: Number of unique sequences
|
| 443 |
+
- uniqueness_ratio: Fraction of unique sequences
|
| 444 |
+
- kmer_diversity: k-mer Jaccard diversity (1 - avg_similarity)
|
| 445 |
+
- kmer_avg_similarity: Average k-mer Jaccard similarity
|
| 446 |
+
- levenshtein_diversity: Levenshtein diversity (if available)
|
| 447 |
+
- levenshtein_avg_similarity: Average Levenshtein similarity (if available)
|
| 448 |
+
"""
|
| 449 |
+
if len(sequences) == 0:
|
| 450 |
+
return {
|
| 451 |
+
'unique_count': 0,
|
| 452 |
+
'uniqueness_ratio': 0.0,
|
| 453 |
+
'kmer_diversity': 0.0,
|
| 454 |
+
'kmer_avg_similarity': 1.0,
|
| 455 |
+
'levenshtein_diversity': 0.0,
|
| 456 |
+
'levenshtein_avg_similarity': 1.0
|
| 457 |
+
}
|
| 458 |
+
|
| 459 |
+
# Unique fraction
|
| 460 |
+
unique_count = len(set(sequences))
|
| 461 |
+
uniqueness_ratio = unique_count / len(sequences) if len(sequences) > 0 else 0.0
|
| 462 |
+
|
| 463 |
+
# k-mer Jaccard diversity
|
| 464 |
+
kmer_div, kmer_sim = diversity_kmer_jaccard(sequences, k=3, pairs=50000, seed=0)
|
| 465 |
+
|
| 466 |
+
# Levenshtein diversity (if available)
|
| 467 |
+
if HAS_RAPIDFUZZ or HAS_LEVENSHTEIN:
|
| 468 |
+
lev_div, lev_sim = diversity_levenshtein(sequences, pairs=20000, seed=0)
|
| 469 |
+
else:
|
| 470 |
+
lev_div, lev_sim = 0.0, 1.0
|
| 471 |
+
|
| 472 |
+
return {
|
| 473 |
+
'unique_count': unique_count,
|
| 474 |
+
'uniqueness_ratio': uniqueness_ratio,
|
| 475 |
+
'kmer_diversity': kmer_div,
|
| 476 |
+
'kmer_avg_similarity': kmer_sim,
|
| 477 |
+
'levenshtein_diversity': lev_div,
|
| 478 |
+
'levenshtein_avg_similarity': lev_sim
|
| 479 |
+
}
|
| 480 |
+
|
| 481 |
+
|
| 482 |
+
def evaluate_cas9_scores(sequences, cas9_classifier, threshold=0.5):
|
| 483 |
+
"""
|
| 484 |
+
Evaluate Cas9 scores for sequences.
|
| 485 |
+
|
| 486 |
+
Args:
|
| 487 |
+
sequences: List of sequence strings
|
| 488 |
+
cas9_classifier: Cas9Classification object
|
| 489 |
+
threshold: Score threshold for validity (default 0.5)
|
| 490 |
+
|
| 491 |
+
Returns:
|
| 492 |
+
validity_rate, average_score, list of scores
|
| 493 |
+
"""
|
| 494 |
+
if len(sequences) == 0:
|
| 495 |
+
return 0.0, 0.0, []
|
| 496 |
+
|
| 497 |
+
# Get scores in batches
|
| 498 |
+
scores = []
|
| 499 |
+
batch_size = 32
|
| 500 |
+
|
| 501 |
+
for i in tqdm(range(0, len(sequences), batch_size), desc="Computing Cas9 scores"):
|
| 502 |
+
batch_seqs = sequences[i:i+batch_size]
|
| 503 |
+
batch_scores = cas9_classifier.get_scores(batch_seqs)
|
| 504 |
+
scores.extend(batch_scores)
|
| 505 |
+
|
| 506 |
+
scores = np.array(scores)
|
| 507 |
+
validity_rate = np.mean(scores > threshold)
|
| 508 |
+
avg_score = np.mean(scores)
|
| 509 |
+
|
| 510 |
+
return validity_rate, avg_score, scores.tolist()
|
| 511 |
+
|
| 512 |
+
|
| 513 |
+
def generate_sequences_multi_edit(model, source_dist, tokenizer, pad_id, bos_id, eos_id, eps_id,
|
| 514 |
+
input_sequences, device, cfg, num_steps=20, batch_size=32, num_generations_per_sequence=1):
|
| 515 |
+
"""
|
| 516 |
+
Generate sequences from input sequences using multi-edit generation.
|
| 517 |
+
|
| 518 |
+
Args:
|
| 519 |
+
model: Editflows model
|
| 520 |
+
input_sequences: List of input sequence strings
|
| 521 |
+
device: torch device
|
| 522 |
+
cfg: Config object
|
| 523 |
+
num_steps: Number of generation steps
|
| 524 |
+
batch_size: Batch size for generation
|
| 525 |
+
num_generations_per_sequence: Number of sequences to generate per input sequence
|
| 526 |
+
|
| 527 |
+
Returns:
|
| 528 |
+
List of generated sequence strings
|
| 529 |
+
"""
|
| 530 |
+
model.eval()
|
| 531 |
+
generated_sequences = []
|
| 532 |
+
|
| 533 |
+
# Get allowed tokens
|
| 534 |
+
allowed_tokens = torch.tensor(
|
| 535 |
+
[tok for tok in source_dist._allowed_tokens if tok not in (eps_id,)],
|
| 536 |
+
device=device,
|
| 537 |
+
dtype=torch.long,
|
| 538 |
+
)
|
| 539 |
+
|
| 540 |
+
print(f"Generating sequences using multi-edit generation (num_steps={num_steps}, batch_size={batch_size}, num_generations_per_sequence={num_generations_per_sequence})...")
|
| 541 |
+
print(f"Total sequences to generate: {len(input_sequences)} input sequences × {num_generations_per_sequence} = {len(input_sequences) * num_generations_per_sequence}")
|
| 542 |
+
|
| 543 |
+
# Process in batches
|
| 544 |
+
for i in tqdm(range(0, len(input_sequences), batch_size), desc="Generating"):
|
| 545 |
+
batch_seqs = input_sequences[i:i+batch_size]
|
| 546 |
+
|
| 547 |
+
# Generate num_generations_per_sequence for each sequence in the batch
|
| 548 |
+
for gen_idx in range(num_generations_per_sequence):
|
| 549 |
+
# Tokenize batch
|
| 550 |
+
x0_batch = []
|
| 551 |
+
for seq_str in batch_seqs:
|
| 552 |
+
x0 = tokenize_input_str(seq_str, cfg, tokenizer, bos_id, eos_id, pad_id, device)
|
| 553 |
+
x0_batch.append(x0.squeeze(0))
|
| 554 |
+
|
| 555 |
+
# Pad to same length
|
| 556 |
+
max_len = max(x.shape[0] for x in x0_batch)
|
| 557 |
+
padded_batch = []
|
| 558 |
+
for x in x0_batch:
|
| 559 |
+
padding = torch.full((max_len - x.shape[0],), pad_id, dtype=torch.long, device=device)
|
| 560 |
+
padded_batch.append(torch.cat([x, padding]))
|
| 561 |
+
x0_tensor = torch.stack(padded_batch).to(device)
|
| 562 |
+
|
| 563 |
+
# Generate using multi-edit generation
|
| 564 |
+
with torch.no_grad():
|
| 565 |
+
x_gen = generate_from_x0_multi_edit(
|
| 566 |
+
model,
|
| 567 |
+
x0_tensor,
|
| 568 |
+
pad_id=pad_id,
|
| 569 |
+
bos_id=bos_id,
|
| 570 |
+
eos_id=eos_id,
|
| 571 |
+
allowed_tokens=allowed_tokens,
|
| 572 |
+
num_steps=num_steps,
|
| 573 |
+
device=device,
|
| 574 |
+
)
|
| 575 |
+
|
| 576 |
+
# Decode generated sequences
|
| 577 |
+
for j in range(x_gen.shape[0]):
|
| 578 |
+
gen_seq = detokenize_output(x_gen[j:j+1], cfg, tokenizer, bos_id, eos_id, pad_id)
|
| 579 |
+
generated_sequences.append(gen_seq)
|
| 580 |
+
|
| 581 |
+
return generated_sequences
|
| 582 |
+
|
| 583 |
+
|
| 584 |
+
def get_evaluate_val_argument_parser():
|
| 585 |
+
parser = argparse.ArgumentParser(description='Evaluate Editflows model by generating sequences')
|
| 586 |
+
parser.add_argument('--config', type=str, required=True,
|
| 587 |
+
help='Path to config YAML file')
|
| 588 |
+
parser.add_argument('--ckpt', type=str, required=True,
|
| 589 |
+
help='Path to model checkpoint (.ckpt file)')
|
| 590 |
+
parser.add_argument('--val_data', type=str, default=None,
|
| 591 |
+
help='Path to validation dataset (output from uniref_pre_batching.py). Deprecated: use --test_data instead.')
|
| 592 |
+
parser.add_argument('--test_data', type=str, default=None,
|
| 593 |
+
help='Path to test dataset (output from uniref_pre_batching.py)')
|
| 594 |
+
parser.add_argument('--cas9_classifier_ckpt', type=str, default=None,
|
| 595 |
+
help='Path to Cas9 classifier checkpoint (optional, if not provided, validity rate will not be calculated)')
|
| 596 |
+
parser.add_argument('--cas9_classifier_config', type=str, default=None,
|
| 597 |
+
help='Path to Cas9 classifier config (optional, required if --cas9_classifier_ckpt is provided)')
|
| 598 |
+
parser.add_argument('--num_samples', type=int, default=1000,
|
| 599 |
+
help='Number of sequences to sample from test set (default: 1000)')
|
| 600 |
+
parser.add_argument('--num_generations_per_sequence', type=int, default=1,
|
| 601 |
+
help='Number of sequences to generate per sampled sequence (default: 1). Total generated = num_samples × num_generations_per_sequence')
|
| 602 |
+
parser.add_argument('--num_steps', type=int, default=20,
|
| 603 |
+
help='Number of generation steps (default: 20)')
|
| 604 |
+
parser.add_argument('--batch_size', type=int, default=32,
|
| 605 |
+
help='Batch size for generation (default: 32)')
|
| 606 |
+
parser.add_argument('--validity_threshold', type=float, default=0.5,
|
| 607 |
+
help='Cas9 score threshold for validity (default: 0.5)')
|
| 608 |
+
parser.add_argument('--device', type=str, default='cuda' if torch.cuda.is_available() else 'cpu',
|
| 609 |
+
help='Device to use (cuda/cpu)')
|
| 610 |
+
parser.add_argument('--seed', type=int, default=42,
|
| 611 |
+
help='Random seed (default: 42)')
|
| 612 |
+
parser.add_argument('--output_dir', type=str, default='./evaluation_output',
|
| 613 |
+
help='Directory to save pLDDT plots (default: ./evaluation_output)')
|
| 614 |
+
parser.add_argument('--output_fasta', type=str, default=None,
|
| 615 |
+
help='Path to save generated sequences as FASTA file (default: {output_dir}/generated_sequences.fasta)')
|
| 616 |
+
parser.add_argument('--num_bins', type=int, default=50,
|
| 617 |
+
help='Number of bins for pLDDT histogram (default: 50)')
|
| 618 |
+
parser.add_argument('--calculate_plddt', action='store_true',
|
| 619 |
+
help='Enable pLDDT calculation and plotting (default: False, pLDDT calculation is disabled by default)')
|
| 620 |
+
parser.add_argument('--include_train', action='store_true',
|
| 621 |
+
help='Also compute losses for the train set (default: False)')
|
| 622 |
+
return parser
|
| 623 |
+
|
| 624 |
+
|
| 625 |
+
def _set_eval_run_seeds(seed: int) -> None:
|
| 626 |
+
random.seed(seed)
|
| 627 |
+
np.random.seed(seed)
|
| 628 |
+
torch.manual_seed(seed)
|
| 629 |
+
if torch.cuda.is_available():
|
| 630 |
+
torch.cuda.manual_seed_all(seed)
|
| 631 |
+
|
| 632 |
+
|
| 633 |
+
def run_evaluate_val(
|
| 634 |
+
args,
|
| 635 |
+
*,
|
| 636 |
+
run_seed=None,
|
| 637 |
+
save_fasta=True,
|
| 638 |
+
fasta_tag=None,
|
| 639 |
+
):
|
| 640 |
+
"""
|
| 641 |
+
Run the full evaluate_val pipeline and return metrics as a nested dict of JSON-serializable values.
|
| 642 |
+
|
| 643 |
+
Args:
|
| 644 |
+
args: Namespace from get_evaluate_val_argument_parser().parse_args() (or compatible).
|
| 645 |
+
run_seed: If int, seeds random/numpy/torch before stochastic steps. If None, behavior matches
|
| 646 |
+
legacy evaluate_val (no seeding).
|
| 647 |
+
save_fasta: If False, skip writing generated sequences to FASTA.
|
| 648 |
+
fasta_tag: If set (and save_fasta), write to output_dir/generated_sequences_{fasta_tag}.fasta
|
| 649 |
+
unless output_fasta is explicitly set on args.
|
| 650 |
+
"""
|
| 651 |
+
if run_seed is not None:
|
| 652 |
+
_set_eval_run_seeds(int(run_seed))
|
| 653 |
+
|
| 654 |
+
device = torch.device(args.device)
|
| 655 |
+
|
| 656 |
+
# Load config
|
| 657 |
+
with open(args.config, 'r') as f:
|
| 658 |
+
cfg = edict(yaml.safe_load(f))
|
| 659 |
+
|
| 660 |
+
# Build model
|
| 661 |
+
print("Building model...")
|
| 662 |
+
editflow, source_dist, tokenizer, pad_id, bos_id, eos_id, eps_id = build_model_and_stuff(cfg, device)
|
| 663 |
+
|
| 664 |
+
# Load checkpoint
|
| 665 |
+
print(f"Loading checkpoint from {args.ckpt}...")
|
| 666 |
+
ckpt = torch.load(args.ckpt, map_location=device, weights_only=False)
|
| 667 |
+
editflow.load_state_dict(ckpt["state_dict"], strict=False)
|
| 668 |
+
model = editflow.model.to(device)
|
| 669 |
+
model.eval()
|
| 670 |
+
|
| 671 |
+
# Initialize Cas9 classifier (optional)
|
| 672 |
+
cas9_classifier = None
|
| 673 |
+
if args.cas9_classifier_ckpt is not None:
|
| 674 |
+
if args.cas9_classifier_config is None:
|
| 675 |
+
raise ValueError("--cas9_classifier_config is required when --cas9_classifier_ckpt is provided")
|
| 676 |
+
|
| 677 |
+
print("Initializing Cas9 classifier...")
|
| 678 |
+
cas9_classifier = Cas9Classification(
|
| 679 |
+
device=device,
|
| 680 |
+
checkpoint_path=args.cas9_classifier_ckpt,
|
| 681 |
+
config_path=args.cas9_classifier_config,
|
| 682 |
+
shared_esm_model=model.esm_emb # Share ESM model to save memory
|
| 683 |
+
)
|
| 684 |
+
|
| 685 |
+
# Test classifier on a known Cas9 sequence to verify it works
|
| 686 |
+
test_cas9_seq = "MDKKYSIGLDIGTNSVGWAVITDEYKVPSKKFKVLGNTDRHSIKKNLIGALLFDSGETAEATRLKRTARRRYTRRKNRICYLQEIFSNEMAKVDDSFFHRLEESFLVEEDKKHERHPIFGNIVDEVAYHEKYPTIYHLRKKLVDSTDKADLRLIYLALAHMIKFRGHFLIEGDLNPDNSDVDKLFIQLVQTYNQLFEENPINASGVDAKAILSARLSKSRRLENLIAQLPGEKKNGLFGNLIALSLGLTPNFKSNFDLAEDAKLQLSKDTYDDDLDNLLAQIGDQYADLFLAAKNLSDAILLSDILRVNTEITKAPLSASMIKRYDEHHQDLTLLKALVRQQLPEKYKEIFFDQSKNGYAGYIDGGASQEEFYKFIKPILEKMDGTEELLVKLNREDLLRKQRTFDNGSIPHQIHLGELHAILRRQEDFYPFLKDNREKIEKILTFRIPYYVGPLARGNSRFAWMTRKSEETITPWNFEEVVDKGASAQSFIERMTNFDKNLPNEKVLPKHSLLYEYFTVYNELTKVKYVTEGMRKPAFLSGEQKKAIVDLLFKTNRKVTVKQLKEDYFKKIECFDSVEISGVEDRFNASLGTYHDLLKIIKDKDFLDNEENEDILEDIVLTLTLFEDREMIEERLKTYAHLFDDKVMKQLKRRRYTGWGRLSRKLINGIRDKQSGKTILDFLKSDGFANRNFMQLIHDDSLTFKEDIQKAQVSGQGDSLHEHIANLAGSPAIKKGILQTVKVVDELVKVMGRHKPENIVIEMARENQTTQKGQKNSRERMKRIEEGIKELGSQILKEHPVENTQLQNEKLYLYYLQNGRDMYVDQELDINRLSDYDVDHIVPQSFLKDDSIDNKVLTRSDKNRGKSDNVPSEEVVKKMKNYWRQLLNAKLITQRKFDNLTKAERGGLSELDKAGFIKRQLVETRQITKHVAQILDSRMNTKYDENDKLIREVKVITLKSKLVSDFRKDFQFYKVREINNYHHAHDAYLNAVVGTALIKKYPKLESEFVYGDYKVYDVRKMIAKSEQEIGKATAKYFFYSNIMNFFKTEITLANGEIRKRPLIETNGETGEIVWDKGRDFATVRKVLSMPQVNIVKKTEVQTGGFSKESILPKRNSDKLIARKKDWDPKKYGGFDSPTVAYSVLVVAKVEKGKSKKLKSVKELLGITIMERSSFEKNPIDFLEAKGYKEVKKDLIIKLPKYSLFELENGRKRMLASAGELQKGNELALPSKYVNFLYLASHYEKLKGSPEDNEQKQLFVEQHKHYLDEIIEQISEFSKRVILADANLDKVLSAYNKHRDKPIREQAENIIHLFTLTNLGAPAAFKYFDTTIDRKRYTSTKEVLDATLIHQSITGLYETRIDLSQLGGD"
|
| 687 |
+
test_score = cas9_classifier.get_scores([test_cas9_seq])[0]
|
| 688 |
+
print(f"Test Cas9 classifier on known Cas9 sequence: score = {test_score:.4f}")
|
| 689 |
+
if test_score < 0.5:
|
| 690 |
+
print(f"WARNING: Known Cas9 sequence got low score! This suggests a problem with the classifier or sequence format.")
|
| 691 |
+
else:
|
| 692 |
+
print(f"Classifier working correctly (score > 0.5 for known Cas9 sequence)")
|
| 693 |
+
else:
|
| 694 |
+
print("No Cas9 classifier provided. Validity rate will not be calculated.")
|
| 695 |
+
|
| 696 |
+
# Determine which dataset path to use (test_data takes precedence)
|
| 697 |
+
if args.test_data is not None:
|
| 698 |
+
dataset_path = args.test_data
|
| 699 |
+
split_name = "test"
|
| 700 |
+
dataset_type = "test"
|
| 701 |
+
elif args.val_data is not None:
|
| 702 |
+
dataset_path = args.val_data
|
| 703 |
+
# Try "validation" or "val" first when --val_data is used
|
| 704 |
+
split_name = None
|
| 705 |
+
dataset_type = "validation"
|
| 706 |
+
else:
|
| 707 |
+
raise ValueError("Either --test_data or --val_data must be provided")
|
| 708 |
+
|
| 709 |
+
# Load dataset
|
| 710 |
+
print(f"\nLoading {dataset_type} dataset from {dataset_path}...")
|
| 711 |
+
dataset_dict = load_from_disk(dataset_path)
|
| 712 |
+
|
| 713 |
+
# Determine which split to use
|
| 714 |
+
if split_name is None:
|
| 715 |
+
# When --val_data is used, prioritize validation splits
|
| 716 |
+
if "validation" in dataset_dict:
|
| 717 |
+
test_dataset = dataset_dict["validation"]
|
| 718 |
+
split_name = "validation"
|
| 719 |
+
elif "val" in dataset_dict:
|
| 720 |
+
test_dataset = dataset_dict["val"]
|
| 721 |
+
split_name = "val"
|
| 722 |
+
elif "test" in dataset_dict:
|
| 723 |
+
# Fallback to test if validation splits not found
|
| 724 |
+
test_dataset = dataset_dict["test"]
|
| 725 |
+
split_name = "test"
|
| 726 |
+
dataset_type = "test"
|
| 727 |
+
else:
|
| 728 |
+
raise ValueError(f"Dataset must have 'validation', 'val', or 'test' split. Found splits: {list(dataset_dict.keys())}")
|
| 729 |
+
else:
|
| 730 |
+
if split_name in dataset_dict:
|
| 731 |
+
test_dataset = dataset_dict[split_name]
|
| 732 |
+
else:
|
| 733 |
+
raise ValueError(f"Dataset must have '{split_name}' split. Found splits: {list(dataset_dict.keys())}")
|
| 734 |
+
|
| 735 |
+
# Load train dataset if --include_train flag is set
|
| 736 |
+
train_dataset = None
|
| 737 |
+
all_train_sequences = None
|
| 738 |
+
train_avg_loss = None
|
| 739 |
+
if args.include_train:
|
| 740 |
+
if "train" in dataset_dict:
|
| 741 |
+
train_dataset = dataset_dict["train"]
|
| 742 |
+
print(f"\nLoading train dataset from {dataset_path}...")
|
| 743 |
+
print(f"Train dataset loaded: {len(train_dataset)} items")
|
| 744 |
+
else:
|
| 745 |
+
print(f"Warning: --include_train flag set but 'train' split not found in dataset. Found splits: {list(dataset_dict.keys())}")
|
| 746 |
+
print("Skipping train set evaluation.")
|
| 747 |
+
|
| 748 |
+
# Step 1: Decode test set into string sequences
|
| 749 |
+
print(f"\n{'='*70}")
|
| 750 |
+
print("STEP 1: Decoding test set")
|
| 751 |
+
print(f"{'='*70}")
|
| 752 |
+
all_decoded_sequences, sampled_decoded_sequences = decode_validation_set(
|
| 753 |
+
test_dataset,
|
| 754 |
+
tokenizer,
|
| 755 |
+
pad_id,
|
| 756 |
+
bos_id,
|
| 757 |
+
eos_id,
|
| 758 |
+
num_samples=args.num_samples
|
| 759 |
+
)
|
| 760 |
+
|
| 761 |
+
# Step 2: Calculate metrics on decoded sequences
|
| 762 |
+
print(f"\n{'='*70}")
|
| 763 |
+
print("STEP 2: Evaluating decoded sequences")
|
| 764 |
+
print(f"{'='*70}")
|
| 765 |
+
|
| 766 |
+
# Calculate sequence loss on FULL test set
|
| 767 |
+
print(f"\nCalculating sequence loss for FULL test set ({len(all_decoded_sequences)} sequences)...")
|
| 768 |
+
orig_losses_full = calculate_sequence_loss(
|
| 769 |
+
editflow, all_decoded_sequences, tokenizer, pad_id, bos_id, eos_id, device, cfg, batch_size=args.batch_size
|
| 770 |
+
)
|
| 771 |
+
orig_avg_loss_full = np.mean(orig_losses_full)
|
| 772 |
+
print(f" Average sequence loss (full test set): {orig_avg_loss_full:.4f}")
|
| 773 |
+
|
| 774 |
+
# Calculate other metrics on sampled sequences only
|
| 775 |
+
print(f"\nCalculating diversity for sampled decoded sequences ({len(sampled_decoded_sequences)} sequences)...")
|
| 776 |
+
orig_diversity = calculate_diversity(sampled_decoded_sequences)
|
| 777 |
+
|
| 778 |
+
# Calculate sequence loss for sampled sequences (for comparison)
|
| 779 |
+
print(f"\nCalculating sequence loss for sampled decoded sequences ({len(sampled_decoded_sequences)} sequences)...")
|
| 780 |
+
orig_losses = calculate_sequence_loss(
|
| 781 |
+
editflow, sampled_decoded_sequences, tokenizer, pad_id, bos_id, eos_id, device, cfg, batch_size=args.batch_size
|
| 782 |
+
)
|
| 783 |
+
orig_avg_loss = np.mean(orig_losses)
|
| 784 |
+
print(f" Average sequence loss (sampled): {orig_avg_loss:.4f}")
|
| 785 |
+
|
| 786 |
+
# Calculate sequence loss for train set if --include_train flag is set
|
| 787 |
+
if args.include_train and train_dataset is not None:
|
| 788 |
+
print(f"\n{'='*70}")
|
| 789 |
+
print("TRAIN SET EVALUATION")
|
| 790 |
+
print(f"{'='*70}")
|
| 791 |
+
print(f"\nDecoding train set into string sequences...")
|
| 792 |
+
all_train_sequences, _ = decode_validation_set(
|
| 793 |
+
train_dataset,
|
| 794 |
+
tokenizer,
|
| 795 |
+
pad_id,
|
| 796 |
+
bos_id,
|
| 797 |
+
eos_id,
|
| 798 |
+
num_samples=10**9 # Decode all sequences, don't sample (use very large number)
|
| 799 |
+
)
|
| 800 |
+
print(f"\nCalculating sequence loss for FULL train set ({len(all_train_sequences)} sequences)...")
|
| 801 |
+
train_losses = calculate_sequence_loss(
|
| 802 |
+
editflow, all_train_sequences, tokenizer, pad_id, bos_id, eos_id, device, cfg, batch_size=args.batch_size
|
| 803 |
+
)
|
| 804 |
+
train_avg_loss = np.mean(train_losses)
|
| 805 |
+
print(f" Average sequence loss (full train set): {train_avg_loss:.4f}")
|
| 806 |
+
|
| 807 |
+
# Calculate Cas9 scores only if classifier is provided (on sampled sequences)
|
| 808 |
+
orig_validity_rate, orig_avg_score, orig_scores = None, None, None
|
| 809 |
+
if cas9_classifier is not None:
|
| 810 |
+
print(f"\nCalculating Cas9 scores for sampled decoded sequences...")
|
| 811 |
+
orig_validity_rate, orig_avg_score, orig_scores = evaluate_cas9_scores(
|
| 812 |
+
sampled_decoded_sequences, cas9_classifier, threshold=args.validity_threshold
|
| 813 |
+
)
|
| 814 |
+
|
| 815 |
+
# Step 3: Generate new sequences using multi-edit generation
|
| 816 |
+
print(f"\n{'='*70}")
|
| 817 |
+
print("STEP 3: Generating new sequences using multi-edit generation")
|
| 818 |
+
print(f"{'='*70}")
|
| 819 |
+
generated_sequences = generate_sequences_multi_edit(
|
| 820 |
+
model, source_dist, tokenizer, pad_id, bos_id, eos_id, eps_id,
|
| 821 |
+
sampled_decoded_sequences, device, cfg, num_steps=args.num_steps, batch_size=args.batch_size,
|
| 822 |
+
num_generations_per_sequence=args.num_generations_per_sequence
|
| 823 |
+
)
|
| 824 |
+
print(f"Generated {len(generated_sequences)} sequences")
|
| 825 |
+
|
| 826 |
+
# Save generated sequences as FASTA file
|
| 827 |
+
fasta_path_written = None
|
| 828 |
+
if save_fasta:
|
| 829 |
+
if args.output_fasta is None:
|
| 830 |
+
os.makedirs(args.output_dir, exist_ok=True)
|
| 831 |
+
if fasta_tag:
|
| 832 |
+
fasta_path = os.path.join(args.output_dir, f"generated_sequences_{fasta_tag}.fasta")
|
| 833 |
+
else:
|
| 834 |
+
fasta_path = os.path.join(args.output_dir, "generated_sequences.fasta")
|
| 835 |
+
else:
|
| 836 |
+
fasta_path = args.output_fasta
|
| 837 |
+
fasta_dir = os.path.dirname(fasta_path)
|
| 838 |
+
if fasta_dir:
|
| 839 |
+
os.makedirs(fasta_dir, exist_ok=True)
|
| 840 |
+
|
| 841 |
+
print(f"\nSaving generated sequences to FASTA file: {fasta_path}")
|
| 842 |
+
with open(fasta_path, 'w') as f:
|
| 843 |
+
for i, seq in enumerate(generated_sequences):
|
| 844 |
+
f.write(f">generated_sequence_{i+1}\n")
|
| 845 |
+
for j in range(0, len(seq), 80):
|
| 846 |
+
f.write(seq[j:j+80] + "\n")
|
| 847 |
+
print(f"Saved {len(generated_sequences)} sequences to {fasta_path}")
|
| 848 |
+
fasta_path_written = fasta_path
|
| 849 |
+
|
| 850 |
+
# Step 4: Calculate metrics on generated sequences
|
| 851 |
+
print(f"\n{'='*70}")
|
| 852 |
+
print("STEP 4: Evaluating generated sequences")
|
| 853 |
+
print(f"{'='*70}")
|
| 854 |
+
|
| 855 |
+
print(f"\nCalculating diversity for generated sequences...")
|
| 856 |
+
gen_diversity = calculate_diversity(generated_sequences)
|
| 857 |
+
|
| 858 |
+
# Calculate sequence loss for generated sequences
|
| 859 |
+
print(f"\nCalculating sequence loss for generated sequences...")
|
| 860 |
+
gen_losses = calculate_sequence_loss(
|
| 861 |
+
editflow, generated_sequences, tokenizer, pad_id, bos_id, eos_id, device, cfg, batch_size=args.batch_size
|
| 862 |
+
)
|
| 863 |
+
gen_avg_loss = np.mean(gen_losses)
|
| 864 |
+
print(f" Average sequence loss: {gen_avg_loss:.4f}")
|
| 865 |
+
|
| 866 |
+
# Calculate Cas9 scores only if classifier is provided
|
| 867 |
+
gen_validity_rate, gen_avg_score, gen_scores = None, None, None
|
| 868 |
+
if cas9_classifier is not None:
|
| 869 |
+
print(f"\nCalculating Cas9 scores for generated sequences...")
|
| 870 |
+
gen_validity_rate, gen_avg_score, gen_scores = evaluate_cas9_scores(
|
| 871 |
+
generated_sequences, cas9_classifier, threshold=args.validity_threshold
|
| 872 |
+
)
|
| 873 |
+
|
| 874 |
+
def _to_float(x):
|
| 875 |
+
if x is None:
|
| 876 |
+
return None
|
| 877 |
+
return float(x)
|
| 878 |
+
|
| 879 |
+
def _diversity_plain(d):
|
| 880 |
+
return {k: int(v) if k == "unique_count" else float(v) for k, v in d.items()}
|
| 881 |
+
|
| 882 |
+
metrics = {
|
| 883 |
+
"run_seed": int(run_seed) if run_seed is not None else None,
|
| 884 |
+
"dataset_type": dataset_type,
|
| 885 |
+
"fasta_path": fasta_path_written,
|
| 886 |
+
"counts": {
|
| 887 |
+
"full_train": len(all_train_sequences) if all_train_sequences is not None else None,
|
| 888 |
+
"full_test": len(all_decoded_sequences),
|
| 889 |
+
"sampled_decoded": len(sampled_decoded_sequences),
|
| 890 |
+
"generated": len(generated_sequences),
|
| 891 |
+
},
|
| 892 |
+
"loss": {
|
| 893 |
+
"train_avg": _to_float(train_avg_loss),
|
| 894 |
+
"decoded_full_avg": _to_float(orig_avg_loss_full),
|
| 895 |
+
"decoded_sampled_avg": _to_float(orig_avg_loss),
|
| 896 |
+
"generated_avg": _to_float(gen_avg_loss),
|
| 897 |
+
},
|
| 898 |
+
"cas9": None,
|
| 899 |
+
"diversity_decoded": _diversity_plain(orig_diversity),
|
| 900 |
+
"diversity_generated": _diversity_plain(gen_diversity),
|
| 901 |
+
"plddt": None,
|
| 902 |
+
}
|
| 903 |
+
if cas9_classifier is not None:
|
| 904 |
+
metrics["cas9"] = {
|
| 905 |
+
"decoded_validity_rate": _to_float(orig_validity_rate),
|
| 906 |
+
"decoded_avg_score": _to_float(orig_avg_score),
|
| 907 |
+
"generated_validity_rate": _to_float(gen_validity_rate),
|
| 908 |
+
"generated_avg_score": _to_float(gen_avg_score),
|
| 909 |
+
}
|
| 910 |
+
|
| 911 |
+
# Step 5: Calculate and plot pLDDT scores (optional)
|
| 912 |
+
if args.calculate_plddt:
|
| 913 |
+
print(f"\n{'='*70}")
|
| 914 |
+
print("STEP 5: Calculating pLDDT scores")
|
| 915 |
+
print(f"{'='*70}")
|
| 916 |
+
|
| 917 |
+
print("\nClearing GPU memory...")
|
| 918 |
+
del model
|
| 919 |
+
del editflow
|
| 920 |
+
if cas9_classifier is not None:
|
| 921 |
+
del cas9_classifier
|
| 922 |
+
if device.type == 'cuda':
|
| 923 |
+
torch.cuda.empty_cache()
|
| 924 |
+
print("GPU memory cleared.")
|
| 925 |
+
|
| 926 |
+
print("Loading ESMFold model for pLDDT calculation...")
|
| 927 |
+
esmfold_tokenizer_path = "facebook/esmfold_v1"
|
| 928 |
+
esmfold_tokenizer = AutoTokenizer.from_pretrained(esmfold_tokenizer_path)
|
| 929 |
+
esmfold_device = device
|
| 930 |
+
esm_model = EsmForProteinFolding.from_pretrained(
|
| 931 |
+
esmfold_tokenizer_path,
|
| 932 |
+
torch_dtype=torch.bfloat16
|
| 933 |
+
).to(esmfold_device).eval()
|
| 934 |
+
print("ESMFold model loaded successfully!")
|
| 935 |
+
|
| 936 |
+
print(f"\nCalculating pLDDT scores for sampled decoded sequences...")
|
| 937 |
+
decoded_plddt = calculate_plddt_scores(
|
| 938 |
+
sampled_decoded_sequences, esmfold_tokenizer, esm_model, esmfold_device, batch_size=1
|
| 939 |
+
)
|
| 940 |
+
decoded_plddt_valid = [s for s in decoded_plddt if s is not None]
|
| 941 |
+
if len(decoded_plddt_valid) > 0:
|
| 942 |
+
print(f" Valid scores: {len(decoded_plddt_valid)}/{len(decoded_plddt)}")
|
| 943 |
+
print(f" Mean pLDDT: {np.mean(decoded_plddt_valid):.2f}, Std: {np.std(decoded_plddt_valid):.2f}")
|
| 944 |
+
|
| 945 |
+
print(f"\nCalculating pLDDT scores for generated sequences...")
|
| 946 |
+
generated_plddt = calculate_plddt_scores(
|
| 947 |
+
generated_sequences, esmfold_tokenizer, esm_model, esmfold_device, batch_size=1
|
| 948 |
+
)
|
| 949 |
+
generated_plddt_valid = [g for g in generated_plddt if g is not None]
|
| 950 |
+
if len(generated_plddt_valid) > 0:
|
| 951 |
+
print(f" Valid scores: {len(generated_plddt_valid)}/{len(generated_plddt)}")
|
| 952 |
+
print(f" Mean pLDDT: {np.mean(generated_plddt_valid):.2f}, Std: {np.std(generated_plddt_valid):.2f}")
|
| 953 |
+
|
| 954 |
+
if len(decoded_plddt_valid) > 0 and len(generated_plddt_valid) > 0:
|
| 955 |
+
print(f"\nPlotting pLDDT histograms...")
|
| 956 |
+
plot_plddt_histogram(decoded_plddt_valid, generated_plddt_valid, args.output_dir, args.num_bins, dataset_type)
|
| 957 |
+
else:
|
| 958 |
+
print("Warning: Not enough valid pLDDT scores to plot.")
|
| 959 |
+
|
| 960 |
+
metrics["plddt"] = {
|
| 961 |
+
"decoded_mean": float(np.mean(decoded_plddt_valid)) if decoded_plddt_valid else None,
|
| 962 |
+
"decoded_std": float(np.std(decoded_plddt_valid)) if decoded_plddt_valid else None,
|
| 963 |
+
"decoded_valid_n": len(decoded_plddt_valid),
|
| 964 |
+
"decoded_total_n": len(decoded_plddt),
|
| 965 |
+
"generated_mean": float(np.mean(generated_plddt_valid)) if generated_plddt_valid else None,
|
| 966 |
+
"generated_std": float(np.std(generated_plddt_valid)) if generated_plddt_valid else None,
|
| 967 |
+
"generated_valid_n": len(generated_plddt_valid),
|
| 968 |
+
"generated_total_n": len(generated_plddt),
|
| 969 |
+
}
|
| 970 |
+
|
| 971 |
+
return metrics
|
| 972 |
+
|
| 973 |
+
|
| 974 |
+
def print_evaluation_results(metrics, args):
|
| 975 |
+
"""Print the summary block (same layout as historical evaluate_val.py)."""
|
| 976 |
+
dataset_type = metrics["dataset_type"]
|
| 977 |
+
counts = metrics["counts"]
|
| 978 |
+
loss_m = metrics["loss"]
|
| 979 |
+
orig_diversity = metrics["diversity_decoded"]
|
| 980 |
+
gen_diversity = metrics["diversity_generated"]
|
| 981 |
+
|
| 982 |
+
print("\n" + "="*70)
|
| 983 |
+
print("EVALUATION RESULTS")
|
| 984 |
+
print("="*70)
|
| 985 |
+
|
| 986 |
+
print(f"\nDataset Statistics:")
|
| 987 |
+
if counts["full_train"] is not None:
|
| 988 |
+
print(f" Full train set sequences: {counts['full_train']}")
|
| 989 |
+
print(f" Full test set sequences: {counts['full_test']}")
|
| 990 |
+
print(f" Sampled decoded sequences: {counts['sampled_decoded']}")
|
| 991 |
+
print(f" Generated sequences: {counts['generated']}")
|
| 992 |
+
|
| 993 |
+
print(f"\n" + "-"*70)
|
| 994 |
+
print("SEQUENCE LOSS (val_unweighted_total_loss)")
|
| 995 |
+
print("-"*70)
|
| 996 |
+
if loss_m["train_avg"] is not None:
|
| 997 |
+
print(f"Train set (FULL train set, {counts['full_train']} sequences):")
|
| 998 |
+
print(f" Average loss: {loss_m['train_avg']:.4f}")
|
| 999 |
+
print(f"\nDecoded sequences (FULL {dataset_type} set, {counts['full_test']} sequences):")
|
| 1000 |
+
print(f" Average loss: {loss_m['decoded_full_avg']:.4f}")
|
| 1001 |
+
print(f"\nDecoded sequences (sampled, {counts['sampled_decoded']} sequences):")
|
| 1002 |
+
print(f" Average loss: {loss_m['decoded_sampled_avg']:.4f}")
|
| 1003 |
+
print(f"\nGenerated sequences ({counts['generated']} sequences):")
|
| 1004 |
+
print(f" Average loss: {loss_m['generated_avg']:.4f}")
|
| 1005 |
+
|
| 1006 |
+
cas9 = metrics["cas9"]
|
| 1007 |
+
if cas9 is not None:
|
| 1008 |
+
print(f"\n" + "-"*70)
|
| 1009 |
+
print("CAS9 SCORES (validity threshold = {})".format(args.validity_threshold))
|
| 1010 |
+
print("-"*70)
|
| 1011 |
+
print(f"NOTE: If {dataset_type} set contains general proteins (not Cas9-specific),")
|
| 1012 |
+
print(" low Cas9 scores are expected. Cas9 classifier is trained to identify Cas9 proteins.")
|
| 1013 |
+
print(f"\nDecoded sequences (sampled from {dataset_type} set, {counts['sampled_decoded']} sequences):")
|
| 1014 |
+
print(f" Validity rate: {cas9['decoded_validity_rate']:.4f} ({cas9['decoded_validity_rate']*100:.2f}%)")
|
| 1015 |
+
print(f" Average Cas9 score: {cas9['decoded_avg_score']:.4f}")
|
| 1016 |
+
print(f"\nGenerated sequences ({counts['generated']} sequences):")
|
| 1017 |
+
print(f" Validity rate: {cas9['generated_validity_rate']:.4f} ({cas9['generated_validity_rate']*100:.2f}%)")
|
| 1018 |
+
print(f" Average Cas9 score: {cas9['generated_avg_score']:.4f}")
|
| 1019 |
+
|
| 1020 |
+
print(f"\n" + "-"*70)
|
| 1021 |
+
print("DIVERSITY METRICS")
|
| 1022 |
+
print("-"*70)
|
| 1023 |
+
print(f"Decoded sequences (sampled from {dataset_type} set, {counts['sampled_decoded']} sequences):")
|
| 1024 |
+
print(f" Unique sequences: {orig_diversity['unique_count']} / {counts['sampled_decoded']} ({orig_diversity['uniqueness_ratio']*100:.2f}%)")
|
| 1025 |
+
print(f" k-mer Jaccard diversity (k=3): {orig_diversity['kmer_diversity']:.4f}")
|
| 1026 |
+
print(f" k-mer Jaccard avg similarity: {orig_diversity['kmer_avg_similarity']:.4f}")
|
| 1027 |
+
if HAS_RAPIDFUZZ or HAS_LEVENSHTEIN:
|
| 1028 |
+
print(f" Levenshtein diversity: {orig_diversity['levenshtein_diversity']:.4f}")
|
| 1029 |
+
print(f" Levenshtein avg similarity: {orig_diversity['levenshtein_avg_similarity']:.4f}")
|
| 1030 |
+
print(f"\nGenerated sequences ({counts['generated']} sequences):")
|
| 1031 |
+
print(f" Unique sequences: {gen_diversity['unique_count']} / {counts['generated']} ({gen_diversity['uniqueness_ratio']*100:.2f}%)")
|
| 1032 |
+
print(f" k-mer Jaccard diversity (k=3): {gen_diversity['kmer_diversity']:.4f}")
|
| 1033 |
+
print(f" k-mer Jaccard avg similarity: {gen_diversity['kmer_avg_similarity']:.4f}")
|
| 1034 |
+
if HAS_RAPIDFUZZ or HAS_LEVENSHTEIN:
|
| 1035 |
+
print(f" Levenshtein diversity: {gen_diversity['levenshtein_diversity']:.4f}")
|
| 1036 |
+
print(f" Levenshtein avg similarity: {gen_diversity['levenshtein_avg_similarity']:.4f}")
|
| 1037 |
+
|
| 1038 |
+
plddt = metrics.get("plddt")
|
| 1039 |
+
if plddt is not None:
|
| 1040 |
+
print(f"\n" + "-"*70)
|
| 1041 |
+
print("PLDDT METRICS")
|
| 1042 |
+
print("-"*70)
|
| 1043 |
+
print(f"Decoded sequences (sampled from {dataset_type} set, {counts['sampled_decoded']} sequences):")
|
| 1044 |
+
if plddt["decoded_mean"] is not None:
|
| 1045 |
+
print(f" Mean pLDDT: {plddt['decoded_mean']:.2f}")
|
| 1046 |
+
print(f" Std pLDDT: {plddt['decoded_std']:.2f}")
|
| 1047 |
+
print(f" Valid scores: {plddt['decoded_valid_n']}/{plddt['decoded_total_n']}")
|
| 1048 |
+
print(f"\nGenerated sequences ({counts['generated']} sequences):")
|
| 1049 |
+
if plddt["generated_mean"] is not None:
|
| 1050 |
+
print(f" Mean pLDDT: {plddt['generated_mean']:.2f}")
|
| 1051 |
+
print(f" Std pLDDT: {plddt['generated_std']:.2f}")
|
| 1052 |
+
print(f" Valid scores: {plddt['generated_valid_n']}/{plddt['generated_total_n']}")
|
| 1053 |
+
|
| 1054 |
+
print("="*70)
|
| 1055 |
+
|
| 1056 |
+
|
| 1057 |
+
def main():
|
| 1058 |
+
parser = get_evaluate_val_argument_parser()
|
| 1059 |
+
args = parser.parse_args()
|
| 1060 |
+
# Legacy behavior: do not fix RNG (matches previous commented-out seed lines).
|
| 1061 |
+
metrics = run_evaluate_val(args)
|
| 1062 |
+
print_evaluation_results(metrics, args)
|
| 1063 |
+
|
| 1064 |
+
|
| 1065 |
+
if __name__ == "__main__":
|
| 1066 |
+
main()
|
|
@@ -0,0 +1,187 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Run evaluate_val-style evaluation multiple times and report per-run metric lists.
|
| 4 |
+
|
| 5 |
+
Stochastic steps (sampling from the test set, flow time / noise, generation) can make
|
| 6 |
+
scalar metrics differ between runs. This script calls run_evaluate_val() repeatedly and
|
| 7 |
+
prints every value plus simple mean / std / min / max summaries.
|
| 8 |
+
|
| 9 |
+
Seeding:
|
| 10 |
+
--base_seed unset: same as legacy evaluate_val (no explicit RNG seeding; variation across runs).
|
| 11 |
+
--base_seed K: run i uses seed K + i before each replicate (reproducible multi-run bracket).
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
import argparse
|
| 15 |
+
import json
|
| 16 |
+
import numpy as np
|
| 17 |
+
|
| 18 |
+
from cas9.evaluate_val import get_evaluate_val_argument_parser, run_evaluate_val, print_evaluation_results
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def _get_nested(d, *keys):
|
| 22 |
+
for k in keys:
|
| 23 |
+
d = d[k]
|
| 24 |
+
return d
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _stats(vals):
|
| 28 |
+
arr = np.array([v for v in vals if v is not None], dtype=float)
|
| 29 |
+
if arr.size == 0:
|
| 30 |
+
return None
|
| 31 |
+
return {
|
| 32 |
+
"mean": float(arr.mean()),
|
| 33 |
+
"std": float(arr.std()),
|
| 34 |
+
"min": float(arr.min()),
|
| 35 |
+
"max": float(arr.max()),
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _fmt_list(vals, prec=4):
|
| 40 |
+
parts = []
|
| 41 |
+
for v in vals:
|
| 42 |
+
if v is None:
|
| 43 |
+
parts.append("None")
|
| 44 |
+
elif isinstance(v, float):
|
| 45 |
+
parts.append(f"{v:.{prec}f}")
|
| 46 |
+
elif isinstance(v, int):
|
| 47 |
+
parts.append(str(v))
|
| 48 |
+
else:
|
| 49 |
+
parts.append(str(v))
|
| 50 |
+
return "[" + ", ".join(parts) + "]"
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def _print_scalar_block(title, rows, all_metrics):
|
| 54 |
+
print(f"\n{title}")
|
| 55 |
+
print("-" * len(title))
|
| 56 |
+
for label, path in rows:
|
| 57 |
+
vals = [_get_nested(m, *path) for m in all_metrics]
|
| 58 |
+
print(f" {label}")
|
| 59 |
+
print(f" values: {_fmt_list(vals)}")
|
| 60 |
+
st = _stats(vals)
|
| 61 |
+
if st:
|
| 62 |
+
print(f" mean={st['mean']:.6f} std={st['std']:.6f} min={st['min']:.6f} max={st['max']:.6f}")
|
| 63 |
+
else:
|
| 64 |
+
print(" (no numeric values)")
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def print_multi_run_summary(all_metrics, args):
|
| 68 |
+
n = len(all_metrics)
|
| 69 |
+
print("\n" + "=" * 70)
|
| 70 |
+
print(f"MULTI-RUN SUMMARY ({n} runs)")
|
| 71 |
+
print("=" * 70)
|
| 72 |
+
seeds = [m["run_seed"] for m in all_metrics]
|
| 73 |
+
print(f"Per-run seeds: {seeds}")
|
| 74 |
+
|
| 75 |
+
loss_rows = [
|
| 76 |
+
("Train avg (full train; None if not computed)", ("loss", "train_avg")),
|
| 77 |
+
("Decoded avg (full test/val)", ("loss", "decoded_full_avg")),
|
| 78 |
+
("Decoded avg (sampled)", ("loss", "decoded_sampled_avg")),
|
| 79 |
+
("Generated avg", ("loss", "generated_avg")),
|
| 80 |
+
]
|
| 81 |
+
_print_scalar_block("SEQUENCE LOSS (val_unweighted_total_loss)", loss_rows, all_metrics)
|
| 82 |
+
|
| 83 |
+
if all_metrics[0]["cas9"] is not None:
|
| 84 |
+
cas9_rows = [
|
| 85 |
+
("Decoded validity rate", ("cas9", "decoded_validity_rate")),
|
| 86 |
+
("Decoded avg Cas9 score", ("cas9", "decoded_avg_score")),
|
| 87 |
+
("Generated validity rate", ("cas9", "generated_validity_rate")),
|
| 88 |
+
("Generated avg Cas9 score", ("cas9", "generated_avg_score")),
|
| 89 |
+
]
|
| 90 |
+
_print_scalar_block("CAS9 SCORES", cas9_rows, all_metrics)
|
| 91 |
+
|
| 92 |
+
div_dec = [
|
| 93 |
+
("unique_count", ("diversity_decoded", "unique_count")),
|
| 94 |
+
("uniqueness_ratio", ("diversity_decoded", "uniqueness_ratio")),
|
| 95 |
+
("kmer_diversity", ("diversity_decoded", "kmer_diversity")),
|
| 96 |
+
("kmer_avg_similarity", ("diversity_decoded", "kmer_avg_similarity")),
|
| 97 |
+
("levenshtein_diversity", ("diversity_decoded", "levenshtein_diversity")),
|
| 98 |
+
("levenshtein_avg_similarity", ("diversity_decoded", "levenshtein_avg_similarity")),
|
| 99 |
+
]
|
| 100 |
+
_print_scalar_block("DIVERSITY — decoded (sampled)", div_dec, all_metrics)
|
| 101 |
+
|
| 102 |
+
div_gen = [
|
| 103 |
+
("unique_count", ("diversity_generated", "unique_count")),
|
| 104 |
+
("uniqueness_ratio", ("diversity_generated", "uniqueness_ratio")),
|
| 105 |
+
("kmer_diversity", ("diversity_generated", "kmer_diversity")),
|
| 106 |
+
("kmer_avg_similarity", ("diversity_generated", "kmer_avg_similarity")),
|
| 107 |
+
("levenshtein_diversity", ("diversity_generated", "levenshtein_diversity")),
|
| 108 |
+
("levenshtein_avg_similarity", ("diversity_generated", "levenshtein_avg_similarity")),
|
| 109 |
+
]
|
| 110 |
+
_print_scalar_block("DIVERSITY — generated", div_gen, all_metrics)
|
| 111 |
+
|
| 112 |
+
if any(m.get("plddt") is not None for m in all_metrics):
|
| 113 |
+
p_rows = [
|
| 114 |
+
("Decoded mean pLDDT", ("plddt", "decoded_mean")),
|
| 115 |
+
("Decoded std pLDDT", ("plddt", "decoded_std")),
|
| 116 |
+
("Generated mean pLDDT", ("plddt", "generated_mean")),
|
| 117 |
+
("Generated std pLDDT", ("plddt", "generated_std")),
|
| 118 |
+
]
|
| 119 |
+
_print_scalar_block("PLDDT", p_rows, all_metrics)
|
| 120 |
+
|
| 121 |
+
print("=" * 70)
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def main():
|
| 125 |
+
parser = get_evaluate_val_argument_parser()
|
| 126 |
+
parser.add_argument(
|
| 127 |
+
"--n_runs",
|
| 128 |
+
type=int,
|
| 129 |
+
default=3,
|
| 130 |
+
help="Number of independent evaluate_val runs (default: 3)",
|
| 131 |
+
)
|
| 132 |
+
parser.add_argument(
|
| 133 |
+
"--base_seed",
|
| 134 |
+
type=int,
|
| 135 |
+
default=None,
|
| 136 |
+
help="If set, run i uses RNG seed (base_seed + i). If unset, do not seed (like default evaluate_val).",
|
| 137 |
+
)
|
| 138 |
+
parser.add_argument(
|
| 139 |
+
"--save_fasta",
|
| 140 |
+
action="store_true",
|
| 141 |
+
help="Write generated_sequences_run{i}.fasta per run under output_dir",
|
| 142 |
+
)
|
| 143 |
+
parser.add_argument(
|
| 144 |
+
"--print_each_run",
|
| 145 |
+
action="store_true",
|
| 146 |
+
help="Print the full EVALUATION RESULTS block after every replicate",
|
| 147 |
+
)
|
| 148 |
+
parser.add_argument(
|
| 149 |
+
"--json_out",
|
| 150 |
+
type=str,
|
| 151 |
+
default=None,
|
| 152 |
+
help="Optional path to write a JSON list of per-run metric dicts",
|
| 153 |
+
)
|
| 154 |
+
args = parser.parse_args()
|
| 155 |
+
|
| 156 |
+
if args.n_runs < 1:
|
| 157 |
+
raise ValueError("--n_runs must be >= 1")
|
| 158 |
+
|
| 159 |
+
all_metrics = []
|
| 160 |
+
for i in range(args.n_runs):
|
| 161 |
+
print("\n" + "#" * 70)
|
| 162 |
+
print(f"RUN {i + 1} / {args.n_runs}")
|
| 163 |
+
print("#" * 70)
|
| 164 |
+
run_seed = (args.base_seed + i) if args.base_seed is not None else None
|
| 165 |
+
if run_seed is not None:
|
| 166 |
+
print(f"(run_seed={run_seed})")
|
| 167 |
+
fasta_tag = f"run{i + 1}" if args.save_fasta else None
|
| 168 |
+
metrics = run_evaluate_val(
|
| 169 |
+
args,
|
| 170 |
+
run_seed=run_seed,
|
| 171 |
+
save_fasta=args.save_fasta,
|
| 172 |
+
fasta_tag=fasta_tag,
|
| 173 |
+
)
|
| 174 |
+
all_metrics.append(metrics)
|
| 175 |
+
if args.print_each_run:
|
| 176 |
+
print_evaluation_results(metrics, args)
|
| 177 |
+
|
| 178 |
+
print_multi_run_summary(all_metrics, args)
|
| 179 |
+
|
| 180 |
+
if args.json_out:
|
| 181 |
+
with open(args.json_out, "w") as f:
|
| 182 |
+
json.dump(all_metrics, f, indent=2)
|
| 183 |
+
print(f"\nWrote per-run metrics to {args.json_out}")
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
if __name__ == "__main__":
|
| 187 |
+
main()
|
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import torch
|
| 2 |
+
from transformers import EsmTokenizer
|
| 3 |
+
|
| 4 |
+
# Cas9 stack uses model/base_models.py (not reparam_models.py, which is the GFP/root variant).
|
| 5 |
+
from cas9.model.base_models import EditFlow, ProteinEditFlowModel, ReparameterizedProteinEditFlowModel
|
| 6 |
+
from cas9.model.utils import generate_from_x0, generate_from_x0_multi_edit
|
| 7 |
+
from cas9.logic import flow
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def build_model_and_stuff(cfg, device):
|
| 11 |
+
"""
|
| 12 |
+
Rebuild exactly what train.py builds, but we won't set up lightning Trainer.
|
| 13 |
+
Returns:
|
| 14 |
+
editflow, source_distribution, tokenizer, pad_id, bos_id, eos_id, eps_id
|
| 15 |
+
"""
|
| 16 |
+
tokenizer = EsmTokenizer.from_pretrained("facebook/esm2_t33_650M_UR50D")
|
| 17 |
+
vocab_size = 24
|
| 18 |
+
source_distribution = flow.get_source_distribution(
|
| 19 |
+
source_distribution=cfg.flow.source_distribution,
|
| 20 |
+
vocab_size=vocab_size,
|
| 21 |
+
special_token_ids=[0, 1, 2, 3],
|
| 22 |
+
)
|
| 23 |
+
pad_id = 1
|
| 24 |
+
bos_id = 0
|
| 25 |
+
eos_id = 2
|
| 26 |
+
# Match train.py: reparameterized (8-value) vs plain (5-value) model per config flag.
|
| 27 |
+
if getattr(cfg.training, "reparameterize", False):
|
| 28 |
+
model = ReparameterizedProteinEditFlowModel(vocab_size=vocab_size, pad_id=pad_id, config=cfg.model)
|
| 29 |
+
else:
|
| 30 |
+
model = ProteinEditFlowModel(vocab_size=vocab_size, pad_id=pad_id, config=cfg.model)
|
| 31 |
+
|
| 32 |
+
eps_id = getattr(cfg.flow, "eps_id", -1)
|
| 33 |
+
path = flow.get_path(
|
| 34 |
+
scheduler_type=cfg.flow.scheduler_type,
|
| 35 |
+
exponent=cfg.flow.exponent,
|
| 36 |
+
eps_id=eps_id,
|
| 37 |
+
)
|
| 38 |
+
loss_fn = flow.get_loss_function(
|
| 39 |
+
loss_function=cfg.flow.loss_function,
|
| 40 |
+
path=path,
|
| 41 |
+
)
|
| 42 |
+
|
| 43 |
+
editflow = EditFlow(
|
| 44 |
+
model,
|
| 45 |
+
loss_fn,
|
| 46 |
+
path,
|
| 47 |
+
source_distribution,
|
| 48 |
+
pad_id,
|
| 49 |
+
bos_id,
|
| 50 |
+
eos_id,
|
| 51 |
+
cfg,
|
| 52 |
+
).to(device)
|
| 53 |
+
|
| 54 |
+
return editflow, source_distribution, tokenizer, pad_id, bos_id, eos_id, eps_id
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def tokenize_input_str(input_str, cfg, tokenizer, bos_id, eos_id, pad_id, device):
|
| 58 |
+
# Signature matches the Cas9 pcomol_cas.py call site (cfg/pad_id accepted for
|
| 59 |
+
# compatibility; the operation is tokenize input + ensure BOS/EOS -> (1, L)).
|
| 60 |
+
toks = tokenizer(input_str, return_tensors='pt')
|
| 61 |
+
ids = toks["input_ids"][0].to(device)
|
| 62 |
+
if ids[0].item() != bos_id:
|
| 63 |
+
ids = torch.cat([torch.tensor([bos_id], device=device), ids], dim=0)
|
| 64 |
+
if ids[-1].item() != eos_id:
|
| 65 |
+
ids = torch.cat([ids, torch.tensor([eos_id], device=device)], dim=0)
|
| 66 |
+
x0 = ids.unsqueeze(0) # (1, L)
|
| 67 |
+
return x0
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def detokenize_output(x, tokenizer, bos_id, eos_id, pad_id):
|
| 71 |
+
"""Convert a single generated sequence (1, L) back to string."""
|
| 72 |
+
seq = x[0].tolist()
|
| 73 |
+
seq = [tok for tok in seq if tok != pad_id] # strip padding
|
| 74 |
+
if len(seq) > 0 and seq[0] == bos_id: # strip BOS
|
| 75 |
+
seq = seq[1:]
|
| 76 |
+
if len(seq) > 0 and seq[-1] == eos_id: # strip EOS
|
| 77 |
+
seq = seq[:-1]
|
| 78 |
+
return tokenizer.batch_decode([seq], skip_special_tokens=True)[0]
|
|
@@ -0,0 +1,348 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Generate CSV outputs for one or more Cas9 inputs using the same rollout sampler
|
| 4 |
+
as `pcomol_cas.py`.
|
| 5 |
+
|
| 6 |
+
This script:
|
| 7 |
+
1. Parses Cas9 name -> sequence mappings from `run_pcomol.sh`
|
| 8 |
+
2. Runs input-conditioned generation via `short_rollout_batch`
|
| 9 |
+
3. Writes one CSV per requested Cas9 name with the same column layout commonly
|
| 10 |
+
produced by `pcomol_cas.py` for Cas9 runs
|
| 11 |
+
|
| 12 |
+
Objective-specific columns are intentionally left empty.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
import argparse
|
| 16 |
+
import csv
|
| 17 |
+
import time
|
| 18 |
+
from pathlib import Path
|
| 19 |
+
from typing import Dict, List
|
| 20 |
+
|
| 21 |
+
import torch
|
| 22 |
+
import yaml
|
| 23 |
+
from easydict import EasyDict as edict
|
| 24 |
+
|
| 25 |
+
from cas9.generate import build_model_and_stuff, detokenize_output, tokenize_input_str
|
| 26 |
+
from cas9.pcomole import short_rollout_batch
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
CSV_COLUMNS = [
|
| 30 |
+
"final_len",
|
| 31 |
+
"length_diff",
|
| 32 |
+
"cas9_score",
|
| 33 |
+
"deletion_count_score",
|
| 34 |
+
"pam_matching_score",
|
| 35 |
+
"cas9_weight",
|
| 36 |
+
"deletion_count_weight",
|
| 37 |
+
"pam_matching_weight",
|
| 38 |
+
"predicted_pam",
|
| 39 |
+
"pam_matching_ce_loss_score",
|
| 40 |
+
"pam_matching_probability",
|
| 41 |
+
"deletion_rate_scale",
|
| 42 |
+
"pam_min_confidence",
|
| 43 |
+
"generation_time",
|
| 44 |
+
"sequence",
|
| 45 |
+
]
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def parse_fasta_sequences(path: Path) -> Dict[str, str]:
|
| 49 |
+
"""Parse a FASTA of Cas9 orthologs into {name: sequence}."""
|
| 50 |
+
seqs: Dict[str, str] = {}
|
| 51 |
+
name, buf = None, []
|
| 52 |
+
for line in Path(path).read_text().splitlines():
|
| 53 |
+
line = line.strip()
|
| 54 |
+
if line.startswith(">"):
|
| 55 |
+
if name:
|
| 56 |
+
seqs[name] = "".join(buf)
|
| 57 |
+
name, buf = line[1:].split()[0], []
|
| 58 |
+
elif line:
|
| 59 |
+
buf.append(line)
|
| 60 |
+
if name:
|
| 61 |
+
seqs[name] = "".join(buf)
|
| 62 |
+
return seqs
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def load_sequences(path: Path) -> Dict[str, str]:
|
| 66 |
+
"""Dispatch on suffix: .sh uses the legacy run_pcomol.sh parser."""
|
| 67 |
+
return (parse_run_pcomol_sequences(path) if Path(path).suffix == ".sh"
|
| 68 |
+
else parse_fasta_sequences(path))
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def parse_run_pcomol_sequences(path: Path) -> Dict[str, str]:
|
| 72 |
+
sequences: Dict[str, str] = {}
|
| 73 |
+
with open(path, "r") as handle:
|
| 74 |
+
for raw_line in handle:
|
| 75 |
+
line = raw_line.strip()
|
| 76 |
+
if not line or line.startswith("#") or "=" not in line:
|
| 77 |
+
continue
|
| 78 |
+
name, value = line.split("=", 1)
|
| 79 |
+
name = name.strip()
|
| 80 |
+
value = value.strip()
|
| 81 |
+
if not value.startswith('"') or not value.endswith('"'):
|
| 82 |
+
continue
|
| 83 |
+
seq = value[1:-1].strip()
|
| 84 |
+
if not seq:
|
| 85 |
+
continue
|
| 86 |
+
if all(ch.isalpha() for ch in seq):
|
| 87 |
+
sequences[name] = seq
|
| 88 |
+
return sequences
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def normalize_cas9_names(requested_names: List[str], available: Dict[str, str]) -> List[str]:
|
| 92 |
+
normalized = []
|
| 93 |
+
missing = []
|
| 94 |
+
lower_to_name = {name.lower(): name for name in available}
|
| 95 |
+
for name in requested_names:
|
| 96 |
+
resolved = available.get(name)
|
| 97 |
+
if resolved is None:
|
| 98 |
+
canonical = lower_to_name.get(name.lower())
|
| 99 |
+
if canonical is None:
|
| 100 |
+
missing.append(name)
|
| 101 |
+
continue
|
| 102 |
+
resolved_name = canonical
|
| 103 |
+
else:
|
| 104 |
+
resolved_name = name
|
| 105 |
+
normalized.append(resolved_name)
|
| 106 |
+
|
| 107 |
+
if missing:
|
| 108 |
+
available_names = ", ".join(sorted(available))
|
| 109 |
+
raise ValueError(
|
| 110 |
+
f"Unknown Cas9 name(s): {', '.join(missing)}. Available names: {available_names}"
|
| 111 |
+
)
|
| 112 |
+
return normalized
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def build_empty_row(final_len: int, length_diff: int, deletion_rate_scale: float, generation_time: float, sequence: str) -> Dict[str, object]:
|
| 116 |
+
row = {column: "" for column in CSV_COLUMNS}
|
| 117 |
+
row["final_len"] = final_len
|
| 118 |
+
row["length_diff"] = length_diff
|
| 119 |
+
row["deletion_rate_scale"] = deletion_rate_scale
|
| 120 |
+
row["generation_time"] = generation_time
|
| 121 |
+
row["sequence"] = sequence
|
| 122 |
+
return row
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def write_csv_rows(output_path: Path, rows: List[Dict[str, object]]) -> None:
|
| 126 |
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
| 127 |
+
with open(output_path, "w", newline="") as handle:
|
| 128 |
+
writer = csv.DictWriter(handle, fieldnames=CSV_COLUMNS)
|
| 129 |
+
writer.writeheader()
|
| 130 |
+
writer.writerows(rows)
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def main() -> None:
|
| 134 |
+
parser = argparse.ArgumentParser(
|
| 135 |
+
description=(
|
| 136 |
+
"Generate n sequences per requested Cas9 input using the same "
|
| 137 |
+
"sampling rollout as pcomol_cas.py."
|
| 138 |
+
)
|
| 139 |
+
)
|
| 140 |
+
parser.add_argument(
|
| 141 |
+
"--pcomol_config",
|
| 142 |
+
type=str,
|
| 143 |
+
required=True,
|
| 144 |
+
help="Path to pcomol/config YAML (e.g. configs/config_uniprot_leq_250.yaml)",
|
| 145 |
+
)
|
| 146 |
+
parser.add_argument(
|
| 147 |
+
"--ckpt",
|
| 148 |
+
type=str,
|
| 149 |
+
required=True,
|
| 150 |
+
help="Path to model checkpoint (.ckpt)",
|
| 151 |
+
)
|
| 152 |
+
parser.add_argument(
|
| 153 |
+
"--cas9_names",
|
| 154 |
+
nargs="+",
|
| 155 |
+
required=True,
|
| 156 |
+
help="Cas9 ortholog names, e.g. spcas9 st1cas9",
|
| 157 |
+
)
|
| 158 |
+
parser.add_argument(
|
| 159 |
+
"--n",
|
| 160 |
+
type=int,
|
| 161 |
+
nargs="+",
|
| 162 |
+
required=True,
|
| 163 |
+
help=(
|
| 164 |
+
"Number of generated sequences to produce. Pass one value to use for "
|
| 165 |
+
"all Cas9 inputs, or one value per Cas9 name in the same order."
|
| 166 |
+
),
|
| 167 |
+
)
|
| 168 |
+
parser.add_argument(
|
| 169 |
+
"--sequences", "--run_pcomol_sh",
|
| 170 |
+
dest="run_pcomol_sh",
|
| 171 |
+
type=str,
|
| 172 |
+
default=str(Path(__file__).with_name("data") / "orthologs.fasta"),
|
| 173 |
+
help="FASTA of Cas9 orthologs (or a legacy run_pcomol.sh)",
|
| 174 |
+
)
|
| 175 |
+
parser.add_argument(
|
| 176 |
+
"--output_dir",
|
| 177 |
+
type=str,
|
| 178 |
+
required=True,
|
| 179 |
+
help="Directory where one CSV per Cas9 name will be written",
|
| 180 |
+
)
|
| 181 |
+
parser.add_argument(
|
| 182 |
+
"--output_suffix",
|
| 183 |
+
type=str,
|
| 184 |
+
default="_generations.csv",
|
| 185 |
+
help="Suffix appended to each Cas9 name for its CSV filename",
|
| 186 |
+
)
|
| 187 |
+
parser.add_argument(
|
| 188 |
+
"--num_steps",
|
| 189 |
+
type=int,
|
| 190 |
+
default=32,
|
| 191 |
+
help="Number of flow steps (default: 32)",
|
| 192 |
+
)
|
| 193 |
+
parser.add_argument(
|
| 194 |
+
"--max_len_cap",
|
| 195 |
+
type=int,
|
| 196 |
+
default=None,
|
| 197 |
+
help="Maximum sequence length cap (default: None)",
|
| 198 |
+
)
|
| 199 |
+
parser.add_argument(
|
| 200 |
+
"--deletion_rate_scale",
|
| 201 |
+
type=float,
|
| 202 |
+
default=1.0,
|
| 203 |
+
help="Scaling factor for deletion rate (default: 1.0)",
|
| 204 |
+
)
|
| 205 |
+
parser.add_argument(
|
| 206 |
+
"--zero_lam_ins",
|
| 207 |
+
action="store_true",
|
| 208 |
+
help="Set insertion rate to 0 (deletions and substitutions only)",
|
| 209 |
+
)
|
| 210 |
+
parser.add_argument(
|
| 211 |
+
"--seed",
|
| 212 |
+
type=int,
|
| 213 |
+
default=None,
|
| 214 |
+
help="Optional random seed for reproducibility",
|
| 215 |
+
)
|
| 216 |
+
parser.add_argument(
|
| 217 |
+
"--device",
|
| 218 |
+
type=str,
|
| 219 |
+
default=None,
|
| 220 |
+
help="Device to use (cuda/cpu). Default: auto-detect",
|
| 221 |
+
)
|
| 222 |
+
args = parser.parse_args()
|
| 223 |
+
|
| 224 |
+
if args.seed is not None:
|
| 225 |
+
torch.manual_seed(args.seed)
|
| 226 |
+
if torch.cuda.is_available():
|
| 227 |
+
torch.cuda.manual_seed_all(args.seed)
|
| 228 |
+
|
| 229 |
+
if args.device is None:
|
| 230 |
+
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 231 |
+
else:
|
| 232 |
+
device = torch.device(args.device)
|
| 233 |
+
|
| 234 |
+
with open(args.pcomol_config, "r") as handle:
|
| 235 |
+
cfg = edict(yaml.safe_load(handle))
|
| 236 |
+
|
| 237 |
+
sequence_map = load_sequences(Path(args.run_pcomol_sh))
|
| 238 |
+
if not sequence_map:
|
| 239 |
+
raise ValueError(f"No Cas9 sequences found in {args.run_pcomol_sh}")
|
| 240 |
+
|
| 241 |
+
cas9_names = normalize_cas9_names(args.cas9_names, sequence_map)
|
| 242 |
+
|
| 243 |
+
if any(n <= 0 for n in args.n):
|
| 244 |
+
raise ValueError("All values passed to --n must be positive")
|
| 245 |
+
|
| 246 |
+
if len(args.n) == 1:
|
| 247 |
+
n_per_cas9 = {name: args.n[0] for name in cas9_names}
|
| 248 |
+
elif len(args.n) == len(cas9_names):
|
| 249 |
+
n_per_cas9 = {name: n for name, n in zip(cas9_names, args.n)}
|
| 250 |
+
else:
|
| 251 |
+
raise ValueError(
|
| 252 |
+
f"--n must have either 1 value or exactly {len(cas9_names)} values "
|
| 253 |
+
f"to match --cas9_names, but got {len(args.n)} values."
|
| 254 |
+
)
|
| 255 |
+
|
| 256 |
+
editflow, source_dist, tokenizer, pad_id, bos_id, eos_id, eps_id = build_model_and_stuff(
|
| 257 |
+
cfg, device
|
| 258 |
+
)
|
| 259 |
+
|
| 260 |
+
ckpt = torch.load(args.ckpt, map_location=device)
|
| 261 |
+
editflow.load_state_dict(ckpt["state_dict"], strict=False)
|
| 262 |
+
model = editflow.model.to(device)
|
| 263 |
+
model.eval()
|
| 264 |
+
|
| 265 |
+
allowed_tokens = torch.tensor(
|
| 266 |
+
[tok for tok in source_dist._allowed_tokens if tok not in (eps_id,)],
|
| 267 |
+
device=device,
|
| 268 |
+
dtype=torch.long,
|
| 269 |
+
)
|
| 270 |
+
time_grid = torch.linspace(0.0, 1.0, steps=args.num_steps, device=device)
|
| 271 |
+
|
| 272 |
+
output_dir = Path(args.output_dir)
|
| 273 |
+
output_dir.mkdir(parents=True, exist_ok=True)
|
| 274 |
+
|
| 275 |
+
for cas9_name in cas9_names:
|
| 276 |
+
input_sequence = sequence_map[cas9_name].replace(" ", "").strip()
|
| 277 |
+
target_n = n_per_cas9[cas9_name]
|
| 278 |
+
x0 = tokenize_input_str(input_sequence, cfg, tokenizer, bos_id, eos_id, pad_id, device)
|
| 279 |
+
|
| 280 |
+
rows: List[Dict[str, object]] = []
|
| 281 |
+
rows.append(
|
| 282 |
+
build_empty_row(
|
| 283 |
+
final_len=len(input_sequence),
|
| 284 |
+
length_diff=0,
|
| 285 |
+
deletion_rate_scale=args.deletion_rate_scale,
|
| 286 |
+
generation_time=0,
|
| 287 |
+
sequence=input_sequence,
|
| 288 |
+
)
|
| 289 |
+
)
|
| 290 |
+
|
| 291 |
+
generated = 0
|
| 292 |
+
attempts = 0
|
| 293 |
+
max_attempts = max(target_n * 10, 50)
|
| 294 |
+
|
| 295 |
+
while generated < target_n and attempts < max_attempts:
|
| 296 |
+
attempts += 1
|
| 297 |
+
start_time = time.time()
|
| 298 |
+
try:
|
| 299 |
+
with torch.no_grad():
|
| 300 |
+
x_t = short_rollout_batch(
|
| 301 |
+
model,
|
| 302 |
+
x0,
|
| 303 |
+
time_grid,
|
| 304 |
+
start_idx=0,
|
| 305 |
+
pad_id=pad_id,
|
| 306 |
+
bos_id=bos_id,
|
| 307 |
+
eos_id=eos_id,
|
| 308 |
+
allowed_tokens=allowed_tokens,
|
| 309 |
+
max_len_cap=args.max_len_cap,
|
| 310 |
+
num_rollouts=1,
|
| 311 |
+
num_steps=args.num_steps,
|
| 312 |
+
protected_mask=None,
|
| 313 |
+
deletion_rate_scale=args.deletion_rate_scale,
|
| 314 |
+
zero_lam_ins=args.zero_lam_ins,
|
| 315 |
+
)
|
| 316 |
+
except torch.cuda.OutOfMemoryError:
|
| 317 |
+
if torch.cuda.is_available():
|
| 318 |
+
torch.cuda.empty_cache()
|
| 319 |
+
continue
|
| 320 |
+
|
| 321 |
+
generation_time = time.time() - start_time
|
| 322 |
+
out_str = detokenize_output(x_t, cfg, tokenizer, bos_id, eos_id, pad_id)
|
| 323 |
+
out_str = out_str.replace(" ", "").strip()
|
| 324 |
+
|
| 325 |
+
rows.append(
|
| 326 |
+
build_empty_row(
|
| 327 |
+
final_len=len(out_str),
|
| 328 |
+
length_diff=len(input_sequence) - len(out_str),
|
| 329 |
+
deletion_rate_scale=args.deletion_rate_scale,
|
| 330 |
+
generation_time=round(generation_time, 4),
|
| 331 |
+
sequence=out_str,
|
| 332 |
+
)
|
| 333 |
+
)
|
| 334 |
+
generated += 1
|
| 335 |
+
|
| 336 |
+
if generated < target_n:
|
| 337 |
+
raise RuntimeError(
|
| 338 |
+
f"Only generated {generated}/{target_n} sequences for {cas9_name} "
|
| 339 |
+
f"after {attempts} attempts."
|
| 340 |
+
)
|
| 341 |
+
|
| 342 |
+
output_path = output_dir / f"{cas9_name}{args.output_suffix}"
|
| 343 |
+
write_csv_rows(output_path, rows)
|
| 344 |
+
print(f"Wrote {generated} generated sequences for {cas9_name} to {output_path}")
|
| 345 |
+
|
| 346 |
+
|
| 347 |
+
if __name__ == "__main__":
|
| 348 |
+
main()
|
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Tier B artifact slot — not yet populated.
|
| 2 |
+
|
| 3 |
+
See the repository README, section 4, for what belongs here.
|
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copyright (c) Meta Platforms, Inc. and affiliates.
|
| 2 |
+
# All rights reserved.
|
| 3 |
+
#
|
| 4 |
+
# This source code is licensed under the CC-by-NC license found in the
|
| 5 |
+
# LICENSE file in the root directory of this source tree.
|
|
@@ -0,0 +1,183 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copyright (c) Meta Platforms, Inc.
|
| 2 |
+
# All rights reserved.
|
| 3 |
+
#
|
| 4 |
+
# This source code is licensed under the CC-by-NC license found in the
|
| 5 |
+
# LICENSE file in the root directory of this source tree.
|
| 6 |
+
# Part of this implementation is adapted from https://github.com/louaaron/Score-Entropy-Discrete-Diffusion
|
| 7 |
+
# which is released under MIT license
|
| 8 |
+
|
| 9 |
+
import math
|
| 10 |
+
from collections import Counter
|
| 11 |
+
from typing import Iterable, List, Optional, Sequence, Tuple
|
| 12 |
+
|
| 13 |
+
import torch
|
| 14 |
+
import torch.nn.functional as F
|
| 15 |
+
from torch import Tensor
|
| 16 |
+
from torch.utils.data import DataLoader
|
| 17 |
+
from tqdm import tqdm
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
# ---------------------------
|
| 21 |
+
# Helpers for ragged handling
|
| 22 |
+
# ---------------------------
|
| 23 |
+
|
| 24 |
+
def _batch_iter(seq: Sequence, batch_size: int) -> Iterable[Sequence]:
|
| 25 |
+
for i in range(0, len(seq), batch_size):
|
| 26 |
+
yield seq[i : i + batch_size]
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def _pad_ragged_to_dense(
|
| 30 |
+
seqs: Sequence[Tensor],
|
| 31 |
+
pad_id: int,
|
| 32 |
+
) -> Tuple[Tensor, Tensor]:
|
| 33 |
+
"""
|
| 34 |
+
Pad a list of 1-D LongTensors -> (B, Lmax) LongTensor and an attention mask (B, Lmax) bool.
|
| 35 |
+
pad_id is written into padded positions; attention_mask == 1 for real tokens.
|
| 36 |
+
"""
|
| 37 |
+
if len(seqs) == 0:
|
| 38 |
+
return torch.empty(0, 0, dtype=torch.long), torch.empty(0, 0, dtype=torch.bool)
|
| 39 |
+
|
| 40 |
+
device = seqs[0].device
|
| 41 |
+
lens = [int(s.numel()) for s in seqs]
|
| 42 |
+
Lmax = max(lens)
|
| 43 |
+
B = len(seqs)
|
| 44 |
+
|
| 45 |
+
out = torch.full((B, Lmax), pad_id, dtype=torch.long, device=device)
|
| 46 |
+
attn = torch.zeros((B, Lmax), dtype=torch.bool, device=device)
|
| 47 |
+
|
| 48 |
+
for i, s in enumerate(seqs):
|
| 49 |
+
L = s.numel()
|
| 50 |
+
if L > 0:
|
| 51 |
+
out[i, :L] = s
|
| 52 |
+
attn[i, :L] = True
|
| 53 |
+
|
| 54 |
+
return out, attn
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
# ---------------------------
|
| 58 |
+
# Perplexity (ragged-friendly)
|
| 59 |
+
# ---------------------------
|
| 60 |
+
|
| 61 |
+
@torch.no_grad()
|
| 62 |
+
def compute_perplexity(
|
| 63 |
+
samples: List[Tensor],
|
| 64 |
+
*,
|
| 65 |
+
lm_model, # a causal LM compatible with your tokenizer/vocab (e.g., HF AutoModelForCausalLM)
|
| 66 |
+
pad_id: int,
|
| 67 |
+
batch_size: int = 16,
|
| 68 |
+
) -> Tensor:
|
| 69 |
+
"""
|
| 70 |
+
Compute perplexity on ragged samples using a provided *compatible* causal LM.
|
| 71 |
+
|
| 72 |
+
Args:
|
| 73 |
+
samples: list of 1-D LongTensors (ragged, token ids in the *same* vocab the LM expects)
|
| 74 |
+
lm_model: a causal LM (e.g., transformers.AutoModelForCausalLM) on the right device
|
| 75 |
+
pad_id: integer id used for padding during evaluation
|
| 76 |
+
batch_size: eval batch size
|
| 77 |
+
|
| 78 |
+
Returns:
|
| 79 |
+
Scalar tensor: mean perplexity across sequences.
|
| 80 |
+
"""
|
| 81 |
+
if len(samples) == 0:
|
| 82 |
+
return torch.tensor(float("nan"))
|
| 83 |
+
|
| 84 |
+
device = samples[0].device
|
| 85 |
+
lm_model.eval()
|
| 86 |
+
|
| 87 |
+
ppl_vals: List[Tensor] = []
|
| 88 |
+
for chunk in _batch_iter(samples, batch_size):
|
| 89 |
+
# pad ragged batch
|
| 90 |
+
x, attn = _pad_ragged_to_dense(chunk, pad_id=pad_id) # (B, L), (B, L)
|
| 91 |
+
|
| 92 |
+
# shift for next-token prediction
|
| 93 |
+
x_in = x[:, :-1]
|
| 94 |
+
x_tgt = x[:, 1:]
|
| 95 |
+
attn_in = attn[:, :-1] # (B, L-1)
|
| 96 |
+
|
| 97 |
+
# forward LM
|
| 98 |
+
out = lm_model(input_ids=x_in, attention_mask=attn_in, use_cache=False)
|
| 99 |
+
logits = out.logits # (B, L-1, V)
|
| 100 |
+
|
| 101 |
+
# compute token-wise NLL with ignore on pads
|
| 102 |
+
# set ignored targets to -100 per HF convention
|
| 103 |
+
labels = x_tgt.clone()
|
| 104 |
+
labels[~attn[:, 1:]] = -100
|
| 105 |
+
|
| 106 |
+
# cross-entropy averaged over *valid* tokens; we want per-sequence mean → then exp
|
| 107 |
+
ce = F.cross_entropy(
|
| 108 |
+
logits.transpose(-1, -2), # (B, V, L-1)
|
| 109 |
+
labels,
|
| 110 |
+
reduction="none",
|
| 111 |
+
ignore_index=-100,
|
| 112 |
+
) # (B, L-1)
|
| 113 |
+
|
| 114 |
+
# per-sequence mean over valid positions
|
| 115 |
+
valid = (labels != -100).float()
|
| 116 |
+
token_counts = valid.sum(dim=1).clamp_min(1.0) # avoid div-by-zero
|
| 117 |
+
ce_seq = (ce * valid).sum(dim=1) / token_counts # (B,)
|
| 118 |
+
ppl_seq = ce_seq.exp() # (B,)
|
| 119 |
+
|
| 120 |
+
ppl_vals.append(ppl_seq)
|
| 121 |
+
|
| 122 |
+
ppl_all = torch.cat(ppl_vals, dim=0) # (N,)
|
| 123 |
+
return ppl_all.mean()
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
# ---------------------------
|
| 127 |
+
# Entropy (ragged-friendly)
|
| 128 |
+
# ---------------------------
|
| 129 |
+
|
| 130 |
+
def _sample_entropy_1d(sample: Tensor) -> float:
|
| 131 |
+
"""
|
| 132 |
+
Entropy (base-2) of a 1-D LongTensor by empirical token histogram.
|
| 133 |
+
"""
|
| 134 |
+
if sample.numel() == 0:
|
| 135 |
+
return 0.0
|
| 136 |
+
histogram = Counter(sample.tolist())
|
| 137 |
+
total = float(sum(histogram.values()))
|
| 138 |
+
ent = 0.0
|
| 139 |
+
for c in histogram.values():
|
| 140 |
+
p = c / total
|
| 141 |
+
ent -= p * math.log2(max(p, 1e-12))
|
| 142 |
+
return ent
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
@torch.no_grad()
|
| 146 |
+
def compute_entropy(samples: List[Tensor]) -> Tensor:
|
| 147 |
+
"""
|
| 148 |
+
Mean per-sequence token entropy (base-2) for ragged samples.
|
| 149 |
+
"""
|
| 150 |
+
if len(samples) == 0:
|
| 151 |
+
return torch.tensor(float("nan"))
|
| 152 |
+
device = samples[0].device
|
| 153 |
+
vals = [_sample_entropy_1d(s) for s in samples]
|
| 154 |
+
return torch.tensor(sum(vals) / len(vals), device=device)
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
# ---------------------------
|
| 158 |
+
# Likelihood / ELBO (EF note)
|
| 159 |
+
# ---------------------------
|
| 160 |
+
|
| 161 |
+
@torch.no_grad()
|
| 162 |
+
def estimate_likelihood(
|
| 163 |
+
model: torch.nn.Module,
|
| 164 |
+
dataloader: DataLoader,
|
| 165 |
+
source_distribution,
|
| 166 |
+
path,
|
| 167 |
+
n_discretization: int,
|
| 168 |
+
device: torch.device,
|
| 169 |
+
batch_size: int = 32,
|
| 170 |
+
epsilon: float = 1e-3,
|
| 171 |
+
) -> Tensor:
|
| 172 |
+
"""
|
| 173 |
+
Placeholder: ELBO-style likelihood estimation used for DFM does not
|
| 174 |
+
directly apply to Edit Flows (different objective / dynamics).
|
| 175 |
+
If you need a quantitative likelihood-like diagnostic for EF,
|
| 176 |
+
consider CTMC pathwise estimators or reverse-time simulators instead.
|
| 177 |
+
|
| 178 |
+
We raise NotImplementedError to avoid silently reporting a mismatched metric.
|
| 179 |
+
"""
|
| 180 |
+
raise NotImplementedError(
|
| 181 |
+
"ELBO/likelihood estimation used for discrete flow matching is not applicable to Edit Flows. "
|
| 182 |
+
"Use task metrics or CTMC-based diagnostics instead."
|
| 183 |
+
)
|
|
@@ -0,0 +1,437 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copyright (c) Meta Platforms, Inc. and affiliates.
|
| 2 |
+
# All rights reserved.
|
| 3 |
+
|
| 4 |
+
from abc import ABC
|
| 5 |
+
from typing import List, Optional, Tuple, Union
|
| 6 |
+
|
| 7 |
+
import torch
|
| 8 |
+
from flow_matching.loss import MixturePathGeneralizedKL, EditFlowsLoss
|
| 9 |
+
from flow_matching.path import MixtureDiscreteProbPath # for the scheduler only
|
| 10 |
+
from flow_matching.path.scheduler import PolynomialConvexScheduler
|
| 11 |
+
from flow_matching.path.editflows_adapter import EditFlowsPathAdapter # <-- NEW import
|
| 12 |
+
from torch import Tensor
|
| 13 |
+
from torch.nn.modules.loss import _Loss
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class SourceDistribution(ABC):
|
| 17 |
+
def __init__(self) -> None:
|
| 18 |
+
...
|
| 19 |
+
|
| 20 |
+
def sample(self, tensor_size: Tuple[int, ...], device: torch.device) -> Tensor:
|
| 21 |
+
...
|
| 22 |
+
|
| 23 |
+
def sample_like(self, tensor_like: Tensor) -> Tensor:
|
| 24 |
+
...
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class MaskedSourceDistribution(SourceDistribution):
|
| 28 |
+
def __init__(self, mask_token: int) -> None:
|
| 29 |
+
self.mask_token = mask_token
|
| 30 |
+
|
| 31 |
+
@property
|
| 32 |
+
def masked(self) -> bool:
|
| 33 |
+
return True
|
| 34 |
+
|
| 35 |
+
def sample(self, tensor_size: Tuple[int, ...], device: torch.device) -> Tensor:
|
| 36 |
+
return torch.zeros(tensor_size, device=device).fill_(self.mask_token).long()
|
| 37 |
+
|
| 38 |
+
def sample_like(self, tensor_like: Tensor) -> Tensor:
|
| 39 |
+
return torch.zeros_like(tensor_like).fill_(self.mask_token).long()
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
import torch
|
| 43 |
+
from typing import List, Tuple, Optional, Union
|
| 44 |
+
Tensor = torch.Tensor
|
| 45 |
+
|
| 46 |
+
class UniformSourceDistribution:
|
| 47 |
+
def __init__(self, vocab_size, special_token_ids = None):
|
| 48 |
+
self.vocab_size = vocab_size
|
| 49 |
+
self.special_token_ids = set(special_token_ids) if special_token_ids is not None else set()
|
| 50 |
+
# Compute allowed tokens by removing all special tokens from vocab
|
| 51 |
+
self._allowed_tokens = [i for i in range(vocab_size) if i not in self.special_token_ids]
|
| 52 |
+
if len(self._allowed_tokens) == 0:
|
| 53 |
+
raise ValueError(f"All tokens are special tokens: {special_token_ids}")
|
| 54 |
+
|
| 55 |
+
@property
|
| 56 |
+
def masked(self) -> bool:
|
| 57 |
+
return False
|
| 58 |
+
|
| 59 |
+
@torch.no_grad()
|
| 60 |
+
def _sample_from_allowed(self, shape, device, allowed_tokens = None, generator = None):
|
| 61 |
+
"""Sample uniformly from allowed tokens with given shape."""
|
| 62 |
+
if allowed_tokens is None:
|
| 63 |
+
allowed_tokens = self._allowed_tokens
|
| 64 |
+
if len(allowed_tokens) == 0:
|
| 65 |
+
raise ValueError("No allowed tokens provided")
|
| 66 |
+
|
| 67 |
+
allowed_tensor = torch.tensor(allowed_tokens, dtype=torch.long, device=device)
|
| 68 |
+
num_allowed = len(allowed_tokens)
|
| 69 |
+
|
| 70 |
+
if shape.numel() == 0:
|
| 71 |
+
# Return an empty tensor (length-0 sequence)
|
| 72 |
+
return torch.empty(shape, dtype=torch.long, device=device)
|
| 73 |
+
|
| 74 |
+
# Sample indices in [0, num_allowed)
|
| 75 |
+
indices = torch.randint(
|
| 76 |
+
low=0, high=num_allowed, size=shape, device=device, generator=generator
|
| 77 |
+
)
|
| 78 |
+
return allowed_tensor[indices]
|
| 79 |
+
|
| 80 |
+
@torch.no_grad()
|
| 81 |
+
def sample(self, tensor_size, device, allowed_tokens = None, generator = None):
|
| 82 |
+
return self._sample_from_allowed(tensor_size, device, allowed_tokens, generator)
|
| 83 |
+
|
| 84 |
+
@torch.no_grad()
|
| 85 |
+
def sample_like(self, tensor_like, allowed_tokens = None, generator = None):
|
| 86 |
+
"""
|
| 87 |
+
Keep original semantics: sample tokens with the SAME SHAPE(S) as tensor_like.
|
| 88 |
+
"""
|
| 89 |
+
if isinstance(tensor_like, (list, tuple)):
|
| 90 |
+
return [
|
| 91 |
+
self._sample_from_allowed(seq.shape, seq.device, allowed_tokens, generator)
|
| 92 |
+
for seq in tensor_like
|
| 93 |
+
]
|
| 94 |
+
return self._sample_from_allowed(tensor_like.shape, tensor_like.device, allowed_tokens, generator)
|
| 95 |
+
|
| 96 |
+
@torch.no_grad()
|
| 97 |
+
def sample_like(
|
| 98 |
+
self,
|
| 99 |
+
tensor_like,
|
| 100 |
+
allowed_tokens: Optional[List[int]] = None,
|
| 101 |
+
min_len_factor: float = 0.0,
|
| 102 |
+
max_len_factor: float = 2.0,
|
| 103 |
+
generator: Optional[torch.Generator] = None,
|
| 104 |
+
):
|
| 105 |
+
"""
|
| 106 |
+
For each reference x1, sample x0 with LENGTH L ~ Uniform{ floor(min_len_factor*N) .. floor(max_len_factor*N) },
|
| 107 |
+
defaulting to [0, 2*N]. Returns an empty tensor if L == 0.
|
| 108 |
+
|
| 109 |
+
Supports Tensor (1D) or List[Tensor] (ragged). If you pass a 2D tensor, we
|
| 110 |
+
interpret N as the last dimension and return a 1D sequence for that tensor.
|
| 111 |
+
"""
|
| 112 |
+
def _one(x1):
|
| 113 |
+
assert x1.dim() >= 1, "x1 must be at least 1D"
|
| 114 |
+
device = x1.device
|
| 115 |
+
dtype = torch.long
|
| 116 |
+
|
| 117 |
+
N = int(x1.shape[-1]) # use last-dim length as reference
|
| 118 |
+
lo = int(max(0, int(min_len_factor * N)))
|
| 119 |
+
hi = int(max(0, int(max_len_factor * N)))
|
| 120 |
+
# randint is [low, high), so make high inclusive with +1
|
| 121 |
+
L = int(torch.randint(low=lo, high=hi + 1, size=(1,), device=device, generator=generator).item())
|
| 122 |
+
if L == 0:
|
| 123 |
+
return torch.empty((0,), dtype=dtype, device=device)
|
| 124 |
+
return self._sample_from_allowed(torch.Size([L]), device, allowed_tokens, generator)
|
| 125 |
+
|
| 126 |
+
if isinstance(tensor_like, (list, tuple)):
|
| 127 |
+
return [_one(seq) for seq in tensor_like]
|
| 128 |
+
else:
|
| 129 |
+
return _one(tensor_like)
|
| 130 |
+
|
| 131 |
+
def sample_x0_from_x1(self, x1, pad_id, allowed_tokens, scale_size = 2, bos_id = 0, eos_id = 2, sample_type='regular', eps_id = -1):
|
| 132 |
+
"""
|
| 133 |
+
For each sequence in x1, sample an x0 whose *core* length (excluding BOS/EOS)
|
| 134 |
+
is in [max(0, (1-scale_size)*len_valid(x1)), (1+scale_size)*len_valid(x1)],
|
| 135 |
+
where len_valid(x1) counts only tokens that are NOT {BOS, EOS, PAD}.
|
| 136 |
+
|
| 137 |
+
Rules:
|
| 138 |
+
- x0 always starts with BOS and ends with EOS
|
| 139 |
+
- x0 core tokens are sampled uniformly from vocab excluding {BOS, EOS, PAD}
|
| 140 |
+
- We batch and pad x0 to a common length (B, L0) with pad_id
|
| 141 |
+
- "length of x0 does not account for BOS and EOS" = the sampled core length
|
| 142 |
+
|
| 143 |
+
Returns:
|
| 144 |
+
x0: (B, L0) Long, padded with pad_id
|
| 145 |
+
"""
|
| 146 |
+
device = x1.device
|
| 147 |
+
B, L1 = x1.shape
|
| 148 |
+
|
| 149 |
+
# compute valid length of x1 per sequence
|
| 150 |
+
# valid = not pad, not BOS, not EOS
|
| 151 |
+
valid_mask_x1 = (x1 != pad_id) & (x1 != bos_id) & (x1 != eos_id)
|
| 152 |
+
valid_len = valid_mask_x1.sum(dim=1) # (B,)
|
| 153 |
+
|
| 154 |
+
# we will store all sequences here before padding
|
| 155 |
+
x0_seqs = []
|
| 156 |
+
|
| 157 |
+
for b in range(B):
|
| 158 |
+
max_core_len = int((1 + scale_size) * valid_len[b].item()) # may be 0
|
| 159 |
+
# sample core length in [max(0, (1-scale_size)*len_valid(x1)), (1+scale_size)*len_valid(x1)]
|
| 160 |
+
min_core_len = max(0, int((1 - scale_size) * valid_len[b].item()))
|
| 161 |
+
# print(f"x1 length: {valid_len[b].item()}, min_core_len: {min_core_len}, max_core_len: {max_core_len}")
|
| 162 |
+
core_len = int(torch.randint(low=min_core_len, high=max_core_len + 1, size=(1,), device=device).item())
|
| 163 |
+
|
| 164 |
+
# 4) sample core tokens
|
| 165 |
+
if core_len > 0:
|
| 166 |
+
idx = torch.randint(0, allowed_tokens.size(0), (core_len,), device=device)
|
| 167 |
+
core_tokens = allowed_tokens[idx] # (core_len,)
|
| 168 |
+
else:
|
| 169 |
+
core_tokens = torch.empty(0, dtype=torch.long, device=device)
|
| 170 |
+
|
| 171 |
+
# 5) build full x0: [BOS] + core + [EOS]
|
| 172 |
+
seq = torch.cat([
|
| 173 |
+
torch.tensor([bos_id], device=device, dtype=torch.long),
|
| 174 |
+
core_tokens,
|
| 175 |
+
torch.tensor([eos_id], device=device, dtype=torch.long),
|
| 176 |
+
], dim=0) # (1 + core_len + 1,)
|
| 177 |
+
|
| 178 |
+
x0_seqs.append(seq)
|
| 179 |
+
|
| 180 |
+
x0 = torch.nn.utils.rnn.pad_sequence(x0_seqs, batch_first=True, padding_value=pad_id)
|
| 181 |
+
|
| 182 |
+
return x0
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
# class UniformSourceDistribution(SourceDistribution):
|
| 186 |
+
# def __init__(self, vocab_size: int, special_token_ids: Optional[List[int]] = None) -> None:
|
| 187 |
+
# self.vocab_size = vocab_size
|
| 188 |
+
# self.special_token_ids = set(special_token_ids) if special_token_ids is not None else set()
|
| 189 |
+
# # Compute allowed tokens by removing all special tokens from vocab
|
| 190 |
+
# self._allowed_tokens = [i for i in range(vocab_size) if i not in self.special_token_ids]
|
| 191 |
+
|
| 192 |
+
# if len(self._allowed_tokens) == 0:
|
| 193 |
+
# raise ValueError(f"All tokens are special tokens: {special_token_ids}")
|
| 194 |
+
|
| 195 |
+
# @property
|
| 196 |
+
# def masked(self) -> bool:
|
| 197 |
+
# return False
|
| 198 |
+
|
| 199 |
+
# def _sample_from_allowed(
|
| 200 |
+
# self, shape: torch.Size, device: torch.device, allowed_tokens: Optional[List[int]] = None
|
| 201 |
+
# ) -> Tensor:
|
| 202 |
+
# """Sample uniformly from allowed tokens."""
|
| 203 |
+
# if allowed_tokens is None:
|
| 204 |
+
# # Use default allowed tokens (vocab minus special tokens)
|
| 205 |
+
# allowed_tokens = self._allowed_tokens
|
| 206 |
+
|
| 207 |
+
# if len(allowed_tokens) == 0:
|
| 208 |
+
# raise ValueError(f"No allowed tokens provided")
|
| 209 |
+
|
| 210 |
+
# # print(f"allowed_tokens: {allowed_tokens}")
|
| 211 |
+
|
| 212 |
+
# # Sample indices into allowed_tokens, then map to actual token IDs
|
| 213 |
+
# allowed_tensor = torch.tensor(allowed_tokens, dtype=torch.long, device=device)
|
| 214 |
+
# num_allowed = len(allowed_tokens)
|
| 215 |
+
|
| 216 |
+
# # Sample indices in [0, num_allowed)
|
| 217 |
+
# indices = torch.randint(size=shape, high=num_allowed, device=device)
|
| 218 |
+
# return allowed_tensor[indices]
|
| 219 |
+
|
| 220 |
+
# def sample(
|
| 221 |
+
# self,
|
| 222 |
+
# tensor_size: Tuple[int, ...],
|
| 223 |
+
# device: torch.device,
|
| 224 |
+
# allowed_tokens: Optional[List[int]] = None
|
| 225 |
+
# ) -> Tensor:
|
| 226 |
+
# return self._sample_from_allowed(tensor_size, device, allowed_tokens)
|
| 227 |
+
|
| 228 |
+
# def sample_like(
|
| 229 |
+
# self,
|
| 230 |
+
# tensor_like: Union[Tensor, List[Tensor]],
|
| 231 |
+
# allowed_tokens: Optional[List[int]] = None
|
| 232 |
+
# ) -> Union[Tensor, List[Tensor]]:
|
| 233 |
+
# """
|
| 234 |
+
# Sample uniform tokens matching the shape of tensor_like.
|
| 235 |
+
# Supports both Tensor and List[Tensor] for ragged inputs.
|
| 236 |
+
|
| 237 |
+
# Args:
|
| 238 |
+
# tensor_like: Either a Tensor or List[Tensor] (for ragged inputs)
|
| 239 |
+
# allowed_tokens: Optional list of allowed token IDs. If None, uses vocab minus special tokens.
|
| 240 |
+
|
| 241 |
+
# Returns:
|
| 242 |
+
# Tensor or List[Tensor] matching the input shape(s)
|
| 243 |
+
# """
|
| 244 |
+
# # Handle ragged input (list of tensors)
|
| 245 |
+
# if isinstance(tensor_like, (list, tuple)):
|
| 246 |
+
# return [
|
| 247 |
+
# self._sample_from_allowed(seq.shape, seq.device, allowed_tokens)
|
| 248 |
+
# for seq in tensor_like
|
| 249 |
+
# ]
|
| 250 |
+
|
| 251 |
+
# # Handle regular tensor input
|
| 252 |
+
# return self._sample_from_allowed(tensor_like.shape, tensor_like.device, allowed_tokens)
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
class EmpiricalSourceDistribution(SourceDistribution):
|
| 256 |
+
def __init__(self, vocab_size: int, probs: torch.Tensor, length: int):
|
| 257 |
+
self.vocab_size = vocab_size
|
| 258 |
+
self.registered = probs / probs.sum()
|
| 259 |
+
self.length = length # e.g., 100 tokens
|
| 260 |
+
|
| 261 |
+
@property
|
| 262 |
+
def masked(self) -> bool:
|
| 263 |
+
return False
|
| 264 |
+
|
| 265 |
+
def sample(self, tensor_size: Tuple[int, ...], device: torch.device) -> Tensor:
|
| 266 |
+
B = tensor_size[0]
|
| 267 |
+
idx = torch.multinomial(self.registered.to(device), num_samples=self.length, replacement=True)
|
| 268 |
+
return idx.view(1, self.length).repeat(B, 1)
|
| 269 |
+
|
| 270 |
+
def sample_like(self, tensor_like: Tensor) -> Tensor:
|
| 271 |
+
B = tensor_like.shape[0]
|
| 272 |
+
idx = torch.multinomial(self.registered.to(tensor_like.device), num_samples=self.length, replacement=True)
|
| 273 |
+
return idx.view(1, self.length).repeat(B, 1)
|
| 274 |
+
|
| 275 |
+
|
| 276 |
+
# NOTE: return type changed to the adapter (ragged). We only rely on .scheduler(t) and .sample(...).
|
| 277 |
+
def get_path(scheduler_type: str, exponent: Optional[float] = None, eps_id: int = -1) -> EditFlowsPathAdapter:
|
| 278 |
+
if scheduler_type == "polynomial":
|
| 279 |
+
# paper uses cubic => exponent=3
|
| 280 |
+
scheduler = PolynomialConvexScheduler(n=exponent)
|
| 281 |
+
else:
|
| 282 |
+
raise ValueError(f"{scheduler_type} is not supported")
|
| 283 |
+
|
| 284 |
+
# MixtureDiscreteProbPath carries the scheduler; the adapter will sample ragged z_t itself
|
| 285 |
+
mixture = MixtureDiscreteProbPath(scheduler=scheduler)
|
| 286 |
+
return EditFlowsPathAdapter(mixture_path=mixture, eps_id=eps_id)
|
| 287 |
+
|
| 288 |
+
|
| 289 |
+
def get_source_distribution(
|
| 290 |
+
source_distribution: str,
|
| 291 |
+
p_emp: Optional[Tensor] = None,
|
| 292 |
+
length: Optional[int] = None,
|
| 293 |
+
vocab_size: Optional[int] = None,
|
| 294 |
+
special_token_ids: Optional[List[int]] = None,
|
| 295 |
+
) -> SourceDistribution:
|
| 296 |
+
if p_emp is not None:
|
| 297 |
+
assert vocab_size is not None and length is not None, "Empirical source requires vocab_size and length"
|
| 298 |
+
return EmpiricalSourceDistribution(vocab_size=vocab_size, probs=p_emp, length=length)
|
| 299 |
+
|
| 300 |
+
if source_distribution == "mask":
|
| 301 |
+
assert vocab_size is not None, "Masked source requires vocab_size"
|
| 302 |
+
return MaskedSourceDistribution(mask_token=vocab_size)
|
| 303 |
+
elif source_distribution == "uniform":
|
| 304 |
+
assert vocab_size is not None, "Uniform source requires vocab_size"
|
| 305 |
+
return UniformSourceDistribution(vocab_size=vocab_size, special_token_ids=special_token_ids)
|
| 306 |
+
else:
|
| 307 |
+
raise ValueError(f"{source_distribution} is not supported")
|
| 308 |
+
|
| 309 |
+
|
| 310 |
+
def get_loss_function(loss_function: str, path: Optional[Union[MixtureDiscreteProbPath, EditFlowsPathAdapter]] = None) -> _Loss:
|
| 311 |
+
if loss_function == "cross_entropy":
|
| 312 |
+
return torch.nn.CrossEntropyLoss()
|
| 313 |
+
elif loss_function == "generalized_kl":
|
| 314 |
+
assert path is not None
|
| 315 |
+
# Generalized KL still expects a (dense) path; fine for DFM experiments
|
| 316 |
+
return MixturePathGeneralizedKL(path=path)
|
| 317 |
+
elif loss_function == "editflows":
|
| 318 |
+
# Ragged EF loss does NOT need the path; training.step precomputes the weight
|
| 319 |
+
return EditFlowsLoss(reduction="mean")
|
| 320 |
+
else:
|
| 321 |
+
raise ValueError(f"{loss_function} is not supported")
|
| 322 |
+
|
| 323 |
+
|
| 324 |
+
# # Copyright (c) Meta Platforms, Inc. and affiliates.
|
| 325 |
+
# # All rights reserved.
|
| 326 |
+
# #
|
| 327 |
+
# # This source code is licensed under the CC-by-NC license found in the
|
| 328 |
+
# # LICENSE file in the root directory of this source tree.
|
| 329 |
+
|
| 330 |
+
# from abc import ABC
|
| 331 |
+
# from typing import Optional, Tuple
|
| 332 |
+
|
| 333 |
+
# import torch
|
| 334 |
+
# from flow_matching.loss import MixturePathGeneralizedKL, EditFlowsLoss
|
| 335 |
+
# from flow_matching.path import MixtureDiscreteProbPath, ProbPath, EditFlowsPathAdapter
|
| 336 |
+
# from flow_matching.path.scheduler import PolynomialConvexScheduler
|
| 337 |
+
# from torch import Tensor
|
| 338 |
+
# from torch.nn.modules.loss import _Loss
|
| 339 |
+
|
| 340 |
+
|
| 341 |
+
# class SourceDistribution(ABC):
|
| 342 |
+
# def __init__(
|
| 343 |
+
# self,
|
| 344 |
+
# ) -> None:
|
| 345 |
+
# ...
|
| 346 |
+
|
| 347 |
+
# def sample(self, tensor_size: Tuple[int, ...], device: torch.device) -> Tensor:
|
| 348 |
+
# ...
|
| 349 |
+
|
| 350 |
+
# def sample_like(self, tensor_like: Tensor) -> Tensor:
|
| 351 |
+
# ...
|
| 352 |
+
|
| 353 |
+
|
| 354 |
+
# class MaskedSourceDistribution(SourceDistribution):
|
| 355 |
+
# def __init__(self, mask_token: int) -> None:
|
| 356 |
+
# self.mask_token = mask_token
|
| 357 |
+
|
| 358 |
+
# @property
|
| 359 |
+
# def masked(self) -> bool:
|
| 360 |
+
# return True
|
| 361 |
+
|
| 362 |
+
# def sample(self, tensor_size: Tuple[int, ...], device: torch.device) -> Tensor:
|
| 363 |
+
# return torch.zeros(tensor_size, device=device).fill_(self.mask_token).long()
|
| 364 |
+
|
| 365 |
+
# def sample_like(self, tensor_like: Tensor) -> Tensor:
|
| 366 |
+
# return torch.zeros_like(tensor_like).fill_(self.mask_token).long()
|
| 367 |
+
|
| 368 |
+
|
| 369 |
+
# class UniformSourceDistribution(SourceDistribution):
|
| 370 |
+
# def __init__(self, vocab_size: int) -> None:
|
| 371 |
+
# self.vocab_size = vocab_size
|
| 372 |
+
|
| 373 |
+
# @property
|
| 374 |
+
# def masked(self) -> bool:
|
| 375 |
+
# return False
|
| 376 |
+
|
| 377 |
+
# def sample(self, tensor_size: Tuple[int, ...], device: torch.device) -> Tensor:
|
| 378 |
+
# return torch.randint(size=tensor_size, high=self.vocab_size, device=device)
|
| 379 |
+
|
| 380 |
+
# def sample_like(self, tensor_like: Tensor) -> Tensor:
|
| 381 |
+
# return torch.randint_like(tensor_like, high=self.vocab_size)
|
| 382 |
+
|
| 383 |
+
# class EmpiricalSourceDistribution(SourceDistribution):
|
| 384 |
+
# def __init__(self, vocab_size: int, probs: torch.Tensor, length: int):
|
| 385 |
+
# self.vocab_size = vocab_size
|
| 386 |
+
# self.registered = probs / probs.sum()
|
| 387 |
+
# self.length = length # e.g., 100 tokens as in the paper’s variant
|
| 388 |
+
|
| 389 |
+
# @property
|
| 390 |
+
# def masked(self) -> bool: return False
|
| 391 |
+
|
| 392 |
+
# def sample(self, tensor_size: Tuple[int, ...], device: torch.device) -> Tensor:
|
| 393 |
+
# B = tensor_size[0]
|
| 394 |
+
# idx = torch.multinomial(self.registered.to(device), num_samples=self.length, replacement=True)
|
| 395 |
+
# return idx.view(1, self.length).repeat(B, 1)
|
| 396 |
+
|
| 397 |
+
# def sample_like(self, tensor_like: Tensor) -> Tensor:
|
| 398 |
+
# B = tensor_like.shape[0]
|
| 399 |
+
# idx = torch.multinomial(self.registered.to(tensor_like.device), num_samples=self.length, replacement=True)
|
| 400 |
+
# return idx.view(1, self.length).repeat(B, 1)
|
| 401 |
+
|
| 402 |
+
|
| 403 |
+
# def get_path(scheduler_type: str, exponent: Optional[float] = None) -> ProbPath:
|
| 404 |
+
# if scheduler_type == "polynomial":
|
| 405 |
+
# scheduler = PolynomialConvexScheduler(n=exponent)
|
| 406 |
+
# else:
|
| 407 |
+
# raise ValueError(f"{scheduler_type} is not supported")
|
| 408 |
+
|
| 409 |
+
# return EditFlowsPathAdapter(path=MixtureDiscreteProbPath(scheduler=scheduler)) # still need to decide (1) whether to implement the z_0, z_1 creation here, and (2) how to pass the eps_id to the adapter
|
| 410 |
+
|
| 411 |
+
|
| 412 |
+
# def get_source_distribution(
|
| 413 |
+
# source_distribution: str, p_emp: Optional[Tensor] = None, length: Optional[int] = None, vocab_size: int = None
|
| 414 |
+
# ) -> SourceDistribution:
|
| 415 |
+
# if p_emp is not None:
|
| 416 |
+
# return EmpiricalSourceDistribution(vocab_size=vocab_size, probs=p_emp, length=length)
|
| 417 |
+
# if source_distribution == "mask":
|
| 418 |
+
# return MaskedSourceDistribution(mask_token=vocab_size)
|
| 419 |
+
# elif source_distribution == "uniform":
|
| 420 |
+
# return UniformSourceDistribution(vocab_size=vocab_size)
|
| 421 |
+
# else:
|
| 422 |
+
# raise ValueError(f"{source_distribution} is not supported")
|
| 423 |
+
|
| 424 |
+
|
| 425 |
+
# def get_loss_function(loss_function: str, path: Optional[ProbPath] = None) -> _Loss:
|
| 426 |
+
# if loss_function == "cross_entropy":
|
| 427 |
+
# return torch.nn.CrossEntropyLoss()
|
| 428 |
+
# elif loss_function == "generalized_kl":
|
| 429 |
+
# assert path is not None
|
| 430 |
+
|
| 431 |
+
# return MixturePathGeneralizedKL(path=path)
|
| 432 |
+
# elif loss_function == "editflows":
|
| 433 |
+
# assert path is not None
|
| 434 |
+
|
| 435 |
+
# return EditFlowsLoss(path=path)
|
| 436 |
+
# else:
|
| 437 |
+
# raise ValueError(f"{loss_function} is not supported")
|
|
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copyright (c) Meta Platforms, Inc.
|
| 2 |
+
# All rights reserved.
|
| 3 |
+
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
from typing import List, Optional, Sequence, Tuple
|
| 6 |
+
|
| 7 |
+
import torch
|
| 8 |
+
from torch import nn, Tensor
|
| 9 |
+
|
| 10 |
+
from transformers.tokenization_utils import PreTrainedTokenizer
|
| 11 |
+
|
| 12 |
+
from flow_matching.path import ProbPath # adapter exposing .scheduler(t)
|
| 13 |
+
from flow_matching.solver import EditFlowsEulerSolver
|
| 14 |
+
from .flow import SourceDistribution
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class WrappedEFModel:
|
| 18 |
+
"""
|
| 19 |
+
Thin wrapper so the solver has a stable interface:
|
| 20 |
+
forward(x_list, t_scalar or t_vec) -> lam_ins, logits_ins, lam_del, lam_sub, logits_sub
|
| 21 |
+
"""
|
| 22 |
+
def __init__(self, model: nn.Module) -> None:
|
| 23 |
+
self.model = model
|
| 24 |
+
|
| 25 |
+
@torch.no_grad()
|
| 26 |
+
def __call__(self, x_list: Sequence[Tensor], t: Tensor):
|
| 27 |
+
# Model already returns ragged lists (lam_ins, logits_ins, lam_del, lam_sub, logits_sub)
|
| 28 |
+
return self.model(x_t=list(x_list), time=t)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _rows_to_ragged(x_dense: Tensor) -> List[Tensor]:
|
| 32 |
+
# Start from a (B, L) dense init -> ragged list of (L,)
|
| 33 |
+
B, L = x_dense.shape
|
| 34 |
+
return [x_dense[b].clone() for b in range(B)]
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _ragged_to_text(
|
| 38 |
+
seqs: Sequence[Tensor],
|
| 39 |
+
tokenizer: PreTrainedTokenizer,
|
| 40 |
+
) -> List[str]:
|
| 41 |
+
# Decode each sequence independently (no padding required)
|
| 42 |
+
out = []
|
| 43 |
+
for s in seqs:
|
| 44 |
+
ids = s.detach().tolist()
|
| 45 |
+
out.append(tokenizer.decode(ids, skip_special_tokens=False))
|
| 46 |
+
return out
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def generate_samples(
|
| 50 |
+
model: nn.Module,
|
| 51 |
+
step: int,
|
| 52 |
+
vocab_size: int,
|
| 53 |
+
tokenizer: PreTrainedTokenizer,
|
| 54 |
+
rank: int,
|
| 55 |
+
device: torch.device,
|
| 56 |
+
path: ProbPath, # adapter or object exposing .scheduler(t)
|
| 57 |
+
source_distribution: SourceDistribution,
|
| 58 |
+
sample_batch_size: int,
|
| 59 |
+
sequence_length: int,
|
| 60 |
+
sampling_steps: int,
|
| 61 |
+
time_epsilon: float = 0.0, # not used in Option A; kept for API parity
|
| 62 |
+
sample_dir: Optional[Path] = None,
|
| 63 |
+
dtype_categorical: torch.dtype = torch.float64,
|
| 64 |
+
) -> List[Tensor]:
|
| 65 |
+
"""
|
| 66 |
+
EditFlows ragged generation with Euler thinning (≤1 jump per step).
|
| 67 |
+
Returns list[LongTensor] of final sequences.
|
| 68 |
+
"""
|
| 69 |
+
wrapped = WrappedEFModel(model=model)
|
| 70 |
+
|
| 71 |
+
# Initial sequences (dense) -> ragged
|
| 72 |
+
x_init_dense = source_distribution.sample(
|
| 73 |
+
tensor_size=(sample_batch_size, sequence_length), device=device
|
| 74 |
+
).long()
|
| 75 |
+
x_list = _rows_to_ragged(x_init_dense)
|
| 76 |
+
|
| 77 |
+
# New ragged solver
|
| 78 |
+
solver = EditFlowsEulerSolver(
|
| 79 |
+
model=wrapped,
|
| 80 |
+
scheduler=path.scheduler,
|
| 81 |
+
vocab_size=vocab_size,
|
| 82 |
+
dtype_categorical=dtype_categorical,
|
| 83 |
+
)
|
| 84 |
+
|
| 85 |
+
# Simulate from t=0 → t=1
|
| 86 |
+
x_final = solver.sample(
|
| 87 |
+
x_list=x_list,
|
| 88 |
+
n_steps=sampling_steps,
|
| 89 |
+
t0=0.0,
|
| 90 |
+
t1=1.0,
|
| 91 |
+
verbose=False,
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
# Decode to text (optional, for logging/inspection)
|
| 95 |
+
sentences = _ragged_to_text(x_final, tokenizer)
|
| 96 |
+
if sample_dir is not None:
|
| 97 |
+
file_name = sample_dir / f"iter_{step}" / f"sample_{rank}.txt"
|
| 98 |
+
file_name.parents[0].mkdir(exist_ok=True, parents=True)
|
| 99 |
+
with open(file_name, "w") as f:
|
| 100 |
+
for s in sentences:
|
| 101 |
+
f.write(s + "\n" + "=" * 20 + " New sample " + "=" * 20 + "\n")
|
| 102 |
+
|
| 103 |
+
return x_final
|
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copyright (c) Meta Platforms, Inc. and affiliates.
|
| 2 |
+
# All rights reserved.
|
| 3 |
+
#
|
| 4 |
+
# This source code is licensed under the CC-by-NC license found in the
|
| 5 |
+
# LICENSE file in the root directory of this source tree.
|
| 6 |
+
|
| 7 |
+
import logging
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
|
| 10 |
+
import torch
|
| 11 |
+
from cas9.data import DataState
|
| 12 |
+
|
| 13 |
+
from torch import nn
|
| 14 |
+
from torch.optim import Optimizer
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class TrainState:
|
| 18 |
+
def __init__(
|
| 19 |
+
self,
|
| 20 |
+
model: nn.Module,
|
| 21 |
+
optimizer: Optimizer,
|
| 22 |
+
step: int,
|
| 23 |
+
# data_state: DataState,
|
| 24 |
+
):
|
| 25 |
+
self._model = model
|
| 26 |
+
self._optimizer = optimizer
|
| 27 |
+
self._step = step
|
| 28 |
+
# self._data_state = data_state
|
| 29 |
+
|
| 30 |
+
@property
|
| 31 |
+
def step(self) -> int:
|
| 32 |
+
return self._step
|
| 33 |
+
|
| 34 |
+
@step.setter
|
| 35 |
+
def step(self, value: int) -> None:
|
| 36 |
+
self._step = value
|
| 37 |
+
|
| 38 |
+
@property
|
| 39 |
+
def optimizer(self) -> Optimizer:
|
| 40 |
+
return self._optimizer
|
| 41 |
+
|
| 42 |
+
@property
|
| 43 |
+
def model(self) -> nn.Module:
|
| 44 |
+
return self._model
|
| 45 |
+
|
| 46 |
+
# @property
|
| 47 |
+
# def data_state(self) -> DataState:
|
| 48 |
+
# return self._data_state
|
| 49 |
+
|
| 50 |
+
def compile_model(self) -> None:
|
| 51 |
+
self._model = torch.compile(self._model)
|
| 52 |
+
|
| 53 |
+
def restore_checkpoint(
|
| 54 |
+
self, ckpt_dir: Path, device: torch.device, rank: int
|
| 55 |
+
) -> None:
|
| 56 |
+
if ckpt_dir.exists():
|
| 57 |
+
loaded_state = torch.load(ckpt_dir, map_location=device, weights_only=True)
|
| 58 |
+
|
| 59 |
+
self.optimizer.load_state_dict(loaded_state["optimizer"])
|
| 60 |
+
self.model.module.load_state_dict(loaded_state["model"])
|
| 61 |
+
self.step = loaded_state["step"]
|
| 62 |
+
# self._data_state.test.load_state_dict(loaded_state["test_sampler"])
|
| 63 |
+
# self._data_state.train.sampler.load_state_dict(
|
| 64 |
+
# loaded_state["train_sampler"]
|
| 65 |
+
# )
|
| 66 |
+
else:
|
| 67 |
+
ckpt_dir.parent.mkdir(exist_ok=True, parents=True)
|
| 68 |
+
|
| 69 |
+
if rank == 0:
|
| 70 |
+
logging.warning(
|
| 71 |
+
f"No checkpoint found at {ckpt_dir}. Returned the same state as input"
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
def save_checkpoint(self, ckpt_dir: str, rank: int) -> None:
|
| 75 |
+
saved_state = {
|
| 76 |
+
"optimizer": self.optimizer.state_dict(),
|
| 77 |
+
"model": self.model.module.state_dict(),
|
| 78 |
+
"step": self.step,
|
| 79 |
+
# "train_sampler": self._data_state.train.sampler.state_dict(),
|
| 80 |
+
# "test_sampler": self._data_state.test.sampler.state_dict(),
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
if rank == 0:
|
| 84 |
+
torch.save(saved_state, ckpt_dir)
|
| 85 |
+
|
| 86 |
+
def eval(self) -> None:
|
| 87 |
+
self.train(training=False)
|
| 88 |
+
|
| 89 |
+
def train(self, training: bool = True) -> None:
|
| 90 |
+
self._model.train(mode=training)
|
|
@@ -0,0 +1,141 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import math
|
| 2 |
+
from contextlib import nullcontext
|
| 3 |
+
from typing import Optional
|
| 4 |
+
|
| 5 |
+
import torch
|
| 6 |
+
from flow_matching.loss import MixturePathGeneralizedKL, EditFlowsLoss
|
| 7 |
+
from flow_matching.path import ProbPath
|
| 8 |
+
from omegaconf.dictconfig import DictConfig
|
| 9 |
+
from torch import nn, Tensor
|
| 10 |
+
from torch.cuda.amp import GradScaler
|
| 11 |
+
from torch.nn.utils.rnn import pad_sequence
|
| 12 |
+
|
| 13 |
+
from torch.utils.data import DataLoader
|
| 14 |
+
from utils.logging import TrainLogger
|
| 15 |
+
|
| 16 |
+
from .flow import SourceDistribution
|
| 17 |
+
from .state import TrainState
|
| 18 |
+
from ..model.utils import build_z0_z1_with_alignment, remove_eps
|
| 19 |
+
|
| 20 |
+
from dataclasses import dataclass
|
| 21 |
+
from typing import List, Tuple, Optional
|
| 22 |
+
import torch
|
| 23 |
+
from torch import Tensor
|
| 24 |
+
|
| 25 |
+
import pdb
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def step(
|
| 29 |
+
state: TrainState,
|
| 30 |
+
loss_fn: nn.Module, # EditFlowsLoss
|
| 31 |
+
path: ProbPath, # EditFlowsPathAdapter (exposes .scheduler)
|
| 32 |
+
scaler: GradScaler,
|
| 33 |
+
iterator: DataLoader,
|
| 34 |
+
device: torch.device,
|
| 35 |
+
source_distribution: SourceDistribution,
|
| 36 |
+
logger: TrainLogger,
|
| 37 |
+
training: bool,
|
| 38 |
+
optim_params: Optional[DictConfig],
|
| 39 |
+
pad_id: int,
|
| 40 |
+
bos_id: int,
|
| 41 |
+
eos_id: int,
|
| 42 |
+
) -> Tensor:
|
| 43 |
+
assert (training and (optim_params is not None)) or (not training)
|
| 44 |
+
state.train() if training else state.eval()
|
| 45 |
+
|
| 46 |
+
batch = next(iterator)
|
| 47 |
+
# x_1 = pad_sequence(batch['input_ids'], batch_first=True, padding_value=pad_id).to(device)
|
| 48 |
+
x_1 = torch.tensor(batch["input_ids"]).to(device)
|
| 49 |
+
B = x_1.shape[0]
|
| 50 |
+
|
| 51 |
+
# === Source & time ===
|
| 52 |
+
with torch.no_grad():
|
| 53 |
+
eps_id = getattr(path, "eps_id", -1)
|
| 54 |
+
allowed_tokens = torch.tensor([tok for tok in source_distribution._allowed_tokens if tok != eps_id]).to(device)
|
| 55 |
+
|
| 56 |
+
x_0 = source_distribution.sample_x0_from_x1(x_1, pad_id=pad_id, allowed_tokens=allowed_tokens, scale_size=2, bos_id = bos_id, eos_id = eos_id)
|
| 57 |
+
t = torch.rand(B, device=device).clamp(max=0.9999)
|
| 58 |
+
|
| 59 |
+
sched = path.scheduler(t)
|
| 60 |
+
precomputed_weight = sched.d_alpha_t / sched.sigma_t # (B,)
|
| 61 |
+
|
| 62 |
+
z_0, z_1 = build_z0_z1_with_alignment(x_0, x_1, eps_id, pad_id, bos_id, eos_id, p_optimal=0.6)
|
| 63 |
+
|
| 64 |
+
z_t = path.sample(z_0, z_1, t=t)
|
| 65 |
+
x_t, mask = remove_eps(z_t, eps_id, pad_id)
|
| 66 |
+
|
| 67 |
+
ctx = torch.amp.autocast('cuda', dtype=torch.float16) if training else torch.no_grad()
|
| 68 |
+
with ctx:
|
| 69 |
+
# pdb.set_trace()
|
| 70 |
+
lam_ins, logits_ins, lam_del, lam_sub, logits_sub = state.model(x_t=x_t, mask=mask,t=t)
|
| 71 |
+
|
| 72 |
+
loss, loss_components = loss_fn(lam_ins, logits_ins, lam_del, lam_sub, logits_sub,
|
| 73 |
+
z_t, z_1, x_t, mask, precomputed_weight, eps_id, bos_id, eos_id)
|
| 74 |
+
|
| 75 |
+
if training:
|
| 76 |
+
optimization_step(
|
| 77 |
+
state=state,
|
| 78 |
+
loss=loss,
|
| 79 |
+
scaler=scaler,
|
| 80 |
+
optim_params=optim_params,
|
| 81 |
+
logger=logger,
|
| 82 |
+
)
|
| 83 |
+
# Log component losses if logger is available
|
| 84 |
+
if logger is not None:
|
| 85 |
+
logger.log_loss(value=loss_components["loss_base"].item(), step=state.step, name="loss_base")
|
| 86 |
+
logger.log_loss(value=loss_components["loss_rate"].item(), step=state.step, name="loss_rate")
|
| 87 |
+
logger.log_loss(value=loss_components["loss_edit"].item(), step=state.step, name="loss_edit")
|
| 88 |
+
logger.log_loss(value=loss_components["loss_aux_unweighted"].item(), step=state.step, name="loss_aux_unweighted")
|
| 89 |
+
logger.log_loss(value=loss_components["loss_aux_weighted"].item(), step=state.step, name="loss_aux_weighted")
|
| 90 |
+
|
| 91 |
+
return loss.detach()
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def _get_lr(lr: float, step: int, warmup: int, n_iters: int, eta_min_ratio: float):
|
| 96 |
+
if step < warmup:
|
| 97 |
+
# Linear warmup
|
| 98 |
+
return lr * (step / warmup)
|
| 99 |
+
else:
|
| 100 |
+
# Cosine annealing
|
| 101 |
+
total_steps = n_iters
|
| 102 |
+
eta_min = eta_min_ratio * lr
|
| 103 |
+
cosine_decay = 0.5 * (
|
| 104 |
+
1 + math.cos(math.pi * (step - warmup) / (total_steps - warmup))
|
| 105 |
+
)
|
| 106 |
+
return eta_min + (lr - eta_min) * cosine_decay
|
| 107 |
+
|
| 108 |
+
def optimization_step(
|
| 109 |
+
state: TrainState,
|
| 110 |
+
scaler: GradScaler,
|
| 111 |
+
loss: Tensor,
|
| 112 |
+
optim_params: DictConfig,
|
| 113 |
+
logger: TrainLogger,
|
| 114 |
+
) -> None:
|
| 115 |
+
scaler.scale(loss).backward()
|
| 116 |
+
scaler.unscale_(state.optimizer)
|
| 117 |
+
|
| 118 |
+
lr = _get_lr(
|
| 119 |
+
lr=optim_params.lr,
|
| 120 |
+
step=state.step,
|
| 121 |
+
warmup=optim_params.warmup,
|
| 122 |
+
n_iters=optim_params.n_iters,
|
| 123 |
+
eta_min_ratio=optim_params.eta_min_ratio,
|
| 124 |
+
)
|
| 125 |
+
|
| 126 |
+
# Update learning rate in optimizer
|
| 127 |
+
for g in state.optimizer.param_groups:
|
| 128 |
+
g["lr"] = lr
|
| 129 |
+
|
| 130 |
+
if state.step % optim_params.log_lr_every == 0:
|
| 131 |
+
logger.log_lr(value=lr, step=state.step)
|
| 132 |
+
|
| 133 |
+
if optim_params.grad_clip >= 0:
|
| 134 |
+
torch.nn.utils.clip_grad_norm_(
|
| 135 |
+
state.model.parameters(), max_norm=optim_params.grad_clip
|
| 136 |
+
)
|
| 137 |
+
|
| 138 |
+
scaler.step(state.optimizer)
|
| 139 |
+
scaler.update()
|
| 140 |
+
|
| 141 |
+
state.optimizer.zero_grad()
|
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copyright (c) Meta Platforms, Inc. and affiliates.
|
| 2 |
+
# All rights reserved.
|
| 3 |
+
#
|
| 4 |
+
# This source code is licensed under the CC-by-NC license found in the
|
| 5 |
+
# LICENSE file in the root directory of this source tree.
|
| 6 |
+
|
| 7 |
+
from .transformer import Transformer
|
| 8 |
+
from .rotary import Rotary
|
| 9 |
+
|
| 10 |
+
__all__ = [
|
| 11 |
+
"Transformer",
|
| 12 |
+
"Rotary",
|
| 13 |
+
]
|
|
@@ -0,0 +1,935 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Optional, Tuple
|
| 2 |
+
import math
|
| 3 |
+
import torch
|
| 4 |
+
import torch.nn as nn
|
| 5 |
+
import torch.nn.functional as F
|
| 6 |
+
from transformers import EsmModel
|
| 7 |
+
import pytorch_lightning as pl
|
| 8 |
+
|
| 9 |
+
from .utils import build_z0_z1_with_alignment, remove_eps
|
| 10 |
+
import pdb
|
| 11 |
+
|
| 12 |
+
# ---------- Utilities ----------
|
| 13 |
+
|
| 14 |
+
def exists(x): return x is not None
|
| 15 |
+
|
| 16 |
+
def default(val, d):
|
| 17 |
+
return val if exists(val) else d
|
| 18 |
+
|
| 19 |
+
# ---------- Timestep embedding (sinusoidal -> MLP) ----------
|
| 20 |
+
|
| 21 |
+
class TimeEmbedding(nn.Module):
|
| 22 |
+
"""
|
| 23 |
+
Sinusoidal time embedding followed by a small MLP.
|
| 24 |
+
Accepts t of shape (B,) or scalar; outputs (B, d_model) and broadcasts over L.
|
| 25 |
+
"""
|
| 26 |
+
def __init__(self, d_model: int, hidden: Optional[int] = None, max_period: int = 10000):
|
| 27 |
+
super().__init__()
|
| 28 |
+
self.d_model = d_model
|
| 29 |
+
self.max_period = max_period
|
| 30 |
+
hidden = default(hidden, d_model * 4)
|
| 31 |
+
# Use even dim for sin/cos pairing
|
| 32 |
+
pe_dim = d_model if d_model % 2 == 0 else d_model - 1
|
| 33 |
+
self.pe_dim = pe_dim
|
| 34 |
+
self.mlp = nn.Sequential(
|
| 35 |
+
nn.Linear(pe_dim, hidden),
|
| 36 |
+
nn.SiLU(),
|
| 37 |
+
nn.Linear(hidden, d_model),
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
def forward(self, t: torch.Tensor, batch_size: Optional[int] = None) -> torch.Tensor:
|
| 41 |
+
"""
|
| 42 |
+
t: (B,) or () in [0,1]
|
| 43 |
+
returns: (B, d_model)
|
| 44 |
+
"""
|
| 45 |
+
if t.dim() == 0:
|
| 46 |
+
# scalar -> expand to batch
|
| 47 |
+
if batch_size is None:
|
| 48 |
+
raise ValueError("When t is scalar, provide batch_size.")
|
| 49 |
+
t = t.expand(batch_size)
|
| 50 |
+
B = t.shape[0]
|
| 51 |
+
device = t.device
|
| 52 |
+
half = self.pe_dim // 2
|
| 53 |
+
# frequencies
|
| 54 |
+
freqs = torch.exp(
|
| 55 |
+
torch.arange(half, device=device, dtype=t.dtype) * (-math.log(self.max_period) / (half - 1 + 1e-8))
|
| 56 |
+
)
|
| 57 |
+
angles = t[:, None] * freqs[None, :] * math.pi # (B, half)
|
| 58 |
+
pe = torch.cat([torch.sin(angles), torch.cos(angles)], dim=-1) # (B, pe_dim)
|
| 59 |
+
if self.pe_dim < self.d_model: # rare case when d_model is odd
|
| 60 |
+
pe = F.pad(pe, (0, 1), value=0.0)
|
| 61 |
+
return self.mlp(pe) # (B, d_model)
|
| 62 |
+
|
| 63 |
+
# ---------- RoPE (rotary position embedding) ----------
|
| 64 |
+
|
| 65 |
+
def apply_rotary(q: torch.Tensor, k: torch.Tensor, cos: torch.Tensor, sin: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]:
|
| 66 |
+
"""
|
| 67 |
+
q, k: (B, h, L, d_head)
|
| 68 |
+
cos, sin: (L, d_head) broadcastable to (B, h, L, d_head)
|
| 69 |
+
"""
|
| 70 |
+
# split last dim into pairs
|
| 71 |
+
d = q.shape[-1]
|
| 72 |
+
if d % 2 != 0:
|
| 73 |
+
# pad to even
|
| 74 |
+
q = F.pad(q, (0, 1), value=0.0)
|
| 75 |
+
k = F.pad(k, (0, 1), value=0.0)
|
| 76 |
+
d += 1
|
| 77 |
+
|
| 78 |
+
q1, q2 = q[..., :d//2], q[..., d//2:]
|
| 79 |
+
k1, k2 = k[..., :d//2], k[..., d//2:]
|
| 80 |
+
|
| 81 |
+
# broadcast cos/sin
|
| 82 |
+
while cos.dim() < q1.dim():
|
| 83 |
+
cos = cos.unsqueeze(0)
|
| 84 |
+
sin = sin.unsqueeze(0)
|
| 85 |
+
|
| 86 |
+
rq = torch.cat([q1 * cos - q2 * sin, q1 * sin + q2 * cos], dim=-1)
|
| 87 |
+
rk = torch.cat([k1 * cos - k2 * sin, k1 * sin + k2 * cos], dim=-1)
|
| 88 |
+
return rq[..., :q.shape[-1]], rk[..., :k.shape[-1]]
|
| 89 |
+
|
| 90 |
+
class RotaryPositionalEmbedding(nn.Module):
|
| 91 |
+
"""
|
| 92 |
+
Precomputes cos/sin for RoPE given max_len and head_dim.
|
| 93 |
+
"""
|
| 94 |
+
def __init__(self, head_dim: int, max_len: int = 8192, base: int = 10000):
|
| 95 |
+
super().__init__()
|
| 96 |
+
if head_dim % 2 != 0:
|
| 97 |
+
# allow odd by padding inside apply_rotary, but prefer even
|
| 98 |
+
pass
|
| 99 |
+
inv_freq = 1.0 / (base ** (torch.arange(0, head_dim, 2).float() / head_dim))
|
| 100 |
+
self.register_buffer("inv_freq", inv_freq, persistent=False)
|
| 101 |
+
self.max_len = max_len
|
| 102 |
+
self.head_dim = head_dim
|
| 103 |
+
self._cached_len = 0
|
| 104 |
+
self.register_buffer("cos_cached", torch.empty(0), persistent=False)
|
| 105 |
+
self.register_buffer("sin_cached", torch.empty(0), persistent=False)
|
| 106 |
+
|
| 107 |
+
def _update_cache(self, seq_len: int, device, dtype):
|
| 108 |
+
if seq_len <= self._cached_len and self.cos_cached.device == device and self.cos_cached.dtype == dtype:
|
| 109 |
+
return
|
| 110 |
+
t = torch.arange(seq_len, device=device, dtype=self.inv_freq.dtype)
|
| 111 |
+
freqs = torch.einsum('n,d->nd', t, self.inv_freq) # (L, head_dim/2)
|
| 112 |
+
# emb = torch.cat((freqs, freqs), dim=-1) # (L, head_dim)
|
| 113 |
+
self.cos_cached = freqs.cos().to(dtype=dtype)
|
| 114 |
+
self.sin_cached = freqs.sin().to(dtype=dtype)
|
| 115 |
+
self._cached_len = seq_len
|
| 116 |
+
|
| 117 |
+
def forward(self, L: int, device, dtype):
|
| 118 |
+
self._update_cache(L, device, dtype)
|
| 119 |
+
return self.cos_cached[:L], self.sin_cached[:L]
|
| 120 |
+
|
| 121 |
+
# ---------- Rotary MHA + Transformer block ----------
|
| 122 |
+
|
| 123 |
+
class RotaryMHA(nn.Module):
|
| 124 |
+
def __init__(self, d_model: int, n_heads: int, rope: RotaryPositionalEmbedding, attn_dropout: float = 0.0, proj_dropout: float = 0.0):
|
| 125 |
+
super().__init__()
|
| 126 |
+
assert d_model % n_heads == 0, "d_model must be divisible by n_heads"
|
| 127 |
+
self.qkv = nn.Linear(d_model, 3 * d_model, bias=False)
|
| 128 |
+
self.proj = nn.Linear(d_model, d_model, bias=False)
|
| 129 |
+
self.n_heads = n_heads
|
| 130 |
+
self.d_head = d_model // n_heads
|
| 131 |
+
self.attn_dropout = nn.Dropout(attn_dropout)
|
| 132 |
+
self.proj_dropout = nn.Dropout(proj_dropout)
|
| 133 |
+
self.rope = rope
|
| 134 |
+
|
| 135 |
+
def forward(self, x: torch.Tensor, key_padding_mask: Optional[torch.Tensor] = None):
|
| 136 |
+
"""
|
| 137 |
+
x: (B, L, d_model)
|
| 138 |
+
key_padding_mask: (B, L) bool, True=pad (masked)
|
| 139 |
+
"""
|
| 140 |
+
B, L, D = x.shape
|
| 141 |
+
qkv = self.qkv(x) # (B, L, 3D)
|
| 142 |
+
q, k, v = qkv.chunk(3, dim=-1)
|
| 143 |
+
q = q.view(B, L, self.n_heads, self.d_head).transpose(1, 2) # (B, h, L, d)
|
| 144 |
+
k = k.view(B, L, self.n_heads, self.d_head).transpose(1, 2)
|
| 145 |
+
v = v.view(B, L, self.n_heads, self.d_head).transpose(1, 2)
|
| 146 |
+
|
| 147 |
+
cos, sin = self.rope(L, x.device, x.dtype)
|
| 148 |
+
q, k = apply_rotary(q, k, cos, sin)
|
| 149 |
+
|
| 150 |
+
attn_scores = torch.matmul(q, k.transpose(-2, -1)) / math.sqrt(self.d_head) # (B, h, L, L)
|
| 151 |
+
|
| 152 |
+
if exists(key_padding_mask):
|
| 153 |
+
# mask: True means pad -> set attention to -inf
|
| 154 |
+
mask = key_padding_mask[:, None, None, :].to(dtype=torch.bool) # (B,1,1,L)
|
| 155 |
+
attn_scores = attn_scores.masked_fill(mask, float('-inf'))
|
| 156 |
+
|
| 157 |
+
attn = torch.softmax(attn_scores, dim=-1)
|
| 158 |
+
attn = self.attn_dropout(attn)
|
| 159 |
+
out = torch.matmul(attn, v) # (B, h, L, d)
|
| 160 |
+
out = out.transpose(1, 2).contiguous().view(B, L, D)
|
| 161 |
+
return self.proj_dropout(self.proj(out))
|
| 162 |
+
|
| 163 |
+
class TransformerBlock(nn.Module):
|
| 164 |
+
def __init__(self, d_model: int, n_heads: int, mlp_ratio: float = 4.0, attn_dropout: float = 0.0, proj_dropout: float = 0.0, rope: Optional[RotaryPositionalEmbedding] = None):
|
| 165 |
+
super().__init__()
|
| 166 |
+
self.norm1 = nn.LayerNorm(d_model)
|
| 167 |
+
self.attn = RotaryMHA(d_model, n_heads, rope=rope, attn_dropout=attn_dropout, proj_dropout=proj_dropout)
|
| 168 |
+
self.norm2 = nn.LayerNorm(d_model)
|
| 169 |
+
hidden = int(d_model * mlp_ratio)
|
| 170 |
+
self.mlp = nn.Sequential(
|
| 171 |
+
nn.Linear(d_model, hidden),
|
| 172 |
+
nn.SiLU(),
|
| 173 |
+
nn.Linear(hidden, d_model),
|
| 174 |
+
)
|
| 175 |
+
|
| 176 |
+
def forward(self, x: torch.Tensor, key_padding_mask: Optional[torch.Tensor] = None):
|
| 177 |
+
x = x + self.attn(self.norm1(x), key_padding_mask=key_padding_mask)
|
| 178 |
+
x = x + self.mlp(self.norm2(x))
|
| 179 |
+
return x
|
| 180 |
+
|
| 181 |
+
class ProteinEditFlowModel(nn.Module):
|
| 182 |
+
"""
|
| 183 |
+
Inputs:
|
| 184 |
+
x_t: (B, L) Long
|
| 185 |
+
mask: (B, L) bool, True=pad (i.e., should be ignored)
|
| 186 |
+
t: (B,) or scalar in [0,1]
|
| 187 |
+
|
| 188 |
+
Outputs:
|
| 189 |
+
lam_ins: (B, L) >= 0
|
| 190 |
+
logits_ins: (B, L, V)
|
| 191 |
+
lam_del: (B, L) >= 0
|
| 192 |
+
lam_sub: (B, L) >= 0
|
| 193 |
+
logits_sub: (B, L, V)
|
| 194 |
+
"""
|
| 195 |
+
def __init__(self, vocab_size, pad_id, config):
|
| 196 |
+
super().__init__()
|
| 197 |
+
self.d_model = getattr(config, "d_model", 768)
|
| 198 |
+
self.n_layers = getattr(config, "n_layers", 12)
|
| 199 |
+
self.n_heads = getattr(config, "n_heads", 12)
|
| 200 |
+
self.mlp_ratio = getattr(config, "mlp_ratio", 4)
|
| 201 |
+
self.max_len = getattr(config, "max_len", 2048)
|
| 202 |
+
self.dropout = getattr(config, "dropout", 0.1)
|
| 203 |
+
self.attn_dropout = getattr(config, "attn_dropout", 0)
|
| 204 |
+
self.proj_dropout = getattr(config, "proj_dropout", 0)
|
| 205 |
+
self.vocab_size = vocab_size
|
| 206 |
+
self.pad_id = pad_id
|
| 207 |
+
|
| 208 |
+
# --- Embedding ---
|
| 209 |
+
esm_model_name = getattr(config, "esm_model_name", "facebook/esm2_t33_650M_UR50D")
|
| 210 |
+
freeze_esm = getattr(config, "freeze_esm", True)
|
| 211 |
+
self.esm_emb = EsmModel.from_pretrained(esm_model_name)
|
| 212 |
+
if freeze_esm:
|
| 213 |
+
for param in self.esm_emb.parameters():
|
| 214 |
+
param.requires_grad = False
|
| 215 |
+
self.time_emb = TimeEmbedding(d_model=self.d_model)
|
| 216 |
+
|
| 217 |
+
self.tok_embed_to_hidden = nn.Linear(1280, self.d_model)
|
| 218 |
+
|
| 219 |
+
# --- RoPE shared by attention blocks ---
|
| 220 |
+
rope = RotaryPositionalEmbedding(head_dim=self.d_model // self.n_heads, max_len=self.max_len)
|
| 221 |
+
|
| 222 |
+
# --- Encoder ---
|
| 223 |
+
self.blocks = nn.ModuleList([
|
| 224 |
+
TransformerBlock(
|
| 225 |
+
d_model=self.d_model,
|
| 226 |
+
n_heads=self.n_heads,
|
| 227 |
+
mlp_ratio=self.mlp_ratio,
|
| 228 |
+
attn_dropout=self.attn_dropout,
|
| 229 |
+
proj_dropout=self.proj_dropout,
|
| 230 |
+
rope=rope
|
| 231 |
+
)
|
| 232 |
+
for _ in range(self.n_layers)
|
| 233 |
+
])
|
| 234 |
+
self.final_norm = nn.LayerNorm(self.d_model)
|
| 235 |
+
|
| 236 |
+
# --- Heads ---
|
| 237 |
+
# We use small MLP heads for rates; logits are linear.
|
| 238 |
+
self.lam_ins_head = nn.Sequential(nn.Linear(self.d_model, self.d_model//2), nn.SiLU(), nn.Linear(self.d_model//2, 1))
|
| 239 |
+
self.lam_del_head = nn.Sequential(nn.Linear(self.d_model, self.d_model//2), nn.SiLU(), nn.Linear(self.d_model//2, 1))
|
| 240 |
+
self.lam_sub_head = nn.Sequential(nn.Linear(self.d_model, self.d_model//2), nn.SiLU(), nn.Linear(self.d_model//2, 1))
|
| 241 |
+
self.logits_ins_head = nn.Linear(self.d_model, vocab_size, bias=False)
|
| 242 |
+
self.logits_sub_head = nn.Linear(self.d_model, vocab_size, bias=False)
|
| 243 |
+
|
| 244 |
+
# nonnegativity via softplus (safer than exp)
|
| 245 |
+
self.softplus = nn.Softplus(beta=1.0)
|
| 246 |
+
|
| 247 |
+
def forward(
|
| 248 |
+
self,
|
| 249 |
+
x_t: torch.LongTensor,
|
| 250 |
+
mask: torch.BoolTensor,
|
| 251 |
+
t: torch.Tensor,
|
| 252 |
+
):
|
| 253 |
+
"""
|
| 254 |
+
x_t: (B, L) long tokens
|
| 255 |
+
mask: (B, L) bool
|
| 256 |
+
t: (B,) or scalar in [0,1]
|
| 257 |
+
esmbed: optional (B, L, esm2_embed_dim) if use_real_esm2=True
|
| 258 |
+
"""
|
| 259 |
+
B, L = x_t.shape
|
| 260 |
+
# pdb.set_trace()
|
| 261 |
+
|
| 262 |
+
# --- Embedding ---
|
| 263 |
+
h = self.esm_emb(x_t, mask).last_hidden_state
|
| 264 |
+
h = self.tok_embed_to_hidden(h)
|
| 265 |
+
|
| 266 |
+
# print(f"mask: {mask.tolist()}")
|
| 267 |
+
|
| 268 |
+
# --- Add time embedding (broadcast across length) ---
|
| 269 |
+
t_emb = self.time_emb(t, batch_size=B) # (B, d_model)
|
| 270 |
+
h = h + t_emb.unsqueeze(1) # (B, L, d_model)
|
| 271 |
+
|
| 272 |
+
# --- Encoder blocks with key padding mask ---
|
| 273 |
+
for blk in self.blocks:
|
| 274 |
+
h = blk(h, key_padding_mask=(~mask))
|
| 275 |
+
|
| 276 |
+
h = self.final_norm(h) # (B, L, d_model)
|
| 277 |
+
|
| 278 |
+
# --- Heads ---
|
| 279 |
+
lam_ins = self.softplus(self.lam_ins_head(h)).squeeze(-1) # (B, L)
|
| 280 |
+
lam_del = self.softplus(self.lam_del_head(h)).squeeze(-1) # (B, L)
|
| 281 |
+
lam_sub = self.softplus(self.lam_sub_head(h)).squeeze(-1) # (B, L)
|
| 282 |
+
logits_ins = self.logits_ins_head(h) # (B, L, V)
|
| 283 |
+
logits_sub = self.logits_sub_head(h) # (B, L, V)
|
| 284 |
+
|
| 285 |
+
# --- Zero-out padded positions so they contribute nothing downstream ---
|
| 286 |
+
if exists(mask):
|
| 287 |
+
# For lambdas: force to 0 on pads
|
| 288 |
+
pad_mask_f = mask.to(h.dtype) # True=valid -> 1.0
|
| 289 |
+
lam_ins = lam_ins * pad_mask_f
|
| 290 |
+
lam_del = lam_del * pad_mask_f
|
| 291 |
+
lam_sub = lam_sub * pad_mask_f
|
| 292 |
+
|
| 293 |
+
# kill logits on pads
|
| 294 |
+
neg_val = torch.tensor(-1e4, device=h.device, dtype=h.dtype)
|
| 295 |
+
logits_ins = logits_ins.masked_fill((~mask).unsqueeze(-1), neg_val)
|
| 296 |
+
logits_sub = logits_sub.masked_fill((~mask).unsqueeze(-1), neg_val)
|
| 297 |
+
|
| 298 |
+
return lam_ins, logits_ins, lam_del, lam_sub, logits_sub
|
| 299 |
+
|
| 300 |
+
def get_esm_embeddings(self, x_t, mask):
|
| 301 |
+
"""
|
| 302 |
+
Extract ESM embeddings for given tokens.
|
| 303 |
+
|
| 304 |
+
This method allows external code to reuse the ESM model for other purposes
|
| 305 |
+
(e.g., sharing with Cas9Classifier to save memory).
|
| 306 |
+
|
| 307 |
+
Args:
|
| 308 |
+
x_t: (B, L) LongTensor - tokenized protein sequences
|
| 309 |
+
mask: (B, L) BoolTensor - True for valid tokens, False for padding
|
| 310 |
+
|
| 311 |
+
Returns:
|
| 312 |
+
embeddings: (B, L, esm_hidden_size) - ESM embeddings
|
| 313 |
+
"""
|
| 314 |
+
return self.esm_emb(x_t, attention_mask=mask).last_hidden_state
|
| 315 |
+
|
| 316 |
+
class ReparameterizedProteinEditFlowModel(nn.Module):
|
| 317 |
+
"""
|
| 318 |
+
Inputs:
|
| 319 |
+
x_t: (B, L) Long
|
| 320 |
+
mask: (B, L) bool, True=pad (i.e., should be ignored)
|
| 321 |
+
t: (B,) or scalar in [0,1]
|
| 322 |
+
|
| 323 |
+
Outputs:
|
| 324 |
+
lam_ins: (B, L) >= 0
|
| 325 |
+
logits_ins: (B, L, V)
|
| 326 |
+
lam_del: (B, L) >= 0
|
| 327 |
+
lam_sub: (B, L) >= 0
|
| 328 |
+
logits_sub: (B, L, V)
|
| 329 |
+
"""
|
| 330 |
+
def __init__(self, vocab_size, pad_id, config):
|
| 331 |
+
super().__init__()
|
| 332 |
+
self.d_model = getattr(config, "d_model", 768)
|
| 333 |
+
self.n_layers = getattr(config, "n_layers", 12)
|
| 334 |
+
self.n_heads = getattr(config, "n_heads", 12)
|
| 335 |
+
self.mlp_ratio = getattr(config, "mlp_ratio", 4)
|
| 336 |
+
self.max_len = getattr(config, "max_len", 2048)
|
| 337 |
+
self.dropout = getattr(config, "dropout", 0.1)
|
| 338 |
+
self.attn_dropout = getattr(config, "attn_dropout", 0)
|
| 339 |
+
self.proj_dropout = getattr(config, "proj_dropout", 0)
|
| 340 |
+
self.vocab_size = vocab_size
|
| 341 |
+
self.pad_id = pad_id
|
| 342 |
+
|
| 343 |
+
# --- Embedding ---
|
| 344 |
+
esm_model_name = getattr(config, "esm_model_name", "facebook/esm2_t33_650M_UR50D")
|
| 345 |
+
freeze_esm = getattr(config, "freeze_esm", True)
|
| 346 |
+
self.esm_emb = EsmModel.from_pretrained(esm_model_name)
|
| 347 |
+
if freeze_esm:
|
| 348 |
+
for param in self.esm_emb.parameters():
|
| 349 |
+
param.requires_grad = False
|
| 350 |
+
self.time_emb = TimeEmbedding(d_model=self.d_model)
|
| 351 |
+
|
| 352 |
+
self.tok_embed_to_hidden = nn.Linear(self.esm_emb.config.hidden_size, self.d_model)
|
| 353 |
+
|
| 354 |
+
# --- RoPE shared by attention blocks ---
|
| 355 |
+
rope = RotaryPositionalEmbedding(head_dim=self.d_model // self.n_heads, max_len=self.max_len)
|
| 356 |
+
|
| 357 |
+
# --- Encoder ---
|
| 358 |
+
self.blocks = nn.ModuleList([
|
| 359 |
+
TransformerBlock(
|
| 360 |
+
d_model=self.d_model,
|
| 361 |
+
n_heads=self.n_heads,
|
| 362 |
+
mlp_ratio=self.mlp_ratio,
|
| 363 |
+
attn_dropout=self.attn_dropout,
|
| 364 |
+
proj_dropout=self.proj_dropout,
|
| 365 |
+
rope=rope
|
| 366 |
+
)
|
| 367 |
+
for _ in range(self.n_layers)
|
| 368 |
+
])
|
| 369 |
+
self.final_norm = nn.LayerNorm(self.d_model)
|
| 370 |
+
|
| 371 |
+
# --- Heads ---
|
| 372 |
+
# We use small MLP heads for rates; logits are linear.
|
| 373 |
+
self.lam_total_head = nn.Sequential(nn.Linear(self.d_model, self.d_model//2), nn.SiLU(), nn.Linear(self.d_model//2, 1))
|
| 374 |
+
self.logits_type_head = nn.Sequential(nn.Linear(self.d_model, self.d_model//2), nn.SiLU(), nn.Linear(self.d_model//2, 3))
|
| 375 |
+
self.logits_ins_head = nn.Linear(self.d_model, vocab_size, bias=False)
|
| 376 |
+
self.logits_sub_head = nn.Linear(self.d_model, vocab_size, bias=False)
|
| 377 |
+
|
| 378 |
+
# nonnegativity via softplus (safer than exp)
|
| 379 |
+
self.softplus = nn.Softplus(beta=1.0)
|
| 380 |
+
|
| 381 |
+
def forward(
|
| 382 |
+
self,
|
| 383 |
+
x_t: torch.LongTensor,
|
| 384 |
+
mask: torch.BoolTensor,
|
| 385 |
+
t: torch.Tensor,
|
| 386 |
+
):
|
| 387 |
+
"""
|
| 388 |
+
x_t: (B, L) long tokens
|
| 389 |
+
mask: (B, L) bool
|
| 390 |
+
t: (B,) or scalar in [0,1]
|
| 391 |
+
esmbed: optional (B, L, esm2_embed_dim) if use_real_esm2=True
|
| 392 |
+
"""
|
| 393 |
+
B, L = x_t.shape
|
| 394 |
+
# pdb.set_trace()
|
| 395 |
+
|
| 396 |
+
# --- Embedding ---
|
| 397 |
+
h = self.esm_emb(x_t, mask).last_hidden_state
|
| 398 |
+
h = self.tok_embed_to_hidden(h)
|
| 399 |
+
|
| 400 |
+
# print(f"mask: {mask.tolist()}")
|
| 401 |
+
|
| 402 |
+
# --- Add time embedding (broadcast across length) ---
|
| 403 |
+
t_emb = self.time_emb(t, batch_size=B) # (B, d_model)
|
| 404 |
+
h = h + t_emb.unsqueeze(1) # (B, L, d_model)
|
| 405 |
+
|
| 406 |
+
# --- Encoder blocks with key padding mask ---
|
| 407 |
+
for blk in self.blocks:
|
| 408 |
+
h = blk(h, key_padding_mask=(~mask))
|
| 409 |
+
|
| 410 |
+
h = self.final_norm(h) # (B, L, d_model)
|
| 411 |
+
|
| 412 |
+
# --- Heads ---
|
| 413 |
+
lam_total = self.softplus(self.lam_total_head(h)).squeeze(-1) # (B, L)
|
| 414 |
+
logits_type = self.logits_type_head(h) # (B, L, 3)
|
| 415 |
+
logits_ins = self.logits_ins_head(h) # (B, L, V)
|
| 416 |
+
logits_sub = self.logits_sub_head(h) # (B, L, V)
|
| 417 |
+
|
| 418 |
+
# --- Compute type probs and per-type rates ---
|
| 419 |
+
pi_type = torch.softmax(logits_type, dim=-1) # (B, L, 3)
|
| 420 |
+
lam_ins = lam_total * pi_type[..., 0] # (B, L)
|
| 421 |
+
lam_del = lam_total * pi_type[..., 1] # (B, L)
|
| 422 |
+
lam_sub = lam_total * pi_type[..., 2] # (B, L)
|
| 423 |
+
|
| 424 |
+
# --- Zero-out padded positions so they contribute nothing downstream ---
|
| 425 |
+
if exists(mask):
|
| 426 |
+
# For lambdas: force to 0 on pads
|
| 427 |
+
pad_mask_f = mask.to(h.dtype) # True=valid -> 1.0
|
| 428 |
+
lam_total = lam_total * pad_mask_f
|
| 429 |
+
lam_ins = lam_ins * pad_mask_f
|
| 430 |
+
lam_del = lam_del * pad_mask_f
|
| 431 |
+
lam_sub = lam_sub * pad_mask_f
|
| 432 |
+
|
| 433 |
+
# kill logits on pads
|
| 434 |
+
neg_val = torch.tensor(-1e4, device=h.device, dtype=h.dtype)
|
| 435 |
+
logits_type = logits_type.masked_fill((~mask).unsqueeze(-1), neg_val)
|
| 436 |
+
logits_ins = logits_ins.masked_fill((~mask).unsqueeze(-1), neg_val)
|
| 437 |
+
logits_sub = logits_sub.masked_fill((~mask).unsqueeze(-1), neg_val)
|
| 438 |
+
|
| 439 |
+
return lam_ins, logits_ins, lam_del, lam_sub, logits_sub, lam_total, logits_type, pi_type
|
| 440 |
+
|
| 441 |
+
class ReparameterizedSMILESEditFlowModel(nn.Module):
|
| 442 |
+
"""
|
| 443 |
+
Inputs:
|
| 444 |
+
x_t: (B, L) Long
|
| 445 |
+
mask: (B, L) bool, True=pad (i.e., should be ignored)
|
| 446 |
+
t: (B,) or scalar in [0,1]
|
| 447 |
+
|
| 448 |
+
Outputs:
|
| 449 |
+
lam_total: (B, L) >= 0
|
| 450 |
+
logits_type: (B, L, 3) over {ins, del, sub}
|
| 451 |
+
logits_ins: (B, L, V)
|
| 452 |
+
logits_sub: (B, L, V)
|
| 453 |
+
"""
|
| 454 |
+
def __init__(self, vocab_size, pad_id, config):
|
| 455 |
+
super().__init__()
|
| 456 |
+
self.d_model = getattr(config, "d_model", 768)
|
| 457 |
+
self.n_layers = getattr(config, "n_layers", 12)
|
| 458 |
+
self.n_heads = getattr(config, "n_heads", 12)
|
| 459 |
+
self.mlp_ratio = getattr(config, "mlp_ratio", 4)
|
| 460 |
+
self.max_len = getattr(config, "max_len", 2048)
|
| 461 |
+
self.dropout = getattr(config, "dropout", 0.1)
|
| 462 |
+
self.attn_dropout = getattr(config, "attn_dropout", 0)
|
| 463 |
+
self.proj_dropout = getattr(config, "proj_dropout", 0)
|
| 464 |
+
self.vocab_size = vocab_size
|
| 465 |
+
self.pad_id = pad_id
|
| 466 |
+
|
| 467 |
+
# --- Embedding ---
|
| 468 |
+
self.seq_emb = nn.Embedding(self.vocab_size, self.d_model, padding_idx=self.pad_id)
|
| 469 |
+
self.time_emb = TimeEmbedding(d_model=self.d_model)
|
| 470 |
+
|
| 471 |
+
# --- RoPE shared by attention blocks ---
|
| 472 |
+
rope = RotaryPositionalEmbedding(head_dim=self.d_model // self.n_heads, max_len=self.max_len)
|
| 473 |
+
|
| 474 |
+
# --- Encoder ---
|
| 475 |
+
self.blocks = nn.ModuleList([
|
| 476 |
+
TransformerBlock(
|
| 477 |
+
d_model=self.d_model,
|
| 478 |
+
n_heads=self.n_heads,
|
| 479 |
+
mlp_ratio=self.mlp_ratio,
|
| 480 |
+
attn_dropout=self.attn_dropout,
|
| 481 |
+
proj_dropout=self.proj_dropout,
|
| 482 |
+
rope=rope
|
| 483 |
+
)
|
| 484 |
+
for _ in range(self.n_layers)
|
| 485 |
+
])
|
| 486 |
+
self.final_norm = nn.LayerNorm(self.d_model)
|
| 487 |
+
|
| 488 |
+
# --- Heads ---
|
| 489 |
+
# We use small MLP heads for rates; logits are linear.
|
| 490 |
+
self.lam_total_head = nn.Sequential(nn.Linear(self.d_model, self.d_model//2), nn.SiLU(), nn.Linear(self.d_model//2, 1))
|
| 491 |
+
self.logits_type_head = nn.Sequential(nn.Linear(self.d_model, self.d_model//2), nn.SiLU(), nn.Linear(self.d_model//2, 3))
|
| 492 |
+
self.logits_ins_head = nn.Linear(self.d_model, vocab_size, bias=False)
|
| 493 |
+
self.logits_sub_head = nn.Linear(self.d_model, vocab_size, bias=False)
|
| 494 |
+
|
| 495 |
+
# nonnegativity via softplus (safer than exp)
|
| 496 |
+
self.softplus = nn.Softplus(beta=1.0)
|
| 497 |
+
|
| 498 |
+
def forward(
|
| 499 |
+
self,
|
| 500 |
+
x_t: torch.LongTensor,
|
| 501 |
+
mask: torch.BoolTensor,
|
| 502 |
+
t: torch.Tensor,
|
| 503 |
+
):
|
| 504 |
+
"""
|
| 505 |
+
x_t: (B, L) long tokens
|
| 506 |
+
mask: (B, L) bool
|
| 507 |
+
t: (B,) or scalar in [0,1]
|
| 508 |
+
"""
|
| 509 |
+
B, L = x_t.shape
|
| 510 |
+
|
| 511 |
+
# --- Embedding ---
|
| 512 |
+
h = self.seq_emb(x_t)
|
| 513 |
+
|
| 514 |
+
# --- Add time embedding (broadcast across length) ---
|
| 515 |
+
t_emb = self.time_emb(t, batch_size=B) # (B, d_model)
|
| 516 |
+
h = h + t_emb.unsqueeze(1) # (B, L, d_model)
|
| 517 |
+
|
| 518 |
+
# --- Encoder blocks with key padding mask ---
|
| 519 |
+
for blk in self.blocks:
|
| 520 |
+
h = blk(h, key_padding_mask=(~mask))
|
| 521 |
+
|
| 522 |
+
h = self.final_norm(h) # (B, L, d_model)
|
| 523 |
+
|
| 524 |
+
# --- Heads ---
|
| 525 |
+
lam_total = self.softplus(self.lam_total_head(h)).squeeze(-1) # (B, L)
|
| 526 |
+
logits_type = self.logits_type_head(h) # (B, L, 3)
|
| 527 |
+
logits_ins = self.logits_ins_head(h) # (B, L, V)
|
| 528 |
+
logits_sub = self.logits_sub_head(h) # (B, L, V)
|
| 529 |
+
|
| 530 |
+
# --- Zero-out padded positions so they contribute nothing downstream ---
|
| 531 |
+
if exists(mask):
|
| 532 |
+
# For lambdas: force to 0 on pads
|
| 533 |
+
pad_mask_f = mask.to(h.dtype) # True=valid -> 1.0
|
| 534 |
+
lam_total = lam_total * pad_mask_f
|
| 535 |
+
|
| 536 |
+
# kill logits on pads
|
| 537 |
+
neg_val = torch.tensor(-1e4, device=h.device, dtype=h.dtype)
|
| 538 |
+
logits_type = logits_type.masked_fill((~mask).unsqueeze(-1), neg_val)
|
| 539 |
+
logits_ins = logits_ins.masked_fill((~mask).unsqueeze(-1), neg_val)
|
| 540 |
+
logits_sub = logits_sub.masked_fill((~mask).unsqueeze(-1), neg_val)
|
| 541 |
+
|
| 542 |
+
return lam_total, logits_type, logits_ins, logits_sub
|
| 543 |
+
|
| 544 |
+
class SMILESEditFlowModel(nn.Module):
|
| 545 |
+
"""
|
| 546 |
+
Inputs:
|
| 547 |
+
x_t: (B, L) Long
|
| 548 |
+
mask: (B, L) bool, True=pad (i.e., should be ignored)
|
| 549 |
+
t: (B,) or scalar in [0,1]
|
| 550 |
+
|
| 551 |
+
Outputs:
|
| 552 |
+
lam_ins: (B, L) >= 0
|
| 553 |
+
logits_ins: (B, L, V)
|
| 554 |
+
lam_del: (B, L) >= 0
|
| 555 |
+
lam_sub: (B, L) >= 0
|
| 556 |
+
logits_sub: (B, L, V)
|
| 557 |
+
"""
|
| 558 |
+
def __init__(self, vocab_size, pad_id, config):
|
| 559 |
+
super().__init__()
|
| 560 |
+
self.d_model = getattr(config, "d_model", 768)
|
| 561 |
+
self.n_layers = getattr(config, "n_layers", 12)
|
| 562 |
+
self.n_heads = getattr(config, "n_heads", 12)
|
| 563 |
+
self.mlp_ratio = getattr(config, "mlp_ratio", 4)
|
| 564 |
+
self.max_len = getattr(config, "max_len", 2048)
|
| 565 |
+
self.dropout = getattr(config, "dropout", 0.1)
|
| 566 |
+
self.attn_dropout = getattr(config, "attn_dropout", 0)
|
| 567 |
+
self.proj_dropout = getattr(config, "proj_dropout", 0)
|
| 568 |
+
self.vocab_size = vocab_size
|
| 569 |
+
self.pad_id = pad_id
|
| 570 |
+
|
| 571 |
+
# --- Embedding ---
|
| 572 |
+
self.seq_emb = nn.Embedding(self.vocab_size, self.d_model, padding_idx=self.pad_id)
|
| 573 |
+
self.time_emb = TimeEmbedding(d_model=self.d_model)
|
| 574 |
+
|
| 575 |
+
# --- RoPE shared by attention blocks ---
|
| 576 |
+
rope = RotaryPositionalEmbedding(head_dim=self.d_model // self.n_heads, max_len=self.max_len)
|
| 577 |
+
|
| 578 |
+
# --- Encoder ---
|
| 579 |
+
self.blocks = nn.ModuleList([
|
| 580 |
+
TransformerBlock(
|
| 581 |
+
d_model=self.d_model,
|
| 582 |
+
n_heads=self.n_heads,
|
| 583 |
+
mlp_ratio=self.mlp_ratio,
|
| 584 |
+
attn_dropout=self.attn_dropout,
|
| 585 |
+
proj_dropout=self.proj_dropout,
|
| 586 |
+
rope=rope
|
| 587 |
+
)
|
| 588 |
+
for _ in range(self.n_layers)
|
| 589 |
+
])
|
| 590 |
+
self.final_norm = nn.LayerNorm(self.d_model)
|
| 591 |
+
|
| 592 |
+
# --- Heads ---
|
| 593 |
+
# We use small MLP heads for rates; logits are linear.
|
| 594 |
+
self.lam_ins_head = nn.Sequential(nn.Linear(self.d_model, self.d_model//2), nn.SiLU(), nn.Linear(self.d_model//2, 1))
|
| 595 |
+
self.lam_del_head = nn.Sequential(nn.Linear(self.d_model, self.d_model//2), nn.SiLU(), nn.Linear(self.d_model//2, 1))
|
| 596 |
+
self.lam_sub_head = nn.Sequential(nn.Linear(self.d_model, self.d_model//2), nn.SiLU(), nn.Linear(self.d_model//2, 1))
|
| 597 |
+
self.logits_ins_head = nn.Linear(self.d_model, vocab_size, bias=False)
|
| 598 |
+
self.logits_sub_head = nn.Linear(self.d_model, vocab_size, bias=False)
|
| 599 |
+
|
| 600 |
+
# nonnegativity via softplus (safer than exp)
|
| 601 |
+
self.softplus = nn.Softplus(beta=1.0)
|
| 602 |
+
|
| 603 |
+
def forward(
|
| 604 |
+
self,
|
| 605 |
+
x_t: torch.LongTensor,
|
| 606 |
+
mask: torch.BoolTensor,
|
| 607 |
+
t: torch.Tensor,
|
| 608 |
+
):
|
| 609 |
+
"""
|
| 610 |
+
x_t: (B, L) long tokens
|
| 611 |
+
mask: (B, L) bool
|
| 612 |
+
t: (B,) or scalar in [0,1]
|
| 613 |
+
esmbed: optional (B, L, esm2_embed_dim) if use_real_esm2=True
|
| 614 |
+
"""
|
| 615 |
+
B, L = x_t.shape
|
| 616 |
+
|
| 617 |
+
# --- Embedding ---
|
| 618 |
+
h = self.seq_emb(x_t)
|
| 619 |
+
|
| 620 |
+
# --- Add time embedding (broadcast across length) ---
|
| 621 |
+
t_emb = self.time_emb(t, batch_size=B) # (B, d_model)
|
| 622 |
+
h = h + t_emb.unsqueeze(1) # (B, L, d_model)
|
| 623 |
+
|
| 624 |
+
# --- Encoder blocks with key padding mask ---
|
| 625 |
+
for blk in self.blocks:
|
| 626 |
+
h = blk(h, key_padding_mask=(~mask))
|
| 627 |
+
|
| 628 |
+
h = self.final_norm(h) # (B, L, d_model)
|
| 629 |
+
|
| 630 |
+
# --- Heads ---
|
| 631 |
+
lam_ins = self.softplus(self.lam_ins_head(h)).squeeze(-1) # (B, L)
|
| 632 |
+
lam_del = self.softplus(self.lam_del_head(h)).squeeze(-1) # (B, L)
|
| 633 |
+
lam_sub = self.softplus(self.lam_sub_head(h)).squeeze(-1) # (B, L)
|
| 634 |
+
logits_ins = self.logits_ins_head(h) # (B, L, V)
|
| 635 |
+
logits_sub = self.logits_sub_head(h) # (B, L, V)
|
| 636 |
+
|
| 637 |
+
# --- Zero-out padded positions so they contribute nothing downstream ---
|
| 638 |
+
if exists(mask):
|
| 639 |
+
# For lambdas: force to 0 on pads
|
| 640 |
+
pad_mask_f = mask.to(h.dtype) # True=valid -> 1.0
|
| 641 |
+
lam_ins = lam_ins * pad_mask_f
|
| 642 |
+
lam_del = lam_del * pad_mask_f
|
| 643 |
+
lam_sub = lam_sub * pad_mask_f
|
| 644 |
+
|
| 645 |
+
# kill logits on pads
|
| 646 |
+
neg_val = torch.tensor(-1e4, device=h.device, dtype=h.dtype)
|
| 647 |
+
logits_ins = logits_ins.masked_fill((~mask).unsqueeze(-1), neg_val)
|
| 648 |
+
logits_sub = logits_sub.masked_fill((~mask).unsqueeze(-1), neg_val)
|
| 649 |
+
|
| 650 |
+
return lam_ins, logits_ins, lam_del, lam_sub, logits_sub
|
| 651 |
+
|
| 652 |
+
class EditFlow(pl.LightningModule):
|
| 653 |
+
def __init__(self,
|
| 654 |
+
model,
|
| 655 |
+
loss_fn,
|
| 656 |
+
path,
|
| 657 |
+
source_distribution,
|
| 658 |
+
pad_id,
|
| 659 |
+
bos_id,
|
| 660 |
+
eos_id,
|
| 661 |
+
config,
|
| 662 |
+
):
|
| 663 |
+
super().__init__()
|
| 664 |
+
|
| 665 |
+
self.cfg = config
|
| 666 |
+
|
| 667 |
+
self.source_distribution = source_distribution
|
| 668 |
+
self.path = path
|
| 669 |
+
self.model = model
|
| 670 |
+
self.loss_fn = loss_fn
|
| 671 |
+
self.loc_prop_path = getattr(config.training, "loc_prop_path", False)
|
| 672 |
+
|
| 673 |
+
self.bos_id = bos_id
|
| 674 |
+
self.eos_id = eos_id
|
| 675 |
+
self.pad_id = pad_id
|
| 676 |
+
self.eps_id = getattr(self.path, "eps_id", -1)
|
| 677 |
+
self.lam_prop = getattr(self.cfg.training, "lam_prop", 0.0)
|
| 678 |
+
self.use_aux_ce = getattr(self.cfg.training, "use_aux_ce", False)
|
| 679 |
+
self.aux_ce_weight = getattr(self.cfg.training, "aux_ce_weight", 0.0)
|
| 680 |
+
self.reparameterize = getattr(self.cfg.training, "reparameterize", False)
|
| 681 |
+
self.gamma_rate = getattr(self.cfg.training, "gamma_rate", 1)
|
| 682 |
+
self.gamma_edit = getattr(self.cfg.training, "gamma_edit", 1)
|
| 683 |
+
# print(f"use_aux_ce: {self.use_aux_ce}, aux_ce_weight: {self.aux_ce_weight}")
|
| 684 |
+
|
| 685 |
+
self._total_steps = None
|
| 686 |
+
|
| 687 |
+
def configure_optimizers(self):
|
| 688 |
+
opt = torch.optim.AdamW(
|
| 689 |
+
self.parameters(),
|
| 690 |
+
lr=float(self.cfg.optim.lr),
|
| 691 |
+
betas=(self.cfg.optim.beta1, self.cfg.optim.beta2),
|
| 692 |
+
eps=float(self.cfg.optim.eps),
|
| 693 |
+
weight_decay=self.cfg.optim.weight_decay,
|
| 694 |
+
fused=self.cfg.optim.fused,
|
| 695 |
+
)
|
| 696 |
+
|
| 697 |
+
warmup_ratio = getattr(self.cfg.optim, "warmup_ratio", 0.1)
|
| 698 |
+
min_scale = 0.1
|
| 699 |
+
|
| 700 |
+
def lr_lambda(global_step: int):
|
| 701 |
+
# until on_train_start runs we just return 1.0
|
| 702 |
+
if self._total_steps is None or self._total_steps == 0:
|
| 703 |
+
return 1.0
|
| 704 |
+
|
| 705 |
+
total_steps = self._total_steps
|
| 706 |
+
warmup_steps = max(1, int(warmup_ratio * total_steps))
|
| 707 |
+
|
| 708 |
+
if global_step < warmup_steps:
|
| 709 |
+
# linear warmup: 0.1 -> 1.0
|
| 710 |
+
alpha = (global_step + 1) / warmup_steps
|
| 711 |
+
return 0.1 + 0.9 * alpha
|
| 712 |
+
else:
|
| 713 |
+
# cosine from 1.0 down to min_scale
|
| 714 |
+
progress = (global_step - warmup_steps) / max(1, total_steps - warmup_steps)
|
| 715 |
+
cosine = 0.5 * (1 + math.cos(math.pi * progress)) # 1 -> 0
|
| 716 |
+
return min_scale + (1.0 - min_scale) * cosine
|
| 717 |
+
|
| 718 |
+
sch = torch.optim.lr_scheduler.LambdaLR(opt, lr_lambda=lr_lambda)
|
| 719 |
+
|
| 720 |
+
return {
|
| 721 |
+
"optimizer": opt,
|
| 722 |
+
"lr_scheduler": {
|
| 723 |
+
"scheduler": sch,
|
| 724 |
+
"interval": "step", # <- per-step
|
| 725 |
+
"frequency": 1,
|
| 726 |
+
},
|
| 727 |
+
}
|
| 728 |
+
|
| 729 |
+
def preparation(self, x_1):
|
| 730 |
+
B = x_1.shape[0]
|
| 731 |
+
|
| 732 |
+
with torch.no_grad():
|
| 733 |
+
allowed_tokens = torch.tensor([tok for tok in self.source_distribution._allowed_tokens if tok != self.eps_id]).to(self.device)
|
| 734 |
+
# print(f"allowed_tokens: {allowed_tokens}")
|
| 735 |
+
|
| 736 |
+
x_0 = self.source_distribution.sample_x0_from_x1(x_1, pad_id=self.pad_id, allowed_tokens=allowed_tokens, scale_size=self.cfg.model.scale_size, bos_id = self.bos_id, eos_id = self.eos_id)
|
| 737 |
+
t = torch.rand(B, device=self.device).clamp(max=0.9999)
|
| 738 |
+
|
| 739 |
+
# Use lambda_indep if available (for convex schedulers), otherwise fallback to d_alpha_t / sigma_t
|
| 740 |
+
if hasattr(self.path, 'lambda_indep'):
|
| 741 |
+
weight = self.path.lambda_indep(t) # (B,)
|
| 742 |
+
else:
|
| 743 |
+
sched = self.path.scheduler(t)
|
| 744 |
+
weight = sched.d_alpha_t / sched.sigma_t # (B,)
|
| 745 |
+
|
| 746 |
+
z_0, z_1 = build_z0_z1_with_alignment(x_0, x_1, self.eps_id, self.pad_id, self.bos_id, self.eos_id, p_optimal=self.cfg.model.p_optimal)
|
| 747 |
+
|
| 748 |
+
if self.loc_prop_path:
|
| 749 |
+
z_t, M_t, m_t = self.path.sample_localized(
|
| 750 |
+
z0=z_0, z1=z_1, t=t, lambda_prop=self.lam_prop, return_M=True
|
| 751 |
+
)
|
| 752 |
+
else:
|
| 753 |
+
z_t = self.path.sample(z_0, z_1, t=t)
|
| 754 |
+
M_t = None
|
| 755 |
+
m_t = None
|
| 756 |
+
|
| 757 |
+
x_t, mask = remove_eps(z_t, self.eps_id, self.pad_id)
|
| 758 |
+
|
| 759 |
+
if self.reparameterize:
|
| 760 |
+
lam_ins, logits_ins, lam_del, lam_sub, logits_sub, lam_total, logits_type, pi_type = self.model(x_t=x_t, mask=mask,t=t)
|
| 761 |
+
|
| 762 |
+
return lam_total, logits_type, logits_ins, logits_sub, z_t, z_1, x_t, mask, weight, M_t
|
| 763 |
+
|
| 764 |
+
else:
|
| 765 |
+
lam_ins, logits_ins, lam_del, lam_sub, logits_sub = self.model(x_t=x_t, mask=mask,t=t)
|
| 766 |
+
|
| 767 |
+
return lam_ins, logits_ins, lam_del, lam_sub, logits_sub, z_t, z_1, x_t, mask, weight, M_t
|
| 768 |
+
|
| 769 |
+
|
| 770 |
+
def training_step(self, batch, batch_idx):
|
| 771 |
+
x_1 = torch.tensor(batch["input_ids"]).to(self.device)
|
| 772 |
+
B = x_1.shape[0]
|
| 773 |
+
|
| 774 |
+
if self.reparameterize:
|
| 775 |
+
lam_total, logits_type, logits_ins, logits_sub, z_t, z_1, x_t, mask, weight, M_t = self.preparation(x_1)
|
| 776 |
+
|
| 777 |
+
if self.loc_prop_path:
|
| 778 |
+
loss, loss_components = self.loss_fn.reparameterized_forward_localized(lam_total, logits_type, logits_ins, logits_sub,
|
| 779 |
+
z_t, z_1, x_t, mask, weight, M_t, self.lam_prop, self.eps_id, self.bos_id, self.eos_id, self.gamma_rate, self.gamma_edit, self.use_aux_ce, self.aux_ce_weight)
|
| 780 |
+
else:
|
| 781 |
+
loss, loss_components = self.loss_fn.reparameterized_forward(lam_total, logits_type, logits_ins, logits_sub,
|
| 782 |
+
z_t, z_1, x_t, mask, weight, self.eps_id, self.bos_id, self.eos_id, self.gamma_rate, self.gamma_edit, self.use_aux_ce, self.aux_ce_weight)
|
| 783 |
+
|
| 784 |
+
else:
|
| 785 |
+
lam_ins, logits_ins, lam_del, lam_sub, logits_sub, z_t, z_1, x_t, mask, weight, M_t = self.preparation(x_1)
|
| 786 |
+
|
| 787 |
+
if self.loc_prop_path:
|
| 788 |
+
loss, loss_components = self.loss_fn.forward_localized(lam_ins, logits_ins, lam_del, lam_sub, logits_sub,
|
| 789 |
+
z_t, z_1, x_t, mask, weight, M_t, self.lam_prop, self.eps_id, self.bos_id, self.eos_id, self.use_aux_ce, self.aux_ce_weight)
|
| 790 |
+
else:
|
| 791 |
+
loss, loss_components = self.loss_fn.forward(lam_ins, logits_ins, lam_del, lam_sub, logits_sub,
|
| 792 |
+
z_t, z_1, x_t, mask, weight, self.eps_id, self.bos_id, self.eos_id, self.use_aux_ce, self.aux_ce_weight)
|
| 793 |
+
|
| 794 |
+
# Log total loss (same metric name for both reparameterized and non-reparameterized models)
|
| 795 |
+
# This ensures both model types appear on the same WandB chart for comparison
|
| 796 |
+
self.log("train_loss", loss, prog_bar=True, on_step=True, on_epoch=True, batch_size=B, sync_dist=True)
|
| 797 |
+
# Log full non-aux loss
|
| 798 |
+
# Log individual non-aux loss components
|
| 799 |
+
self.log("train_loss_rate", loss_components["loss_rate"], prog_bar=False, on_step=True, on_epoch=True, batch_size=B, sync_dist=True)
|
| 800 |
+
self.log("train_loss_edit", loss_components["loss_edit"], prog_bar=False, on_step=True, on_epoch=True, batch_size=B, sync_dist=True)
|
| 801 |
+
# Log unweighted and weighted aux losses (only if use_aux_ce is True)
|
| 802 |
+
if self.use_aux_ce and self.aux_ce_weight > 0.0:
|
| 803 |
+
self.log("train_loss_base", loss_components["loss_base"], prog_bar=False, on_step=True, on_epoch=True, batch_size=B, sync_dist=True)
|
| 804 |
+
self.log("train_loss_aux_unweighted", loss_components["loss_aux_unweighted"], prog_bar=False, on_step=True, on_epoch=True, batch_size=B, sync_dist=True)
|
| 805 |
+
self.log("train_loss_aux_weighted", loss_components["loss_aux_weighted"], prog_bar=False, on_step=True, on_epoch=True, batch_size=B, sync_dist=True)
|
| 806 |
+
# Log new weighted/unweighted components (only available for reparameterized methods)
|
| 807 |
+
if "loss_rate_unweighted" in loss_components:
|
| 808 |
+
self.log("train_loss_rate_unweighted", loss_components["loss_rate_unweighted"], prog_bar=False, on_step=True, on_epoch=True, batch_size=B, sync_dist=True)
|
| 809 |
+
self.log("train_loss_rate_weighted", loss_components["loss_rate_weighted"], prog_bar=False, on_step=True, on_epoch=True, batch_size=B, sync_dist=True)
|
| 810 |
+
self.log("train_loss_edit_unweighted", loss_components["loss_edit_unweighted"], prog_bar=False, on_step=True, on_epoch=True, batch_size=B, sync_dist=True)
|
| 811 |
+
self.log("train_loss_edit_weighted", loss_components["loss_edit_weighted"], prog_bar=False, on_step=True, on_epoch=True, batch_size=B, sync_dist=True)
|
| 812 |
+
self.log("train_loss_total_weighted", loss_components["loss_total_weighted"], prog_bar=False, on_step=True, on_epoch=True, batch_size=B, sync_dist=True)
|
| 813 |
+
self.log("train_loss_total_unweighted", loss_components["loss_total_unweighted"], prog_bar=False, on_step=True, on_epoch=True, batch_size=B, sync_dist=True)
|
| 814 |
+
|
| 815 |
+
return loss
|
| 816 |
+
|
| 817 |
+
def validation_step(self, batch, batch_idx):
|
| 818 |
+
x_1 = torch.tensor(batch["input_ids"]).to(self.device)
|
| 819 |
+
B = x_1.shape[0]
|
| 820 |
+
|
| 821 |
+
if self.reparameterize:
|
| 822 |
+
lam_total, logits_type, logits_ins, logits_sub, z_t, z_1, x_t, mask, weight, M_t = self.preparation(x_1)
|
| 823 |
+
|
| 824 |
+
if self.loc_prop_path:
|
| 825 |
+
loss, loss_components = self.loss_fn.reparameterized_forward_localized(lam_total, logits_type, logits_ins, logits_sub,
|
| 826 |
+
z_t, z_1, x_t, mask, weight, M_t, self.lam_prop, self.eps_id, self.bos_id, self.eos_id, self.gamma_rate, self.gamma_edit, self.use_aux_ce, self.aux_ce_weight)
|
| 827 |
+
else:
|
| 828 |
+
loss, loss_components = self.loss_fn.reparameterized_forward(lam_total, logits_type, logits_ins, logits_sub,
|
| 829 |
+
z_t, z_1, x_t, mask, weight, self.eps_id, self.bos_id, self.eos_id, self.gamma_rate, self.gamma_edit, self.use_aux_ce, self.aux_ce_weight)
|
| 830 |
+
else:
|
| 831 |
+
lam_ins, logits_ins, lam_del, lam_sub, logits_sub, z_t, z_1, x_t, mask, weight, M_t = self.preparation(x_1)
|
| 832 |
+
|
| 833 |
+
if self.loc_prop_path:
|
| 834 |
+
loss, loss_components = self.loss_fn.forward_localized(lam_ins, logits_ins, lam_del, lam_sub, logits_sub,
|
| 835 |
+
z_t, z_1, x_t, mask, weight, M_t, self.lam_prop, self.eps_id, self.bos_id, self.eos_id, self.use_aux_ce, self.aux_ce_weight)
|
| 836 |
+
else:
|
| 837 |
+
loss, loss_components = self.loss_fn.forward(lam_ins, logits_ins, lam_del, lam_sub, logits_sub,
|
| 838 |
+
z_t, z_1, x_t, mask, weight, self.eps_id, self.bos_id, self.eos_id, self.use_aux_ce, self.aux_ce_weight)
|
| 839 |
+
|
| 840 |
+
# Log total loss (same metric name for both reparameterized and non-reparameterized models)
|
| 841 |
+
# This ensures both model types appear on the same WandB chart for comparison
|
| 842 |
+
self.log("val_loss", loss, prog_bar=True, on_step=True, on_epoch=True, batch_size=B, sync_dist=True)
|
| 843 |
+
# Log full non-aux loss
|
| 844 |
+
# Log individual non-aux loss components
|
| 845 |
+
self.log("val_loss_rate", loss_components["loss_rate"], prog_bar=False, on_step=True, on_epoch=True, batch_size=B, sync_dist=True)
|
| 846 |
+
self.log("val_loss_edit", loss_components["loss_edit"], prog_bar=False, on_step=True, on_epoch=True, batch_size=B, sync_dist=True)
|
| 847 |
+
# Log unweighted and weighted aux losses (only if use_aux_ce is True)
|
| 848 |
+
if self.use_aux_ce and self.aux_ce_weight > 0.0:
|
| 849 |
+
self.log("val_loss_base", loss_components["loss_base"], prog_bar=False, on_step=True, on_epoch=True, batch_size=B, sync_dist=True)
|
| 850 |
+
self.log("val_loss_aux_unweighted", loss_components["loss_aux_unweighted"], prog_bar=False, on_step=True, on_epoch=True, batch_size=B, sync_dist=True)
|
| 851 |
+
self.log("val_loss_aux_weighted", loss_components["loss_aux_weighted"], prog_bar=False, on_step=True, on_epoch=True, batch_size=B, sync_dist=True)
|
| 852 |
+
# Log new weighted/unweighted components (only available for reparameterized methods)
|
| 853 |
+
if "loss_rate_unweighted" in loss_components:
|
| 854 |
+
self.log("val_loss_rate_unweighted", loss_components["loss_rate_unweighted"], prog_bar=False, on_step=True, on_epoch=True, batch_size=B, sync_dist=True)
|
| 855 |
+
self.log("val_loss_rate_weighted", loss_components["loss_rate_weighted"], prog_bar=False, on_step=True, on_epoch=True, batch_size=B, sync_dist=True)
|
| 856 |
+
self.log("val_loss_edit_unweighted", loss_components["loss_edit_unweighted"], prog_bar=False, on_step=True, on_epoch=True, batch_size=B, sync_dist=True)
|
| 857 |
+
self.log("val_loss_edit_weighted", loss_components["loss_edit_weighted"], prog_bar=False, on_step=True, on_epoch=True, batch_size=B, sync_dist=True)
|
| 858 |
+
self.log("val_loss_total_weighted", loss_components["loss_total_weighted"], prog_bar=False, on_step=True, on_epoch=True, batch_size=B, sync_dist=True)
|
| 859 |
+
self.log("val_loss_total_unweighted", loss_components["loss_total_unweighted"], prog_bar=False, on_step=True, on_epoch=True, batch_size=B, sync_dist=True)
|
| 860 |
+
|
| 861 |
+
return loss
|
| 862 |
+
|
| 863 |
+
def on_train_start(self):
|
| 864 |
+
# how many optimizer steps we will take in this fit
|
| 865 |
+
self._total_steps = self.trainer.estimated_stepping_batches
|
| 866 |
+
|
| 867 |
+
def on_after_backward(self):
|
| 868 |
+
"""
|
| 869 |
+
Hook called after backward() but before gradient clipping.
|
| 870 |
+
Used to log gradient information before clipping occurs.
|
| 871 |
+
"""
|
| 872 |
+
# Get gradient clipping value from trainer (default is 1.0)
|
| 873 |
+
clip_val = getattr(self.trainer, 'gradient_clip_val', 1.0)
|
| 874 |
+
if clip_val is None:
|
| 875 |
+
clip_val = float('inf')
|
| 876 |
+
|
| 877 |
+
# Collect all parameter gradients
|
| 878 |
+
total_grad_norm = 0.0
|
| 879 |
+
param_grad_norms = {}
|
| 880 |
+
num_clipped = 0
|
| 881 |
+
total_params = 0
|
| 882 |
+
|
| 883 |
+
for name, param in self.named_parameters():
|
| 884 |
+
if param.grad is not None:
|
| 885 |
+
# Compute gradient norm for this parameter
|
| 886 |
+
grad_norm = param.grad.data.norm(2).item()
|
| 887 |
+
param_grad_norms[name] = grad_norm
|
| 888 |
+
total_grad_norm += grad_norm ** 2
|
| 889 |
+
total_params += 1
|
| 890 |
+
|
| 891 |
+
# Check if this gradient would be clipped
|
| 892 |
+
if grad_norm > clip_val:
|
| 893 |
+
num_clipped += 1
|
| 894 |
+
|
| 895 |
+
# Compute total gradient norm (L2 norm across all parameters)
|
| 896 |
+
total_grad_norm = total_grad_norm ** 0.5
|
| 897 |
+
|
| 898 |
+
# Log total gradient norm
|
| 899 |
+
self.log("train_grad_norm", total_grad_norm, on_step=True, on_epoch=False, sync_dist=True)
|
| 900 |
+
|
| 901 |
+
# Log ratio of clipped gradients
|
| 902 |
+
if total_params > 0:
|
| 903 |
+
clip_ratio = num_clipped / total_params
|
| 904 |
+
self.log("train_grad_clip_ratio", clip_ratio, on_step=True, on_epoch=False, sync_dist=True)
|
| 905 |
+
else:
|
| 906 |
+
self.log("train_grad_clip_ratio", 0.0, on_step=True, on_epoch=False, sync_dist=True)
|
| 907 |
+
|
| 908 |
+
# Log individual parameter gradient norms (as a dictionary/table in wandb)
|
| 909 |
+
# We'll log the max, min, mean, and std of parameter gradients
|
| 910 |
+
if param_grad_norms:
|
| 911 |
+
grad_norms_list = list(param_grad_norms.values())
|
| 912 |
+
self.log("train_grad_norm_max", max(grad_norms_list), on_step=True, on_epoch=False, sync_dist=True)
|
| 913 |
+
self.log("train_grad_norm_min", min(grad_norms_list), on_step=True, on_epoch=False, sync_dist=True)
|
| 914 |
+
self.log("train_grad_norm_mean", sum(grad_norms_list) / len(grad_norms_list), on_step=True, on_epoch=False, sync_dist=True)
|
| 915 |
+
|
| 916 |
+
# Compute std
|
| 917 |
+
mean_grad = sum(grad_norms_list) / len(grad_norms_list)
|
| 918 |
+
variance = sum((x - mean_grad) ** 2 for x in grad_norms_list) / len(grad_norms_list)
|
| 919 |
+
std_grad = variance ** 0.5
|
| 920 |
+
self.log("train_grad_norm_std", std_grad, on_step=True, on_epoch=False, sync_dist=True)
|
| 921 |
+
|
| 922 |
+
# Log full parameter gradients as a wandb table (every N steps to avoid too much data)
|
| 923 |
+
# For now, we'll log a summary. To log full details, we can use wandb.Table
|
| 924 |
+
if hasattr(self.logger, 'experiment') and hasattr(self.logger.experiment, 'log'):
|
| 925 |
+
# Log detailed gradient info every 100 steps
|
| 926 |
+
if self.global_step % 100 == 0:
|
| 927 |
+
import wandb
|
| 928 |
+
# Create a table with parameter names and their gradient norms
|
| 929 |
+
table_data = [[name, norm] for name, norm in param_grad_norms.items()]
|
| 930 |
+
table = wandb.Table(columns=["parameter", "grad_norm"], data=table_data)
|
| 931 |
+
self.logger.experiment.log({"train_grad_norms_table": table}, step=self.global_step)
|
| 932 |
+
|
| 933 |
+
# 2
|
| 934 |
+
|
| 935 |
+
|
|
@@ -0,0 +1,115 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copyright (c) Meta Platforms, Inc. and affiliates.
|
| 2 |
+
# All rights reserved.
|
| 3 |
+
#
|
| 4 |
+
# This source code is licensed under the CC-by-NC license found in the
|
| 5 |
+
# LICENSE file in the root directory of this source tree.
|
| 6 |
+
# Part of this implementation is adapted from https://github.com/Dao-AILab/flash-attention/blob/main/flash_attn/layers/rotary.py#L20
|
| 7 |
+
# which is released under BSD-3 license
|
| 8 |
+
# Part of this implementation is adapted from https://github.com/louaaron/Score-Entropy-Discrete-Diffusion
|
| 9 |
+
# which is released under MIT license
|
| 10 |
+
|
| 11 |
+
from typing import Tuple, Optional
|
| 12 |
+
|
| 13 |
+
import torch
|
| 14 |
+
from einops import repeat
|
| 15 |
+
from torch import Tensor
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class Rotary(torch.nn.Module):
|
| 19 |
+
"""
|
| 20 |
+
From: https://github.com/louaaron/Score-Entropy-Discrete-Diffusion
|
| 21 |
+
"""
|
| 22 |
+
|
| 23 |
+
def __init__(self, dim: int, base: int = 10_000):
|
| 24 |
+
super().__init__()
|
| 25 |
+
inv_freq = 1.0 / (base ** (torch.arange(0, dim, 2).float() / dim))
|
| 26 |
+
self.register_buffer("inv_freq", inv_freq)
|
| 27 |
+
self.seq_len_cached = None
|
| 28 |
+
self.cos_cached = None
|
| 29 |
+
self.sin_cached = None
|
| 30 |
+
|
| 31 |
+
def positions_like(self, lengths: torch.Tensor) -> torch.Tensor:
|
| 32 |
+
# returns (T,) positions 0..len_i-1 concatenated across sequences
|
| 33 |
+
return torch.cat([torch.arange(int(L), device=lengths.device) for L in lengths], dim=0)
|
| 34 |
+
|
| 35 |
+
def forward(self, x: Tensor, seq_dim: int = 1) -> Tuple[Tensor, Tensor]:
|
| 36 |
+
seq_len = x.shape[seq_dim]
|
| 37 |
+
if seq_len != self.seq_len_cached:
|
| 38 |
+
self.seq_len_cached = seq_len
|
| 39 |
+
t = torch.arange(x.shape[seq_dim], device=x.device).type_as(self.inv_freq)
|
| 40 |
+
freqs = torch.einsum("i,j->ij", t, self.inv_freq.clone())
|
| 41 |
+
emb = torch.cat((freqs, freqs), dim=-1).to(x.device)
|
| 42 |
+
|
| 43 |
+
# dims are: batch, seq_len, qkv, head, dim
|
| 44 |
+
self.cos_cached = emb.cos()[None, :, None, None, :].repeat(1, 1, 3, 1, 1)
|
| 45 |
+
self.sin_cached = emb.sin()[None, :, None, None, :].repeat(1, 1, 3, 1, 1)
|
| 46 |
+
|
| 47 |
+
# This makes the transformation on v an identity.
|
| 48 |
+
self.cos_cached[:, :, 2, :, :].fill_(1.0)
|
| 49 |
+
self.sin_cached[:, :, 2, :, :].fill_(0.0)
|
| 50 |
+
|
| 51 |
+
return self.cos_cached, self.sin_cached
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def rotate_half(x: Tensor) -> Tensor:
|
| 55 |
+
x1, x2 = x[..., : x.shape[-1] // 2], x[..., x.shape[-1] // 2 :]
|
| 56 |
+
|
| 57 |
+
return torch.cat((-x2, x1), dim=-1)
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def apply_rotary_emb_torch(x, cos, sin, interleaved: bool = False, rotary_dim: Optional[int] = None):
|
| 61 |
+
# cos/sin cached as in your Rotary.forward
|
| 62 |
+
cos = cos[0, :, 0, 0, : cos.shape[-1] // 2].to(dtype=x.dtype, device=x.device) # (S, d/2)
|
| 63 |
+
sin = sin[0, :, 0, 0, : sin.shape[-1] // 2].to(dtype=x.dtype, device=x.device) # (S, d/2)
|
| 64 |
+
|
| 65 |
+
# Decide how many head dims get RoPE
|
| 66 |
+
if rotary_dim is None:
|
| 67 |
+
rotary_dim = cos.shape[-1] * 2 # default: full cached rotary span
|
| 68 |
+
rotary_dim = min(rotary_dim, x.shape[-1])
|
| 69 |
+
assert rotary_dim % 2 == 0, "rotary_dim must be even"
|
| 70 |
+
|
| 71 |
+
# Broadcast to (..., S, 1, rotary_dim)
|
| 72 |
+
cos = repeat(cos, "... d -> ... 1 (2 d)" if not interleaved else "... d -> ... 1 (d 2)")
|
| 73 |
+
sin = repeat(sin, "... d -> ... 1 (2 d)" if not interleaved else "... d -> ... 1 (d 2)")
|
| 74 |
+
|
| 75 |
+
x_rot = x[..., :rotary_dim]
|
| 76 |
+
x_tail = x[..., rotary_dim:]
|
| 77 |
+
|
| 78 |
+
x_rot = x_rot * cos + rotate_half(x_rot) * sin
|
| 79 |
+
return torch.cat([x_rot, x_tail], dim=-1)
|
| 80 |
+
|
| 81 |
+
def apply_rotary_emb_ragged(
|
| 82 |
+
q: torch.Tensor, k: torch.Tensor,
|
| 83 |
+
cos: torch.Tensor, sin: torch.Tensor,
|
| 84 |
+
positions: torch.Tensor, head_dim: Optional[int] = None,
|
| 85 |
+
interleaved: bool = False, inplace: bool = True
|
| 86 |
+
):
|
| 87 |
+
# q,k: (T, H, Dh), positions: (T,)
|
| 88 |
+
cos_half = cos[0, :, 0, 0, : cos.shape[-1] // 2].to(dtype=q.dtype, device=q.device) # (S, d/2)
|
| 89 |
+
sin_half = sin[0, :, 0, 0, : sin.shape[-1] // 2].to(dtype=q.dtype, device=q.device)
|
| 90 |
+
assert positions.max().item() < cos_half.size(0), "position index exceeds cached length"
|
| 91 |
+
|
| 92 |
+
cos_sel = cos_half.index_select(0, positions) # (T, d/2)
|
| 93 |
+
sin_sel = sin_half.index_select(0, positions) # (T, d/2)
|
| 94 |
+
|
| 95 |
+
cos_sel = repeat(cos_sel, "... d -> ... 1 (2 d)" if not interleaved else "... d -> ... 1 (d 2)") # (T,1,d)
|
| 96 |
+
sin_sel = repeat(sin_sel, "... d -> ... 1 (2 d)" if not interleaved else "... d -> ... 1 (d 2)")
|
| 97 |
+
|
| 98 |
+
ro_dim = head_dim or cos_sel.shape[-1]
|
| 99 |
+
ro_dim = min(ro_dim, q.shape[-1])
|
| 100 |
+
assert ro_dim % 2 == 0, "rotary_dim/head_dim must be even"
|
| 101 |
+
|
| 102 |
+
if not inplace:
|
| 103 |
+
q = q.clone()
|
| 104 |
+
k = k.clone()
|
| 105 |
+
|
| 106 |
+
def rot(x):
|
| 107 |
+
x1, x2 = x[..., : ro_dim // 2], x[..., ro_dim // 2: ro_dim]
|
| 108 |
+
return torch.cat([-x2, x1], dim=-1)
|
| 109 |
+
|
| 110 |
+
q_head = q[..., :ro_dim]
|
| 111 |
+
k_head = k[..., :ro_dim]
|
| 112 |
+
q[..., :ro_dim] = q_head * cos_sel + rot(q_head) * sin_sel
|
| 113 |
+
k[..., :ro_dim] = k_head * cos_sel + rot(k_head) * sin_sel
|
| 114 |
+
return q, k
|
| 115 |
+
|
|
@@ -0,0 +1,478 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copyright (c) Meta Platforms, Inc. and affiliates.
|
| 2 |
+
# All rights reserved.
|
| 3 |
+
#
|
| 4 |
+
# This source code is licensed under the CC-by-NC license found in the
|
| 5 |
+
# LICENSE file in the root directory of this source tree.
|
| 6 |
+
# Part of this implementation is adapted from https://github.com/facebookresearch/DiT
|
| 7 |
+
# which is released under NonCommercial-4.0 license
|
| 8 |
+
# Part of this implementation is adapted from https://github.com/openai/glide-text2im/blob/main/glide_text2im/nn.py
|
| 9 |
+
# which is released under MIT license
|
| 10 |
+
# Part of this implementation is adapted from https://github.com/louaaron/Score-Entropy-Discrete-Diffusion
|
| 11 |
+
# which is released under MIT license
|
| 12 |
+
|
| 13 |
+
import math
|
| 14 |
+
from typing import Optional, Tuple
|
| 15 |
+
|
| 16 |
+
import torch
|
| 17 |
+
import torch.nn.functional as F
|
| 18 |
+
from torch.nn.attention.flex_attention import flex_attention
|
| 19 |
+
|
| 20 |
+
from einops import rearrange
|
| 21 |
+
from omegaconf import OmegaConf
|
| 22 |
+
from omegaconf.dictconfig import DictConfig
|
| 23 |
+
|
| 24 |
+
from torch import nn, Tensor
|
| 25 |
+
from transformers import EsmModel
|
| 26 |
+
|
| 27 |
+
from . import rotary
|
| 28 |
+
|
| 29 |
+
import sys
|
| 30 |
+
import os
|
| 31 |
+
# Add flow_matching to path if needed for imports
|
| 32 |
+
_flow_matching_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'flow_matching')
|
| 33 |
+
if _flow_matching_dir not in sys.path:
|
| 34 |
+
sys.path.insert(0, _flow_matching_dir)
|
| 35 |
+
|
| 36 |
+
import pdb
|
| 37 |
+
|
| 38 |
+
def lengths_to_offsets(lengths: torch.Tensor) -> torch.Tensor:
|
| 39 |
+
# lengths: (B,) long -> offsets: (B+1,) long
|
| 40 |
+
return F.pad(lengths.cumsum(0), (1, 0))
|
| 41 |
+
|
| 42 |
+
def build_seq_ids(lengths: torch.Tensor, device=None) -> torch.Tensor:
|
| 43 |
+
# lengths: (B,) -> seq_ids: (T,) where T=sum(lengths)
|
| 44 |
+
device = device or lengths.device
|
| 45 |
+
return torch.repeat_interleave(torch.arange(lengths.numel(), device=device), lengths)
|
| 46 |
+
|
| 47 |
+
def make_score_mod_for_intra_sequence_only(lengths: torch.Tensor):
|
| 48 |
+
seq_ids = build_seq_ids(lengths, device=lengths.device) # (T,)
|
| 49 |
+
|
| 50 |
+
def score_mod(scores, b, h, q_idx, k_idx):
|
| 51 |
+
# scores: (..., q_block, k_block)
|
| 52 |
+
same = (seq_ids[q_idx] == seq_ids[k_idx]) # bool, broadcastable to scores
|
| 53 |
+
# Set cross-sequence scores to a very negative value *without* in-place ops
|
| 54 |
+
neg_large = torch.finfo(scores.dtype).min # dtype-safe "−large" (no -inf issues)
|
| 55 |
+
return torch.where(same, scores, torch.full_like(scores, neg_large))
|
| 56 |
+
|
| 57 |
+
return score_mod
|
| 58 |
+
|
| 59 |
+
# def make_score_mod_for_intra_sequence_only(lengths: torch.Tensor):
|
| 60 |
+
# """
|
| 61 |
+
# Returns a score_mod callback for FlexAttention that sets logits to -inf
|
| 62 |
+
# when query and key belong to different sequences (ragged, no padding).
|
| 63 |
+
# """
|
| 64 |
+
# seq_ids = build_seq_ids(lengths, device=lengths.device) # (T,)
|
| 65 |
+
|
| 66 |
+
# def score_mod(scores, b, h, q_idx, k_idx):
|
| 67 |
+
# # scores: (..., q_block, k_block); q_idx/k_idx: flat token indices in [0..T-1]
|
| 68 |
+
# original_type = scores.dtype
|
| 69 |
+
# scores = scores.float()
|
| 70 |
+
# same = (seq_ids[q_idx] == seq_ids[k_idx]).to(scores.dtype)
|
| 71 |
+
# scores += (same - 1) * 1e9 # subtract a large number for cross-seq pairs
|
| 72 |
+
# return scores.to(original_type)
|
| 73 |
+
|
| 74 |
+
# return score_mod
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def bias_dropout_add_scale(
|
| 79 |
+
x: Tensor, scale: Tensor, residual: Optional[Tensor], prob: float, training: bool
|
| 80 |
+
) -> Tensor:
|
| 81 |
+
return residual + scale * F.dropout(x, p=prob, training=training)
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def modulate(x: Tensor, shift: Tensor, scale: Tensor) -> Tensor:
|
| 85 |
+
return x * (1 + scale) + shift
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
class LayerNorm(nn.Module):
|
| 89 |
+
def __init__(self, dim: int):
|
| 90 |
+
super().__init__()
|
| 91 |
+
self.weight = nn.Parameter(torch.ones([dim]))
|
| 92 |
+
self.dim = dim
|
| 93 |
+
|
| 94 |
+
def forward(self, x: Tensor) -> Tensor:
|
| 95 |
+
with torch.amp.autocast("cuda", enabled=False):
|
| 96 |
+
y = F.layer_norm(x.float(), [self.dim])
|
| 97 |
+
if y.dim() == 3: # (B,S,H)
|
| 98 |
+
scale = self.weight[None, None, :]
|
| 99 |
+
elif y.dim() == 2: # (T,H)
|
| 100 |
+
scale = self.weight[None, :]
|
| 101 |
+
else:
|
| 102 |
+
raise ValueError(f"LayerNorm expects 2D/3D, got {y.shape}")
|
| 103 |
+
return y * scale
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
class TimestepEmbedder(nn.Module):
|
| 108 |
+
"""
|
| 109 |
+
Embeds scalar timesteps into vector representations.
|
| 110 |
+
"""
|
| 111 |
+
|
| 112 |
+
def __init__(self, hidden_size: int, frequency_embedding_size: int = 256):
|
| 113 |
+
super().__init__()
|
| 114 |
+
self.mlp = nn.Sequential(
|
| 115 |
+
nn.Linear(frequency_embedding_size, hidden_size, bias=True),
|
| 116 |
+
nn.SiLU(),
|
| 117 |
+
nn.Linear(hidden_size, hidden_size, bias=True),
|
| 118 |
+
)
|
| 119 |
+
self.frequency_embedding_size = frequency_embedding_size
|
| 120 |
+
|
| 121 |
+
@staticmethod
|
| 122 |
+
def timestep_embedding(time: Tensor, dim: int, max_period: int = 10000) -> Tensor:
|
| 123 |
+
"""
|
| 124 |
+
Create sinusoidal timestep embeddings.
|
| 125 |
+
:param t: a 1-D Tensor of N indices, one per batch element.
|
| 126 |
+
These may be fractional.
|
| 127 |
+
:param dim: the dimension of the output.
|
| 128 |
+
:param max_period: controls the minimum frequency of the embeddings.
|
| 129 |
+
:return: an (N, D) Tensor of positional embeddings.
|
| 130 |
+
"""
|
| 131 |
+
half = dim // 2
|
| 132 |
+
freqs = torch.exp(
|
| 133 |
+
-math.log(max_period)
|
| 134 |
+
* torch.arange(start=0, end=half, dtype=torch.float32)
|
| 135 |
+
/ half
|
| 136 |
+
).to(device=time.device)
|
| 137 |
+
args = time[:, None].float() * freqs[None]
|
| 138 |
+
embedding = torch.cat([torch.cos(args), torch.sin(args)], dim=-1)
|
| 139 |
+
if dim % 2:
|
| 140 |
+
embedding = torch.cat(
|
| 141 |
+
[embedding, torch.zeros_like(embedding[:, :1])], dim=-1
|
| 142 |
+
)
|
| 143 |
+
return embedding
|
| 144 |
+
|
| 145 |
+
def forward(self, time: Tensor) -> Tensor:
|
| 146 |
+
t_freq = self.timestep_embedding(time=time, dim=self.frequency_embedding_size)
|
| 147 |
+
t_emb = self.mlp(t_freq)
|
| 148 |
+
return t_emb
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
class DDiTBlock(nn.Module):
|
| 152 |
+
def __init__(
|
| 153 |
+
self,
|
| 154 |
+
dim: int,
|
| 155 |
+
n_heads: int,
|
| 156 |
+
cond_dim: int,
|
| 157 |
+
mlp_ratio: int = 4,
|
| 158 |
+
dropout: float = 0.1,
|
| 159 |
+
):
|
| 160 |
+
super().__init__()
|
| 161 |
+
assert dim % n_heads == 0, "dim must be devisable by n_heads"
|
| 162 |
+
|
| 163 |
+
self.n_heads = n_heads
|
| 164 |
+
self.dim = dim
|
| 165 |
+
self.dropout = dropout
|
| 166 |
+
|
| 167 |
+
self.head_dim = self.dim // self.n_heads
|
| 168 |
+
|
| 169 |
+
self.norm1 = LayerNorm(dim=dim)
|
| 170 |
+
|
| 171 |
+
self.qw = nn.Linear(dim, dim, bias=False)
|
| 172 |
+
self.kw = nn.Linear(dim, dim, bias=False)
|
| 173 |
+
self.vw = nn.Linear(dim, dim, bias=False)
|
| 174 |
+
|
| 175 |
+
self.attn_out = nn.Linear(dim, dim, bias=False)
|
| 176 |
+
self.dropout1 = nn.Dropout(dropout)
|
| 177 |
+
|
| 178 |
+
self.norm2 = LayerNorm(dim=dim)
|
| 179 |
+
self.mlp = nn.Sequential(
|
| 180 |
+
nn.Linear(dim, mlp_ratio * dim, bias=True),
|
| 181 |
+
nn.GELU(approximate="tanh"),
|
| 182 |
+
nn.Linear(mlp_ratio * dim, dim, bias=True),
|
| 183 |
+
)
|
| 184 |
+
|
| 185 |
+
self.adaLN_modulation = nn.Linear(cond_dim, 6 * dim, bias=True)
|
| 186 |
+
self.adaLN_modulation.weight.data.zero_()
|
| 187 |
+
self.adaLN_modulation.bias.data.zero_()
|
| 188 |
+
|
| 189 |
+
def forward(
|
| 190 |
+
self,
|
| 191 |
+
x: Tensor, # (T, H) flat tokens; T = sum(lengths)
|
| 192 |
+
rotary_caches: tuple, # (cos, sin)
|
| 193 |
+
c: Tensor # (B, cond_dim)
|
| 194 |
+
) -> Tensor:
|
| 195 |
+
|
| 196 |
+
(
|
| 197 |
+
shift_msa,
|
| 198 |
+
scale_msa,
|
| 199 |
+
gate_msa,
|
| 200 |
+
shift_mlp,
|
| 201 |
+
scale_mlp,
|
| 202 |
+
gate_mlp,
|
| 203 |
+
) = self.adaLN_modulation(c)[:, None].chunk(6, dim=2)
|
| 204 |
+
|
| 205 |
+
seq_ids = build_seq_ids(lengths, device=x.device) # (T,)
|
| 206 |
+
|
| 207 |
+
shift_msa_b = shift_msa.squeeze(1)
|
| 208 |
+
scale_msa_b = scale_msa.squeeze(1)
|
| 209 |
+
gate_msa_b = gate_msa.squeeze(1)
|
| 210 |
+
shift_mlp_b = shift_mlp.squeeze(1)
|
| 211 |
+
scale_mlp_b = scale_mlp.squeeze(1)
|
| 212 |
+
gate_mlp_b = gate_mlp.squeeze(1)
|
| 213 |
+
|
| 214 |
+
# Token-wise (T,H)
|
| 215 |
+
shift_msa_t = shift_msa_b[seq_ids]
|
| 216 |
+
scale_msa_t = scale_msa_b[seq_ids]
|
| 217 |
+
gate_msa_t = gate_msa_b[seq_ids]
|
| 218 |
+
shift_mlp_t = shift_mlp_b[seq_ids]
|
| 219 |
+
scale_mlp_t = scale_mlp_b[seq_ids]
|
| 220 |
+
gate_mlp_t = gate_mlp_b[seq_ids]
|
| 221 |
+
|
| 222 |
+
x_skip = x
|
| 223 |
+
x = modulate(self.norm1(x), shift=shift_msa_t, scale=scale_msa_t) # (T, H)
|
| 224 |
+
|
| 225 |
+
q = self.qw(x); k = self.kw(x); v = self.vw(x) # (T, H)
|
| 226 |
+
T = x.shape[0]
|
| 227 |
+
q = q.view(T, self.n_heads, self.head_dim)
|
| 228 |
+
k = k.view(T, self.n_heads, self.head_dim)
|
| 229 |
+
v = v.view(T, self.n_heads, self.head_dim)
|
| 230 |
+
|
| 231 |
+
with torch.amp.autocast("cuda", enabled=False):
|
| 232 |
+
##################
|
| 233 |
+
# TODO:
|
| 234 |
+
##################
|
| 235 |
+
q = q.to(dtype); k = k.to(dtype)
|
| 236 |
+
|
| 237 |
+
# fold heads into batch
|
| 238 |
+
q = q.transpose(0, 1).contiguous() # (Hh, T, Dh)
|
| 239 |
+
k = k.transpose(0, 1).contiguous()
|
| 240 |
+
v = v.transpose(0, 1).contiguous()
|
| 241 |
+
|
| 242 |
+
q = q.unsqueeze(0) # -> (1, Hh, T, Dh)
|
| 243 |
+
k = k.unsqueeze(0) # -> (1, Hh, T, Dh)
|
| 244 |
+
v = v.unsqueeze(0) # -> (1, Hh, T, Dh)
|
| 245 |
+
|
| 246 |
+
score_mod = make_score_mod_for_intra_sequence_only(lengths)
|
| 247 |
+
attn_out = flex_attention(q, k, v, score_mod=score_mod) # (Hh, T, Dh)
|
| 248 |
+
attn_out = attn_out.squeeze(0) # -> (Hh, T, Dh)
|
| 249 |
+
|
| 250 |
+
x = attn_out.transpose(0, 1).contiguous().view(T, self.dim) # (T,H)
|
| 251 |
+
x = bias_dropout_add_scale(self.attn_out(x), gate_msa_t, x_skip, self.dropout, self.training)
|
| 252 |
+
|
| 253 |
+
x = bias_dropout_add_scale(
|
| 254 |
+
self.mlp(modulate(self.norm2(x), shift=shift_mlp_t, scale=scale_mlp_t)),
|
| 255 |
+
gate_mlp_t, x, self.dropout, self.training
|
| 256 |
+
)
|
| 257 |
+
return x
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
|
| 261 |
+
class DDitFinalLayer(nn.Module):
|
| 262 |
+
def __init__(self, hidden_size: int, out_channels: int, cond_dim: int):
|
| 263 |
+
super().__init__()
|
| 264 |
+
self.norm_final = LayerNorm(hidden_size)
|
| 265 |
+
self.linear = nn.Linear(hidden_size, out_channels)
|
| 266 |
+
self.linear.weight.data.zero_()
|
| 267 |
+
self.linear.bias.data.zero_()
|
| 268 |
+
|
| 269 |
+
self.adaLN_modulation = nn.Linear(cond_dim, 2 * hidden_size, bias=True)
|
| 270 |
+
self.adaLN_modulation.weight.data.zero_()
|
| 271 |
+
self.adaLN_modulation.bias.data.zero_()
|
| 272 |
+
|
| 273 |
+
def forward(self, x: Tensor, c: Tensor) -> Tensor:
|
| 274 |
+
shift, scale = self.adaLN_modulation(c)[:, None].chunk(2, dim=2)
|
| 275 |
+
x = modulate(x=self.norm_final(x), shift=shift, scale=scale)
|
| 276 |
+
x = self.linear(x)
|
| 277 |
+
|
| 278 |
+
return x
|
| 279 |
+
|
| 280 |
+
# -------------------------------------------------------------
|
| 281 |
+
|
| 282 |
+
# NEW: small helper to keep λ ≥ 0
|
| 283 |
+
class Positive(nn.Module):
|
| 284 |
+
def __init__(self):
|
| 285 |
+
super().__init__()
|
| 286 |
+
self.bias = nn.Parameter(torch.tensor(0.0))
|
| 287 |
+
def forward(self, x: Tensor) -> Tensor:
|
| 288 |
+
return F.softplus(x + self.bias)
|
| 289 |
+
|
| 290 |
+
# NEW: project token hidden states to "slot" hidden states (n+1)
|
| 291 |
+
class SlotProjector(nn.Module):
|
| 292 |
+
"""
|
| 293 |
+
Builds n+1 slot states from n token states using learnable boundaries.
|
| 294 |
+
slot i uses left h_{i-1} and right h_i (with learned BOS/EOS).
|
| 295 |
+
"""
|
| 296 |
+
def __init__(self, hidden_size: int):
|
| 297 |
+
super().__init__()
|
| 298 |
+
self.bos = nn.Parameter(torch.zeros(hidden_size))
|
| 299 |
+
self.eos = nn.Parameter(torch.zeros(hidden_size))
|
| 300 |
+
self.proj = nn.Linear(2 * hidden_size, hidden_size, bias=True)
|
| 301 |
+
self.act = nn.GELU()
|
| 302 |
+
|
| 303 |
+
def forward(self, h_tok: Tensor) -> Tensor:
|
| 304 |
+
# h_tok: (B, n, H)
|
| 305 |
+
B, n, H = h_tok.shape
|
| 306 |
+
bos = self.bos.expand(B, 1, H) # (B, 1, H)
|
| 307 |
+
eos = self.eos.expand(B, 1, H) # (B, 1, H)
|
| 308 |
+
|
| 309 |
+
# left/right neighbors for the n+1 between-token slots
|
| 310 |
+
left = torch.cat([bos, h_tok], dim=1) # (B, n+1, H)
|
| 311 |
+
right = torch.cat([h_tok, eos], dim=1) # (B, n+1, H)
|
| 312 |
+
|
| 313 |
+
slots = torch.cat([left, right], dim=-1) # (B, n+1, 2H)
|
| 314 |
+
return self.act(self.proj(slots)) # (B, n+1, H)
|
| 315 |
+
|
| 316 |
+
# NEW: multi-head output layer for edit flows
|
| 317 |
+
class EditFlowsHead(nn.Module):
|
| 318 |
+
"""
|
| 319 |
+
Produces:
|
| 320 |
+
- λ_ins: (B, n+1), Q_ins: (B, n+1, V)
|
| 321 |
+
- λ_del: (B, n), λ_sub: (B, n), Q_sub: (B, n, V)
|
| 322 |
+
"""
|
| 323 |
+
def __init__(self, hidden_size: int, vocab_size: int, cond_dim: int):
|
| 324 |
+
super().__init__()
|
| 325 |
+
self.norm = LayerNorm(hidden_size)
|
| 326 |
+
self.cond = nn.Linear(cond_dim, 2 * hidden_size, bias=True)
|
| 327 |
+
self.cond.weight.data.zero_()
|
| 328 |
+
self.cond.bias.data.zero_()
|
| 329 |
+
|
| 330 |
+
# token-position heads (n)
|
| 331 |
+
self.lambda_del = nn.Linear(hidden_size, 1, bias=True)
|
| 332 |
+
self.lambda_sub = nn.Linear(hidden_size, 1, bias=True)
|
| 333 |
+
self.q_sub = nn.Linear(hidden_size, vocab_size, bias=True)
|
| 334 |
+
|
| 335 |
+
# slot-position heads (n+1)
|
| 336 |
+
self.slot_proj = SlotProjector(hidden_size)
|
| 337 |
+
self.lambda_ins = nn.Linear(hidden_size, 1, bias=True)
|
| 338 |
+
self.q_ins = nn.Linear(hidden_size, vocab_size, bias=True)
|
| 339 |
+
|
| 340 |
+
self.to_positive = Positive()
|
| 341 |
+
|
| 342 |
+
# init: keep outputs small at start
|
| 343 |
+
for m in [self.lambda_del, self.lambda_sub, self.lambda_ins, self.q_sub, self.q_ins]:
|
| 344 |
+
nn.init.zeros_(m.weight); nn.init.zeros_(m.bias)
|
| 345 |
+
|
| 346 |
+
def forward(self, h_tok: Tensor, c: Tensor) -> tuple[Tensor, Tensor, Tensor, Tensor, Tensor]:
|
| 347 |
+
# FiLM-style modulation like your DDitFinalLayer
|
| 348 |
+
shift, scale = self.cond(c)[:, None].chunk(2, dim=-1)
|
| 349 |
+
h_tok = modulate(self.norm(h_tok), shift=shift, scale=scale) # (B, n, H)
|
| 350 |
+
|
| 351 |
+
# token positions (n)
|
| 352 |
+
lam_del = self.to_positive(self.lambda_del(h_tok)).squeeze(-1) # (B, n)
|
| 353 |
+
lam_sub = self.to_positive(self.lambda_sub(h_tok)).squeeze(-1) # (B, n)
|
| 354 |
+
q_sub = self.q_sub(h_tok) # (B, n, V) -- removed softmax here, may need to adjust tensor shape
|
| 355 |
+
|
| 356 |
+
# slot positions (n+1)
|
| 357 |
+
h_slot = self.slot_proj(h_tok) # (B, n+1, H)
|
| 358 |
+
lam_ins = self.to_positive(self.lambda_ins(h_slot)).squeeze(-1) # (B, n+1)
|
| 359 |
+
q_ins = self.q_ins(h_slot) # (B, n+1, V) -- removed softmax here, may need to adjust tensor shape
|
| 360 |
+
|
| 361 |
+
return lam_ins, q_ins, lam_del, lam_sub, q_sub
|
| 362 |
+
|
| 363 |
+
|
| 364 |
+
class Transformer(nn.Module):
|
| 365 |
+
def __init__(self, vocab_size: int, masked: bool, config: DictConfig):
|
| 366 |
+
super().__init__()
|
| 367 |
+
if isinstance(config, dict):
|
| 368 |
+
config = OmegaConf.create(config)
|
| 369 |
+
self.config = config
|
| 370 |
+
self.vocab_size = vocab_size
|
| 371 |
+
self.pad_id = getattr(config, "pad_id", 0)
|
| 372 |
+
|
| 373 |
+
add_token = 1 if masked else 0 # keep if you need a mask token elsewhere
|
| 374 |
+
|
| 375 |
+
# ESM-2 embedding approach (similar to gpm_model.py)
|
| 376 |
+
esm_model_name = getattr(config, "esm_model_name", "facebook/esm2_t33_650M_UR50D")
|
| 377 |
+
freeze_esm = getattr(config, "freeze_esm", True)
|
| 378 |
+
|
| 379 |
+
if esm_model_name is not None:
|
| 380 |
+
self.tok_embedder = EsmModel.from_pretrained(esm_model_name)
|
| 381 |
+
tok_embed_dim = self.tok_embedder.config.hidden_size
|
| 382 |
+
|
| 383 |
+
if freeze_esm:
|
| 384 |
+
for param in self.tok_embedder.parameters():
|
| 385 |
+
param.requires_grad = False
|
| 386 |
+
self.tok_embedder.eval()
|
| 387 |
+
|
| 388 |
+
# Project from ESM hidden size to model hidden size
|
| 389 |
+
self.tok_embed_to_hidden = nn.Linear(tok_embed_dim, config.hidden_size)
|
| 390 |
+
self.vocab_embed = None # Not needed when using ESM
|
| 391 |
+
else:
|
| 392 |
+
# Fallback to embedding layer if ESM is not used
|
| 393 |
+
self.tok_embedder = None
|
| 394 |
+
self.tok_embed_to_hidden = None # Not needed when using standard embedding
|
| 395 |
+
self.vocab_embed = nn.Embedding(self.vocab_size + add_token, config.hidden_size)
|
| 396 |
+
|
| 397 |
+
self.time_embedding = TimestepEmbedder(hidden_size=config.cond_dim)
|
| 398 |
+
self.rotary_emb = rotary.Rotary(dim=config.hidden_size // config.n_heads)
|
| 399 |
+
|
| 400 |
+
self.blocks = nn.ModuleList(
|
| 401 |
+
[
|
| 402 |
+
DDiTBlock(
|
| 403 |
+
dim=config.hidden_size,
|
| 404 |
+
n_heads=config.n_heads,
|
| 405 |
+
cond_dim=config.cond_dim,
|
| 406 |
+
dropout=config.dropout,
|
| 407 |
+
)
|
| 408 |
+
for _ in range(config.n_blocks)
|
| 409 |
+
]
|
| 410 |
+
)
|
| 411 |
+
|
| 412 |
+
# CHANGED: use EditFlowsHead instead of DDitFinalLayer
|
| 413 |
+
self.output_layer = EditFlowsHead(
|
| 414 |
+
hidden_size=config.hidden_size,
|
| 415 |
+
vocab_size=vocab_size + add_token,
|
| 416 |
+
cond_dim=config.cond_dim,
|
| 417 |
+
)
|
| 418 |
+
|
| 419 |
+
def _embed_ragged(self, x_t, mask):
|
| 420 |
+
# Handle device - use ESM device or vocab_embed device
|
| 421 |
+
if self.tok_embedder is not None:
|
| 422 |
+
device = next(self.tok_embedder.parameters()).device
|
| 423 |
+
else:
|
| 424 |
+
device = self.vocab_embed.weight.device
|
| 425 |
+
|
| 426 |
+
if self.tok_embedder is not None:
|
| 427 |
+
x_t_emb = self.tok_embedder(x_t, mask).last_hidden_state
|
| 428 |
+
x = self.tok_embed_to_hidden(x_t_emb)
|
| 429 |
+
else:
|
| 430 |
+
# Fallback to standard embedding
|
| 431 |
+
x = self.vocab_embed(x_t) # (T, H)
|
| 432 |
+
|
| 433 |
+
# positions from RoPE helper:
|
| 434 |
+
B, S = x_t.shape
|
| 435 |
+
lengths = torch.full((B,), S, device=device, dtype=torch.long)
|
| 436 |
+
positions = self.rotary_emb.positions_like(lengths) # (T,)
|
| 437 |
+
return x, lengths, positions
|
| 438 |
+
|
| 439 |
+
|
| 440 |
+
def forward(self, x_t, time: torch.Tensor):
|
| 441 |
+
"""
|
| 442 |
+
x_t: List[LongTensor] of variable lengths OR a (B,S) tensor
|
| 443 |
+
time: (B,) float in [0,1]
|
| 444 |
+
returns lists (ragged): lam_ins, q_ins, lam_del, lam_sub, q_sub
|
| 445 |
+
"""
|
| 446 |
+
# 0) pad x_t
|
| 447 |
+
x_t = torch.nn.utils.rnn.pad_sequence(x_t, batch_first=True, padding_value=self.pad_id)
|
| 448 |
+
mask = (x_t != self.pad_id).long()
|
| 449 |
+
|
| 450 |
+
# 1) embed ragged
|
| 451 |
+
x_emb = self._embed_ragged(x_t, mask)
|
| 452 |
+
|
| 453 |
+
# 2) time conditioning
|
| 454 |
+
B = x_t.shape[0]
|
| 455 |
+
t_emb = F.silu(self.time_embedding(time)) # (B, cond_dim)
|
| 456 |
+
|
| 457 |
+
# 3) rotary caches (you can keep your existing cache logic)
|
| 458 |
+
###############
|
| 459 |
+
# TODO:
|
| 460 |
+
###############
|
| 461 |
+
|
| 462 |
+
# 4) transformer blocks
|
| 463 |
+
with torch.amp.autocast("cuda", dtype=torch.bfloat16):
|
| 464 |
+
for blk in self.blocks:
|
| 465 |
+
x = blk(
|
| 466 |
+
x=x_emb,
|
| 467 |
+
mask=mask,
|
| 468 |
+
rotary_caches=rotary_caches,
|
| 469 |
+
c=t_emb,
|
| 470 |
+
)
|
| 471 |
+
|
| 472 |
+
#############
|
| 473 |
+
# TODO:
|
| 474 |
+
#############
|
| 475 |
+
lam_ins, q_ins, lam_del, lam_sub, q_sub = zip(*outs)
|
| 476 |
+
|
| 477 |
+
# 6) return ragged lists (keep padding outside the model if needed)
|
| 478 |
+
return list(lam_ins), list(q_ins), list(lam_del), list(lam_sub), list(q_sub)
|
|
@@ -0,0 +1,949 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from dataclasses import dataclass
|
| 2 |
+
from typing import List, Tuple, Optional, Sequence
|
| 3 |
+
|
| 4 |
+
import torch
|
| 5 |
+
import torch.nn as nn
|
| 6 |
+
import torch.nn.functional as F
|
| 7 |
+
from torch.nn.utils.rnn import pad_sequence
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
Tensor = torch.Tensor
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def _optimal_align_core(core0: torch.Tensor, core1: torch.Tensor, eps_id: int):
|
| 14 |
+
"""
|
| 15 |
+
Edit-distance alignment on the *core* (no BOS/EOS).
|
| 16 |
+
Returns two python lists of ints of the same length, using eps_id for gaps.
|
| 17 |
+
"""
|
| 18 |
+
L0 = core0.size(0)
|
| 19 |
+
L1 = core1.size(0)
|
| 20 |
+
|
| 21 |
+
dp = torch.zeros((L0 + 1, L1 + 1), dtype=torch.long, device=core0.device)
|
| 22 |
+
for i in range(1, L0 + 1):
|
| 23 |
+
dp[i, 0] = i
|
| 24 |
+
for j in range(1, L1 + 1):
|
| 25 |
+
dp[0, j] = j
|
| 26 |
+
|
| 27 |
+
for i in range(1, L0 + 1):
|
| 28 |
+
for j in range(1, L1 + 1):
|
| 29 |
+
cost_sub = 0 if core0[i-1].item() == core1[j-1].item() else 1
|
| 30 |
+
dp[i, j] = min(
|
| 31 |
+
dp[i-1, j] + 1, # delete core0[i-1]
|
| 32 |
+
dp[i, j-1] + 1, # insert core1[j-1]
|
| 33 |
+
dp[i-1, j-1] + cost_sub # match/sub
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
z0_core = []
|
| 37 |
+
z1_core = []
|
| 38 |
+
i, j = L0, L1
|
| 39 |
+
while i > 0 or j > 0:
|
| 40 |
+
if i > 0 and j > 0:
|
| 41 |
+
cost_sub = 0 if core0[i-1].item() == core1[j-1].item() else 1
|
| 42 |
+
if dp[i, j].item() == dp[i-1, j-1].item() + cost_sub:
|
| 43 |
+
z0_core.append(int(core0[i-1].item()))
|
| 44 |
+
z1_core.append(int(core1[j-1].item()))
|
| 45 |
+
i -= 1
|
| 46 |
+
j -= 1
|
| 47 |
+
continue
|
| 48 |
+
if i > 0 and dp[i, j].item() == dp[i-1, j].item() + 1:
|
| 49 |
+
z0_core.append(int(core0[i-1].item()))
|
| 50 |
+
z1_core.append(eps_id)
|
| 51 |
+
i -= 1
|
| 52 |
+
continue
|
| 53 |
+
if j > 0 and dp[i, j].item() == dp[i, j-1].item() + 1:
|
| 54 |
+
z0_core.append(eps_id)
|
| 55 |
+
z1_core.append(int(core1[j-1].item()))
|
| 56 |
+
j -= 1
|
| 57 |
+
continue
|
| 58 |
+
|
| 59 |
+
z0_core.reverse()
|
| 60 |
+
z1_core.reverse()
|
| 61 |
+
return z0_core, z1_core
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def _suboptimal_align_core(core0: torch.Tensor, core1: torch.Tensor, eps_id: int):
|
| 65 |
+
"""
|
| 66 |
+
Left-align cores; pad the shorter core with eps_id.
|
| 67 |
+
"""
|
| 68 |
+
L0 = core0.size(0)
|
| 69 |
+
L1 = core1.size(0)
|
| 70 |
+
N = max(L0, L1)
|
| 71 |
+
z0_core, z1_core = [], []
|
| 72 |
+
for k in range(N):
|
| 73 |
+
tok0 = int(core0[k].item()) if k < L0 else eps_id
|
| 74 |
+
tok1 = int(core1[k].item()) if k < L1 else eps_id
|
| 75 |
+
z0_core.append(tok0)
|
| 76 |
+
z1_core.append(tok1)
|
| 77 |
+
return z0_core, z1_core
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def build_z0_z1_with_alignment(
|
| 81 |
+
x0: torch.Tensor, # (B, L0), padded with pad_id, contains BOS/EOS
|
| 82 |
+
x1: torch.Tensor, # (B, L1), padded with pad_id, contains BOS/EOS
|
| 83 |
+
eps_id: int,
|
| 84 |
+
pad_id: int,
|
| 85 |
+
bos_id: int,
|
| 86 |
+
eos_id: int,
|
| 87 |
+
p_optimal: float = 0.6,
|
| 88 |
+
sample_type: str = 'regular',
|
| 89 |
+
):
|
| 90 |
+
"""
|
| 91 |
+
Align x0 and x1 such that:
|
| 92 |
+
- BOS aligns with BOS
|
| 93 |
+
- EOS aligns with EOS
|
| 94 |
+
- between BOS and EOS we align with eps_id
|
| 95 |
+
- after EOS we pad with pad_id
|
| 96 |
+
|
| 97 |
+
Returns:
|
| 98 |
+
z0: (B, N_max)
|
| 99 |
+
z1: (B, N_max)
|
| 100 |
+
"""
|
| 101 |
+
device = x0.device
|
| 102 |
+
B = x0.size(0)
|
| 103 |
+
|
| 104 |
+
z0_list = []
|
| 105 |
+
z1_list = []
|
| 106 |
+
max_len = 0
|
| 107 |
+
|
| 108 |
+
rand = torch.rand(B, device=device)
|
| 109 |
+
|
| 110 |
+
for b in range(B):
|
| 111 |
+
# strip pads
|
| 112 |
+
seq0 = x0[b][x0[b] != pad_id] # e.g. [BOS, ..., EOS]
|
| 113 |
+
seq1 = x1[b][x1[b] != pad_id]
|
| 114 |
+
|
| 115 |
+
# find BOS/EOS positions (assume 1 each, in order)
|
| 116 |
+
# usually BOS is at index 0, but let's be safe
|
| 117 |
+
bos_pos0 = (seq0 == bos_id).nonzero(as_tuple=False)[0, 0].item()
|
| 118 |
+
bos_pos1 = (seq1 == bos_id).nonzero(as_tuple=False)[0, 0].item()
|
| 119 |
+
eos_pos0 = (seq0 == eos_id).nonzero(as_tuple=False)[0, 0].item()
|
| 120 |
+
eos_pos1 = (seq1 == eos_id).nonzero(as_tuple=False)[0, 0].item()
|
| 121 |
+
|
| 122 |
+
# cores: everything between BOS and EOS
|
| 123 |
+
core0 = seq0[bos_pos0 + 1 : eos_pos0] # may be empty
|
| 124 |
+
core1 = seq1[bos_pos1 + 1 : eos_pos1]
|
| 125 |
+
|
| 126 |
+
# pick alignment strategy for the core
|
| 127 |
+
if rand[b].item() < p_optimal:
|
| 128 |
+
core0_aligned, core1_aligned = _optimal_align_core(core0, core1, eps_id)
|
| 129 |
+
else:
|
| 130 |
+
core0_aligned, core1_aligned = _suboptimal_align_core(core0, core1, eps_id)
|
| 131 |
+
|
| 132 |
+
# rebuild full aligned sequences: [BOS] + core_aligned + [EOS]
|
| 133 |
+
aligned0 = [bos_id] + core0_aligned + [eos_id]
|
| 134 |
+
aligned1 = [bos_id] + core1_aligned + [eos_id]
|
| 135 |
+
|
| 136 |
+
cur_len = len(aligned0)
|
| 137 |
+
assert cur_len == len(aligned1)
|
| 138 |
+
if cur_len > max_len:
|
| 139 |
+
max_len = cur_len
|
| 140 |
+
|
| 141 |
+
z0_list.append(aligned0)
|
| 142 |
+
z1_list.append(aligned1)
|
| 143 |
+
|
| 144 |
+
# pad with pad_id AFTER eos
|
| 145 |
+
z0 = torch.full((B, max_len), pad_id, dtype=torch.long, device=device)
|
| 146 |
+
z1 = torch.full((B, max_len), pad_id, dtype=torch.long, device=device)
|
| 147 |
+
|
| 148 |
+
for b in range(B):
|
| 149 |
+
cur = len(z0_list[b])
|
| 150 |
+
z0[b, :cur] = torch.tensor(z0_list[b], device=device, dtype=torch.long)
|
| 151 |
+
z1[b, :cur] = torch.tensor(z1_list[b], device=device, dtype=torch.long)
|
| 152 |
+
|
| 153 |
+
return z0, z1
|
| 154 |
+
|
| 155 |
+
def remove_eps(
|
| 156 |
+
z_t: torch.Tensor, # (B, N)
|
| 157 |
+
eps_id: int,
|
| 158 |
+
pad_id: int,
|
| 159 |
+
return_mask: bool = True,
|
| 160 |
+
):
|
| 161 |
+
device = z_t.device
|
| 162 |
+
B, N = z_t.shape
|
| 163 |
+
|
| 164 |
+
x_t = []
|
| 165 |
+
for b in range(B):
|
| 166 |
+
seq = z_t[b]
|
| 167 |
+
core = seq[seq != eps_id] # remove eps
|
| 168 |
+
x_t.append(core)
|
| 169 |
+
|
| 170 |
+
x_t = pad_sequence(x_t, batch_first=True, padding_value=pad_id)
|
| 171 |
+
mask = (x_t != pad_id).bool()
|
| 172 |
+
|
| 173 |
+
if return_mask:
|
| 174 |
+
return x_t, mask
|
| 175 |
+
return x_t
|
| 176 |
+
|
| 177 |
+
@torch.no_grad()
|
| 178 |
+
def generate_from_x0(
|
| 179 |
+
model,
|
| 180 |
+
x0: torch.Tensor, # (B, L) long, has BOS/EOS, padded with pad_id
|
| 181 |
+
*,
|
| 182 |
+
pad_id: int,
|
| 183 |
+
bos_id: int,
|
| 184 |
+
eos_id: int,
|
| 185 |
+
allowed_tokens: torch.Tensor = None, # 1D tensor of vocab ids we can generate
|
| 186 |
+
num_steps: int = 32,
|
| 187 |
+
max_len_cap: int = None,
|
| 188 |
+
op_temperature: float = 1.0, # temperature for choosing insert vs delete vs sub
|
| 189 |
+
token_temperature: float = 1.0, # temperature for choosing the token to insert/sub
|
| 190 |
+
pos_temperature: float = 1.0, # temperature for sampling position (reparameterized models only)
|
| 191 |
+
device: torch.device = None,
|
| 192 |
+
is_reparameterized: bool = None, # If None, will auto-detect from model output
|
| 193 |
+
convert_to_vanilla_outputs: bool = False, # If False, use direct sampling for reparameterized models
|
| 194 |
+
):
|
| 195 |
+
"""
|
| 196 |
+
Discrete edit sampler for Edit Flows with temperature on:
|
| 197 |
+
- operation choice (insert/delete/sub)
|
| 198 |
+
- token choice (for insert/sub)
|
| 199 |
+
- position choice (for reparameterized models when convert_to_vanilla_outputs=False)
|
| 200 |
+
|
| 201 |
+
At each step we apply at most ONE edit per sequence.
|
| 202 |
+
|
| 203 |
+
Supports both base and reparameterized models:
|
| 204 |
+
- Base: outputs (lam_ins, logits_ins, lam_del, lam_sub, logits_sub)
|
| 205 |
+
- Reparameterized: outputs (lam_total, logits_type, logits_ins, logits_sub)
|
| 206 |
+
|
| 207 |
+
For reparameterized models:
|
| 208 |
+
- If convert_to_vanilla_outputs=True (default): converts to base format and uses
|
| 209 |
+
best-position-per-operation approach
|
| 210 |
+
- If convert_to_vanilla_outputs=False: uses direct sampling:
|
| 211 |
+
1. Samples position from lam_total using pos_temperature
|
| 212 |
+
2. Samples edit type from logits_type at sampled position using op_temperature
|
| 213 |
+
3. Samples token if needed (insert/sub) using token_temperature
|
| 214 |
+
"""
|
| 215 |
+
if device is None:
|
| 216 |
+
device = x0.device
|
| 217 |
+
x = x0.clone().to(device)
|
| 218 |
+
B = x.size(0)
|
| 219 |
+
|
| 220 |
+
def sample_token_from_logits(logits_row: torch.Tensor) -> int:
|
| 221 |
+
"""
|
| 222 |
+
logits_row: (V,)
|
| 223 |
+
Apply temperature + allowed_tokens filtering, then sample.
|
| 224 |
+
"""
|
| 225 |
+
logit = logits_row
|
| 226 |
+
if allowed_tokens is not None:
|
| 227 |
+
mask = torch.zeros_like(logit, dtype=torch.bool)
|
| 228 |
+
mask[allowed_tokens] = True
|
| 229 |
+
logit = logit.masked_fill(~mask, -1e4)
|
| 230 |
+
|
| 231 |
+
if token_temperature is not None and token_temperature > 0.0:
|
| 232 |
+
logit = logit / token_temperature
|
| 233 |
+
|
| 234 |
+
probs = F.softmax(logit, dim=-1)
|
| 235 |
+
# multinomial expects probs >= 0 and sum=1
|
| 236 |
+
idx = torch.multinomial(probs, num_samples=1)
|
| 237 |
+
return int(idx.item())
|
| 238 |
+
|
| 239 |
+
# Auto-detect model type if not specified
|
| 240 |
+
if is_reparameterized is None:
|
| 241 |
+
# Try to detect from model class name first (more efficient)
|
| 242 |
+
model_class_name = model.__class__.__name__
|
| 243 |
+
if "Reparameterized" in model_class_name:
|
| 244 |
+
is_reparameterized = True
|
| 245 |
+
else:
|
| 246 |
+
# Fall back to test forward pass to detect model type
|
| 247 |
+
test_t = torch.zeros(1, device=device)
|
| 248 |
+
test_mask = torch.ones(1, x.size(1), dtype=torch.bool, device=device)
|
| 249 |
+
test_output = model(x_t=x[:1], mask=test_mask, t=test_t)
|
| 250 |
+
# Reparameterized models return 4 (SMILES) or 8 (Protein) values
|
| 251 |
+
is_reparameterized = len(test_output) in (4, 8)
|
| 252 |
+
|
| 253 |
+
for step in range(num_steps):
|
| 254 |
+
# t in [0,1]
|
| 255 |
+
t = torch.full((B,), float(step) / float(max(1, num_steps - 1)), device=device)
|
| 256 |
+
|
| 257 |
+
# build mask: True = valid, False = pad
|
| 258 |
+
mask = (x != pad_id)
|
| 259 |
+
|
| 260 |
+
# forward through model
|
| 261 |
+
model_output = model(x_t=x, mask=mask, t=t)
|
| 262 |
+
|
| 263 |
+
if is_reparameterized:
|
| 264 |
+
if len(model_output) == 8:
|
| 265 |
+
# ReparameterizedProteinEditFlowModel: returns all decomposed rates + total + type info
|
| 266 |
+
lam_ins, logits_ins, lam_del, lam_sub, logits_sub, lam_total, logits_type, pi_type = model_output
|
| 267 |
+
# pi_type is already computed, no need to recompute
|
| 268 |
+
elif len(model_output) == 4:
|
| 269 |
+
# ReparameterizedSMILESEditFlowModel: (lam_total, logits_type, logits_ins, logits_sub)
|
| 270 |
+
lam_total, logits_type, logits_ins, logits_sub = model_output
|
| 271 |
+
pi_type = F.softmax(logits_type, dim=-1) # (B, L, 3) over {ins, del, sub}
|
| 272 |
+
lam_ins = lam_total * pi_type[:, :, 0] # (B, L)
|
| 273 |
+
lam_del = lam_total * pi_type[:, :, 1] # (B, L)
|
| 274 |
+
lam_sub = lam_total * pi_type[:, :, 2] # (B, L)
|
| 275 |
+
else:
|
| 276 |
+
raise ValueError(f"Unexpected reparameterized model output length: {len(model_output)}. Expected 4 or 8.")
|
| 277 |
+
|
| 278 |
+
if convert_to_vanilla_outputs:
|
| 279 |
+
# For ReparameterizedProteinEditFlowModel, we already have lam_ins/del/sub
|
| 280 |
+
# For ReparameterizedSMILESEditFlowModel, we computed them above
|
| 281 |
+
pass # lam_ins, lam_del, lam_sub are already set
|
| 282 |
+
else:
|
| 283 |
+
# Use direct sampling approach: keep reparameterized outputs as-is
|
| 284 |
+
# We'll sample position and edit type separately below
|
| 285 |
+
lam_ins = None # Not used in direct sampling mode
|
| 286 |
+
lam_del = None
|
| 287 |
+
lam_sub = None
|
| 288 |
+
else:
|
| 289 |
+
# Base model: (lam_ins, logits_ins, lam_del, lam_sub, logits_sub)
|
| 290 |
+
lam_ins, logits_ins, lam_del, lam_sub, logits_sub = model_output
|
| 291 |
+
|
| 292 |
+
# collect new sequences
|
| 293 |
+
new_seqs = []
|
| 294 |
+
max_len_this_round = 0
|
| 295 |
+
|
| 296 |
+
for b in range(B):
|
| 297 |
+
seq = x[b]
|
| 298 |
+
valid = (seq != pad_id)
|
| 299 |
+
tokens = seq[valid].tolist() # python list
|
| 300 |
+
|
| 301 |
+
if len(tokens) == 0:
|
| 302 |
+
new_seq = torch.tensor([], device=device, dtype=torch.long)
|
| 303 |
+
new_seqs.append(new_seq)
|
| 304 |
+
continue
|
| 305 |
+
|
| 306 |
+
# find EOS pos
|
| 307 |
+
try:
|
| 308 |
+
eos_pos = tokens.index(eos_id)
|
| 309 |
+
except ValueError:
|
| 310 |
+
eos_pos = len(tokens) - 1
|
| 311 |
+
|
| 312 |
+
length_b = valid.sum().item()
|
| 313 |
+
|
| 314 |
+
if is_reparameterized and not convert_to_vanilla_outputs:
|
| 315 |
+
# Direct sampling approach for reparameterized models
|
| 316 |
+
lam_total_b = lam_total[b] # (L,)
|
| 317 |
+
logits_type_b = logits_type[b] # (L, 3)
|
| 318 |
+
logits_ins_b = logits_ins[b] # (L, V)
|
| 319 |
+
logits_sub_b = logits_sub[b] # (L, V)
|
| 320 |
+
|
| 321 |
+
# 1. Sample position from lam_total with pos_temperature
|
| 322 |
+
# Mask out invalid positions (after EOS, or BOS/EOS for certain operations)
|
| 323 |
+
# For now, we'll allow sampling from all valid positions, then filter based on edit type
|
| 324 |
+
lam_total_valid = lam_total_b[:length_b].clone() # Only consider valid positions
|
| 325 |
+
|
| 326 |
+
# Apply temperature to position distribution
|
| 327 |
+
if pos_temperature is not None and pos_temperature > 0.0:
|
| 328 |
+
pos_logits = lam_total_valid / pos_temperature
|
| 329 |
+
pos_probs = F.softmax(pos_logits, dim=-1)
|
| 330 |
+
sampled_pos = int(torch.multinomial(pos_probs, 1).item())
|
| 331 |
+
elif pos_temperature == 0.0:
|
| 332 |
+
# Greedy sampling: choose position with highest lam_total
|
| 333 |
+
sampled_pos = int(torch.argmax(lam_total_valid).item())
|
| 334 |
+
else:
|
| 335 |
+
# Default behavior when pos_temperature is None: use softmax without temperature scaling
|
| 336 |
+
pos_probs = F.softmax(lam_total_valid, dim=-1)
|
| 337 |
+
sampled_pos = int(torch.multinomial(pos_probs, 1).item())
|
| 338 |
+
|
| 339 |
+
# 2. Sample edit type from logits_type at the sampled position with op_temperature
|
| 340 |
+
edit_type_logits = logits_type_b[sampled_pos] # (3,) for {ins, del, sub}
|
| 341 |
+
|
| 342 |
+
if op_temperature is not None and op_temperature > 0.0:
|
| 343 |
+
edit_type_logits_scaled = edit_type_logits / op_temperature
|
| 344 |
+
else:
|
| 345 |
+
edit_type_logits_scaled = edit_type_logits
|
| 346 |
+
|
| 347 |
+
edit_type_probs = F.softmax(edit_type_logits_scaled, dim=-1)
|
| 348 |
+
op_idx = int(torch.multinomial(edit_type_probs, 1).item())
|
| 349 |
+
|
| 350 |
+
# 3. Apply the sampled edit
|
| 351 |
+
# 0 -> insert, 1 -> delete, 2 -> sub
|
| 352 |
+
if op_idx == 0:
|
| 353 |
+
# insertion: can insert at any position, but skip after EOS
|
| 354 |
+
if sampled_pos < eos_pos:
|
| 355 |
+
ins_tok = sample_token_from_logits(logits_ins_b[sampled_pos])
|
| 356 |
+
tokens = tokens[:sampled_pos + 1] + [ins_tok] + tokens[sampled_pos + 1:]
|
| 357 |
+
# else: skip insertion if position is at or after EOS
|
| 358 |
+
elif op_idx == 1:
|
| 359 |
+
# deletion: skip BOS/EOS
|
| 360 |
+
if tokens[sampled_pos] != bos_id and tokens[sampled_pos] != eos_id:
|
| 361 |
+
tokens = tokens[:sampled_pos] + tokens[sampled_pos + 1:]
|
| 362 |
+
# else: skip deletion if position is BOS/EOS
|
| 363 |
+
else: # op_idx == 2
|
| 364 |
+
# substitution: skip BOS/EOS
|
| 365 |
+
if tokens[sampled_pos] != bos_id and tokens[sampled_pos] != eos_id:
|
| 366 |
+
sub_tok = sample_token_from_logits(logits_sub_b[sampled_pos])
|
| 367 |
+
tokens = tokens[:sampled_pos] + [sub_tok] + tokens[sampled_pos + 1:]
|
| 368 |
+
# else: skip substitution if position is BOS/EOS
|
| 369 |
+
else:
|
| 370 |
+
# Original approach: convert to vanilla outputs or use base model outputs
|
| 371 |
+
lam_ins_b = lam_ins[b]
|
| 372 |
+
lam_del_b = lam_del[b]
|
| 373 |
+
lam_sub_b = lam_sub[b]
|
| 374 |
+
logits_ins_b = logits_ins[b]
|
| 375 |
+
logits_sub_b = logits_sub[b]
|
| 376 |
+
|
| 377 |
+
# --- collect best candidate per op ---
|
| 378 |
+
|
| 379 |
+
# insertion: pick position with highest lambda, but skip after EOS
|
| 380 |
+
best_ins_pos = None
|
| 381 |
+
best_ins_val = 0.0
|
| 382 |
+
for i in range(length_b):
|
| 383 |
+
if tokens[i] == eos_id:
|
| 384 |
+
continue
|
| 385 |
+
val = lam_ins_b[i].item()
|
| 386 |
+
if val > best_ins_val:
|
| 387 |
+
best_ins_val = val
|
| 388 |
+
best_ins_pos = i
|
| 389 |
+
|
| 390 |
+
# deletion: pick position with highest lambda, skip BOS/EOS
|
| 391 |
+
best_del_pos = None
|
| 392 |
+
best_del_val = 0.0
|
| 393 |
+
for i in range(length_b):
|
| 394 |
+
if tokens[i] == bos_id or tokens[i] == eos_id:
|
| 395 |
+
continue
|
| 396 |
+
val = lam_del_b[i].item()
|
| 397 |
+
if val > best_del_val:
|
| 398 |
+
best_del_val = val
|
| 399 |
+
best_del_pos = i
|
| 400 |
+
|
| 401 |
+
# substitution: pick position with highest lambda, skip BOS/EOS
|
| 402 |
+
best_sub_pos = None
|
| 403 |
+
best_sub_val = 0.0
|
| 404 |
+
for i in range(length_b):
|
| 405 |
+
if tokens[i] == bos_id or tokens[i] == eos_id:
|
| 406 |
+
continue
|
| 407 |
+
val = lam_sub_b[i].item()
|
| 408 |
+
if val > best_sub_val:
|
| 409 |
+
best_sub_val = val
|
| 410 |
+
best_sub_pos = i
|
| 411 |
+
|
| 412 |
+
# --- choose which operation to apply ---
|
| 413 |
+
# we form a 3-vector of op "scores" = the lambdas
|
| 414 |
+
op_scores = torch.tensor(
|
| 415 |
+
[best_ins_val, best_del_val, best_sub_val],
|
| 416 |
+
device=device,
|
| 417 |
+
dtype=torch.float32,
|
| 418 |
+
)
|
| 419 |
+
|
| 420 |
+
# if all zero-ish, just keep sequence
|
| 421 |
+
if torch.all(op_scores <= 1e-6):
|
| 422 |
+
new_seq = torch.tensor(tokens, device=device, dtype=torch.long)
|
| 423 |
+
new_seqs.append(new_seq)
|
| 424 |
+
max_len_this_round = max(max_len_this_round, new_seq.size(0))
|
| 425 |
+
continue
|
| 426 |
+
|
| 427 |
+
# temperature over ops
|
| 428 |
+
if op_temperature is not None and op_temperature > 0.0:
|
| 429 |
+
op_logits = op_scores / op_temperature
|
| 430 |
+
op_probs = F.softmax(op_logits, dim=0)
|
| 431 |
+
op_idx = int(torch.multinomial(op_probs, 1).item())
|
| 432 |
+
else:
|
| 433 |
+
op_idx = int(torch.argmax(op_scores).item())
|
| 434 |
+
|
| 435 |
+
# 0 -> insert, 1 -> delete, 2 -> sub
|
| 436 |
+
if op_idx == 0:
|
| 437 |
+
# insertion
|
| 438 |
+
pos = best_ins_pos
|
| 439 |
+
if pos is not None:
|
| 440 |
+
ins_tok = sample_token_from_logits(logits_ins_b[pos])
|
| 441 |
+
tokens = tokens[:pos + 1] + [ins_tok] + tokens[pos + 1:]
|
| 442 |
+
|
| 443 |
+
elif op_idx == 1:
|
| 444 |
+
# deletion
|
| 445 |
+
pos = best_del_pos
|
| 446 |
+
if pos is not None:
|
| 447 |
+
tokens = tokens[:pos] + tokens[pos + 1:]
|
| 448 |
+
|
| 449 |
+
else:
|
| 450 |
+
# substitution
|
| 451 |
+
pos = best_sub_pos
|
| 452 |
+
if pos is not None:
|
| 453 |
+
sub_tok = sample_token_from_logits(logits_sub_b[pos])
|
| 454 |
+
tokens = tokens[:pos] + [sub_tok] + tokens[pos + 1:]
|
| 455 |
+
|
| 456 |
+
# make sure we still end with EOS
|
| 457 |
+
if len(tokens) == 0 or tokens[-1] != eos_id:
|
| 458 |
+
tokens.append(eos_id)
|
| 459 |
+
|
| 460 |
+
# enforce max_len_cap
|
| 461 |
+
if max_len_cap is not None and len(tokens) > max_len_cap:
|
| 462 |
+
tokens = tokens[:max_len_cap]
|
| 463 |
+
if tokens[-1] != eos_id:
|
| 464 |
+
tokens[-1] = eos_id
|
| 465 |
+
|
| 466 |
+
new_seq = torch.tensor(tokens, device=device, dtype=torch.long)
|
| 467 |
+
new_seqs.append(new_seq)
|
| 468 |
+
max_len_this_round = max(max_len_this_round, new_seq.size(0))
|
| 469 |
+
|
| 470 |
+
# pad batch back to tensor
|
| 471 |
+
x = x.new_full((B, max_len_this_round), pad_id)
|
| 472 |
+
for b, seq_b in enumerate(new_seqs):
|
| 473 |
+
x[b, :seq_b.size(0)] = seq_b
|
| 474 |
+
|
| 475 |
+
return x
|
| 476 |
+
|
| 477 |
+
@torch.no_grad()
|
| 478 |
+
def generate_from_x0_ctmc(
|
| 479 |
+
model,
|
| 480 |
+
x0: torch.Tensor, # (B, L) long, has BOS/EOS, padded with pad_id
|
| 481 |
+
*,
|
| 482 |
+
pad_id: int,
|
| 483 |
+
bos_id: int,
|
| 484 |
+
eos_id: int,
|
| 485 |
+
allowed_tokens: Optional[torch.Tensor] = None, # 1D tensor of vocab ids we can generate
|
| 486 |
+
num_steps: int = 32,
|
| 487 |
+
max_len_cap: Optional[int] = None,
|
| 488 |
+
op_temperature: float = 1.0, # accepted but unused (for API compat)
|
| 489 |
+
token_temperature: float = 1.0,
|
| 490 |
+
pos_temperature: float = 1.0, # accepted but unused (for API compat)
|
| 491 |
+
is_reparameterized: Optional[bool] = None,
|
| 492 |
+
convert_to_vanilla_outputs: bool = False,
|
| 493 |
+
device: Optional[torch.device] = None,
|
| 494 |
+
):
|
| 495 |
+
"""
|
| 496 |
+
CTMC-style discrete-time sampler for Edit Flows / DFM.
|
| 497 |
+
|
| 498 |
+
At each step:
|
| 499 |
+
- For each position j, we sample independent Bernoulli events:
|
| 500 |
+
insert with prob h * λ_ins[t,j]
|
| 501 |
+
delete/sub with prob h * (λ_del[t,j] + λ_sub[t,j])
|
| 502 |
+
and, if a del/sub event occurs, choose delete vs sub proportional to λ_del vs λ_sub.
|
| 503 |
+
- We apply all resulting edit operations simultaneously (left-to-right).
|
| 504 |
+
|
| 505 |
+
Supports:
|
| 506 |
+
- Base model: (lam_ins, logits_ins, lam_del, lam_sub, logits_sub)
|
| 507 |
+
- Reparameterized: (lam_total, logits_type, logits_ins, logits_sub)
|
| 508 |
+
* If convert_to_vanilla_outputs=True:
|
| 509 |
+
lam_ins/del/sub = lam_total * softmax(logits_type)[..., k]
|
| 510 |
+
* If convert_to_vanilla_outputs=False:
|
| 511 |
+
probabilities are computed directly from lam_total and π_type.
|
| 512 |
+
"""
|
| 513 |
+
|
| 514 |
+
if device is None:
|
| 515 |
+
device = x0.device
|
| 516 |
+
|
| 517 |
+
x = x0.clone().to(device)
|
| 518 |
+
B = x.size(0)
|
| 519 |
+
|
| 520 |
+
if num_steps <= 0:
|
| 521 |
+
return x
|
| 522 |
+
|
| 523 |
+
def sample_token_from_logits(logits_row: torch.Tensor) -> int:
|
| 524 |
+
"""
|
| 525 |
+
logits_row: (V,). Apply allowed_tokens mask + temperature, then sample.
|
| 526 |
+
"""
|
| 527 |
+
logit = logits_row
|
| 528 |
+
if allowed_tokens is not None:
|
| 529 |
+
mask = torch.zeros_like(logit, dtype=torch.bool)
|
| 530 |
+
mask[allowed_tokens] = True
|
| 531 |
+
logit = logit.masked_fill(~mask, -1e9) # effectively remove disallowed tokens
|
| 532 |
+
|
| 533 |
+
if token_temperature is not None and token_temperature > 0.0 and token_temperature != 1.0:
|
| 534 |
+
logit = logit / token_temperature
|
| 535 |
+
|
| 536 |
+
probs = F.softmax(logit, dim=-1)
|
| 537 |
+
idx = torch.multinomial(probs, num_samples=1)
|
| 538 |
+
return int(idx.item())
|
| 539 |
+
|
| 540 |
+
# Auto-detect reparameterized vs base model if not specified
|
| 541 |
+
if is_reparameterized is None:
|
| 542 |
+
model_class_name = model.__class__.__name__
|
| 543 |
+
if "Reparameterized" in model_class_name:
|
| 544 |
+
is_reparameterized = True
|
| 545 |
+
else:
|
| 546 |
+
# Fallback: look at forward output length
|
| 547 |
+
with torch.no_grad():
|
| 548 |
+
t_test = torch.zeros(1, device=device)
|
| 549 |
+
mask_test = torch.ones(1, x.size(1), dtype=torch.bool, device=device)
|
| 550 |
+
test_out = model(x_t=x[:1], mask=mask_test, t=t_test)
|
| 551 |
+
# Reparameterized models return 4 (SMILES) or 8 (Protein) values
|
| 552 |
+
is_reparameterized = (len(test_out) in (4, 8))
|
| 553 |
+
|
| 554 |
+
# Time step size h; t_k = k * h, k = 0..num_steps-1
|
| 555 |
+
if num_steps == 1:
|
| 556 |
+
h = 1.0
|
| 557 |
+
else:
|
| 558 |
+
h = 1.0 / float(num_steps - 1)
|
| 559 |
+
|
| 560 |
+
for step in range(num_steps):
|
| 561 |
+
t_scalar = step * h
|
| 562 |
+
t = torch.full((B,), t_scalar, device=device, dtype=torch.float32)
|
| 563 |
+
|
| 564 |
+
mask = (x != pad_id)
|
| 565 |
+
model_out = model(x_t=x, mask=mask, t=t)
|
| 566 |
+
|
| 567 |
+
if is_reparameterized:
|
| 568 |
+
if len(model_out) == 8:
|
| 569 |
+
# ReparameterizedProteinEditFlowModel: returns all decomposed rates + total + type info
|
| 570 |
+
lam_ins, logits_ins, lam_del, lam_sub, logits_sub, lam_total, logits_type, pi_type = model_out
|
| 571 |
+
# pi_type is already computed, no need to recompute
|
| 572 |
+
elif len(model_out) == 4:
|
| 573 |
+
# ReparameterizedSMILESEditFlowModel: (lam_total, logits_type, logits_ins, logits_sub)
|
| 574 |
+
lam_total, logits_type, logits_ins, logits_sub = model_out
|
| 575 |
+
pi_type = F.softmax(logits_type, dim=-1) # (B, L, 3) over {ins, del, sub}
|
| 576 |
+
else:
|
| 577 |
+
raise ValueError(f"Unexpected reparameterized model output length: {len(model_out)}. Expected 4 or 8.")
|
| 578 |
+
|
| 579 |
+
if convert_to_vanilla_outputs:
|
| 580 |
+
if len(model_out) == 8:
|
| 581 |
+
# For ReparameterizedProteinEditFlowModel, we already have lam_ins/del/sub
|
| 582 |
+
pass # lam_ins, lam_del, lam_sub are already set
|
| 583 |
+
else:
|
| 584 |
+
# Convert to "vanilla" λ_ins/λ_del/λ_sub, then reuse base logic
|
| 585 |
+
lam_ins = lam_total * pi_type[..., 0]
|
| 586 |
+
lam_del = lam_total * pi_type[..., 1]
|
| 587 |
+
lam_sub = lam_total * pi_type[..., 2]
|
| 588 |
+
else:
|
| 589 |
+
# We'll use lam_total + pi_type directly in the loop
|
| 590 |
+
lam_ins = lam_del = lam_sub = None # not used in this branch
|
| 591 |
+
else:
|
| 592 |
+
# Base model: (lam_ins, logits_ins, lam_del, lam_sub, logits_sub)
|
| 593 |
+
lam_ins, logits_ins, lam_del, lam_sub, logits_sub = model_out
|
| 594 |
+
pi_type = None # not used for base
|
| 595 |
+
|
| 596 |
+
new_batch = []
|
| 597 |
+
max_len_this_round = 0
|
| 598 |
+
|
| 599 |
+
for b in range(B):
|
| 600 |
+
seq = x[b]
|
| 601 |
+
valid = (seq != pad_id)
|
| 602 |
+
tokens = seq[valid].tolist()
|
| 603 |
+
|
| 604 |
+
# If sequence somehow became empty, reinsert BOS/EOS
|
| 605 |
+
if len(tokens) == 0:
|
| 606 |
+
tokens = [bos_id, eos_id]
|
| 607 |
+
|
| 608 |
+
# Find EOS position (default to last if missing)
|
| 609 |
+
try:
|
| 610 |
+
eos_pos = tokens.index(eos_id)
|
| 611 |
+
except ValueError:
|
| 612 |
+
eos_pos = len(tokens) - 1
|
| 613 |
+
|
| 614 |
+
Lb = len(tokens)
|
| 615 |
+
|
| 616 |
+
delete_mask = [False] * Lb
|
| 617 |
+
sub_tokens = [None] * Lb
|
| 618 |
+
ins_tokens = [None] * Lb
|
| 619 |
+
|
| 620 |
+
if is_reparameterized and not convert_to_vanilla_outputs:
|
| 621 |
+
# ----- Reparameterized CTMC branch: use lam_total + π_type directly -----
|
| 622 |
+
lam_total_b = lam_total[b, :Lb] # (Lb,)
|
| 623 |
+
pi_b = pi_type[b, :Lb, :] # (Lb, 3)
|
| 624 |
+
logits_ins_b = logits_ins[b, :Lb, :] # (Lb, V)
|
| 625 |
+
logits_sub_b = logits_sub[b, :Lb, :] # (Lb, V)
|
| 626 |
+
|
| 627 |
+
for j in range(Lb):
|
| 628 |
+
tok_j = tokens[j]
|
| 629 |
+
|
| 630 |
+
# π_type components
|
| 631 |
+
pi_ins = float(pi_b[j, 0].item())
|
| 632 |
+
pi_del = float(pi_b[j, 1].item())
|
| 633 |
+
pi_sub = float(pi_b[j, 2].item())
|
| 634 |
+
lam_tot_ij = float(lam_total_b[j].item())
|
| 635 |
+
|
| 636 |
+
# -------- Insertion event at position j --------
|
| 637 |
+
if j < eos_pos and lam_tot_ij > 0.0 and pi_ins > 0.0:
|
| 638 |
+
p_ins = h * lam_tot_ij * pi_ins
|
| 639 |
+
p_ins = min(p_ins, 1.0)
|
| 640 |
+
if p_ins > 0.0 and torch.rand(1, device=device).item() < p_ins:
|
| 641 |
+
ins_tok = sample_token_from_logits(logits_ins_b[j])
|
| 642 |
+
ins_tokens[j] = ins_tok
|
| 643 |
+
|
| 644 |
+
# -------- Delete/substitute event at position j --------
|
| 645 |
+
if tok_j == bos_id or tok_j == eos_id:
|
| 646 |
+
continue # never delete/sub BOS/EOS
|
| 647 |
+
|
| 648 |
+
pi_ds = pi_del + pi_sub
|
| 649 |
+
if lam_tot_ij <= 0.0 or pi_ds <= 0.0:
|
| 650 |
+
continue
|
| 651 |
+
|
| 652 |
+
lam_ds = lam_tot_ij * pi_ds
|
| 653 |
+
p_ds = h * lam_ds
|
| 654 |
+
p_ds = min(p_ds, 1.0)
|
| 655 |
+
if p_ds <= 0.0:
|
| 656 |
+
continue
|
| 657 |
+
|
| 658 |
+
if torch.rand(1, device=device).item() < p_ds:
|
| 659 |
+
# A delete/sub event occurs; choose which
|
| 660 |
+
p_del_given = pi_del / pi_ds
|
| 661 |
+
choose_del = (torch.rand(1, device=device).item() < p_del_given)
|
| 662 |
+
|
| 663 |
+
if choose_del:
|
| 664 |
+
delete_mask[j] = True
|
| 665 |
+
ins_tokens[j] = None
|
| 666 |
+
sub_tokens[j] = None
|
| 667 |
+
else:
|
| 668 |
+
sub_tok = sample_token_from_logits(logits_sub_b[j])
|
| 669 |
+
sub_tokens[j] = sub_tok
|
| 670 |
+
|
| 671 |
+
else:
|
| 672 |
+
# ----- Base CTMC branch (or reparam+vanilla with lam_ins/lam_del/lam_sub) -----
|
| 673 |
+
lam_ins_b = lam_ins[b, :Lb]
|
| 674 |
+
lam_del_b = lam_del[b, :Lb]
|
| 675 |
+
lam_sub_b = lam_sub[b, :Lb]
|
| 676 |
+
logits_ins_b = logits_ins[b, :Lb, :]
|
| 677 |
+
logits_sub_b = logits_sub[b, :Lb, :]
|
| 678 |
+
|
| 679 |
+
for j in range(Lb):
|
| 680 |
+
tok_j = tokens[j]
|
| 681 |
+
|
| 682 |
+
# -------- Insertion event at position j --------
|
| 683 |
+
if j < eos_pos:
|
| 684 |
+
lam_ij = float(lam_ins_b[j].item())
|
| 685 |
+
if lam_ij > 0.0:
|
| 686 |
+
p_ins = h * lam_ij
|
| 687 |
+
p_ins = min(p_ins, 1.0)
|
| 688 |
+
if p_ins > 0.0 and torch.rand(1, device=device).item() < p_ins:
|
| 689 |
+
ins_tok = sample_token_from_logits(logits_ins_b[j])
|
| 690 |
+
ins_tokens[j] = ins_tok
|
| 691 |
+
|
| 692 |
+
# -------- Delete/substitute event at position j --------
|
| 693 |
+
if tok_j == bos_id or tok_j == eos_id:
|
| 694 |
+
continue
|
| 695 |
+
|
| 696 |
+
lam_del_ij = float(lam_del_b[j].item())
|
| 697 |
+
lam_sub_ij = float(lam_sub_b[j].item())
|
| 698 |
+
lam_ds = lam_del_ij + lam_sub_ij
|
| 699 |
+
if lam_ds <= 0.0:
|
| 700 |
+
continue
|
| 701 |
+
|
| 702 |
+
p_ds = h * lam_ds
|
| 703 |
+
p_ds = min(p_ds, 1.0)
|
| 704 |
+
if p_ds <= 0.0:
|
| 705 |
+
continue
|
| 706 |
+
|
| 707 |
+
if torch.rand(1, device=device).item() < p_ds:
|
| 708 |
+
if lam_del_ij == 0.0:
|
| 709 |
+
choose_del = False
|
| 710 |
+
elif lam_sub_ij == 0.0:
|
| 711 |
+
choose_del = True
|
| 712 |
+
else:
|
| 713 |
+
p_del_given = lam_del_ij / lam_ds
|
| 714 |
+
choose_del = (torch.rand(1, device=device).item() < p_del_given)
|
| 715 |
+
|
| 716 |
+
if choose_del:
|
| 717 |
+
delete_mask[j] = True
|
| 718 |
+
ins_tokens[j] = None
|
| 719 |
+
sub_tokens[j] = None
|
| 720 |
+
else:
|
| 721 |
+
sub_tok = sample_token_from_logits(logits_sub_b[j])
|
| 722 |
+
sub_tokens[j] = sub_tok
|
| 723 |
+
|
| 724 |
+
# -------- Apply all edits simultaneously (left-to-right) --------
|
| 725 |
+
new_tokens = []
|
| 726 |
+
for j in range(Lb):
|
| 727 |
+
tok_j = tokens[j]
|
| 728 |
+
|
| 729 |
+
if delete_mask[j]:
|
| 730 |
+
pass
|
| 731 |
+
elif sub_tokens[j] is not None:
|
| 732 |
+
new_tokens.append(sub_tokens[j])
|
| 733 |
+
else:
|
| 734 |
+
new_tokens.append(tok_j)
|
| 735 |
+
|
| 736 |
+
if ins_tokens[j] is not None:
|
| 737 |
+
new_tokens.append(ins_tokens[j])
|
| 738 |
+
|
| 739 |
+
# Ensure EOS is present
|
| 740 |
+
if eos_id not in new_tokens:
|
| 741 |
+
new_tokens.append(eos_id)
|
| 742 |
+
|
| 743 |
+
# Enforce max length cap
|
| 744 |
+
if max_len_cap is not None and len(new_tokens) > max_len_cap:
|
| 745 |
+
new_tokens = new_tokens[:max_len_cap]
|
| 746 |
+
if new_tokens[-1] != eos_id:
|
| 747 |
+
new_tokens[-1] = eos_id
|
| 748 |
+
|
| 749 |
+
new_seq = torch.tensor(new_tokens, device=device, dtype=torch.long)
|
| 750 |
+
new_batch.append(new_seq)
|
| 751 |
+
max_len_this_round = max(max_len_this_round, new_seq.size(0))
|
| 752 |
+
|
| 753 |
+
x_next = x.new_full((B, max_len_this_round), pad_id)
|
| 754 |
+
for b, seq_b in enumerate(new_batch):
|
| 755 |
+
x_next[b, :seq_b.size(0)] = seq_b
|
| 756 |
+
|
| 757 |
+
x = x_next
|
| 758 |
+
|
| 759 |
+
return x
|
| 760 |
+
|
| 761 |
+
def generate_from_x0_multi_edit(
|
| 762 |
+
model,
|
| 763 |
+
x0: torch.Tensor, # (B, L) long, has BOS/EOS, padded with pad_id
|
| 764 |
+
*,
|
| 765 |
+
pad_id: int,
|
| 766 |
+
bos_id: int,
|
| 767 |
+
eos_id: int,
|
| 768 |
+
allowed_tokens: torch.Tensor = None, # 1D tensor of vocab ids we can generate
|
| 769 |
+
num_steps: int = 32,
|
| 770 |
+
max_len_cap: int = None,
|
| 771 |
+
op_temperature: float = 1.0, # temperature for choosing insert vs delete vs sub
|
| 772 |
+
token_temperature: float = 1.0, # temperature for choosing the token to insert/sub
|
| 773 |
+
device: torch.device = None,
|
| 774 |
+
):
|
| 775 |
+
"""
|
| 776 |
+
Multi-edit discrete edit sampler for Edit Flows.
|
| 777 |
+
|
| 778 |
+
At each step:
|
| 779 |
+
- For each position i, independently "fire" an edit with probability
|
| 780 |
+
p_i = 1 - exp(-delta * lambda_i),
|
| 781 |
+
where lambda_i = lam_ins[i] + lam_del[i] + lam_sub[i] (after masking illegal ops).
|
| 782 |
+
- If fired, sample ONE op type at that position (ins/del/sub) proportional to rates,
|
| 783 |
+
with optional op_temperature.
|
| 784 |
+
- For ins/sub, sample token from logits with optional token_temperature and allowed_tokens.
|
| 785 |
+
- Apply edits in a single left-to-right pass (avoids index-shift headaches).
|
| 786 |
+
"""
|
| 787 |
+
if device is None:
|
| 788 |
+
device = x0.device
|
| 789 |
+
x = x0.clone().to(device)
|
| 790 |
+
B = x.size(0)
|
| 791 |
+
|
| 792 |
+
# User-requested: delta = 1 / num_steps
|
| 793 |
+
delta = 1.0 / float(max(1, num_steps))
|
| 794 |
+
|
| 795 |
+
def sample_token_from_logits(logits_row: torch.Tensor) -> int:
|
| 796 |
+
"""
|
| 797 |
+
logits_row: (V,)
|
| 798 |
+
Apply temperature + allowed_tokens filtering, then sample.
|
| 799 |
+
"""
|
| 800 |
+
logit = logits_row
|
| 801 |
+
if allowed_tokens is not None:
|
| 802 |
+
mask = torch.zeros_like(logit, dtype=torch.bool)
|
| 803 |
+
mask[allowed_tokens] = True
|
| 804 |
+
logit = logit.masked_fill(~mask, -1e4)
|
| 805 |
+
|
| 806 |
+
if token_temperature is not None and token_temperature > 0.0:
|
| 807 |
+
logit = logit / token_temperature
|
| 808 |
+
|
| 809 |
+
probs = F.softmax(logit, dim=-1)
|
| 810 |
+
idx = torch.multinomial(probs, num_samples=1)
|
| 811 |
+
return int(idx.item())
|
| 812 |
+
|
| 813 |
+
for step in range(num_steps):
|
| 814 |
+
# t in [0,1]
|
| 815 |
+
t = torch.full((B,), float(step) / float(max(1, num_steps - 1)), device=device)
|
| 816 |
+
|
| 817 |
+
# mask: True = valid (non-pad)
|
| 818 |
+
mask = (x != pad_id)
|
| 819 |
+
|
| 820 |
+
# forward
|
| 821 |
+
model_output = model(x_t=x, mask=mask, t=t)
|
| 822 |
+
|
| 823 |
+
# Handle both base models (5 values) and ReparameterizedProteinEditFlowModel (8 values)
|
| 824 |
+
# ReparameterizedSMILESEditFlowModel returns 4 values, but we don't use it here
|
| 825 |
+
if len(model_output) == 8:
|
| 826 |
+
# ReparameterizedProteinEditFlowModel: returns all decomposed rates + total + type info
|
| 827 |
+
lam_ins, logits_ins, lam_del, lam_sub, logits_sub, lam_total, logits_type, pi_type = model_output
|
| 828 |
+
elif len(model_output) == 5:
|
| 829 |
+
# Base model: (lam_ins, logits_ins, lam_del, lam_sub, logits_sub)
|
| 830 |
+
lam_ins, logits_ins, lam_del, lam_sub, logits_sub = model_output
|
| 831 |
+
lam_total = None # Not used in base model path
|
| 832 |
+
logits_type = None
|
| 833 |
+
pi_type = None
|
| 834 |
+
else:
|
| 835 |
+
raise ValueError(f"Unexpected model output length: {len(model_output)}. Expected 5 (base) or 8 (ReparameterizedProteinEditFlowModel)")
|
| 836 |
+
|
| 837 |
+
new_seqs = []
|
| 838 |
+
max_len_this_round = 0
|
| 839 |
+
|
| 840 |
+
for b in range(B):
|
| 841 |
+
seq = x[b]
|
| 842 |
+
valid = (seq != pad_id)
|
| 843 |
+
tokens = seq[valid].tolist()
|
| 844 |
+
|
| 845 |
+
if len(tokens) == 0:
|
| 846 |
+
new_seq = torch.tensor([], device=device, dtype=torch.long)
|
| 847 |
+
new_seqs.append(new_seq)
|
| 848 |
+
continue
|
| 849 |
+
|
| 850 |
+
# Ensure there's an EOS somewhere (fallback: append later)
|
| 851 |
+
if eos_id not in tokens:
|
| 852 |
+
tokens = tokens + [eos_id]
|
| 853 |
+
|
| 854 |
+
Lb = len(tokens)
|
| 855 |
+
|
| 856 |
+
lam_ins_b = lam_ins[b][:Lb].clone()
|
| 857 |
+
lam_del_b = lam_del[b][:Lb].clone()
|
| 858 |
+
lam_sub_b = lam_sub[b][:Lb].clone()
|
| 859 |
+
logits_ins_b = logits_ins[b][:Lb]
|
| 860 |
+
logits_sub_b = logits_sub[b][:Lb]
|
| 861 |
+
|
| 862 |
+
# --- operation legality masks at current positions ---
|
| 863 |
+
tok_tensor = torch.tensor(tokens, device=device, dtype=torch.long)
|
| 864 |
+
is_bos = (tok_tensor == bos_id)
|
| 865 |
+
is_eos = (tok_tensor == eos_id)
|
| 866 |
+
|
| 867 |
+
# insertion not allowed at EOS
|
| 868 |
+
lam_ins_b = lam_ins_b.masked_fill(is_eos, 0.0)
|
| 869 |
+
# deletion/substitution not allowed at BOS/EOS
|
| 870 |
+
lam_del_b = lam_del_b.masked_fill(is_bos | is_eos, 0.0)
|
| 871 |
+
lam_sub_b = lam_sub_b.masked_fill(is_bos | is_eos, 0.0)
|
| 872 |
+
|
| 873 |
+
lam_pos_total = lam_ins_b + lam_del_b + lam_sub_b
|
| 874 |
+
|
| 875 |
+
# fire prob per position
|
| 876 |
+
# p_i = 1 - exp(-delta * lambda_i)
|
| 877 |
+
p_fire = 1.0 - torch.exp(-delta * lam_pos_total.clamp(min=0.0))
|
| 878 |
+
|
| 879 |
+
# sample fired positions
|
| 880 |
+
fired = (torch.rand(Lb, device=device) < p_fire) & (lam_pos_total > 1e-12)
|
| 881 |
+
|
| 882 |
+
# sample op type (0=ins, 1=del, 2=sub) for ALL positions (we'll use only where fired)
|
| 883 |
+
rates = torch.stack([lam_ins_b, lam_del_b, lam_sub_b], dim=-1) # (Lb, 3)
|
| 884 |
+
|
| 885 |
+
# temperature over ops: probs ∝ rate^(1/temp) == softmax(log(rate)/temp)
|
| 886 |
+
if op_temperature is not None and op_temperature > 0.0:
|
| 887 |
+
op_logits = torch.log(rates + 1e-20) / op_temperature
|
| 888 |
+
op_probs = F.softmax(op_logits, dim=-1)
|
| 889 |
+
else:
|
| 890 |
+
# greedy: pick max-rate op; represent as one-hot probs for multinomial compatibility
|
| 891 |
+
op_idx_greedy = torch.argmax(rates, dim=-1) # (Lb,)
|
| 892 |
+
op_probs = F.one_hot(op_idx_greedy, num_classes=3).float()
|
| 893 |
+
|
| 894 |
+
# multinomial per row
|
| 895 |
+
# torch.multinomial accepts (n, m) -> (n, num_samples)
|
| 896 |
+
op_idx = torch.multinomial(op_probs, num_samples=1).squeeze(-1) # (Lb,)
|
| 897 |
+
|
| 898 |
+
# pre-sample tokens for fired ins/sub positions (loop only over fired positions)
|
| 899 |
+
ins_tok_map = {}
|
| 900 |
+
sub_tok_map = {}
|
| 901 |
+
fired_idx = fired.nonzero(as_tuple=True)[0].tolist()
|
| 902 |
+
for i in fired_idx:
|
| 903 |
+
oi = int(op_idx[i].item())
|
| 904 |
+
if oi == 0:
|
| 905 |
+
# insertion
|
| 906 |
+
ins_tok_map[i] = sample_token_from_logits(logits_ins_b[i])
|
| 907 |
+
elif oi == 2:
|
| 908 |
+
# substitution
|
| 909 |
+
sub_tok_map[i] = sample_token_from_logits(logits_sub_b[i])
|
| 910 |
+
|
| 911 |
+
# apply edits in one pass (left-to-right)
|
| 912 |
+
out = []
|
| 913 |
+
for i in range(Lb):
|
| 914 |
+
tok = tokens[i]
|
| 915 |
+
if fired[i]:
|
| 916 |
+
oi = int(op_idx[i].item())
|
| 917 |
+
if oi == 1:
|
| 918 |
+
# deletion (already masked for BOS/EOS)
|
| 919 |
+
continue
|
| 920 |
+
elif oi == 2:
|
| 921 |
+
# substitution
|
| 922 |
+
tok = sub_tok_map.get(i, tok)
|
| 923 |
+
|
| 924 |
+
out.append(tok)
|
| 925 |
+
|
| 926 |
+
# insertion happens AFTER this token (and never after EOS, due to masking)
|
| 927 |
+
if fired[i] and int(op_idx[i].item()) == 0:
|
| 928 |
+
out.append(ins_tok_map.get(i))
|
| 929 |
+
|
| 930 |
+
# ensure EOS at end
|
| 931 |
+
if len(out) == 0 or out[-1] != eos_id:
|
| 932 |
+
out.append(eos_id)
|
| 933 |
+
|
| 934 |
+
# enforce max_len_cap
|
| 935 |
+
if max_len_cap is not None and len(out) > max_len_cap:
|
| 936 |
+
out = out[:max_len_cap]
|
| 937 |
+
if out[-1] != eos_id:
|
| 938 |
+
out[-1] = eos_id
|
| 939 |
+
|
| 940 |
+
new_seq = torch.tensor(out, device=device, dtype=torch.long)
|
| 941 |
+
new_seqs.append(new_seq)
|
| 942 |
+
max_len_this_round = max(max_len_this_round, new_seq.size(0))
|
| 943 |
+
|
| 944 |
+
# pad batch
|
| 945 |
+
x = x.new_full((B, max_len_this_round), pad_id)
|
| 946 |
+
for b, seq_b in enumerate(new_seqs):
|
| 947 |
+
x[b, :seq_b.size(0)] = seq_b
|
| 948 |
+
|
| 949 |
+
return x
|
|
@@ -0,0 +1,1248 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# import warnings
|
| 2 |
+
# import logging
|
| 3 |
+
# warnings.filterwarnings("ignore", message="to-Python converter.*already registered", category=RuntimeWarning)
|
| 4 |
+
# warnings.filterwarnings("ignore", message="pkg_resources is deprecated", category=UserWarning)
|
| 5 |
+
# warnings.filterwarnings("ignore", category=FutureWarning)
|
| 6 |
+
# warnings.filterwarnings("ignore", category=DeprecationWarning)
|
| 7 |
+
# warnings.filterwarnings("ignore", category=UserWarning)
|
| 8 |
+
# from rdkit import RDLogger, rdBase
|
| 9 |
+
# RDLogger.DisableLog("rdApp.*")
|
| 10 |
+
# logging.basicConfig(level=logging.ERROR)
|
| 11 |
+
# logging.getLogger("chemprop").setLevel(logging.ERROR)
|
| 12 |
+
# logging.getLogger("hyperopt").setLevel(logging.ERROR)
|
| 13 |
+
# rdBase.DisableLog('rdApp.error')
|
| 14 |
+
|
| 15 |
+
import torch
|
| 16 |
+
import torch.nn.functional as F
|
| 17 |
+
import logging
|
| 18 |
+
from rdkit import Chem
|
| 19 |
+
# Make chemprop imports conditional to avoid import errors when not needed
|
| 20 |
+
try:
|
| 21 |
+
from chemprop import data, models
|
| 22 |
+
# Try to import featurizers separately as it may not exist in all versions
|
| 23 |
+
try:
|
| 24 |
+
from chemprop import featurizers
|
| 25 |
+
except (ImportError, AttributeError):
|
| 26 |
+
featurizers = None
|
| 27 |
+
CHEMPROP_AVAILABLE = True
|
| 28 |
+
except (ImportError, AttributeError) as e:
|
| 29 |
+
CHEMPROP_AVAILABLE = False
|
| 30 |
+
# Create dummy objects to avoid NameError if someone tries to use them
|
| 31 |
+
data = None
|
| 32 |
+
featurizers = None
|
| 33 |
+
models = None
|
| 34 |
+
from lightning import pytorch as pl
|
| 35 |
+
|
| 36 |
+
# ---- QUIET MODE (put these lines at the top of your script) ----
|
| 37 |
+
import os, warnings, logging
|
| 38 |
+
os.environ["TOKENIZERS_PARALLELISM"] = "false"
|
| 39 |
+
os.environ["TRANSFORMERS_VERBOSITY"] = "error"
|
| 40 |
+
os.environ["HF_HUB_DISABLE_TELEMETRY"] = "1"
|
| 41 |
+
|
| 42 |
+
# Make PyTorch stop suggesting Tensor Core settings
|
| 43 |
+
torch.set_float32_matmul_precision("high")
|
| 44 |
+
|
| 45 |
+
# Silence Python warnings (fine-tune as needed)
|
| 46 |
+
warnings.filterwarnings("ignore", category=FutureWarning)
|
| 47 |
+
warnings.filterwarnings("ignore", category=UserWarning, message=r".*predict_dataloader.*many workers.*")
|
| 48 |
+
warnings.filterwarnings("ignore", message=r"Dropping last batch of size .*")
|
| 49 |
+
|
| 50 |
+
# Quiet RDKit
|
| 51 |
+
from rdkit import RDLogger
|
| 52 |
+
RDLogger.DisableLog("rdApp.*")
|
| 53 |
+
|
| 54 |
+
# Quiet common loggers (Lightning, Chemprop, etc.)
|
| 55 |
+
logging.basicConfig(level=logging.ERROR, force=True)
|
| 56 |
+
for name in [
|
| 57 |
+
"lightning", "pytorch_lightning", "lightning.pytorch",
|
| 58 |
+
"chemprop", "rdkit", "urllib3", "torch"
|
| 59 |
+
]:
|
| 60 |
+
logging.getLogger(name).setLevel(logging.ERROR)
|
| 61 |
+
# ---------------------------------------------------------------
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
# from admet_ai import ADMETModel
|
| 65 |
+
import sys
|
| 66 |
+
import os
|
| 67 |
+
import numpy as np
|
| 68 |
+
from transformers import AutoModelForMaskedLM
|
| 69 |
+
import warnings
|
| 70 |
+
import numpy as np
|
| 71 |
+
import torch.nn as nn
|
| 72 |
+
from rdkit import Chem
|
| 73 |
+
from collections import defaultdict
|
| 74 |
+
import pdb
|
| 75 |
+
|
| 76 |
+
import math
|
| 77 |
+
import sys
|
| 78 |
+
import os
|
| 79 |
+
from pathlib import Path
|
| 80 |
+
|
| 81 |
+
# Resolve cas_predictor / protein2pam through the repo-root path helpers.
|
| 82 |
+
# Both are optional: if absent, the dependent objectives raise a clear error
|
| 83 |
+
# at construction time rather than at import time.
|
| 84 |
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
| 85 |
+
from paths import cas_predictor_root, protein2pam_root # noqa: E402
|
| 86 |
+
|
| 87 |
+
try:
|
| 88 |
+
cas_predictor_path = cas_predictor_root()
|
| 89 |
+
if str(cas_predictor_path) not in sys.path:
|
| 90 |
+
sys.path.insert(0, str(cas_predictor_path))
|
| 91 |
+
except FileNotFoundError:
|
| 92 |
+
cas_predictor_path = Path("/nonexistent/cas_predictor")
|
| 93 |
+
|
| 94 |
+
try:
|
| 95 |
+
protein2pam_path = protein2pam_root()
|
| 96 |
+
if str(protein2pam_path) not in sys.path:
|
| 97 |
+
sys.path.insert(0, str(protein2pam_path))
|
| 98 |
+
except FileNotFoundError:
|
| 99 |
+
protein2pam_path = Path("/nonexistent/protein2pam")
|
| 100 |
+
|
| 101 |
+
try:
|
| 102 |
+
# Import using importlib to avoid conflicts with editflows/model package
|
| 103 |
+
import importlib.util
|
| 104 |
+
import importlib
|
| 105 |
+
|
| 106 |
+
# Temporarily add cas_predictor to sys.path at the front to ensure it takes precedence
|
| 107 |
+
cas_predictor_str = str(cas_predictor_path)
|
| 108 |
+
original_path = sys.path.copy()
|
| 109 |
+
if cas_predictor_str not in sys.path:
|
| 110 |
+
sys.path.insert(0, cas_predictor_str)
|
| 111 |
+
|
| 112 |
+
try:
|
| 113 |
+
# Load model.py directly - use cas_predictor.model as the module name
|
| 114 |
+
model_file = cas_predictor_path / "model.py"
|
| 115 |
+
spec = importlib.util.spec_from_file_location("cas_predictor.model", model_file)
|
| 116 |
+
cas_model = importlib.util.module_from_spec(spec)
|
| 117 |
+
# Set __file__ to help with relative imports
|
| 118 |
+
cas_model.__file__ = str(model_file)
|
| 119 |
+
# Register the module in sys.modules before exec_module so imports work
|
| 120 |
+
sys.modules['cas_predictor.model'] = cas_model
|
| 121 |
+
sys.modules['model'] = cas_model # Also register as 'model' for lightning_module imports
|
| 122 |
+
spec.loader.exec_module(cas_model)
|
| 123 |
+
Cas9Classifier = cas_model.Cas9Classifier
|
| 124 |
+
|
| 125 |
+
# Load lightning_module.py - it will import from 'model' which should now resolve correctly
|
| 126 |
+
lightning_file = cas_predictor_path / "lightning_module.py"
|
| 127 |
+
spec_lightning = importlib.util.spec_from_file_location("cas_predictor.lightning_module", lightning_file)
|
| 128 |
+
cas_lightning = importlib.util.module_from_spec(spec_lightning)
|
| 129 |
+
# Set __file__ to help with relative imports
|
| 130 |
+
cas_lightning.__file__ = str(lightning_file)
|
| 131 |
+
# Register the module in sys.modules before exec_module
|
| 132 |
+
sys.modules['cas_predictor.lightning_module'] = cas_lightning
|
| 133 |
+
spec_lightning.loader.exec_module(cas_lightning)
|
| 134 |
+
Cas9ClassifierModule = cas_lightning.Cas9ClassifierModule
|
| 135 |
+
|
| 136 |
+
import yaml
|
| 137 |
+
try:
|
| 138 |
+
from easydict import EasyDict as edict
|
| 139 |
+
except ImportError:
|
| 140 |
+
# Fallback: use regular dict if easydict not available
|
| 141 |
+
class EasyDict(dict):
|
| 142 |
+
def __init__(self, *args, **kwargs):
|
| 143 |
+
super().__init__(*args, **kwargs)
|
| 144 |
+
def __getattr__(self, key):
|
| 145 |
+
try:
|
| 146 |
+
return self[key]
|
| 147 |
+
except KeyError:
|
| 148 |
+
raise AttributeError(key)
|
| 149 |
+
def __setattr__(self, key, value):
|
| 150 |
+
self[key] = value
|
| 151 |
+
edict = EasyDict
|
| 152 |
+
CAS9_PREDICTOR_AVAILABLE = True
|
| 153 |
+
finally:
|
| 154 |
+
# Restore original sys.path
|
| 155 |
+
sys.path[:] = original_path
|
| 156 |
+
except (ImportError, Exception) as e:
|
| 157 |
+
print(f"Warning: Could not import Cas9 predictor modules: {e}")
|
| 158 |
+
import traceback
|
| 159 |
+
traceback.print_exc()
|
| 160 |
+
CAS9_PREDICTOR_AVAILABLE = False
|
| 161 |
+
|
| 162 |
+
# SMARTS patterns
|
| 163 |
+
_AMIDE_SMARTS = Chem.MolFromSmarts("[CX3](=[OX1])[NX3]") # C(=O)-N
|
| 164 |
+
_CARBONYL_C_SMARTS = Chem.MolFromSmarts("[CX3](=[OX1])") # carbonyl C
|
| 165 |
+
_DIPEPTIDE_SMARTS = Chem.MolFromSmarts("[CX3](=[OX1])N[#6X4][CX3](=[OX1])N") # amide–C(sp3)–amide
|
| 166 |
+
|
| 167 |
+
def _amide_bond_indices(mol, ignore_ring_amides=False):
|
| 168 |
+
ids = set()
|
| 169 |
+
for c_idx, _, n_idx in mol.GetSubstructMatches(_AMIDE_SMARTS):
|
| 170 |
+
b = mol.GetBondBetweenAtoms(c_idx, n_idx)
|
| 171 |
+
if b and b.GetBondType() == Chem.rdchem.BondType.SINGLE:
|
| 172 |
+
if ignore_ring_amides and b.IsInRing():
|
| 173 |
+
continue
|
| 174 |
+
ids.add(b.GetIdx())
|
| 175 |
+
return ids
|
| 176 |
+
|
| 177 |
+
def _carbonyl_c_indices(mol):
|
| 178 |
+
return {m[0] for m in mol.GetSubstructMatches(_CARBONYL_C_SMARTS)}
|
| 179 |
+
|
| 180 |
+
def _carbonyl_neighbor_stats(mol, c_indices):
|
| 181 |
+
stats = {"total": 0, "with_N": 0, "with_O": 0, "with_S": 0, "pure_amide": 0}
|
| 182 |
+
for c_idx in c_indices:
|
| 183 |
+
c = mol.GetAtomWithIdx(c_idx)
|
| 184 |
+
stats["total"] += 1
|
| 185 |
+
hasN = hasO = hasS = False
|
| 186 |
+
for b in c.GetBonds():
|
| 187 |
+
if b.GetBondType() != Chem.rdchem.BondType.SINGLE:
|
| 188 |
+
continue
|
| 189 |
+
z = b.GetOtherAtom(c).GetAtomicNum()
|
| 190 |
+
if z == 7: hasN = True
|
| 191 |
+
elif z == 8: hasO = True
|
| 192 |
+
elif z == 16: hasS = True
|
| 193 |
+
stats["with_N"] += int(hasN)
|
| 194 |
+
stats["with_O"] += int(hasO)
|
| 195 |
+
stats["with_S"] += int(hasS)
|
| 196 |
+
if hasN and not (hasO or hasS):
|
| 197 |
+
stats["pure_amide"] += 1
|
| 198 |
+
return stats
|
| 199 |
+
|
| 200 |
+
def _adjacent_amide_pairs(mol):
|
| 201 |
+
# Count distinct amide–C(sp3)–amide windows (dedup by central carbon)
|
| 202 |
+
centers = set()
|
| 203 |
+
for match in mol.GetSubstructMatches(_DIPEPTIDE_SMARTS):
|
| 204 |
+
centers.add(match[3]) # central sp3 carbon index
|
| 205 |
+
return len(centers)
|
| 206 |
+
|
| 207 |
+
def analyze_peptide_likeness(smiles: str,
|
| 208 |
+
ignore_ring_amides: bool = False,
|
| 209 |
+
amide_density_target: float = 0.12):
|
| 210 |
+
"""
|
| 211 |
+
Compute peptide-likeness metrics and a continuous score in [0,1].
|
| 212 |
+
- ignore_ring_amides=False: include macro/cyclic peptides by default.
|
| 213 |
+
- amide_density_target: amide-per-atom density to hit score~1 for peptides
|
| 214 |
+
(~0.10–0.15 works well; default 0.12 ≈ 1 amide per ~8 heavy atoms).
|
| 215 |
+
"""
|
| 216 |
+
mol = Chem.MolFromSmiles(smiles)
|
| 217 |
+
if mol is None:
|
| 218 |
+
raise ValueError(f"Invalid SMILES: {smiles}")
|
| 219 |
+
|
| 220 |
+
n_heavy_atoms = mol.GetNumAtoms()
|
| 221 |
+
n_heavy_bonds = mol.GetNumBonds()
|
| 222 |
+
|
| 223 |
+
amide_bonds = _amide_bond_indices(mol, ignore_ring_amides=ignore_ring_amides)
|
| 224 |
+
n_amide_bonds = len(amide_bonds)
|
| 225 |
+
|
| 226 |
+
carbonyl_cs = _carbonyl_c_indices(mol)
|
| 227 |
+
cstats = _carbonyl_neighbor_stats(mol, carbonyl_cs)
|
| 228 |
+
total_carb = max(1, cstats["total"])
|
| 229 |
+
|
| 230 |
+
# Core features
|
| 231 |
+
f1 = cstats["with_N"] / total_carb # acyl-N fraction ∈ [0,1]
|
| 232 |
+
amide_per_atom = n_amide_bonds / max(1, n_heavy_atoms)
|
| 233 |
+
f2 = min(1.0, amide_per_atom / max(1e-8, amide_density_target)) # saturate at 1
|
| 234 |
+
n_adjacent = _adjacent_amide_pairs(mol)
|
| 235 |
+
f3 = 1.0 - math.exp(-n_adjacent) # 0, 0.63, 0.86, 0.95, ... as pairs increase
|
| 236 |
+
|
| 237 |
+
# Penalty for non-peptidic carbonyls (carbamates/anhydrides/thioesters)
|
| 238 |
+
pure_amide_fraction = cstats["pure_amide"] / total_carb
|
| 239 |
+
penalty = 1.0 - pure_amide_fraction # 0 (all pure amide) … 1 (no pure amide)
|
| 240 |
+
|
| 241 |
+
# Final heuristic score in [0,1]
|
| 242 |
+
score = 0.55 * f1 + 0.25 * f2 + 0.20 * f3 - 0.25 * penalty
|
| 243 |
+
score = max(0.0, min(1.0, score))
|
| 244 |
+
|
| 245 |
+
return {
|
| 246 |
+
"n_heavy_atoms": n_heavy_atoms,
|
| 247 |
+
"n_heavy_bonds": n_heavy_bonds,
|
| 248 |
+
"n_carbonyls": cstats["total"],
|
| 249 |
+
"n_amide_bonds": n_amide_bonds,
|
| 250 |
+
"amide_bond_ratio_all_bonds": n_amide_bonds / max(1, n_heavy_bonds),
|
| 251 |
+
"acyl_N_fraction": f1,
|
| 252 |
+
"pure_amide_fraction": pure_amide_fraction,
|
| 253 |
+
"amide_per_atom": amide_per_atom,
|
| 254 |
+
"n_adjacent_amide_pairs": n_adjacent,
|
| 255 |
+
"peptide_likeness": score, # <<< continuous score in [0,1]
|
| 256 |
+
}
|
| 257 |
+
|
| 258 |
+
|
| 259 |
+
def score_combination(ratios, scores, admet_scores):
|
| 260 |
+
high_mask = ratios > 0.6
|
| 261 |
+
low_mask = ratios < 0.1
|
| 262 |
+
mid_mask = ~(high_mask | low_mask)
|
| 263 |
+
|
| 264 |
+
# start with zeros
|
| 265 |
+
final_scores = torch.zeros_like(scores)
|
| 266 |
+
|
| 267 |
+
# high-peptide: use peptide scores
|
| 268 |
+
final_scores[high_mask] = scores[high_mask]
|
| 269 |
+
|
| 270 |
+
# low-peptide: use admet scores
|
| 271 |
+
final_scores[low_mask] = admet_scores[low_mask]
|
| 272 |
+
|
| 273 |
+
# middle band: linear blend
|
| 274 |
+
if mid_mask.any():
|
| 275 |
+
r_mid = ratios[mid_mask]
|
| 276 |
+
alpha = (r_mid - 0.1) / 0.5 # in [0, 1]
|
| 277 |
+
blended = alpha * scores[mid_mask] + (1 - alpha) * admet_scores[mid_mask]
|
| 278 |
+
final_scores[mid_mask] = blended
|
| 279 |
+
|
| 280 |
+
def detokenize_output(x, cfg, tokenizer, bos_id, eos_id, pad_id):
|
| 281 |
+
"""
|
| 282 |
+
Convert a single generated sequence (1, L) back to string.
|
| 283 |
+
"""
|
| 284 |
+
seq = x[0].tolist()
|
| 285 |
+
# strip padding
|
| 286 |
+
seq = [tok for tok in seq if tok != pad_id]
|
| 287 |
+
# strip BOS/EOS
|
| 288 |
+
if len(seq) > 0 and seq[0] == bos_id:
|
| 289 |
+
seq = seq[1:]
|
| 290 |
+
if len(seq) > 0 and seq[-1] == eos_id:
|
| 291 |
+
seq = seq[:-1]
|
| 292 |
+
|
| 293 |
+
if cfg.task == 'protein':
|
| 294 |
+
# esm tokenizer has batch_decode
|
| 295 |
+
return tokenizer.batch_decode([seq], skip_special_tokens=True)[0]
|
| 296 |
+
elif cfg.task in ('smiles', 'selfies'):
|
| 297 |
+
return tokenizer.decode(seq)
|
| 298 |
+
else:
|
| 299 |
+
return " ".join(map(str, seq))
|
| 300 |
+
|
| 301 |
+
|
| 302 |
+
|
| 303 |
+
class Cas9Classification:
|
| 304 |
+
"""
|
| 305 |
+
Cas9 binary classification objective.
|
| 306 |
+
|
| 307 |
+
This objective works with protein sequences (not SMILES).
|
| 308 |
+
It uses ESM-2 embeddings and a trained Cas9Classifier to predict
|
| 309 |
+
whether a protein sequence is Cas9-like.
|
| 310 |
+
|
| 311 |
+
Note: This objective expects protein sequences, not SMILES sequences.
|
| 312 |
+
If used with SMILES-based generation, you'll need to convert SMILES to proteins first.
|
| 313 |
+
"""
|
| 314 |
+
def __init__(self, device, checkpoint_path=None, config_path=None, shared_esm_model=None):
|
| 315 |
+
if not CAS9_PREDICTOR_AVAILABLE:
|
| 316 |
+
raise ImportError(
|
| 317 |
+
"Cas9 predictor modules not available. "
|
| 318 |
+
"Ensure cas_predictor is in the correct location."
|
| 319 |
+
)
|
| 320 |
+
|
| 321 |
+
self.device = device
|
| 322 |
+
|
| 323 |
+
# Default checkpoint path if not provided
|
| 324 |
+
if checkpoint_path is None:
|
| 325 |
+
from paths import cas9_classifier_ckpt
|
| 326 |
+
checkpoint_path = str(cas9_classifier_ckpt())
|
| 327 |
+
|
| 328 |
+
# Load config
|
| 329 |
+
config = None
|
| 330 |
+
if config_path:
|
| 331 |
+
with open(config_path, "r") as f:
|
| 332 |
+
config_dict = yaml.safe_load(f)
|
| 333 |
+
config = edict(config_dict)
|
| 334 |
+
else:
|
| 335 |
+
# Try to load from checkpoint
|
| 336 |
+
try:
|
| 337 |
+
checkpoint = torch.load(checkpoint_path, map_location="cpu")
|
| 338 |
+
if "hyper_parameters" in checkpoint:
|
| 339 |
+
config_dict = checkpoint["hyper_parameters"]
|
| 340 |
+
if isinstance(config_dict, dict):
|
| 341 |
+
config = edict(config_dict)
|
| 342 |
+
except Exception as e:
|
| 343 |
+
print(f"Warning: Could not load config from checkpoint: {e}")
|
| 344 |
+
|
| 345 |
+
# If config still not found, create a default config with reasonable defaults
|
| 346 |
+
if config is None:
|
| 347 |
+
print("Warning: Could not load config from checkpoint or config_path. Using default config.")
|
| 348 |
+
config = edict({
|
| 349 |
+
"model": edict({
|
| 350 |
+
"esm_model_name": "facebook/esm2_t33_650M_UR50D",
|
| 351 |
+
"freeze_esm": True,
|
| 352 |
+
"hidden_size": 512,
|
| 353 |
+
"dropout": 0.1,
|
| 354 |
+
"pooling_type": "mean"
|
| 355 |
+
}),
|
| 356 |
+
"optim": edict({
|
| 357 |
+
"lr": 5e-5,
|
| 358 |
+
"beta1": 0.9,
|
| 359 |
+
"beta2": 0.999,
|
| 360 |
+
"eps": 1e-8,
|
| 361 |
+
"weight_decay": 0.01,
|
| 362 |
+
"warmup_ratio": 0.1
|
| 363 |
+
})
|
| 364 |
+
})
|
| 365 |
+
|
| 366 |
+
# Load model from checkpoint
|
| 367 |
+
# If shared_esm_model is provided, we need to manually instantiate and load
|
| 368 |
+
# to avoid creating a duplicate ESM model
|
| 369 |
+
if shared_esm_model is not None:
|
| 370 |
+
# Manually instantiate with shared ESM model
|
| 371 |
+
self.model = Cas9ClassifierModule(config, shared_esm_model=shared_esm_model)
|
| 372 |
+
# Load checkpoint state dict (excluding ESM weights since we're using shared model)
|
| 373 |
+
checkpoint = torch.load(checkpoint_path, map_location="cpu")
|
| 374 |
+
state_dict = checkpoint["state_dict"]
|
| 375 |
+
# Filter out ESM weights to avoid loading them (we're using shared model)
|
| 376 |
+
filtered_state_dict = {k: v for k, v in state_dict.items()
|
| 377 |
+
if not k.startswith("model.esm_emb")}
|
| 378 |
+
self.model.load_state_dict(filtered_state_dict, strict=False)
|
| 379 |
+
print(f"Loaded Cas9Classifier checkpoint with shared ESM model. "
|
| 380 |
+
f"Filtered out {len(state_dict) - len(filtered_state_dict)} ESM parameters.")
|
| 381 |
+
else:
|
| 382 |
+
# Original behavior: use load_from_checkpoint (creates its own ESM model)
|
| 383 |
+
self.model = Cas9ClassifierModule.load_from_checkpoint(
|
| 384 |
+
checkpoint_path,
|
| 385 |
+
config=config,
|
| 386 |
+
strict=False,
|
| 387 |
+
)
|
| 388 |
+
|
| 389 |
+
self.model.eval()
|
| 390 |
+
self.model.to(device)
|
| 391 |
+
|
| 392 |
+
# Load ESM tokenizer (same approach as BindingAffinity)
|
| 393 |
+
# We only need the alphabet for tokenization, not the full model
|
| 394 |
+
# If we have a shared ESM model, we can get the tokenizer from it
|
| 395 |
+
# Otherwise, load the ESM model just to get the alphabet
|
| 396 |
+
if shared_esm_model is not None:
|
| 397 |
+
# Use the shared ESM model's tokenizer if available
|
| 398 |
+
# For transformers.EsmModel, we need to use EsmTokenizer separately
|
| 399 |
+
from transformers import EsmTokenizer
|
| 400 |
+
esm_model_name = getattr(config.model, "esm_model_name", "facebook/esm2_t33_650M_UR50D")
|
| 401 |
+
tokenizer = EsmTokenizer.from_pretrained(esm_model_name)
|
| 402 |
+
# Create a simple batch converter function
|
| 403 |
+
def batch_converter(data):
|
| 404 |
+
# data is list of (name, sequence) tuples
|
| 405 |
+
sequences = [seq for _, seq in data]
|
| 406 |
+
encoded = tokenizer(sequences, padding=True, return_tensors="pt", add_special_tokens=True)
|
| 407 |
+
# Return format: (names, sequences, tokens)
|
| 408 |
+
names = [name for name, _ in data]
|
| 409 |
+
return names, sequences, encoded["input_ids"]
|
| 410 |
+
self.batch_converter = batch_converter
|
| 411 |
+
else:
|
| 412 |
+
# Use HuggingFace transformers API instead of old esm.pretrained API
|
| 413 |
+
from transformers import EsmTokenizer
|
| 414 |
+
esm_model_name = getattr(config.model, "esm_model_name", "facebook/esm2_t33_650M_UR50D")
|
| 415 |
+
tokenizer = EsmTokenizer.from_pretrained(esm_model_name)
|
| 416 |
+
# Create a simple batch converter function
|
| 417 |
+
def batch_converter(data):
|
| 418 |
+
# data is list of (name, sequence) tuples
|
| 419 |
+
sequences = [seq for _, seq in data]
|
| 420 |
+
encoded = tokenizer(sequences, padding=True, return_tensors="pt", add_special_tokens=True)
|
| 421 |
+
# Return format: (names, sequences, tokens)
|
| 422 |
+
names = [name for name, _ in data]
|
| 423 |
+
return names, sequences, encoded["input_ids"]
|
| 424 |
+
self.batch_converter = batch_converter
|
| 425 |
+
|
| 426 |
+
def get_scores(self, protein_seqs):
|
| 427 |
+
"""
|
| 428 |
+
Get Cas9 classification scores for protein sequences.
|
| 429 |
+
|
| 430 |
+
Args:
|
| 431 |
+
protein_seqs: List of protein sequence strings (amino acid sequences) or single string
|
| 432 |
+
|
| 433 |
+
Returns:
|
| 434 |
+
scores: List of probabilities (0-1) that each sequence is Cas9-like
|
| 435 |
+
"""
|
| 436 |
+
# Handle single string input
|
| 437 |
+
if isinstance(protein_seqs, str):
|
| 438 |
+
protein_seqs = [protein_seqs]
|
| 439 |
+
|
| 440 |
+
if not protein_seqs:
|
| 441 |
+
return []
|
| 442 |
+
|
| 443 |
+
scores = []
|
| 444 |
+
|
| 445 |
+
with torch.no_grad():
|
| 446 |
+
# Tokenize sequences using ESM batch converter
|
| 447 |
+
data = [(f"seq_{i}", seq) for i, seq in enumerate(protein_seqs)]
|
| 448 |
+
_, _, batch_tokens = self.batch_converter(data)
|
| 449 |
+
batch_tokens = batch_tokens.to(self.device)
|
| 450 |
+
|
| 451 |
+
# Create attention mask (True for valid tokens, False for padding)
|
| 452 |
+
# ESM uses 1 for padding, so we check for non-padding tokens
|
| 453 |
+
# Convert to bool tensor explicitly (model expects BoolTensor)
|
| 454 |
+
attention_mask = (batch_tokens != 1).bool().to(self.device) # 1 is ESM's padding token
|
| 455 |
+
|
| 456 |
+
# Debug: Print shapes and values for first sequence if debugging
|
| 457 |
+
if len(protein_seqs) == 1 and len(protein_seqs[0]) < 2000: # Only debug for single sequences and reasonable lengths
|
| 458 |
+
import os
|
| 459 |
+
if os.environ.get("DEBUG_CAS9", "0") == "1":
|
| 460 |
+
print(f"[DEBUG] Cas9 classifier - sequence length: {len(protein_seqs[0])}")
|
| 461 |
+
print(f"[DEBUG] Cas9 classifier - batch_tokens shape: {batch_tokens.shape}")
|
| 462 |
+
print(f"[DEBUG] Cas9 classifier - attention_mask shape: {attention_mask.shape}")
|
| 463 |
+
print(f"[DEBUG] Cas9 classifier - attention_mask sum (valid tokens): {attention_mask.sum().item()}")
|
| 464 |
+
print(f"[DEBUG] Cas9 classifier - batch_tokens min/max: {batch_tokens.min().item()}/{batch_tokens.max().item()}")
|
| 465 |
+
|
| 466 |
+
# Forward pass
|
| 467 |
+
logits = self.model(batch_tokens, attention_mask)
|
| 468 |
+
|
| 469 |
+
# Debug: Print logits if debugging
|
| 470 |
+
if len(protein_seqs) == 1 and len(protein_seqs[0]) < 2000:
|
| 471 |
+
import os
|
| 472 |
+
if os.environ.get("DEBUG_CAS9", "0") == "1":
|
| 473 |
+
print(f"[DEBUG] Cas9 classifier - logits shape: {logits.shape}")
|
| 474 |
+
print(f"[DEBUG] Cas9 classifier - logits value: {logits.item()}")
|
| 475 |
+
|
| 476 |
+
probs = torch.sigmoid(logits).cpu()
|
| 477 |
+
|
| 478 |
+
# Debug: Print probabilities if debugging
|
| 479 |
+
if len(protein_seqs) == 1 and len(protein_seqs[0]) < 2000:
|
| 480 |
+
import os
|
| 481 |
+
if os.environ.get("DEBUG_CAS9", "0") == "1":
|
| 482 |
+
print(f"[DEBUG] Cas9 classifier - probabilities: {probs.tolist()}")
|
| 483 |
+
|
| 484 |
+
scores = probs.tolist()
|
| 485 |
+
|
| 486 |
+
return scores
|
| 487 |
+
|
| 488 |
+
def __call__(self, protein_tokens, protein_seqs):
|
| 489 |
+
"""
|
| 490 |
+
Objective call interface.
|
| 491 |
+
|
| 492 |
+
Args:
|
| 493 |
+
protein_tokens: Unused (kept for interface compatibility)
|
| 494 |
+
protein_seqs: List of protein sequence strings
|
| 495 |
+
|
| 496 |
+
Returns:
|
| 497 |
+
Tuple of ('cas9', scores) where scores is a list of probabilities
|
| 498 |
+
"""
|
| 499 |
+
scores = self.get_scores(protein_seqs)
|
| 500 |
+
return 'cas9', scores
|
| 501 |
+
|
| 502 |
+
|
| 503 |
+
class DeletionCount:
|
| 504 |
+
"""
|
| 505 |
+
Objective that maximizes the number of deletions (length reduction) in protein sequences.
|
| 506 |
+
|
| 507 |
+
This objective computes the difference between the original sequence length and
|
| 508 |
+
the current sequence length, normalized by a maximum deletion percentage to produce
|
| 509 |
+
a value between 0 and 1.
|
| 510 |
+
|
| 511 |
+
Args:
|
| 512 |
+
original_seq: The original protein sequence string (before any edits)
|
| 513 |
+
max_deletion_percentage: Maximum deletion percentage (0-1). The objective value will be computed as
|
| 514 |
+
(deletion_count / (original_length * max_deletion_percentage)), clamped to [0, 1].
|
| 515 |
+
If None, defaults to 1.0 (100% deletion possible).
|
| 516 |
+
"""
|
| 517 |
+
def __init__(self, original_seq, max_deletion_percentage=None):
|
| 518 |
+
# Store original sequence length (excluding spaces)
|
| 519 |
+
self.original_length = len(original_seq.replace(' ', ''))
|
| 520 |
+
# Set max_deletion_percentage to 1.0 (100%) if not provided
|
| 521 |
+
if max_deletion_percentage is None:
|
| 522 |
+
self.max_deletion_percentage = 1.0
|
| 523 |
+
else:
|
| 524 |
+
if max_deletion_percentage <= 0 or max_deletion_percentage > 1.0:
|
| 525 |
+
raise ValueError(f"max_deletion_percentage must be in (0, 1], got {max_deletion_percentage}")
|
| 526 |
+
self.max_deletion_percentage = float(max_deletion_percentage)
|
| 527 |
+
|
| 528 |
+
# Calculate max_deletion as absolute value for backward compatibility
|
| 529 |
+
self.max_deletion = self.original_length * self.max_deletion_percentage
|
| 530 |
+
|
| 531 |
+
def __call__(self, protein_tokens, protein_seqs):
|
| 532 |
+
"""
|
| 533 |
+
Objective call interface.
|
| 534 |
+
|
| 535 |
+
Args:
|
| 536 |
+
protein_tokens: Unused (kept for interface compatibility)
|
| 537 |
+
protein_seqs: List of protein sequence strings
|
| 538 |
+
|
| 539 |
+
Returns:
|
| 540 |
+
Tuple of ('deletion_count', scores) where scores is a list of normalized deletion
|
| 541 |
+
percentages (deletion_count / (original_length * max_deletion_percentage)), clamped to [0, 1]
|
| 542 |
+
"""
|
| 543 |
+
# Handle single string input
|
| 544 |
+
if isinstance(protein_seqs, str):
|
| 545 |
+
protein_seqs = [protein_seqs]
|
| 546 |
+
|
| 547 |
+
if not protein_seqs:
|
| 548 |
+
return 'deletion_count', []
|
| 549 |
+
|
| 550 |
+
scores = []
|
| 551 |
+
for seq in protein_seqs:
|
| 552 |
+
# Remove spaces and compute current length
|
| 553 |
+
current_length = len(seq.replace(' ', ''))
|
| 554 |
+
# Number of deletions = original_length - current_length
|
| 555 |
+
deletion_count = self.original_length - current_length
|
| 556 |
+
# Normalize by max_deletion and clamp to [0, 1]
|
| 557 |
+
normalized_score = max(0.0, min(1.0, deletion_count / self.max_deletion))
|
| 558 |
+
scores.append(float(normalized_score))
|
| 559 |
+
|
| 560 |
+
return 'deletion_count', scores
|
| 561 |
+
|
| 562 |
+
|
| 563 |
+
class PAMMatching:
|
| 564 |
+
"""
|
| 565 |
+
PAM matching objective using temperature-scaled log probabilities.
|
| 566 |
+
|
| 567 |
+
This objective uses the HuggingFace protein2pam model (cas9_full) to predict PAM logits
|
| 568 |
+
for protein sequences. The score is computed using temperature-scaled log probabilities:
|
| 569 |
+
at each target position (non-N), we compute log P(target_nucleotide | position) after
|
| 570 |
+
applying temperature scaling to the logits. The final score is the geometric mean of
|
| 571 |
+
probabilities (exp of mean log prob) over target positions.
|
| 572 |
+
|
| 573 |
+
This approach amplifies small improvements, making them more visible in the weighted sum
|
| 574 |
+
during optimization, which helps when candidates show only marginal improvements.
|
| 575 |
+
|
| 576 |
+
Args:
|
| 577 |
+
device: torch device
|
| 578 |
+
target_pam: Target PAM sequence string of length 10 (e.g., "NGGNNNNNNN")
|
| 579 |
+
model_name: HuggingFace model identifier (default: "Profluent-Bio/protein2pam-cas9_full")
|
| 580 |
+
sigmoid_temperature: Temperature for scaling logits before softmax. Lower values (0.1-0.3)
|
| 581 |
+
sharpen the distribution, making small improvements more visible.
|
| 582 |
+
Default 0.2. (Note: parameter name kept for backward compatibility)
|
| 583 |
+
pam_prediction_temperature: Temperature for PAM prediction (in predict_pam method).
|
| 584 |
+
Values < 1.0 make distributions sharper (more confident),
|
| 585 |
+
> 1.0 make them softer. Default 1.0 (no temperature scaling).
|
| 586 |
+
Note: This only affects prediction, not scoring.
|
| 587 |
+
shared_esm_model: Optional shared ESM model (transformers.EsmModel) to use instead of
|
| 588 |
+
loading a separate ESM backbone. This reduces memory usage when multiple
|
| 589 |
+
models need ESM embeddings. Default None (loads its own ESM model).
|
| 590 |
+
"""
|
| 591 |
+
def __init__(self, device, target_pam, model_name="Profluent-Bio/protein2pam-cas9_full", sigmoid_temperature=0.2, use_entropy_for_n_positions=True, use_ce_loss=False, pam_min_confidence=0.55, pam_prediction_temperature=1.0, shared_esm_model=None):
|
| 592 |
+
self.device = device
|
| 593 |
+
self.target_pam = target_pam.upper()
|
| 594 |
+
|
| 595 |
+
# Validate target PAM length
|
| 596 |
+
if len(self.target_pam) != 10:
|
| 597 |
+
raise ValueError(f"Target PAM must be exactly 10 nucleotides, got {len(self.target_pam)}")
|
| 598 |
+
|
| 599 |
+
# Validate nucleotides
|
| 600 |
+
valid_nucleotides = set('ACGTN')
|
| 601 |
+
if not all(nuc in valid_nucleotides for nuc in self.target_pam):
|
| 602 |
+
raise ValueError(f"Target PAM contains invalid nucleotides. Only ACGTN allowed.")
|
| 603 |
+
|
| 604 |
+
# Convert target PAM to class indices (ACGT = 0,1,2,3, N = -1 for masking)
|
| 605 |
+
nucleotides = ['A', 'C', 'G', 'T']
|
| 606 |
+
self.target_indices = []
|
| 607 |
+
self.target_mask = []
|
| 608 |
+
for nuc in self.target_pam:
|
| 609 |
+
if nuc == 'N':
|
| 610 |
+
# N means any nucleotide - we'll mask this position in loss calculation
|
| 611 |
+
self.target_indices.append(-1) # Placeholder, will be masked
|
| 612 |
+
self.target_mask.append(False)
|
| 613 |
+
else:
|
| 614 |
+
self.target_indices.append(nucleotides.index(nuc))
|
| 615 |
+
self.target_mask.append(True)
|
| 616 |
+
|
| 617 |
+
self.target_indices = torch.tensor(self.target_indices, device=device, dtype=torch.long)
|
| 618 |
+
self.target_mask = torch.tensor(self.target_mask, device=device, dtype=torch.bool)
|
| 619 |
+
self.sigmoid_temperature = float(sigmoid_temperature)
|
| 620 |
+
self.use_entropy_for_n_positions = bool(use_entropy_for_n_positions)
|
| 621 |
+
self.use_ce_loss = bool(use_ce_loss)
|
| 622 |
+
self.pam_min_confidence = float(pam_min_confidence)
|
| 623 |
+
self.pam_prediction_temperature = float(pam_prediction_temperature)
|
| 624 |
+
|
| 625 |
+
# Load HuggingFace model. The protein2pam package root is resolved by
|
| 626 |
+
# paths.protein2pam_root() (honours $PROTEIN2PAM_ROOT).
|
| 627 |
+
|
| 628 |
+
try:
|
| 629 |
+
protein2pam_path_resolved = protein2pam_root()
|
| 630 |
+
except FileNotFoundError as exc:
|
| 631 |
+
raise ImportError(str(exc)) from exc
|
| 632 |
+
|
| 633 |
+
# Ensure the path is in sys.path
|
| 634 |
+
protein2pam_path_str = str(protein2pam_path_resolved)
|
| 635 |
+
if protein2pam_path_str not in sys.path:
|
| 636 |
+
sys.path.insert(0, protein2pam_path_str)
|
| 637 |
+
print(f"[DEBUG] Added protein2pam path to sys.path: {protein2pam_path_str}")
|
| 638 |
+
|
| 639 |
+
try:
|
| 640 |
+
# Try importing directly from huggingface submodule to avoid protein2pam/__init__.py
|
| 641 |
+
# which imports PAMOracle (requires torch at import time)
|
| 642 |
+
import importlib.util
|
| 643 |
+
|
| 644 |
+
# Import the modules directly without going through __init__.py
|
| 645 |
+
huggingface_dir = protein2pam_path_resolved / "protein2pam" / "huggingface"
|
| 646 |
+
|
| 647 |
+
# Load configuration_esm
|
| 648 |
+
config_file = huggingface_dir / "configuration_esm.py"
|
| 649 |
+
if config_file.exists():
|
| 650 |
+
spec_config = importlib.util.spec_from_file_location("protein2pam.huggingface.configuration_esm", config_file)
|
| 651 |
+
if spec_config and spec_config.loader:
|
| 652 |
+
# Create package structure
|
| 653 |
+
if 'protein2pam' not in sys.modules:
|
| 654 |
+
sys.modules['protein2pam'] = type(sys)('protein2pam')
|
| 655 |
+
if 'protein2pam.huggingface' not in sys.modules:
|
| 656 |
+
sys.modules['protein2pam.huggingface'] = type(sys)('protein2pam.huggingface')
|
| 657 |
+
|
| 658 |
+
config_module = importlib.util.module_from_spec(spec_config)
|
| 659 |
+
sys.modules['protein2pam.huggingface.configuration_esm'] = config_module
|
| 660 |
+
spec_config.loader.exec_module(config_module)
|
| 661 |
+
|
| 662 |
+
# Load modeling_esm (depends on configuration_esm)
|
| 663 |
+
modeling_file = huggingface_dir / "modeling_esm.py"
|
| 664 |
+
if modeling_file.exists():
|
| 665 |
+
spec_modeling = importlib.util.spec_from_file_location("protein2pam.huggingface.modeling_esm", modeling_file)
|
| 666 |
+
if spec_modeling and spec_modeling.loader:
|
| 667 |
+
modeling_module = importlib.util.module_from_spec(spec_modeling)
|
| 668 |
+
sys.modules['protein2pam.huggingface.modeling_esm'] = modeling_module
|
| 669 |
+
spec_modeling.loader.exec_module(modeling_module)
|
| 670 |
+
|
| 671 |
+
# Load tokenizer
|
| 672 |
+
tokenizer_file = huggingface_dir / "tokenizer.py"
|
| 673 |
+
if tokenizer_file.exists():
|
| 674 |
+
spec_tokenizer = importlib.util.spec_from_file_location("protein2pam.huggingface.tokenizer", tokenizer_file)
|
| 675 |
+
if spec_tokenizer and spec_tokenizer.loader:
|
| 676 |
+
tokenizer_module = importlib.util.module_from_spec(spec_tokenizer)
|
| 677 |
+
sys.modules['protein2pam.huggingface.tokenizer'] = tokenizer_module
|
| 678 |
+
spec_tokenizer.loader.exec_module(tokenizer_module)
|
| 679 |
+
|
| 680 |
+
# Now import from the loaded modules
|
| 681 |
+
EsmForSequenceClassification = sys.modules['protein2pam.huggingface.modeling_esm'].EsmForSequenceClassification
|
| 682 |
+
get_tokenizer = sys.modules['protein2pam.huggingface.tokenizer'].get_tokenizer
|
| 683 |
+
|
| 684 |
+
self.tokenizer = get_tokenizer()
|
| 685 |
+
print(f"Loading PAM prediction model: {model_name}...")
|
| 686 |
+
# Load model without device_map (device_map expects string/dict, not torch.device)
|
| 687 |
+
# Then move to device manually for compatibility with different transformers versions
|
| 688 |
+
self.model = EsmForSequenceClassification.from_pretrained(model_name)
|
| 689 |
+
|
| 690 |
+
# If shared_esm_model is provided, replace the ESM backbone to share parameters
|
| 691 |
+
if shared_esm_model is not None:
|
| 692 |
+
print(f"Replacing PAM model's ESM backbone with shared ESM model...")
|
| 693 |
+
# EsmForSequenceClassification typically has an 'esm' attribute containing the backbone
|
| 694 |
+
if hasattr(self.model, 'esm'):
|
| 695 |
+
self.model.esm = shared_esm_model
|
| 696 |
+
print(f"Successfully replaced PAM model's ESM backbone with shared model.")
|
| 697 |
+
elif hasattr(self.model, 'model') and hasattr(self.model.model, 'esm'):
|
| 698 |
+
# Some models wrap ESM in a 'model' attribute
|
| 699 |
+
self.model.model.esm = shared_esm_model
|
| 700 |
+
print(f"Successfully replaced PAM model's ESM backbone (via .model.esm) with shared model.")
|
| 701 |
+
else:
|
| 702 |
+
print(f"Warning: Could not find ESM backbone in PAM model structure. Available attributes: {[attr for attr in dir(self.model) if not attr.startswith('_')]}")
|
| 703 |
+
# Try to find it recursively
|
| 704 |
+
for attr_name in ['esm', 'backbone', 'encoder']:
|
| 705 |
+
if hasattr(self.model, attr_name):
|
| 706 |
+
setattr(self.model, attr_name, shared_esm_model)
|
| 707 |
+
print(f"Replaced {attr_name} with shared ESM model.")
|
| 708 |
+
break
|
| 709 |
+
|
| 710 |
+
self.model = self.model.to(device)
|
| 711 |
+
self.model.eval()
|
| 712 |
+
print(f"PAM prediction model loaded successfully.")
|
| 713 |
+
except ImportError as e:
|
| 714 |
+
# Show more details about the import error
|
| 715 |
+
import traceback
|
| 716 |
+
print(f"[DEBUG] protein2pam import error: {e}")
|
| 717 |
+
print(f"[DEBUG] sys.path entries containing 'protein2pam': {[p for p in sys.path if 'protein2pam' in p]}")
|
| 718 |
+
print(f"[DEBUG] Expected protein2pam path: {protein2pam_path_resolved}")
|
| 719 |
+
print(f"[DEBUG] Path exists: {protein2pam_path_resolved.exists() if protein2pam_path_resolved else False}")
|
| 720 |
+
traceback.print_exc()
|
| 721 |
+
raise ImportError(
|
| 722 |
+
f"protein2pam package not available: {e}. "
|
| 723 |
+
"Please ensure protein2pam is installed and in the correct location."
|
| 724 |
+
)
|
| 725 |
+
except Exception as e:
|
| 726 |
+
import traceback
|
| 727 |
+
print(f"[DEBUG] Failed to load PAM prediction model: {e}")
|
| 728 |
+
traceback.print_exc()
|
| 729 |
+
raise RuntimeError(f"Failed to load PAM prediction model: {e}")
|
| 730 |
+
|
| 731 |
+
def get_pam_probability_distributions(self, protein_seqs):
|
| 732 |
+
"""
|
| 733 |
+
Get detailed probability distributions for PAM predictions.
|
| 734 |
+
|
| 735 |
+
Args:
|
| 736 |
+
protein_seqs: List of protein sequence strings
|
| 737 |
+
|
| 738 |
+
Returns:
|
| 739 |
+
List of dictionaries, each containing:
|
| 740 |
+
- 'probabilities': (10, 4) tensor of probabilities for each position and nucleotide
|
| 741 |
+
- 'predicted_pam': Predicted PAM string
|
| 742 |
+
- 'per_position': List of dicts with position info (nucleotide probs, entropy, etc.)
|
| 743 |
+
"""
|
| 744 |
+
if not protein_seqs:
|
| 745 |
+
return []
|
| 746 |
+
|
| 747 |
+
# Handle single string input
|
| 748 |
+
if isinstance(protein_seqs, str):
|
| 749 |
+
protein_seqs = [protein_seqs]
|
| 750 |
+
|
| 751 |
+
nucleotides = ['A', 'C', 'G', 'T']
|
| 752 |
+
results = []
|
| 753 |
+
|
| 754 |
+
with torch.no_grad():
|
| 755 |
+
# Tokenize sequences
|
| 756 |
+
encodings = self.tokenizer.encode_batch(protein_seqs)
|
| 757 |
+
input_batch = dict(
|
| 758 |
+
input_ids=torch.tensor([encoding.ids for encoding in encodings], device=self.device),
|
| 759 |
+
attention_mask=torch.tensor([encoding.attention_mask for encoding in encodings], device=self.device),
|
| 760 |
+
)
|
| 761 |
+
|
| 762 |
+
# Get PAM predictions
|
| 763 |
+
output = self.model(**input_batch)
|
| 764 |
+
logits = output.logits # (batch_size, 10, 4)
|
| 765 |
+
probabilities = F.softmax(logits, dim=-1) # (batch_size, 10, 4)
|
| 766 |
+
log_probs = F.log_softmax(logits, dim=-1) # (batch_size, 10, 4)
|
| 767 |
+
|
| 768 |
+
# Process each sequence
|
| 769 |
+
for i in range(probabilities.shape[0]):
|
| 770 |
+
probs = probabilities[i] # (10, 4)
|
| 771 |
+
log_probs_seq = log_probs[i] # (10, 4)
|
| 772 |
+
pam_seq = []
|
| 773 |
+
per_position = []
|
| 774 |
+
|
| 775 |
+
for pos_idx in range(probs.shape[0]):
|
| 776 |
+
pos_probs = probs[pos_idx, :] # (4,)
|
| 777 |
+
pos_log_probs = log_probs_seq[pos_idx, :] # (4,)
|
| 778 |
+
|
| 779 |
+
# Compute entropy for this position
|
| 780 |
+
entropy = -(pos_probs * pos_log_probs).sum().item()
|
| 781 |
+
max_entropy = math.log(4) # Maximum entropy for uniform distribution
|
| 782 |
+
normalized_entropy = entropy / max_entropy
|
| 783 |
+
|
| 784 |
+
max_prob = pos_probs.max().item()
|
| 785 |
+
max_idx = pos_probs.argmax().item()
|
| 786 |
+
predicted_nuc = nucleotides[max_idx]
|
| 787 |
+
|
| 788 |
+
# Create position info
|
| 789 |
+
pos_info = {
|
| 790 |
+
'position': pos_idx,
|
| 791 |
+
'probabilities': {nuc: pos_probs[j].item() for j, nuc in enumerate(nucleotides)},
|
| 792 |
+
'predicted': predicted_nuc,
|
| 793 |
+
'max_probability': max_prob,
|
| 794 |
+
'entropy': entropy,
|
| 795 |
+
'normalized_entropy': normalized_entropy,
|
| 796 |
+
}
|
| 797 |
+
per_position.append(pos_info)
|
| 798 |
+
|
| 799 |
+
# Predict nucleotide (use instance min_confidence)
|
| 800 |
+
if max_prob < self.pam_min_confidence:
|
| 801 |
+
pam_seq.append('N')
|
| 802 |
+
else:
|
| 803 |
+
pam_seq.append(predicted_nuc)
|
| 804 |
+
|
| 805 |
+
results.append({
|
| 806 |
+
'probabilities': probs.cpu(),
|
| 807 |
+
'predicted_pam': ''.join(pam_seq),
|
| 808 |
+
'per_position': per_position,
|
| 809 |
+
})
|
| 810 |
+
|
| 811 |
+
return results
|
| 812 |
+
|
| 813 |
+
def predict_pam(self, protein_seqs, min_confidence=None):
|
| 814 |
+
"""
|
| 815 |
+
Predict PAM sequences for protein sequences, with support for 'N' when confidence is low.
|
| 816 |
+
|
| 817 |
+
Uses temperature scaling (pam_prediction_temperature) to control distribution sharpness.
|
| 818 |
+
Lower temperatures (< 1.0) produce sharper distributions and more confident predictions.
|
| 819 |
+
|
| 820 |
+
Args:
|
| 821 |
+
protein_seqs: List of protein sequence strings
|
| 822 |
+
min_confidence: Minimum probability threshold for predicting a specific nucleotide.
|
| 823 |
+
If max probability < min_confidence, predict 'N'.
|
| 824 |
+
If None, uses self.pam_min_confidence (default: None)
|
| 825 |
+
|
| 826 |
+
Returns:
|
| 827 |
+
predicted_pams: List of predicted PAM strings (10 nucleotides each)
|
| 828 |
+
"""
|
| 829 |
+
if min_confidence is None:
|
| 830 |
+
min_confidence = self.pam_min_confidence
|
| 831 |
+
if not protein_seqs:
|
| 832 |
+
return []
|
| 833 |
+
|
| 834 |
+
# Handle single string input
|
| 835 |
+
if isinstance(protein_seqs, str):
|
| 836 |
+
protein_seqs = [protein_seqs]
|
| 837 |
+
|
| 838 |
+
# Check if model and tokenizer are initialized
|
| 839 |
+
if not hasattr(self, 'model') or self.model is None:
|
| 840 |
+
print(f"[ERROR] PAMMatching.predict_pam: model is not initialized. Cannot predict PAM.")
|
| 841 |
+
return []
|
| 842 |
+
|
| 843 |
+
if not hasattr(self, 'tokenizer') or self.tokenizer is None:
|
| 844 |
+
print(f"[ERROR] PAMMatching.predict_pam: tokenizer is not initialized. Cannot predict PAM.")
|
| 845 |
+
return []
|
| 846 |
+
|
| 847 |
+
predicted_pams = []
|
| 848 |
+
nucleotides = ['A', 'C', 'G', 'T']
|
| 849 |
+
|
| 850 |
+
try:
|
| 851 |
+
with torch.no_grad():
|
| 852 |
+
# Tokenize sequences
|
| 853 |
+
encodings = self.tokenizer.encode_batch(protein_seqs)
|
| 854 |
+
input_batch = dict(
|
| 855 |
+
input_ids=torch.tensor([encoding.ids for encoding in encodings], device=self.device),
|
| 856 |
+
attention_mask=torch.tensor([encoding.attention_mask for encoding in encodings], device=self.device),
|
| 857 |
+
)
|
| 858 |
+
|
| 859 |
+
# Get PAM predictions
|
| 860 |
+
output = self.model(**input_batch)
|
| 861 |
+
logits = output.logits # (batch_size, 10, 4) - 10 positions, 4 nucleotides (ACGT)
|
| 862 |
+
# Apply temperature scaling for prediction (sharper distributions when < 1.0)
|
| 863 |
+
scaled_logits = logits / self.pam_prediction_temperature
|
| 864 |
+
probabilities = F.softmax(scaled_logits, dim=-1) # (batch_size, 10, 4)
|
| 865 |
+
|
| 866 |
+
# Predict PAM for each sequence
|
| 867 |
+
for i in range(probabilities.shape[0]):
|
| 868 |
+
probs = probabilities[i] # (10, 4)
|
| 869 |
+
pam_seq = []
|
| 870 |
+
|
| 871 |
+
for pos_idx in range(probs.shape[0]):
|
| 872 |
+
pos_probs = probs[pos_idx, :] # (4,)
|
| 873 |
+
max_prob = pos_probs.max().item()
|
| 874 |
+
max_idx = pos_probs.argmax().item()
|
| 875 |
+
|
| 876 |
+
# If confidence is low, predict 'N'
|
| 877 |
+
if max_prob < min_confidence:
|
| 878 |
+
pam_seq.append('N')
|
| 879 |
+
else:
|
| 880 |
+
pam_seq.append(nucleotides[max_idx])
|
| 881 |
+
|
| 882 |
+
predicted_pams.append(''.join(pam_seq))
|
| 883 |
+
except Exception as e:
|
| 884 |
+
import traceback
|
| 885 |
+
print(f"[ERROR] PAMMatching.predict_pam failed: {e}")
|
| 886 |
+
traceback.print_exc()
|
| 887 |
+
return []
|
| 888 |
+
|
| 889 |
+
return predicted_pams
|
| 890 |
+
|
| 891 |
+
def get_scores(self, protein_seqs):
|
| 892 |
+
"""
|
| 893 |
+
Get PAM matching scores using either cross-entropy loss or log probability approach.
|
| 894 |
+
|
| 895 |
+
If use_ce_loss=True:
|
| 896 |
+
Uses cross-entropy loss between predicted logits and target PAM indices.
|
| 897 |
+
Score = exp(-mean_ce_loss) to convert to [0, 1] range (higher is better).
|
| 898 |
+
|
| 899 |
+
If use_ce_loss=False (default):
|
| 900 |
+
Uses raw probabilities (no temperature scaling) to maintain high probability values.
|
| 901 |
+
For non-N positions: score is the geometric mean of probabilities (exp of mean log prob).
|
| 902 |
+
For N positions: if use_entropy_for_n_positions=True, score is based on entropy.
|
| 903 |
+
|
| 904 |
+
Args:
|
| 905 |
+
protein_seqs: List of protein sequence strings
|
| 906 |
+
|
| 907 |
+
Returns:
|
| 908 |
+
scores: List of scores in [0, 1] (higher = better match to target PAM)
|
| 909 |
+
"""
|
| 910 |
+
if not protein_seqs:
|
| 911 |
+
return []
|
| 912 |
+
|
| 913 |
+
# Handle single string input
|
| 914 |
+
if isinstance(protein_seqs, str):
|
| 915 |
+
protein_seqs = [protein_seqs]
|
| 916 |
+
|
| 917 |
+
scores = []
|
| 918 |
+
max_entropy = math.log(4) # Maximum entropy for uniform distribution over 4 nucleotides
|
| 919 |
+
|
| 920 |
+
with torch.no_grad():
|
| 921 |
+
# Tokenize sequences
|
| 922 |
+
encodings = self.tokenizer.encode_batch(protein_seqs)
|
| 923 |
+
input_batch = dict(
|
| 924 |
+
input_ids=torch.tensor([encoding.ids for encoding in encodings], device=self.device),
|
| 925 |
+
attention_mask=torch.tensor([encoding.attention_mask for encoding in encodings], device=self.device),
|
| 926 |
+
)
|
| 927 |
+
|
| 928 |
+
# Get PAM predictions (logits)
|
| 929 |
+
output = self.model(**input_batch)
|
| 930 |
+
logits = output.logits # (batch_size, 10, 4) - 10 positions, 4 nucleotides (ACGT)
|
| 931 |
+
|
| 932 |
+
for i in range(logits.shape[0]):
|
| 933 |
+
seq_logits = logits[i] # (10, 4)
|
| 934 |
+
|
| 935 |
+
if self.use_ce_loss:
|
| 936 |
+
# Cross-entropy loss approach
|
| 937 |
+
# Separate non-N and N positions
|
| 938 |
+
non_n_mask = self.target_mask # True for non-N positions
|
| 939 |
+
n_mask = ~non_n_mask # True for N positions
|
| 940 |
+
|
| 941 |
+
score_components = []
|
| 942 |
+
|
| 943 |
+
# For non-N positions: compute cross-entropy loss against target nucleotides
|
| 944 |
+
if non_n_mask.any():
|
| 945 |
+
masked_logits = seq_logits[non_n_mask] # (K, 4)
|
| 946 |
+
masked_targets = self.target_indices[non_n_mask] # (K,) in {0,1,2,3}
|
| 947 |
+
|
| 948 |
+
# Compute cross-entropy loss per position (reduction='none')
|
| 949 |
+
ce_loss_per_pos = F.cross_entropy(
|
| 950 |
+
masked_logits, masked_targets, reduction='none'
|
| 951 |
+
) # (K,)
|
| 952 |
+
|
| 953 |
+
# Mean cross-entropy loss over non-N positions
|
| 954 |
+
mean_ce_loss = ce_loss_per_pos.mean().item()
|
| 955 |
+
|
| 956 |
+
# Convert to score in [0, 1] range using exp(-ce_loss)
|
| 957 |
+
# Lower CE loss = higher score (better match)
|
| 958 |
+
non_n_score = math.exp(-mean_ce_loss)
|
| 959 |
+
score_components.append(non_n_score)
|
| 960 |
+
|
| 961 |
+
# For N positions: handle based on entropy flag
|
| 962 |
+
if n_mask.any():
|
| 963 |
+
if self.use_entropy_for_n_positions:
|
| 964 |
+
# Use entropy scoring (encourage uniform distribution)
|
| 965 |
+
log_probs = F.log_softmax(seq_logits, dim=-1) # (10, 4)
|
| 966 |
+
probs = torch.exp(log_probs) # (10, 4)
|
| 967 |
+
n_probs = probs[n_mask] # (M, 4) where M is number of N positions
|
| 968 |
+
n_log_probs = log_probs[n_mask] # (M, 4)
|
| 969 |
+
entropy_per_pos = -(n_probs * n_log_probs).sum(dim=-1) # (M,)
|
| 970 |
+
mean_entropy = entropy_per_pos.mean().item()
|
| 971 |
+
n_score = mean_entropy / max_entropy
|
| 972 |
+
score_components.append(n_score)
|
| 973 |
+
else:
|
| 974 |
+
# Use CE loss against uniform distribution (consistent with CE loss approach)
|
| 975 |
+
# This encourages uniform distribution over all 4 nucleotides
|
| 976 |
+
n_logits = seq_logits[n_mask] # (M, 4) where M is number of N positions
|
| 977 |
+
|
| 978 |
+
# Compute cross-entropy loss against uniform distribution [0.25, 0.25, 0.25, 0.25]
|
| 979 |
+
# CE_uniform = -sum(0.25 * log(p_i)) = -0.25 * sum(log(p_i))
|
| 980 |
+
# Lower CE loss (closer to uniform) = higher score
|
| 981 |
+
log_probs = F.log_softmax(n_logits, dim=-1) # (M, 4)
|
| 982 |
+
uniform_target = 0.25 # Uniform probability for each nucleotide
|
| 983 |
+
ce_loss_uniform_per_pos = -(uniform_target * log_probs).sum(dim=-1) # (M,)
|
| 984 |
+
|
| 985 |
+
# Mean cross-entropy loss over N positions
|
| 986 |
+
mean_ce_loss_uniform = ce_loss_uniform_per_pos.mean().item()
|
| 987 |
+
|
| 988 |
+
# Convert to score in [0, 1] range using exp(-ce_loss)
|
| 989 |
+
# Lower CE loss (more uniform) = higher score
|
| 990 |
+
n_score = math.exp(-mean_ce_loss_uniform)
|
| 991 |
+
score_components.append(n_score)
|
| 992 |
+
|
| 993 |
+
# Combine scores: if both non-N and N positions exist, take weighted average
|
| 994 |
+
# Weight by number of positions of each type
|
| 995 |
+
if len(score_components) == 2:
|
| 996 |
+
num_non_n = non_n_mask.sum().item()
|
| 997 |
+
num_n = n_mask.sum().item()
|
| 998 |
+
total_positions = num_non_n + num_n
|
| 999 |
+
score = (score_components[0] * num_non_n + score_components[1] * num_n) / total_positions
|
| 1000 |
+
elif len(score_components) == 1:
|
| 1001 |
+
score = score_components[0]
|
| 1002 |
+
else:
|
| 1003 |
+
score = 0.0
|
| 1004 |
+
|
| 1005 |
+
scores.append(score)
|
| 1006 |
+
else:
|
| 1007 |
+
# Original log probability approach (default)
|
| 1008 |
+
# Use raw probabilities (no temperature scaling) to maintain high probabilities
|
| 1009 |
+
# This treats all probability values equally, appropriate for maintaining
|
| 1010 |
+
# high probability rather than optimizing low probabilities upward
|
| 1011 |
+
log_probs = F.log_softmax(seq_logits, dim=-1) # (10, 4)
|
| 1012 |
+
probs = torch.exp(log_probs) # (10, 4) - needed for entropy calculation
|
| 1013 |
+
|
| 1014 |
+
# Separate non-N and N positions
|
| 1015 |
+
non_n_mask = self.target_mask # True for non-N positions
|
| 1016 |
+
n_mask = ~non_n_mask # True for N positions
|
| 1017 |
+
|
| 1018 |
+
score_components = []
|
| 1019 |
+
|
| 1020 |
+
# For non-N positions: maximize log probability of target nucleotide
|
| 1021 |
+
if non_n_mask.any():
|
| 1022 |
+
masked_log_probs = log_probs[non_n_mask] # (K, 4)
|
| 1023 |
+
masked_targets = self.target_indices[non_n_mask] # (K,) in {0,1,2,3}
|
| 1024 |
+
|
| 1025 |
+
# Get log probability of target nucleotide at each position
|
| 1026 |
+
target_log_probs = masked_log_probs.gather(
|
| 1027 |
+
1, masked_targets.unsqueeze(1)
|
| 1028 |
+
).squeeze(1) # (K,)
|
| 1029 |
+
|
| 1030 |
+
# Mean log probability (geometric mean in probability space)
|
| 1031 |
+
mean_log_prob = target_log_probs.mean().item()
|
| 1032 |
+
|
| 1033 |
+
# Convert to score in [0, 1] range using geometric mean probability
|
| 1034 |
+
# This gives equal weight to maintaining high probabilities
|
| 1035 |
+
non_n_score = math.exp(mean_log_prob)
|
| 1036 |
+
score_components.append(non_n_score)
|
| 1037 |
+
|
| 1038 |
+
# For N positions: maximize entropy (encourage uniform distribution) if enabled
|
| 1039 |
+
if n_mask.any() and self.use_entropy_for_n_positions:
|
| 1040 |
+
n_probs = probs[n_mask] # (M, 4) where M is number of N positions
|
| 1041 |
+
|
| 1042 |
+
# Compute entropy for each N position: H = -sum(p_i * log(p_i))
|
| 1043 |
+
# Use log_probs for numerical stability: H = -sum(p_i * log_p_i)
|
| 1044 |
+
n_log_probs = log_probs[n_mask] # (M, 4)
|
| 1045 |
+
entropy_per_pos = -(n_probs * n_log_probs).sum(dim=-1) # (M,)
|
| 1046 |
+
|
| 1047 |
+
# Mean entropy across N positions
|
| 1048 |
+
mean_entropy = entropy_per_pos.mean().item()
|
| 1049 |
+
|
| 1050 |
+
# Normalize by max entropy to get score in [0, 1]
|
| 1051 |
+
# Higher entropy (more uniform) = higher score
|
| 1052 |
+
n_score = mean_entropy / max_entropy
|
| 1053 |
+
score_components.append(n_score)
|
| 1054 |
+
|
| 1055 |
+
# Combine scores: if both non-N and N positions exist, take weighted average
|
| 1056 |
+
# Weight by number of positions of each type
|
| 1057 |
+
# If entropy is disabled, only use non-N score
|
| 1058 |
+
if len(score_components) == 2:
|
| 1059 |
+
# Weighted average based on number of positions
|
| 1060 |
+
num_non_n = non_n_mask.sum().item()
|
| 1061 |
+
num_n = n_mask.sum().item()
|
| 1062 |
+
total_positions = num_non_n + num_n
|
| 1063 |
+
score = (score_components[0] * num_non_n + score_components[1] * num_n) / total_positions
|
| 1064 |
+
elif len(score_components) == 1:
|
| 1065 |
+
score = score_components[0]
|
| 1066 |
+
else:
|
| 1067 |
+
# No positions to evaluate (shouldn't happen, but handle gracefully)
|
| 1068 |
+
score = 0.0
|
| 1069 |
+
|
| 1070 |
+
scores.append(score)
|
| 1071 |
+
|
| 1072 |
+
return scores
|
| 1073 |
+
|
| 1074 |
+
def get_score_for_pam(self, protein_seqs, pam_sequence, use_temperature_scaling=False):
|
| 1075 |
+
"""
|
| 1076 |
+
Get probability score for a specific PAM sequence (not necessarily the target).
|
| 1077 |
+
|
| 1078 |
+
This computes the log probability score for an arbitrary PAM sequence.
|
| 1079 |
+
By default, uses raw probabilities (no temperature scaling) for more
|
| 1080 |
+
interpretable results. Set use_temperature_scaling=True to match the
|
| 1081 |
+
optimization objective.
|
| 1082 |
+
|
| 1083 |
+
Args:
|
| 1084 |
+
protein_seqs: List of protein sequence strings
|
| 1085 |
+
pam_sequence: PAM sequence string of length 10 (e.g., "NGGNNNNNNN")
|
| 1086 |
+
use_temperature_scaling: If True, apply temperature scaling (default: False for display)
|
| 1087 |
+
|
| 1088 |
+
Returns:
|
| 1089 |
+
scores: List of scores in [0, 1] (probability of the given PAM sequence)
|
| 1090 |
+
"""
|
| 1091 |
+
if not protein_seqs:
|
| 1092 |
+
return []
|
| 1093 |
+
|
| 1094 |
+
# Handle single string input
|
| 1095 |
+
if isinstance(protein_seqs, str):
|
| 1096 |
+
protein_seqs = [protein_seqs]
|
| 1097 |
+
|
| 1098 |
+
# Validate and convert PAM sequence to indices
|
| 1099 |
+
pam_sequence = pam_sequence.upper()
|
| 1100 |
+
if len(pam_sequence) != 10:
|
| 1101 |
+
raise ValueError(f"PAM sequence must be exactly 10 nucleotides, got {len(pam_sequence)}")
|
| 1102 |
+
|
| 1103 |
+
nucleotides = ['A', 'C', 'G', 'T']
|
| 1104 |
+
pam_indices = []
|
| 1105 |
+
pam_mask = []
|
| 1106 |
+
for nuc in pam_sequence:
|
| 1107 |
+
if nuc == 'N':
|
| 1108 |
+
pam_indices.append(-1)
|
| 1109 |
+
pam_mask.append(False)
|
| 1110 |
+
else:
|
| 1111 |
+
if nuc not in nucleotides:
|
| 1112 |
+
raise ValueError(f"Invalid nucleotide in PAM sequence: {nuc}")
|
| 1113 |
+
pam_indices.append(nucleotides.index(nuc))
|
| 1114 |
+
pam_mask.append(True)
|
| 1115 |
+
|
| 1116 |
+
pam_indices = torch.tensor(pam_indices, device=self.device, dtype=torch.long)
|
| 1117 |
+
pam_mask = torch.tensor(pam_mask, device=self.device, dtype=torch.bool)
|
| 1118 |
+
|
| 1119 |
+
scores = []
|
| 1120 |
+
|
| 1121 |
+
with torch.no_grad():
|
| 1122 |
+
# Tokenize sequences
|
| 1123 |
+
encodings = self.tokenizer.encode_batch(protein_seqs)
|
| 1124 |
+
input_batch = dict(
|
| 1125 |
+
input_ids=torch.tensor([encoding.ids for encoding in encodings], device=self.device),
|
| 1126 |
+
attention_mask=torch.tensor([encoding.attention_mask for encoding in encodings], device=self.device),
|
| 1127 |
+
)
|
| 1128 |
+
|
| 1129 |
+
# Get PAM predictions (logits)
|
| 1130 |
+
output = self.model(**input_batch)
|
| 1131 |
+
logits = output.logits # (batch_size, 10, 4) - 10 positions, 4 nucleotides (ACGT)
|
| 1132 |
+
|
| 1133 |
+
for i in range(logits.shape[0]):
|
| 1134 |
+
seq_logits = logits[i] # (10, 4)
|
| 1135 |
+
|
| 1136 |
+
# Apply temperature scaling only if requested (for display, use raw probabilities)
|
| 1137 |
+
if use_temperature_scaling:
|
| 1138 |
+
scaled_logits = seq_logits / self.sigmoid_temperature
|
| 1139 |
+
log_probs = F.log_softmax(scaled_logits, dim=-1) # (10, 4)
|
| 1140 |
+
else:
|
| 1141 |
+
# Use raw probabilities (no temperature scaling) for more interpretable results
|
| 1142 |
+
log_probs = F.log_softmax(seq_logits, dim=-1) # (10, 4)
|
| 1143 |
+
|
| 1144 |
+
if pam_mask.any():
|
| 1145 |
+
masked_log_probs = log_probs[pam_mask] # (K, 4)
|
| 1146 |
+
masked_pam_indices = pam_indices[pam_mask] # (K,) in {0,1,2,3}
|
| 1147 |
+
|
| 1148 |
+
# Get log probability of PAM nucleotide at each position
|
| 1149 |
+
pam_log_probs = masked_log_probs.gather(
|
| 1150 |
+
1, masked_pam_indices.unsqueeze(1)
|
| 1151 |
+
).squeeze(1) # (K,)
|
| 1152 |
+
|
| 1153 |
+
# Mean log probability (linear in log space)
|
| 1154 |
+
mean_log_prob = pam_log_probs.mean().item()
|
| 1155 |
+
|
| 1156 |
+
# Convert to score in [0, 1] range using geometric mean probability
|
| 1157 |
+
score = math.exp(mean_log_prob)
|
| 1158 |
+
else:
|
| 1159 |
+
# All positions are N: use neutral score
|
| 1160 |
+
score = 0.0
|
| 1161 |
+
|
| 1162 |
+
scores.append(score)
|
| 1163 |
+
|
| 1164 |
+
return scores
|
| 1165 |
+
|
| 1166 |
+
# ========================================================================
|
| 1167 |
+
# OLD IMPLEMENTATION (logit-margin-based with sigmoid) - COMMENTED OUT
|
| 1168 |
+
# ========================================================================
|
| 1169 |
+
# This was the previous implementation that struggled to show small improvements
|
| 1170 |
+
# because sigmoid saturates quickly. Kept for reference.
|
| 1171 |
+
#
|
| 1172 |
+
# def get_scores_old(self, protein_seqs):
|
| 1173 |
+
# """
|
| 1174 |
+
# Get PAM matching scores (logit-margin-based, in [0, 1]) for protein sequences.
|
| 1175 |
+
#
|
| 1176 |
+
# At each target (non-N) position: margin = (target PAM logit) - (largest non-target logit),
|
| 1177 |
+
# then score_pos = sigmoid(margin / sigmoid_temperature). The sequence score is the mean
|
| 1178 |
+
# over those positions. Higher is better.
|
| 1179 |
+
#
|
| 1180 |
+
# Args:
|
| 1181 |
+
# protein_seqs: List of protein sequence strings
|
| 1182 |
+
#
|
| 1183 |
+
# Returns:
|
| 1184 |
+
# scores: List of scores in [0, 1] (higher = target PAM preferred over alternatives)
|
| 1185 |
+
# """
|
| 1186 |
+
# if not protein_seqs:
|
| 1187 |
+
# return []
|
| 1188 |
+
#
|
| 1189 |
+
# # Handle single string input
|
| 1190 |
+
# if isinstance(protein_seqs, str):
|
| 1191 |
+
# protein_seqs = [protein_seqs]
|
| 1192 |
+
#
|
| 1193 |
+
# scores = []
|
| 1194 |
+
#
|
| 1195 |
+
# with torch.no_grad():
|
| 1196 |
+
# # Tokenize sequences
|
| 1197 |
+
# encodings = self.tokenizer.encode_batch(protein_seqs)
|
| 1198 |
+
# input_batch = dict(
|
| 1199 |
+
# input_ids=torch.tensor([encoding.ids for encoding in encodings], device=self.device),
|
| 1200 |
+
# attention_mask=torch.tensor([encoding.attention_mask for encoding in encodings], device=self.device),
|
| 1201 |
+
# )
|
| 1202 |
+
#
|
| 1203 |
+
# # Get PAM predictions (logits)
|
| 1204 |
+
# output = self.model(**input_batch)
|
| 1205 |
+
# logits = output.logits # (batch_size, 10, 4) - 10 positions, 4 nucleotides (ACGT)
|
| 1206 |
+
#
|
| 1207 |
+
# for i in range(logits.shape[0]):
|
| 1208 |
+
# seq_logits = logits[i] # (10, 4)
|
| 1209 |
+
# masked_positions = self.target_mask
|
| 1210 |
+
# if masked_positions.any():
|
| 1211 |
+
# masked_logits = seq_logits[masked_positions] # (K, 4)
|
| 1212 |
+
# masked_targets = self.target_indices[masked_positions] # (K,) in {0,1,2,3}
|
| 1213 |
+
# # Target logit at each position
|
| 1214 |
+
# target_logits = masked_logits.gather(1, masked_targets.unsqueeze(1)).squeeze(1) # (K,)
|
| 1215 |
+
# # Largest non-target logit: mask out target class then max over dim=-1
|
| 1216 |
+
# logits_copy = masked_logits.clone()
|
| 1217 |
+
# logits_copy.scatter_(1, masked_targets.unsqueeze(1), -1e9)
|
| 1218 |
+
# max_non_target = logits_copy.max(dim=1).values # (K,)
|
| 1219 |
+
# margin = target_logits - max_non_target # (K,)
|
| 1220 |
+
# margin = margin / self.sigmoid_temperature
|
| 1221 |
+
# score_per_pos = torch.sigmoid(margin)
|
| 1222 |
+
# score = score_per_pos.mean().item()
|
| 1223 |
+
# else:
|
| 1224 |
+
# # All positions are N: no target to match, use neutral score
|
| 1225 |
+
# score = 0.5
|
| 1226 |
+
# scores.append(score)
|
| 1227 |
+
#
|
| 1228 |
+
# return scores
|
| 1229 |
+
|
| 1230 |
+
def __call__(self, protein_tokens, protein_seqs):
|
| 1231 |
+
"""
|
| 1232 |
+
Objective call interface.
|
| 1233 |
+
|
| 1234 |
+
Args:
|
| 1235 |
+
protein_tokens: Unused (kept for interface compatibility)
|
| 1236 |
+
protein_seqs: List of protein sequence strings
|
| 1237 |
+
|
| 1238 |
+
Returns:
|
| 1239 |
+
Tuple of ('pam_matching', scores) where scores is a list of values in [0, 1]:
|
| 1240 |
+
- For non-N positions: temperature-scaled log probability (geometric mean over positions)
|
| 1241 |
+
- For N positions: if use_entropy_for_n_positions=True, normalized entropy (encouraging uniform distribution)
|
| 1242 |
+
- If use_entropy_for_n_positions=False, N positions are ignored (score only considers non-N positions)
|
| 1243 |
+
- Combined score is weighted average if both types exist and entropy is enabled
|
| 1244 |
+
Higher scores indicate better match to target PAM.
|
| 1245 |
+
"""
|
| 1246 |
+
scores = self.get_scores(protein_seqs)
|
| 1247 |
+
return 'pam_matching', scores
|
| 1248 |
+
|
|
@@ -0,0 +1,456 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# cas9_pam_hmm.py
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import os
|
| 5 |
+
import re
|
| 6 |
+
import hashlib
|
| 7 |
+
import subprocess
|
| 8 |
+
import tempfile
|
| 9 |
+
from dataclasses import dataclass
|
| 10 |
+
from typing import Dict, Iterable, List, Optional, Tuple, Union
|
| 11 |
+
|
| 12 |
+
import torch
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
# ----------------------------
|
| 16 |
+
# Data structures
|
| 17 |
+
# ----------------------------
|
| 18 |
+
@dataclass(frozen=True)
|
| 19 |
+
class HMMHit:
|
| 20 |
+
"""
|
| 21 |
+
Represents one domain hit line from HMMER domtblout.
|
| 22 |
+
Coordinates are 1-based (HMMER convention).
|
| 23 |
+
"""
|
| 24 |
+
model: str
|
| 25 |
+
accession: str
|
| 26 |
+
query_id: str
|
| 27 |
+
query_len: int
|
| 28 |
+
|
| 29 |
+
# Per-domain statistics
|
| 30 |
+
i_evalue: float
|
| 31 |
+
bitscore: float
|
| 32 |
+
|
| 33 |
+
# Alignment coordinates
|
| 34 |
+
hmm_from: int
|
| 35 |
+
hmm_to: int
|
| 36 |
+
ali_from: int
|
| 37 |
+
ali_to: int
|
| 38 |
+
env_from: int
|
| 39 |
+
env_to: int
|
| 40 |
+
|
| 41 |
+
# Optional
|
| 42 |
+
hmm_len: Optional[int] = None
|
| 43 |
+
bias: Optional[float] = None
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
# ----------------------------
|
| 47 |
+
# domtblout parsing
|
| 48 |
+
# ----------------------------
|
| 49 |
+
class DomTbloutParser:
|
| 50 |
+
"""
|
| 51 |
+
Robust parser for HMMER3 --domtblout files.
|
| 52 |
+
Assumes standard HMMER3 domtblout layout (space-delimited; description is trailing).
|
| 53 |
+
"""
|
| 54 |
+
|
| 55 |
+
@staticmethod
|
| 56 |
+
def parse(domtbl_path: str) -> List[HMMHit]:
|
| 57 |
+
hits: List[HMMHit] = []
|
| 58 |
+
with open(domtbl_path, "r") as f:
|
| 59 |
+
for line in f:
|
| 60 |
+
if not line.strip() or line.startswith("#"):
|
| 61 |
+
continue
|
| 62 |
+
cols = line.strip().split()
|
| 63 |
+
|
| 64 |
+
# Standard domtblout columns (HMMER3):
|
| 65 |
+
# 1 target name cols[0]
|
| 66 |
+
# 2 target accession cols[1]
|
| 67 |
+
# 3 tlen cols[2]
|
| 68 |
+
# 4 query name cols[3]
|
| 69 |
+
# 5 query accession cols[4]
|
| 70 |
+
# 6 qlen cols[5]
|
| 71 |
+
# 7 E-value (full) cols[6]
|
| 72 |
+
# 8 score (full) cols[7]
|
| 73 |
+
# 9 bias (full) cols[8]
|
| 74 |
+
# 10 # cols[9]
|
| 75 |
+
# 11 of cols[10]
|
| 76 |
+
# 12 c-Evalue cols[11]
|
| 77 |
+
# 13 i-Evalue cols[12]
|
| 78 |
+
# 14 score (domain) cols[13]
|
| 79 |
+
# 15 bias (domain) cols[14]
|
| 80 |
+
# 16 hmm_from cols[15]
|
| 81 |
+
# 17 hmm_to cols[16]
|
| 82 |
+
# 18 ali_from cols[17]
|
| 83 |
+
# 19 ali_to cols[18]
|
| 84 |
+
# 20 env_from cols[19]
|
| 85 |
+
# 21 env_to cols[20]
|
| 86 |
+
# 22 acc cols[21]
|
| 87 |
+
# 23+ description cols[22:]
|
| 88 |
+
|
| 89 |
+
try:
|
| 90 |
+
model = cols[0]
|
| 91 |
+
accession = cols[1]
|
| 92 |
+
hmm_len = int(cols[2])
|
| 93 |
+
query_id = cols[3]
|
| 94 |
+
query_len = int(cols[5])
|
| 95 |
+
|
| 96 |
+
i_evalue = float(cols[12])
|
| 97 |
+
bitscore = float(cols[13])
|
| 98 |
+
bias = float(cols[14])
|
| 99 |
+
|
| 100 |
+
hmm_from = int(cols[15])
|
| 101 |
+
hmm_to = int(cols[16])
|
| 102 |
+
ali_from = int(cols[17])
|
| 103 |
+
ali_to = int(cols[18])
|
| 104 |
+
env_from = int(cols[19])
|
| 105 |
+
env_to = int(cols[20])
|
| 106 |
+
except (IndexError, ValueError) as e:
|
| 107 |
+
# If your HMMER output is slightly different, raise with context.
|
| 108 |
+
raise RuntimeError(f"Failed parsing domtblout line:\n{line}\nError: {e}") from e
|
| 109 |
+
|
| 110 |
+
hits.append(
|
| 111 |
+
HMMHit(
|
| 112 |
+
model=model,
|
| 113 |
+
accession=accession,
|
| 114 |
+
query_id=query_id,
|
| 115 |
+
query_len=query_len,
|
| 116 |
+
i_evalue=i_evalue,
|
| 117 |
+
bitscore=bitscore,
|
| 118 |
+
hmm_from=hmm_from,
|
| 119 |
+
hmm_to=hmm_to,
|
| 120 |
+
ali_from=ali_from,
|
| 121 |
+
ali_to=ali_to,
|
| 122 |
+
env_from=env_from,
|
| 123 |
+
env_to=env_to,
|
| 124 |
+
hmm_len=hmm_len,
|
| 125 |
+
bias=bias,
|
| 126 |
+
)
|
| 127 |
+
)
|
| 128 |
+
return hits
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
# ----------------------------
|
| 132 |
+
# hmmscan runner
|
| 133 |
+
# ----------------------------
|
| 134 |
+
class HMMSCAN:
|
| 135 |
+
"""
|
| 136 |
+
Thin wrapper around hmmscan for 1 or many sequences.
|
| 137 |
+
Uses --domtblout for machine-parseable results.
|
| 138 |
+
"""
|
| 139 |
+
|
| 140 |
+
def __init__(
|
| 141 |
+
self,
|
| 142 |
+
hmm_db_path: str,
|
| 143 |
+
hmmscan_bin: str = "hmmscan",
|
| 144 |
+
cpu: int = 1,
|
| 145 |
+
extra_args: Optional[List[str]] = None,
|
| 146 |
+
env: Optional[Dict[str, str]] = None,
|
| 147 |
+
):
|
| 148 |
+
self.hmm_db_path = hmm_db_path
|
| 149 |
+
self.hmmscan_bin = hmmscan_bin
|
| 150 |
+
self.cpu = int(cpu)
|
| 151 |
+
self.extra_args = extra_args or []
|
| 152 |
+
self.env = env
|
| 153 |
+
|
| 154 |
+
if not os.path.exists(self.hmm_db_path):
|
| 155 |
+
raise FileNotFoundError(f"HMM DB not found: {self.hmm_db_path}")
|
| 156 |
+
|
| 157 |
+
def scan_fasta(self, fasta_path: str, domtblout_path: str) -> None:
|
| 158 |
+
cmd = [
|
| 159 |
+
self.hmmscan_bin,
|
| 160 |
+
"--cpu",
|
| 161 |
+
str(self.cpu),
|
| 162 |
+
"--domtblout",
|
| 163 |
+
domtblout_path,
|
| 164 |
+
self.hmm_db_path,
|
| 165 |
+
fasta_path,
|
| 166 |
+
] + self.extra_args
|
| 167 |
+
|
| 168 |
+
try:
|
| 169 |
+
result = subprocess.run(
|
| 170 |
+
cmd,
|
| 171 |
+
check=True,
|
| 172 |
+
stdout=subprocess.PIPE,
|
| 173 |
+
stderr=subprocess.PIPE,
|
| 174 |
+
env=self.env,
|
| 175 |
+
text=True,
|
| 176 |
+
)
|
| 177 |
+
except subprocess.CalledProcessError as e:
|
| 178 |
+
error_msg = e.stderr.decode('utf-8') if isinstance(e.stderr, bytes) else e.stderr
|
| 179 |
+
stdout_msg = e.stdout.decode('utf-8') if isinstance(e.stdout, bytes) else e.stdout
|
| 180 |
+
raise RuntimeError(
|
| 181 |
+
f"hmmscan failed with exit code {e.returncode}.\n"
|
| 182 |
+
f"Command: {' '.join(cmd)}\n"
|
| 183 |
+
f"STDOUT:\n{stdout_msg}\n"
|
| 184 |
+
f"STDERR:\n{error_msg}\n"
|
| 185 |
+
f"HMM DB path: {self.hmm_db_path}\n"
|
| 186 |
+
f"FASTA path: {fasta_path}\n"
|
| 187 |
+
f"FASTA exists: {os.path.exists(fasta_path)}"
|
| 188 |
+
) from e
|
| 189 |
+
|
| 190 |
+
def scan_sequences(
|
| 191 |
+
self,
|
| 192 |
+
seqs: Union[str, List[str]],
|
| 193 |
+
ids: Optional[Union[str, List[str]]] = None,
|
| 194 |
+
) -> Tuple[str, str]:
|
| 195 |
+
"""
|
| 196 |
+
Writes temporary FASTA and runs hmmscan.
|
| 197 |
+
Returns: (domtblout_path, fasta_path) inside a tempdir.
|
| 198 |
+
Caller is responsible for tempdir cleanup if they want persistence.
|
| 199 |
+
"""
|
| 200 |
+
if isinstance(seqs, str):
|
| 201 |
+
seq_list = [seqs]
|
| 202 |
+
else:
|
| 203 |
+
seq_list = list(seqs)
|
| 204 |
+
|
| 205 |
+
if ids is None:
|
| 206 |
+
id_list = [f"q{i}" for i in range(len(seq_list))]
|
| 207 |
+
elif isinstance(ids, str):
|
| 208 |
+
id_list = [ids]
|
| 209 |
+
else:
|
| 210 |
+
id_list = list(ids)
|
| 211 |
+
|
| 212 |
+
if len(id_list) != len(seq_list):
|
| 213 |
+
raise ValueError("ids must have same length as seqs")
|
| 214 |
+
|
| 215 |
+
td = tempfile.TemporaryDirectory()
|
| 216 |
+
fasta_path = os.path.join(td.name, "queries.faa")
|
| 217 |
+
domtbl_path = os.path.join(td.name, "out.domtbl")
|
| 218 |
+
|
| 219 |
+
with open(fasta_path, "w") as f:
|
| 220 |
+
for qid, seq in zip(id_list, seq_list):
|
| 221 |
+
seq = seq.replace(" ", "").strip()
|
| 222 |
+
f.write(f">{qid}\n{seq}\n")
|
| 223 |
+
|
| 224 |
+
self.scan_fasta(fasta_path, domtbl_path)
|
| 225 |
+
|
| 226 |
+
# Return temp paths; also return tempdir object by attaching it for lifecycle
|
| 227 |
+
# (so it doesn't get GC'ed early).
|
| 228 |
+
setattr(self, "_last_tempdir", td)
|
| 229 |
+
return domtbl_path, fasta_path
|
| 230 |
+
|
| 231 |
+
|
| 232 |
+
# ----------------------------
|
| 233 |
+
# PI-domain masking policy ("Option 1")
|
| 234 |
+
# ----------------------------
|
| 235 |
+
class Cas9PIMasker:
|
| 236 |
+
"""
|
| 237 |
+
Builds a compact 'no deletion' mask for the PAM-interacting (PI) region.
|
| 238 |
+
|
| 239 |
+
Option 1 implemented:
|
| 240 |
+
- choose highest-confidence PI-family hit
|
| 241 |
+
- then mask a capped window around the alignment core (ali_from..ali_to)
|
| 242 |
+
using max_mask_len
|
| 243 |
+
|
| 244 |
+
Notes:
|
| 245 |
+
- HMMER coords are 1-based AA positions.
|
| 246 |
+
- Assumes your token tensor x is [BOS] + AAs + [EOS] (+ pad),
|
| 247 |
+
so AA position p corresponds to token index p.
|
| 248 |
+
"""
|
| 249 |
+
|
| 250 |
+
DEFAULT_MODEL_PREFERENCE = ["Cas9_PI", "Cas9_PI_C", "Cas9_PI2", "CjCas9_PI_CTD"]
|
| 251 |
+
|
| 252 |
+
def __init__(
|
| 253 |
+
self,
|
| 254 |
+
hmmscan: HMMSCAN,
|
| 255 |
+
*,
|
| 256 |
+
model_preference: Optional[List[str]] = None,
|
| 257 |
+
use_env_coords: bool = False, # False = ali coords (smaller)
|
| 258 |
+
max_mask_len: int = 200, # cap masked window length
|
| 259 |
+
evalue_cutoff: float = 1e-5, # per-domain i-evalue cutoff
|
| 260 |
+
min_ali_len: int = 30, # ignore tiny alignments
|
| 261 |
+
fallback_last_n: Optional[int] = 250, # if no hit, optionally mask last N AAs
|
| 262 |
+
cache_size: int = 2048,
|
| 263 |
+
):
|
| 264 |
+
self.hmmscan = hmmscan
|
| 265 |
+
self.model_preference = model_preference or self.DEFAULT_MODEL_PREFERENCE
|
| 266 |
+
self.model_rank = {m: i for i, m in enumerate(self.model_preference)}
|
| 267 |
+
self.use_env_coords = use_env_coords
|
| 268 |
+
self.max_mask_len = int(max_mask_len)
|
| 269 |
+
self.evalue_cutoff = float(evalue_cutoff)
|
| 270 |
+
self.min_ali_len = int(min_ali_len)
|
| 271 |
+
self.fallback_last_n = fallback_last_n
|
| 272 |
+
self.cache_size = int(cache_size)
|
| 273 |
+
|
| 274 |
+
# Simple LRU-ish cache keyed by sha1(sequence)
|
| 275 |
+
self._cache: Dict[str, Optional[Tuple[int, int]]] = {}
|
| 276 |
+
self._cache_order: List[str] = []
|
| 277 |
+
|
| 278 |
+
# ---- selection logic ----
|
| 279 |
+
def _hit_interval(self, hit: HMMHit) -> Tuple[int, int]:
|
| 280 |
+
if self.use_env_coords:
|
| 281 |
+
s, t = hit.env_from, hit.env_to
|
| 282 |
+
else:
|
| 283 |
+
s, t = hit.ali_from, hit.ali_to
|
| 284 |
+
if s > t:
|
| 285 |
+
s, t = t, s
|
| 286 |
+
return s, t
|
| 287 |
+
|
| 288 |
+
def _hit_ali_len(self, hit: HMMHit) -> int:
|
| 289 |
+
s, t = self._hit_interval(hit)
|
| 290 |
+
return t - s + 1
|
| 291 |
+
|
| 292 |
+
def _is_candidate(self, hit: HMMHit) -> bool:
|
| 293 |
+
if hit.model not in self.model_rank:
|
| 294 |
+
return False
|
| 295 |
+
if hit.i_evalue > self.evalue_cutoff:
|
| 296 |
+
return False
|
| 297 |
+
if self._hit_ali_len(hit) < self.min_ali_len:
|
| 298 |
+
return False
|
| 299 |
+
return True
|
| 300 |
+
|
| 301 |
+
def _choose_best_hit(self, hits: List[HMMHit]) -> Optional[HMMHit]:
|
| 302 |
+
"""
|
| 303 |
+
Pick the highest-confidence PI-family hit.
|
| 304 |
+
|
| 305 |
+
Ranking:
|
| 306 |
+
1) model preference (Cas9_PI preferred over PI_C over PI2 over CTD)
|
| 307 |
+
2) lowest i-evalue
|
| 308 |
+
3) highest bitscore
|
| 309 |
+
"""
|
| 310 |
+
candidates = [h for h in hits if self._is_candidate(h)]
|
| 311 |
+
if not candidates:
|
| 312 |
+
return None
|
| 313 |
+
|
| 314 |
+
def key(h: HMMHit):
|
| 315 |
+
return (self.model_rank[h.model], h.i_evalue, -h.bitscore)
|
| 316 |
+
|
| 317 |
+
return min(candidates, key=key)
|
| 318 |
+
|
| 319 |
+
def _cap_interval(self, s: int, t: int, seq_len: int) -> Tuple[int, int]:
|
| 320 |
+
"""
|
| 321 |
+
Cap [s,t] to length max_mask_len around the alignment core midpoint.
|
| 322 |
+
"""
|
| 323 |
+
if s > t:
|
| 324 |
+
s, t = t, s
|
| 325 |
+
|
| 326 |
+
s = max(1, min(s, seq_len))
|
| 327 |
+
t = max(1, min(t, seq_len))
|
| 328 |
+
|
| 329 |
+
L = t - s + 1
|
| 330 |
+
if L <= self.max_mask_len:
|
| 331 |
+
return s, t
|
| 332 |
+
|
| 333 |
+
mid = (s + t) // 2
|
| 334 |
+
half = self.max_mask_len // 2
|
| 335 |
+
s2 = mid - half
|
| 336 |
+
t2 = s2 + self.max_mask_len - 1
|
| 337 |
+
|
| 338 |
+
if s2 < 1:
|
| 339 |
+
s2 = 1
|
| 340 |
+
t2 = min(seq_len, self.max_mask_len)
|
| 341 |
+
if t2 > seq_len:
|
| 342 |
+
t2 = seq_len
|
| 343 |
+
s2 = max(1, seq_len - self.max_mask_len + 1)
|
| 344 |
+
|
| 345 |
+
return s2, t2
|
| 346 |
+
|
| 347 |
+
# ---- caching ----
|
| 348 |
+
def _seq_key(self, seq: str) -> str:
|
| 349 |
+
return hashlib.sha1(seq.encode("utf-8")).hexdigest()
|
| 350 |
+
|
| 351 |
+
def _cache_get(self, seq: str) -> Optional[Tuple[int, int]]:
|
| 352 |
+
k = self._seq_key(seq)
|
| 353 |
+
if k in self._cache:
|
| 354 |
+
return self._cache[k]
|
| 355 |
+
return None
|
| 356 |
+
|
| 357 |
+
def _cache_put(self, seq: str, interval: Optional[Tuple[int, int]]) -> None:
|
| 358 |
+
k = self._seq_key(seq)
|
| 359 |
+
if k in self._cache:
|
| 360 |
+
self._cache[k] = interval
|
| 361 |
+
return
|
| 362 |
+
self._cache[k] = interval
|
| 363 |
+
self._cache_order.append(k)
|
| 364 |
+
if len(self._cache_order) > self.cache_size:
|
| 365 |
+
old = self._cache_order.pop(0)
|
| 366 |
+
self._cache.pop(old, None)
|
| 367 |
+
|
| 368 |
+
# ---- public API ----
|
| 369 |
+
def pi_core_interval(self, seq: str) -> Optional[Tuple[int, int]]:
|
| 370 |
+
"""
|
| 371 |
+
Returns (start,end) 1-based AA coordinates of the compact PI-core region,
|
| 372 |
+
after applying the max_mask_len cap.
|
| 373 |
+
|
| 374 |
+
If no PI hit is found and fallback_last_n is set, returns last N AAs.
|
| 375 |
+
"""
|
| 376 |
+
seq = seq.replace(" ", "").strip()
|
| 377 |
+
if not seq:
|
| 378 |
+
return None
|
| 379 |
+
|
| 380 |
+
cached = self._cache_get(seq)
|
| 381 |
+
if cached is not None or (self._seq_key(seq) in self._cache):
|
| 382 |
+
# cached could be None meaning "no hit"
|
| 383 |
+
return cached
|
| 384 |
+
|
| 385 |
+
domtbl, _ = self.hmmscan.scan_sequences(seq, ids="q")
|
| 386 |
+
hits = DomTbloutParser.parse(domtbl)
|
| 387 |
+
best = self._choose_best_hit(hits)
|
| 388 |
+
|
| 389 |
+
if best is None:
|
| 390 |
+
if self.fallback_last_n is None:
|
| 391 |
+
self._cache_put(seq, None)
|
| 392 |
+
return None
|
| 393 |
+
n = int(self.fallback_last_n)
|
| 394 |
+
L = len(seq)
|
| 395 |
+
s = max(1, L - n + 1)
|
| 396 |
+
t = L
|
| 397 |
+
interval = (s, t)
|
| 398 |
+
self._cache_put(seq, interval)
|
| 399 |
+
return interval
|
| 400 |
+
|
| 401 |
+
s, t = self._hit_interval(best)
|
| 402 |
+
interval = self._cap_interval(s, t, seq_len=len(seq))
|
| 403 |
+
self._cache_put(seq, interval)
|
| 404 |
+
return interval
|
| 405 |
+
|
| 406 |
+
def build_no_del_mask(
|
| 407 |
+
self,
|
| 408 |
+
x: torch.Tensor,
|
| 409 |
+
aa_seq: str,
|
| 410 |
+
pad_id: int,
|
| 411 |
+
*,
|
| 412 |
+
bos_at_index0: bool = True,
|
| 413 |
+
) -> torch.Tensor:
|
| 414 |
+
"""
|
| 415 |
+
Build (B,Lmax) bool mask where True => deletion disallowed.
|
| 416 |
+
Currently supports B=1 (single accepted sequence), which matches your use.
|
| 417 |
+
"""
|
| 418 |
+
if x.dim() != 2 or x.shape[0] != 1:
|
| 419 |
+
raise ValueError(f"Expected x shape (1,Lmax), got {tuple(x.shape)}")
|
| 420 |
+
|
| 421 |
+
Lmax = x.shape[1]
|
| 422 |
+
device = x.device
|
| 423 |
+
mask = torch.zeros((1, Lmax), device=device, dtype=torch.bool)
|
| 424 |
+
|
| 425 |
+
aa_seq = aa_seq.replace(" ", "").strip()
|
| 426 |
+
seq_len = len(aa_seq)
|
| 427 |
+
|
| 428 |
+
interval = self.pi_core_interval(aa_seq)
|
| 429 |
+
if interval is None:
|
| 430 |
+
return mask
|
| 431 |
+
|
| 432 |
+
s, t = interval # 1-based AA positions
|
| 433 |
+
s = max(1, min(s, seq_len))
|
| 434 |
+
t = max(1, min(t, seq_len))
|
| 435 |
+
if s > t:
|
| 436 |
+
s, t = t, s
|
| 437 |
+
|
| 438 |
+
# Token mapping:
|
| 439 |
+
# if BOS at index 0, AA position p is at token index p
|
| 440 |
+
# else AA position p is at token index p-1
|
| 441 |
+
offset = 0 if bos_at_index0 else -1
|
| 442 |
+
ts = s + offset
|
| 443 |
+
tt = t + offset
|
| 444 |
+
|
| 445 |
+
ts = max(0, min(ts, Lmax - 1))
|
| 446 |
+
tt = max(0, min(tt, Lmax - 1))
|
| 447 |
+
if ts > tt:
|
| 448 |
+
ts, tt = tt, ts
|
| 449 |
+
|
| 450 |
+
mask[0, ts:tt + 1] = True
|
| 451 |
+
|
| 452 |
+
# Don't bother masking PAD; your deletion rates are already masked on PAD tokens.
|
| 453 |
+
# But if you want, you can explicitly clear PAD positions:
|
| 454 |
+
mask[0, x[0] == pad_id] = False
|
| 455 |
+
|
| 456 |
+
return mask
|
|
The diff for this file is too large to render.
See raw diff
|
|
|
|
@@ -0,0 +1,206 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Script to predict PAM sequences for protein sequences and print the PAM distribution.
|
| 4 |
+
|
| 5 |
+
Edit the SEQUENCES dictionary below with your protein sequences.
|
| 6 |
+
This script shows the full softmaxed probability distribution for each position in the PAM.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from collections import Counter
|
| 10 |
+
import torch
|
| 11 |
+
|
| 12 |
+
# Import PAMMatching from cas9.objectives
|
| 13 |
+
from cas9.objectives import PAMMatching
|
| 14 |
+
|
| 15 |
+
# ============================================================================
|
| 16 |
+
# EDIT THIS DICTIONARY WITH YOUR SEQUENCES
|
| 17 |
+
# Key = sequence name, Value = protein sequence string
|
| 18 |
+
# ============================================================================
|
| 19 |
+
|
| 20 |
+
SEQUENCES = {
|
| 21 |
+
"sacas9": "MKRNYILGLDIGITSVGYGIIDYETRDVIDAGVRLFKEANVENNEGRRSKRGARRLKRRRRHRIQRVKKLLFDYNLLTDHSELSGINPYEARVKGLSQKLSEEEFSAALLHLAKRRGVHNVNEVEEDTGNELSTKEQISRNSKALEEKYVAELQLERLKKDGEVRGSINRFKTSDYVKEAKQLLKVQKAYHQLDQSFIDTYIDLLETRRTYYEGPGEGSPFGWKDIKEWYEMLMGHCTYFPEELRSVKYAYNADLYNALNDLNNLVITRDENEKLEYYEKFQIIENVFKQKKKPTLKQIAKEILVNEEDIKGYRVTSTGKPEFTNLKVYHDIKDITARKEIIENAELLDQIAKILTIYQSSEDIQEELTNLNSELTQEEIEQISNLKGYTGTHNLSLKAINLILDELWHTNDNQIAIFNRLKLVPKKVDLSQQKEIPTTLVDDFILSPVVKRSFIQSIKVINAIIKKYGLPNDIIIELAREKNSKDAQKMINEMQKRNRQTNERIEEIIRTTGKENAKYLIEKIKLHDMQEGKCLYSLEAIPLEDLLNNPFNYEVDHIIPRSVSFDNSFNNKVLVKQEENSKKGNRTPFQYLSSSDSKISYETFKKHILNLAKGKGRISKTKKEYLLEERDINRFSVQKDFINRNLVDTRYATRGLMNLLRSYFRVNNLDVKVKSINGGFTSFLRRKWKFKKERNKGYKHHAEDALIIANADFIFKEWKKLDKAKKVMENQMFEEKQAESMPEIETEQEYKEIFITPHQIKHIKDFKDYKYSHRVDKKPNRELINDTLYSTRKDDKGNTLIVNNLNGLYDKDNDKLKKLINKSPEKLLMYHHDPQTYQKLKLIMEQYGDEKNPLYKYYEETGNYLTKYSKKDNGPVIKKIKYYGNKLNAHLDITDDYPNSRNKVVKLSLKPYRFDVYLDNGVYKFVTVKNLDVIKKENYYEVNSKCYEEAKKLKKISNQAEFIASFYNNDLIKINGELYRVIGVNNDLLNRIEVNMIDITYREYLENMNDKRPPRIIKTIASKTQSIKKYSTDILGNLYEVKSKKHPQIIKKG",
|
| 22 |
+
|
| 23 |
+
"cjcas9": "MARILAFDIGISSIGWAFSENDELKDCGVRIFTKVENPKTGESLALPRRLARSARKRLARRKARLNHLKHLIANEFKLNYEDYQSFDESLAKAYKGSLISPYELRFRALNELLSKQDFARVILHIAKRRGYDDIKNSDDKEKGAILKAIKQNEEKLANYQSVGEYLYKEYFQKFKENSKEFTNVRNKKESYERCIAQSFLKDELKLIFKKQREFGFSFSKKFEEEVLSVAFYKRALKDFSHLVGNCSFFTDEKRAPKNSPLAFMFVALTRIINLLNNLKNTEGILYTKDDLNALLNEVLKNGTLTYKQTKKLLGLSDDYEFKGEKGTYFIEFKKYKEFIKALGEHNLSQDDLNEIAKDITLIKDEIKLKKALAKYDLNQNQIDSLSKLEFKDHLNISFKALKLVTPLMLEGKKYDEACNELNLKVAINEDKKDFLPAFNETYYKDEVTNPVVLRAIKEYRKVLNALLKKYGKVHKINIELAREVGKNHSQRAKIEKEQNENYKAKKDAELECEKLGLKINSKNILKLRLFKEQKEFCAYSGEKIKISDLQDEKMLEIDHIYPYSRSFDDSYMNKVLVFTKQNQEKLNQTPFEAFGNDSAKWQKIEVLAKNLPTKKQKRILDKNYKDKEQKNFKDRNLNDTRYIARLVLNYTKDYLDFLPLSDDENTKLNDTQKGSKVHVEAKSGMLTSALRHTWGFSAKDRNNHLHHAIDAVIIAYANNSIVKAFSDFKKEQESNSAELYAKKISELDYKNKRKFFEPFSGFRQKVLDKIDEIFVSKPERKKPSGALHEETFRKEEEFYQSYGGKEGVLKALELGKIRKVNGKIVKNGDMFRVDIFKHKKTNKFYAVPIYTMDFALKVLPNKAVARSKKGEIKDWILMDENYEFCFSLYKDSLILIQTKDMQEPEFVYYNAFTSSTVSLIVSKHDNKFETLSKNQKILFKNANEKEVIAKSIGIQNLKVFEKYIVSALGEVTKAEFRQREDFKK",
|
| 24 |
+
|
| 25 |
+
"spcas9": "MDKKYSIGLDIGTNSVGWAVITDEYKVPSKKFKVLGNTDRHSIKKNLIGALLFDSGETAEATRLKRTARRRYTRRKNRICYLQEIFSNEMAKVDDSFFHRLEESFLVEEDKKHERHPIFGNIVDEVAYHEKYPTIYHLRKKLVDSTDKADLRLIYLALAHMIKFRGHFLIEGDLNPDNSDVDKLFIQLVQTYNQLFEENPINASGVDAKAILSARLSKSRRLENLIAQLPGEKKNGLFGNLIALSLGLTPNFKSNFDLAEDAKLQLSKDTYDDDLDNLLAQIGDQYADLFLAAKNLSDAILLSDILRVNTEITKAPLSASMIKRYDEHHQDLTLLKALVRQQLPEKYKEIFFDQSKNGYAGYIDGGASQEEFYKFIKPILEKMDGTEELLVKLNREDLLRKQRTFDNGSIPHQIHLGELHAILRRQEDFYPFLKDNREKIEKILTFRIPYYVGPLARGNSRFAWMTRKSEETITPWNFEEVVDKGASAQSFIERMTNFDKNLPNEKVLPKHSLLYEYFTVYNELTKVKYVTEGMRKPAFLSGEQKKAIVDLLFKTNRKVTVKQLKEDYFKKIECFDSVEISGVEDRFNASLGTYHDLLKIIKDKDFLDNEENEDILEDIVLTLTLFEDREMIEERLKTYAHLFDDKVMKQLKRRRYTGWGRLSRKLINGIRDKQSGKTILDFLKSDGFANRNFMQLIHDDSLTFKEDIQKAQVSGQGDSLHEHIANLAGSPAIKKGILQTVKVVDELVKVMGRHKPENIVIEMARENQTTQKGQKNSRERMKRIEEGIKELGSQILKEHPVENTQLQNEKLYLYYLQNGRDMYVDQELDINRLSDYDVDHIVPQSFLKDDSIDNKVLTRSDKNRGKSDNVPSEEVVKKMKNYWRQLLNAKLITQRKFDNLTKAERGGLSELDKAGFIKRQLVETRQITKHVAQILDSRMNTKYDENDKLIREVKVITLKSKLVSDFRKDFQFYKVREINNYHHAHDAYLNAVVGTALIKKYPKLESEFVYGDYKVYDVRKMIAKSEQEIGKATAKYFFYSNIMNFFKTEITLANGEIRKRPLIETNGETGEIVWDKGRDFATVRKVLSMPQVNIVKKTEVQTGGFSKESILPKRNSDKLIARKKDWDPKKYGGFDSPTVAYSVLVVAKVEKGKSKKLKSVKELLGITIMERSSFEKNPIDFLEAKGYKEVKKDLIIKLPKYSLFELENGRKRMLASAGELQKGNELALPSKYVNFLYLASHYEKLKGSPEDNEQKQLFVEQHKHYLDEIIEQISEFSKRVILADANLDKVLSAYNKHRDKPIREQAENIIHLFTLTNLGAPAAFKYFDTTIDRKRYTSTKEVLDATLIHQSITGLYETRIDLSQLGGD",
|
| 26 |
+
|
| 27 |
+
"nmcas9": "MAAFKPNPINYILGLDIGIASVGWAMVEIDEEENPIRLIDLGVRVFERAEVPKTGDSLAMVRRLARSVRRLTRRRAHRLLRARRLLKREGVLQAADFDENGLIKSLPNTPWQLRAAALDRKLTPLEWSAVLLHLIKHRGYLSQRKNEGETADKELGALLKGVADNAHALQTGDFRTPAELALNKFEKESGHIRNQRGDYSHTFSRKDLQAELILLFEKQKEFGNPHISGGLKEGIETLLMTQRPALSGDAVQKMLGHCTFEPAEPKAAKNTYTAERFIWLTKLNNLRILEQGSERPLTDTERATLMDEPYRKSKLTYAQARKLLGLEDTAFFKGLRYGKDNAEASTLMEMKAYHAISRALEKEGLKDKKSPLNLSPELQDEIGTAFSLFKTDEDITGRLKDRIQPEILEALLKHISFDKFVQISLKALRRIVPLMEQGKRYDEACAEIYGDHYGKKNTEEKIYLPPIPADEIRNPVVLRALSQARKVINGVVRRYGSPARIHIETAREVGKSFKDRKEIEKRQEENRKDREKAAAKFREYFPNFVGEPKSKDILKLRLYEQQHGKCLYSGKEINLGRLNEKGYVEIDHALPFSRTWDDSFNNKVLVLGSENQNKGNQTPYEYFNGKDNSREWQEFKARVETSRFPRSKKQRILLQKFDEDGFKERNLNDTRYVNRFLCQFVADRMRLTGKGKKRVFASNGQITNLLRGFWGLRKVRAENDRHHALDAVVVACSTVAMQQKITRFVRYKEMNAFDGKTIDKETGEVLHQKTHFPQPWEFFAQEVMIRVFGKPDGKPEFEEADTPEKLRTLLAEKLSSRPEAVHEYVTPLFVSRAPNRKMSGQGHMETVKSAKRLDEGVSVLRVPLTQLKLKDLEKMVNREREPKLYEALKARLEAHKDDPAKAFAEPFYKYDKAGNRTQQVKAVRVEQVQKTGVWVRNHNGIADNATMVRVDVFEKGDKYYLVPIYSWQVAKGILPDRAVVQGKDEEDWQLIDDSFNFKFSLHPNDLVEVITKKARMFGYFASCHRGTGNINIRIHDLDHKIGKNGILEGIGVKTALSFQKYQIDELGKEIRPCRLKKRPPVR",
|
| 28 |
+
|
| 29 |
+
"st1cas9": "MSDLVLGLDIGIGSVGVGILNKVTGEIIHKNSRIFPAAQAENNLVRRTNRQGRRLARRKKHRRVRLNRLFEESGLITDFTKISINLNPYQLRVKGLTDELSNEELFIALKNMVKHRGISYLDDASDDGNSSVGDYAQIVKENSKQLETKTPGQIQLERYQTYGQLRGDFTVEKDGKKHRLINVFPTSAYRSEALRILQTQQEFNPQITDEFINRYLEILTGKRKYYHGPGNEKSRTDYGRYRTSGETLDNIFGILIGKCTFYPDEFRAAKASYTAQEFNLLNDLNNLTVPTETKKLSKEQKNQIINYVKNEKAMGPAKLFKYIAKLLSCDVADIKGYRIDKSGKAEIHTFEAYRKMKTLETLDIEQMDRETLDKLAYVLTLNTEREGIQEALEHEFADGSFSQKQVDELVQFRKANSSIFGKGWHNFSVKLMMELIPELYETSEEQMTILTRLGKQKTTSSSNKTKYIDEKLLTEEIYNPVVAKSVRQAIKIVNAAIKEYGDFDNIVIEMARETNEDDEKKAIQKIQKANKDEKDAAMLKAANQYNGKAELPHSVFHGHKQLATKIRLWHQQGERCLYTGKTISIHDLINNSNQFEVDHILPLSITFDDSLANKVLVYATANQEKGQRTPYQALDSMDDAWSFRELKAFVRESKTLSNKKKEYLLTEEDISKFDVRKKFIERNLVDTRYASRVVLNALQEHFRAHKIDTKVSVVRGQFTSQLRRHWGIEKTRDTYHHHAVDALIIAASSQLNLWKKQKNTLVSYSEDQLLDIETGELISDDEYKESVFKAPYQHFVDTLKSKEFEDSILFSYQVDSKFNRKISDATIYATRQAKVGKDKADETYVLGKIKDIYTQDGYDAFMKIYKKDKSKFLMYRHDPQTFEKVIEPILENYPNKQINEKGKEVPCNPFLKYKEEHGYIRKYSKKGNGPEIKSLKYYDSKLGNHIDITPKDSNNKVVLQSVSPWRADVYFNKTTGKYEILGLKYADLQFEKGTGTYKISQEKYNDIKKKEGVDSDSEFKFTLYKNDLLLVKDTETKEQQLFRFLSRTMPKQKHYVELKPYDKQKFEGGEALIKVLGNVANSGQCKKGLGKSNISIYKVRTDVLGNQHIIKNEGDKPKLDF",
|
| 30 |
+
"nmecas9":
|
| 31 |
+
"MAAFKPNPINYILGLDIGIASVGWAMVEIDEEENPIRLIDLGVRVFERAEVPKTGDSLAMVRRLARSVRRLTRRRAHRLLRARRLLKREGVLQAADFDENGLIKSLPNTPWQLRAAALDRKLTPLEWSAVLLHLIKHRGYLSQRKNEGETADKELGALLKGVADNAHALQTGDFRTPAELALNKFEKESGHIRNQRGDYSHTFSRKDLQAELILLFEKQKEFGNPHISGGLKEGIETLLMTQRPALSGDAVQKMLGHCTFEPAEPKAAKNTYTAERFIWLTKLNNLRILEQGSERPLTDTERATLMDEPYRKSKLTYAQARKLLGLEDTAFFKGLRYGKDNAEASTLMEMKAYHAISRALEKEGLKDKKSPLNLSPELQDEIGTAFSLFKTDEDITGRLKDRIQPEILEALLKHISFDKFVQISLKALRRIVPLMEQGKRYDEACAEIYGDHYGKKNTEEKIYLPPIPADEIRNPVVLRALSQARKVINGVVRRYGSPARIHIETAREVGKSFKDRKEIEKRQEENRKDREKAAAKFREYFPNFVGEPKSKDILKLRLYEQQHGKCLYSGKEINLGRLNEKGYVEIDHALPFSRTWDDSFNNKVLVLGSENQNKGNQTPYEYFNGKDNSREWQEFKARVETSRFPRSKKQRILLQKFDEDGFKERNLNDTRYVNRFLCQFVADRMRLTGKGKKRVFASNGQITNLLRGFWGLRKVRAENDRHHALDAVVVACSTVAMQQKITRFVRYKEMNAFDGKTIDKETGEVLHQKTHFPQPWEFFAQEVMIRVFGKPDGKPEFEEADTPEKLRTLLAEKLSSRPEAVHEYVTPLFVSRAPNRKMSGQGHMETVKSAKRLDEGVSVLRVPLTQLKLKDLEKMVNREREPKLYEALKARLEAHKDDPAKAFAEPFYKYDKAGNRTQQVKAVRVEQVQKTGVWVRNHNGIADNATMVRVDVFEKGDKYYLVPIYSWQVAKGILPDRAVVQGKDEEDWQLIDDSFNFKFSLHPNDLVEVITKKARMFGYFASCHRGTGNINIRIHDLDHKIGKNGILEGIGVKTALSFQKYQIDELGKEIRPCRLKKRPPVR"
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
# Model configuration
|
| 35 |
+
MODEL_NAME = "Profluent-Bio/protein2pam-cas9_full" # HuggingFace model name
|
| 36 |
+
DEVICE = None # None = auto-detect (cuda if available, else cpu)
|
| 37 |
+
MIN_CONFIDENCE = 0.7 # Minimum probability threshold for predicting a specific nucleotide (vs. 'N')
|
| 38 |
+
# If max probability < MIN_CONFIDENCE, predict 'N'
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def get_pam_probability_distributions(sequences_list, device=None, model_name="Profluent-Bio/protein2pam-cas9_full", min_confidence=0.55):
|
| 42 |
+
"""
|
| 43 |
+
Get detailed probability distributions for PAM predictions.
|
| 44 |
+
|
| 45 |
+
Args:
|
| 46 |
+
sequences_list: List of protein sequence strings
|
| 47 |
+
device: torch device (default: auto-detect)
|
| 48 |
+
model_name: HuggingFace model name for PAM prediction
|
| 49 |
+
min_confidence: Minimum probability threshold for predicting a specific nucleotide
|
| 50 |
+
|
| 51 |
+
Returns:
|
| 52 |
+
List of dictionaries containing probability distributions and predicted PAMs
|
| 53 |
+
"""
|
| 54 |
+
if not sequences_list:
|
| 55 |
+
print("Error: No sequences provided.")
|
| 56 |
+
return []
|
| 57 |
+
|
| 58 |
+
# Auto-detect device if not provided
|
| 59 |
+
if device is None:
|
| 60 |
+
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 61 |
+
|
| 62 |
+
print(f"Using device: {device}")
|
| 63 |
+
print(f"Loading PAM prediction model: {model_name}...")
|
| 64 |
+
|
| 65 |
+
# Create a dummy PAMMatching object (target_pam doesn't matter for prediction)
|
| 66 |
+
pam_predictor = PAMMatching(
|
| 67 |
+
device=device,
|
| 68 |
+
target_pam="NNNNNNNNNN", # Dummy target, we only use get_pam_probability_distributions()
|
| 69 |
+
model_name=model_name,
|
| 70 |
+
use_entropy_for_n_positions=True,
|
| 71 |
+
use_ce_loss=False
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
print(f"Computing PAM probability distributions for {len(sequences_list)} sequences...")
|
| 75 |
+
|
| 76 |
+
# Get probability distributions
|
| 77 |
+
prob_distributions = pam_predictor.get_pam_probability_distributions(sequences_list)
|
| 78 |
+
|
| 79 |
+
# Recompute predicted PAMs with custom min_confidence
|
| 80 |
+
nucleotides = ['A', 'C', 'G', 'T']
|
| 81 |
+
for dist in prob_distributions:
|
| 82 |
+
predicted_pam = []
|
| 83 |
+
for pos_info in dist['per_position']:
|
| 84 |
+
max_prob = pos_info['max_probability']
|
| 85 |
+
if max_prob < min_confidence:
|
| 86 |
+
predicted_pam.append('N')
|
| 87 |
+
else:
|
| 88 |
+
predicted_pam.append(pos_info['predicted'])
|
| 89 |
+
dist['predicted_pam_custom'] = ''.join(predicted_pam)
|
| 90 |
+
|
| 91 |
+
return prob_distributions
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def print_sequence_pam_distribution(seq_name, prob_dist, min_confidence):
|
| 95 |
+
"""Print the PAM probability distribution for a single sequence."""
|
| 96 |
+
print("\n" + "="*80)
|
| 97 |
+
print(f"Sequence: {seq_name}")
|
| 98 |
+
print("="*80)
|
| 99 |
+
|
| 100 |
+
print(f"\nPredicted PAM (min_confidence={min_confidence:.2f}): {prob_dist['predicted_pam_custom']}")
|
| 101 |
+
print(f"Predicted PAM (default min_confidence=0.55): {prob_dist['predicted_pam']}")
|
| 102 |
+
|
| 103 |
+
print("\nPosition-wise Probability Distribution:")
|
| 104 |
+
print("-" * 80)
|
| 105 |
+
print(f"{'Pos':<5} {'A':<10} {'C':<10} {'G':<10} {'T':<10} {'Pred':<6} {'Max Prob':<10} {'Entropy':<10}")
|
| 106 |
+
print("-" * 80)
|
| 107 |
+
|
| 108 |
+
nucleotides = ['A', 'C', 'G', 'T']
|
| 109 |
+
for pos_info in prob_dist['per_position']:
|
| 110 |
+
pos = pos_info['position']
|
| 111 |
+
probs = pos_info['probabilities']
|
| 112 |
+
pred = pos_info['predicted']
|
| 113 |
+
max_prob = pos_info['max_probability']
|
| 114 |
+
entropy = pos_info['entropy']
|
| 115 |
+
|
| 116 |
+
# Determine if this position would be 'N' with custom threshold
|
| 117 |
+
final_pred = 'N' if max_prob < min_confidence else pred
|
| 118 |
+
|
| 119 |
+
print(f"{pos+1:<5} "
|
| 120 |
+
f"{probs['A']:<10.4f} "
|
| 121 |
+
f"{probs['C']:<10.4f} "
|
| 122 |
+
f"{probs['G']:<10.4f} "
|
| 123 |
+
f"{probs['T']:<10.4f} "
|
| 124 |
+
f"{final_pred:<6} "
|
| 125 |
+
f"{max_prob:<10.4f} "
|
| 126 |
+
f"{entropy:<10.4f}")
|
| 127 |
+
|
| 128 |
+
print("-" * 80)
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def print_summary_distribution(pam_distribution_dict):
|
| 132 |
+
"""Print summary distribution across all sequences."""
|
| 133 |
+
print("\n" + "="*80)
|
| 134 |
+
print("SUMMARY: PAM DISTRIBUTION ACROSS ALL SEQUENCES")
|
| 135 |
+
print("="*80)
|
| 136 |
+
|
| 137 |
+
total = len(pam_distribution_dict)
|
| 138 |
+
pam_counter = Counter(pam_distribution_dict.values())
|
| 139 |
+
|
| 140 |
+
print(f"\nTotal sequences: {total}")
|
| 141 |
+
print(f"Unique PAMs: {len(pam_counter)}")
|
| 142 |
+
|
| 143 |
+
# Sort by frequency (descending)
|
| 144 |
+
sorted_pams = sorted(pam_counter.items(), key=lambda x: x[1], reverse=True)
|
| 145 |
+
|
| 146 |
+
print("\nPAM Distribution (sorted by frequency):")
|
| 147 |
+
print("-" * 80)
|
| 148 |
+
print(f"{'PAM Sequence':<15} {'Count':<10} {'Percentage':<10}")
|
| 149 |
+
print("-" * 80)
|
| 150 |
+
|
| 151 |
+
for pam, count in sorted_pams:
|
| 152 |
+
percentage = (count / total) * 100
|
| 153 |
+
print(f"{pam:<15} {count:<10} {percentage:>6.2f}%")
|
| 154 |
+
|
| 155 |
+
print("-" * 80)
|
| 156 |
+
print(f"{'Total':<15} {total:<10} {'100.00%':<10}")
|
| 157 |
+
print("="*80)
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def main():
|
| 161 |
+
# Get sequences from the dictionary at the top
|
| 162 |
+
sequences_dict = SEQUENCES
|
| 163 |
+
|
| 164 |
+
if not sequences_dict:
|
| 165 |
+
print("Error: SEQUENCES dictionary is empty. Please add sequences to the dictionary at the top of the script.")
|
| 166 |
+
return
|
| 167 |
+
|
| 168 |
+
print(f"Processing {len(sequences_dict)} sequences...")
|
| 169 |
+
print(f"Using min_confidence threshold: {MIN_CONFIDENCE}")
|
| 170 |
+
|
| 171 |
+
# Extract sequence names and values
|
| 172 |
+
sequence_names = list(sequences_dict.keys())
|
| 173 |
+
sequences_list = list(sequences_dict.values())
|
| 174 |
+
|
| 175 |
+
# Set device
|
| 176 |
+
device = DEVICE
|
| 177 |
+
if device is not None:
|
| 178 |
+
device = torch.device(device)
|
| 179 |
+
|
| 180 |
+
# Get probability distributions
|
| 181 |
+
prob_distributions = get_pam_probability_distributions(
|
| 182 |
+
sequences_list,
|
| 183 |
+
device=device,
|
| 184 |
+
model_name=MODEL_NAME,
|
| 185 |
+
min_confidence=MIN_CONFIDENCE
|
| 186 |
+
)
|
| 187 |
+
|
| 188 |
+
# Create dictionary mapping sequence names to predicted PAMs
|
| 189 |
+
predicted_pams_dict = {
|
| 190 |
+
name: dist['predicted_pam_custom']
|
| 191 |
+
for name, dist in zip(sequence_names, prob_distributions)
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
# Print detailed distributions for each sequence
|
| 195 |
+
for seq_name, prob_dist in zip(sequence_names, prob_distributions):
|
| 196 |
+
print_sequence_pam_distribution(seq_name, prob_dist, MIN_CONFIDENCE)
|
| 197 |
+
|
| 198 |
+
# Print summary distribution
|
| 199 |
+
print_summary_distribution(predicted_pams_dict)
|
| 200 |
+
|
| 201 |
+
# Return dictionaries for potential programmatic use
|
| 202 |
+
return predicted_pams_dict, prob_distributions
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
if __name__ == "__main__":
|
| 206 |
+
main()
|
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
# Validation metrics over N sampling runs (mean/std/min/max).
|
| 3 |
+
set -euo pipefail
|
| 4 |
+
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
| 5 |
+
cd "$ROOT"
|
| 6 |
+
python -u -m cas9.evaluate_val_multi_sample \
|
| 7 |
+
--n_runs "${N_RUNS:-10}" \
|
| 8 |
+
--config "$ROOT/configs/config_cas9.yaml" \
|
| 9 |
+
--ckpt "$ROOT/cas9/ckpt/last.ckpt" \
|
| 10 |
+
--output_dir "$ROOT/outputs/cas9_eval" \
|
| 11 |
+
--json_out "$ROOT/outputs/cas9_eval/multi_run_metrics.json" \
|
| 12 |
+
"$@"
|
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
# Unguided / seeded Cas9 rollouts (no objectives) -> one CSV per ortholog.
|
| 3 |
+
set -euo pipefail
|
| 4 |
+
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
| 5 |
+
cd "$ROOT"
|
| 6 |
+
python -u -m cas9.generate_cas9_csvs \
|
| 7 |
+
--config "$ROOT/configs/config_cas9.yaml" \
|
| 8 |
+
--ckpt "$ROOT/cas9/ckpt/last.ckpt" \
|
| 9 |
+
--sequences "$ROOT/cas9/data/orthologs.fasta" \
|
| 10 |
+
--num_steps 32 \
|
| 11 |
+
--output_dir "$ROOT/outputs" \
|
| 12 |
+
"$@"
|
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
# Official Cas9 pCoMole editor: cas9-likeness + deletion-count + PAM-matching,
|
| 3 |
+
# under Cas9-score, PAM-matching and length constraints.
|
| 4 |
+
set -euo pipefail
|
| 5 |
+
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
| 6 |
+
cd "$ROOT"
|
| 7 |
+
|
| 8 |
+
ORTHOLOG="${ORTHOLOG:-cjcas9}"
|
| 9 |
+
INPUT_SEQUENCE="$(awk -v n=">$ORTHOLOG" '
|
| 10 |
+
$1==n {f=1; next} /^>/{f=0} f {printf "%s", $0}' cas9/data/orthologs.fasta)"
|
| 11 |
+
if [ -z "$INPUT_SEQUENCE" ]; then
|
| 12 |
+
echo "Ortholog '$ORTHOLOG' not found in cas9/data/orthologs.fasta" >&2; exit 1
|
| 13 |
+
fi
|
| 14 |
+
|
| 15 |
+
python -u -m cas9.pcomole \
|
| 16 |
+
--pcomol_config "$ROOT/configs/config_cas9.yaml" \
|
| 17 |
+
--ckpt "$ROOT/cas9/ckpt/last.ckpt" \
|
| 18 |
+
--input "$INPUT_SEQUENCE" \
|
| 19 |
+
--cas9_objective --deletion_count_obj --pam_distr_objective \
|
| 20 |
+
--objective_weights 1 1 1 \
|
| 21 |
+
--target_pam matching \
|
| 22 |
+
--num_steps 5 \
|
| 23 |
+
--num_rollouts 5 \
|
| 24 |
+
--num_candidates 10 \
|
| 25 |
+
--num_sequences 50 \
|
| 26 |
+
--max_deletion_percentage 0.2 \
|
| 27 |
+
--deletion_rate_scale 2500 \
|
| 28 |
+
--pam_min_confidence 0.7 \
|
| 29 |
+
--pam_prediction_temperature 1 \
|
| 30 |
+
--pam_probability_threshold 0.0 \
|
| 31 |
+
--cas9_score_threshold 0.5 \
|
| 32 |
+
--max_target_length 930 \
|
| 33 |
+
--min_target_length 890 \
|
| 34 |
+
--use_ce_loss --allow_equal_length --zero_lam_ins \
|
| 35 |
+
--output_file "$ROOT/outputs/cas9_${ORTHOLOG}_pcomole.csv" \
|
| 36 |
+
"$@"
|
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
set -euo pipefail
|
| 3 |
+
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
| 4 |
+
cd "$ROOT"
|
| 5 |
+
python -u -m cas9.train --config configs/config_cas9.yaml "$@"
|
|
@@ -0,0 +1,275 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import argparse
|
| 3 |
+
from datasets import load_from_disk
|
| 4 |
+
import torch
|
| 5 |
+
from torch.utils.data import DataLoader
|
| 6 |
+
import pytorch_lightning as pl
|
| 7 |
+
from pytorch_lightning.callbacks import ModelCheckpoint, LearningRateMonitor
|
| 8 |
+
from pytorch_lightning.loggers import WandbLogger
|
| 9 |
+
|
| 10 |
+
from cas9.data import data
|
| 11 |
+
from cas9.model.base_models import EditFlow, ProteinEditFlowModel, SMILESEditFlowModel, ReparameterizedProteinEditFlowModel, ReparameterizedSMILESEditFlowModel
|
| 12 |
+
from smiles_tokenizer.my_tokenizers import SMILES_SPE_Tokenizer
|
| 13 |
+
from smiles_tokenizer.selfies_tokenizers import SelfiesTokenizer
|
| 14 |
+
|
| 15 |
+
from cas9.logic import flow
|
| 16 |
+
from transformers import EsmTokenizer
|
| 17 |
+
import datetime
|
| 18 |
+
|
| 19 |
+
import yaml
|
| 20 |
+
from easydict import EasyDict as edict
|
| 21 |
+
|
| 22 |
+
import pdb
|
| 23 |
+
|
| 24 |
+
DEFAULT_CONFIG_PATH = 'configs/config_test.yaml'
|
| 25 |
+
|
| 26 |
+
def main(config_path=None):
|
| 27 |
+
# Use provided config path or default to hardcoded path
|
| 28 |
+
if config_path is None:
|
| 29 |
+
config_path = DEFAULT_CONFIG_PATH
|
| 30 |
+
|
| 31 |
+
with open(config_path, 'r') as f:
|
| 32 |
+
config_dict = yaml.safe_load(f)
|
| 33 |
+
cfg = edict(config_dict)
|
| 34 |
+
|
| 35 |
+
print(f"cfg: {cfg}")
|
| 36 |
+
|
| 37 |
+
run_name = f"lr{cfg.optim.lr}_epoch{cfg.optim.n_epochs}_scale{cfg.model.scale_size}_optimal{cfg.model.p_optimal}_{cfg.logging.run_name}"
|
| 38 |
+
workdir = os.path.join(cfg.work_dir, run_name)
|
| 39 |
+
os.makedirs(workdir, exist_ok=True)
|
| 40 |
+
|
| 41 |
+
pl.seed_everything(cfg.training.seed, workers=True)
|
| 42 |
+
|
| 43 |
+
# Data
|
| 44 |
+
if cfg.task == 'protein':
|
| 45 |
+
tokenizer = EsmTokenizer.from_pretrained("facebook/esm2_t33_650M_UR50D")
|
| 46 |
+
vocab_size = 24
|
| 47 |
+
source_distribution = flow.get_source_distribution(
|
| 48 |
+
source_distribution=cfg.flow.source_distribution, vocab_size=vocab_size, special_token_ids=[0,1,2,3]
|
| 49 |
+
)
|
| 50 |
+
pad_id = 1
|
| 51 |
+
bos_id = 0
|
| 52 |
+
eos_id = 2
|
| 53 |
+
if cfg.training.reparameterize:
|
| 54 |
+
model = ReparameterizedProteinEditFlowModel(vocab_size=vocab_size, pad_id=pad_id, config=cfg.model)
|
| 55 |
+
else:
|
| 56 |
+
model = ProteinEditFlowModel(vocab_size=vocab_size, pad_id=pad_id, config=cfg.model)
|
| 57 |
+
# model = SMILESEditFlowModel(vocab_size=vocab_size, pad_id=pad_id, config=cfg.model)
|
| 58 |
+
|
| 59 |
+
if cfg.training.no_training:
|
| 60 |
+
print(f"No training mode: freezing all parameters")
|
| 61 |
+
for param in model.parameters():
|
| 62 |
+
param.requires_grad = False
|
| 63 |
+
|
| 64 |
+
first_param = next(model.parameters())
|
| 65 |
+
first_param.requires_grad = True
|
| 66 |
+
print(f"Keeping parameter with shape {first_param.shape} with requires_grad=True for backward compatibility")
|
| 67 |
+
|
| 68 |
+
# if cfg.task == 'protein':
|
| 69 |
+
# tokenizer = EsmTokenizer.from_pretrained("facebook/esm2_t33_650M_UR50D")
|
| 70 |
+
# vocab_size = tokenizer.vocab_size
|
| 71 |
+
# source_distribution = flow.get_source_distribution(
|
| 72 |
+
# source_distribution=cfg.flow.source_distribution, vocab_size=vocab_size, special_token_ids=[0,1,2,3, 24, 25, 26, 27, 28, 29, 30, 31]
|
| 73 |
+
# )
|
| 74 |
+
# pad_id = 1
|
| 75 |
+
# bos_id = 0
|
| 76 |
+
# eos_id = 2
|
| 77 |
+
# if cfg.training.reparameterize:
|
| 78 |
+
# model = ReparameterizedProteinEditFlowModel(vocab_size=vocab_size, pad_id=pad_id, config=cfg.model)
|
| 79 |
+
# else:
|
| 80 |
+
# model = ProteinEditFlowModel(vocab_size=vocab_size, pad_id=pad_id, config=cfg.model)
|
| 81 |
+
# # model = SMILESEditFlowModel(vocab_size=vocab_size, pad_id=pad_id, config=cfg.model)
|
| 82 |
+
|
| 83 |
+
# if cfg.training.no_training:
|
| 84 |
+
# print(f"No training mode: freezing all parameters")
|
| 85 |
+
# for param in model.parameters():
|
| 86 |
+
# param.requires_grad = False
|
| 87 |
+
|
| 88 |
+
# first_param = next(model.parameters())
|
| 89 |
+
# first_param.requires_grad = True
|
| 90 |
+
# print(f"Keeping parameter with shape {first_param.shape} with requires_grad=True for backward compatibility")
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
# if cfg.task == 'protein':
|
| 94 |
+
# tokenizer = EsmTokenizer.from_pretrained("facebook/esm2_t33_650M_UR50D")
|
| 95 |
+
# vocab_size = 24
|
| 96 |
+
# source_distribution = flow.get_source_distribution(source_distribution=cfg.flow.source_distribution, vocab_size=vocab_size, special_token_ids=[0,1,2,3])
|
| 97 |
+
|
| 98 |
+
# pad_id = 1
|
| 99 |
+
# bos_id = 0
|
| 100 |
+
# eos_id = 2
|
| 101 |
+
# model = ProteinEditFlowModel(vocab_size=vocab_size, pad_id=pad_id, config=cfg.model)
|
| 102 |
+
elif cfg.task == 'smiles':
|
| 103 |
+
vocab_size = 587
|
| 104 |
+
tokenizer = SMILES_SPE_Tokenizer('smiles_tokenizer/new_vocab.txt',
|
| 105 |
+
'smiles_tokenizer/new_splits.txt')
|
| 106 |
+
source_distribution = flow.get_source_distribution(
|
| 107 |
+
source_distribution=cfg.flow.source_distribution, vocab_size=vocab_size, special_token_ids=[0,1,2,3,4]
|
| 108 |
+
)
|
| 109 |
+
pad_id = 0
|
| 110 |
+
bos_id = 2
|
| 111 |
+
eos_id = 3
|
| 112 |
+
|
| 113 |
+
if cfg.training.reparameterize:
|
| 114 |
+
model = ReparameterizedSMILESEditFlowModel(vocab_size=vocab_size, pad_id=pad_id, config=cfg.model)
|
| 115 |
+
else:
|
| 116 |
+
model = SMILESEditFlowModel(vocab_size=vocab_size, pad_id=pad_id, config=cfg.model)
|
| 117 |
+
elif cfg.task == 'selfies':
|
| 118 |
+
vocab_size = 44
|
| 119 |
+
tokenizer = SelfiesTokenizer.load(cfg.data.get('tokenizer_path', 'path/to/tokenizer/vocab.json'))
|
| 120 |
+
source_distribution = flow.get_source_distribution(
|
| 121 |
+
source_distribution=cfg.flow.source_distribution, vocab_size=vocab_size, special_token_ids=[0,1,2]
|
| 122 |
+
)
|
| 123 |
+
pad_id = 0
|
| 124 |
+
bos_id = 1
|
| 125 |
+
eos_id = 2
|
| 126 |
+
if cfg.training.reparameterize:
|
| 127 |
+
print(f"Using reparameterized SMILES edit flow model")
|
| 128 |
+
model = ReparameterizedSMILESEditFlowModel(vocab_size=vocab_size, pad_id=pad_id, config=cfg.model)
|
| 129 |
+
else:
|
| 130 |
+
model = SMILESEditFlowModel(vocab_size=vocab_size, pad_id=pad_id, config=cfg.model)
|
| 131 |
+
else:
|
| 132 |
+
raise NotImplementedError
|
| 133 |
+
|
| 134 |
+
num_parameters = sum(p.numel() for p in model.parameters())
|
| 135 |
+
print(f"NUM PARAMETERS: {num_parameters}")
|
| 136 |
+
|
| 137 |
+
# Print accumulation batch size
|
| 138 |
+
grad_accumulation_steps = getattr(cfg.training, 'grad_accumulation_steps', 1)
|
| 139 |
+
batch_size = getattr(cfg.training, 'batch_size', 1)
|
| 140 |
+
num_gpus = getattr(cfg.compute, 'ngpus', 1)
|
| 141 |
+
effective_batch_size = batch_size * grad_accumulation_steps * num_gpus
|
| 142 |
+
print(f"GRADIENT ACCUMULATION STEPS: {grad_accumulation_steps}")
|
| 143 |
+
print(f"EFFECTIVE BATCH SIZE (batch_size * grad_accum_steps * num_gpus): {effective_batch_size} (batch_size={batch_size}, grad_accum_steps={grad_accumulation_steps}, num_gpus={num_gpus})")
|
| 144 |
+
print(f"LAM_PROP: {getattr(cfg.training, 'lam_prop', None)}")
|
| 145 |
+
|
| 146 |
+
eps_id = getattr(cfg.flow, "eps_id", -1)
|
| 147 |
+
path = flow.get_path(scheduler_type=cfg.flow.scheduler_type, exponent=cfg.flow.exponent, eps_id=eps_id)
|
| 148 |
+
loss_fn = flow.get_loss_function(loss_function=cfg.flow.loss_function, path=path)
|
| 149 |
+
|
| 150 |
+
editflow = EditFlow(
|
| 151 |
+
model,
|
| 152 |
+
loss_fn,
|
| 153 |
+
path,
|
| 154 |
+
source_distribution,
|
| 155 |
+
pad_id,
|
| 156 |
+
bos_id,
|
| 157 |
+
eos_id,
|
| 158 |
+
cfg
|
| 159 |
+
)
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
print("--------------------------------")
|
| 163 |
+
print(f"run_name: {run_name}")
|
| 164 |
+
print(f"config.training.loc_prop_path: {getattr(cfg.training, 'loc_prop_path', False)}")
|
| 165 |
+
print(f"config.model.d_model: {cfg.model.d_model}")
|
| 166 |
+
print("--------------------------------")
|
| 167 |
+
|
| 168 |
+
# Dataloader
|
| 169 |
+
if cfg.task == 'protein':
|
| 170 |
+
# train_dataloader, val_dataloader = data.get_data_loaders(config=cfg, data_state=None)
|
| 171 |
+
train_dataset = load_from_disk(cfg.data.train_path)
|
| 172 |
+
val_dataset = load_from_disk(cfg.data.val_path)
|
| 173 |
+
|
| 174 |
+
# Apply debug subset if specified
|
| 175 |
+
subset_ratio = getattr(cfg.data, 'debug_subset_ratio', None)
|
| 176 |
+
if subset_ratio is not None and subset_ratio < 1.0:
|
| 177 |
+
train_size = len(train_dataset)
|
| 178 |
+
train_subset_size = int(train_size * subset_ratio)
|
| 179 |
+
train_dataset = train_dataset.shuffle(seed=cfg.training.seed).select(range(train_subset_size))
|
| 180 |
+
|
| 181 |
+
val_size = len(val_dataset)
|
| 182 |
+
val_subset_size = int(val_size * subset_ratio)
|
| 183 |
+
val_dataset = val_dataset.select(range(val_subset_size))
|
| 184 |
+
|
| 185 |
+
print(f"DEBUG MODE: Using {train_subset_size}/{train_size} training samples ({subset_ratio*100:.1f}%) and {val_subset_size}/{val_size} validation samples ({subset_ratio*100:.1f}%)")
|
| 186 |
+
else:
|
| 187 |
+
train_dataset = train_dataset.shuffle(seed=cfg.training.seed)
|
| 188 |
+
|
| 189 |
+
print(f"train_dataset: {train_dataset[0]['input_ids']}")
|
| 190 |
+
print(f"train_dataset length: {len(train_dataset[0]['input_ids'])}")
|
| 191 |
+
print(f"train_dataset keys: {train_dataset[0].keys()}")
|
| 192 |
+
train_dataloader = DataLoader(train_dataset, batch_size=None, shuffle=True, num_workers=4)
|
| 193 |
+
val_dataloader = DataLoader(val_dataset, batch_size=None, shuffle=False, num_workers=4)
|
| 194 |
+
elif cfg.task == 'smiles':
|
| 195 |
+
train_dataset = load_from_disk(cfg.data.train_path)
|
| 196 |
+
val_dataset = load_from_disk(cfg.data.val_path)
|
| 197 |
+
train_dataloader = DataLoader(train_dataset, batch_size=None, shuffle=True, num_workers=4)
|
| 198 |
+
val_dataloader = DataLoader(val_dataset, batch_size=None, shuffle=False, num_workers=4)
|
| 199 |
+
elif cfg.task == 'selfies':
|
| 200 |
+
train_dataset = load_from_disk(cfg.data.train_path)
|
| 201 |
+
val_dataset = load_from_disk(cfg.data.val_path)
|
| 202 |
+
|
| 203 |
+
# Apply debug subset if specified
|
| 204 |
+
subset_ratio = getattr(cfg.data, 'debug_subset_ratio', None)
|
| 205 |
+
if subset_ratio is not None and subset_ratio < 1.0:
|
| 206 |
+
train_size = len(train_dataset)
|
| 207 |
+
train_subset_size = int(train_size * subset_ratio)
|
| 208 |
+
train_dataset = train_dataset.shuffle(seed=cfg.training.seed).select(range(train_subset_size))
|
| 209 |
+
|
| 210 |
+
val_size = len(val_dataset)
|
| 211 |
+
val_subset_size = int(val_size * subset_ratio)
|
| 212 |
+
val_dataset = val_dataset.select(range(val_subset_size))
|
| 213 |
+
|
| 214 |
+
print(f"DEBUG MODE: Using {train_subset_size}/{train_size} training samples ({subset_ratio*100:.1f}%) and {val_subset_size}/{val_size} validation samples ({subset_ratio*100:.1f}%)")
|
| 215 |
+
else:
|
| 216 |
+
train_dataset = train_dataset.shuffle(seed=cfg.training.seed)
|
| 217 |
+
|
| 218 |
+
print(f"train_dataset: {train_dataset[0]['input_ids']}")
|
| 219 |
+
print(f"train_dataset length: {len(train_dataset[0]['input_ids'])}")
|
| 220 |
+
train_dataloader = DataLoader(train_dataset, batch_size=None, shuffle=True, num_workers=4)
|
| 221 |
+
val_dataloader = DataLoader(val_dataset, batch_size=None, shuffle=False, num_workers=4)
|
| 222 |
+
else:
|
| 223 |
+
raise NotImplementedError
|
| 224 |
+
|
| 225 |
+
ckpt = ModelCheckpoint(
|
| 226 |
+
dirpath=os.path.join(workdir, "checkpoint"),
|
| 227 |
+
monitor="val_loss", # the metric you log
|
| 228 |
+
mode="min", # lower is better
|
| 229 |
+
save_top_k=3, # keep best 3
|
| 230 |
+
save_last=True,
|
| 231 |
+
filename="epoch{epoch:04d}-val{val_loss:.2f}",
|
| 232 |
+
auto_insert_metric_name=False, # <- this stops the extra "val_loss=..."
|
| 233 |
+
)
|
| 234 |
+
lrmon = LearningRateMonitor(logging_interval="step")
|
| 235 |
+
|
| 236 |
+
# Get checkpoint path from config (if specified)
|
| 237 |
+
ckpt_path = getattr(cfg.training, 'ckpt_path', None) or getattr(cfg, 'ckpt_path', None)
|
| 238 |
+
|
| 239 |
+
# Only resume wandb if we're actually resuming from a checkpoint
|
| 240 |
+
# Otherwise, start a fresh run to avoid step counter mismatches
|
| 241 |
+
# Setting resume=None ensures a fresh run, preventing "step less than current step" warnings
|
| 242 |
+
wandb_resume = "allow" if ckpt_path else None
|
| 243 |
+
|
| 244 |
+
wandb_logger = WandbLogger(
|
| 245 |
+
project=cfg.logging.get('project', 'Gated proposal model'),
|
| 246 |
+
name=run_name,
|
| 247 |
+
entity=cfg.logging.get('entity') or None,
|
| 248 |
+
resume=wandb_resume,
|
| 249 |
+
)
|
| 250 |
+
|
| 251 |
+
trainer = pl.Trainer(
|
| 252 |
+
default_root_dir=workdir,
|
| 253 |
+
accelerator="gpu" if torch.cuda.is_available() else "cpu",
|
| 254 |
+
devices=cfg.compute.ngpus,
|
| 255 |
+
strategy="ddp_find_unused_parameters_true" if cfg.compute.ngpus > 1 else "auto",
|
| 256 |
+
precision='bf16-mixed',
|
| 257 |
+
max_epochs=cfg.optim.n_epochs,
|
| 258 |
+
log_every_n_steps=10,
|
| 259 |
+
callbacks=[ckpt, lrmon],
|
| 260 |
+
enable_checkpointing=True,
|
| 261 |
+
gradient_clip_val=1.0,
|
| 262 |
+
deterministic=False,
|
| 263 |
+
logger=wandb_logger,
|
| 264 |
+
)
|
| 265 |
+
|
| 266 |
+
trainer.fit(editflow, train_dataloader, val_dataloader, ckpt_path=ckpt_path if ckpt_path else None)
|
| 267 |
+
|
| 268 |
+
|
| 269 |
+
if __name__ == '__main__':
|
| 270 |
+
parser = argparse.ArgumentParser(description='Train EditFlow model')
|
| 271 |
+
parser.add_argument('--config', type=str, default=None,
|
| 272 |
+
help='Path to config YAML file (default: uses hardcoded default config)')
|
| 273 |
+
args = parser.parse_args()
|
| 274 |
+
print(f"args.config: {args.config}")
|
| 275 |
+
main(config_path=args.config)
|
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
task: protein
|
| 2 |
+
|
| 3 |
+
compute:
|
| 4 |
+
ngpus: 1
|
| 5 |
+
nodes: 1
|
| 6 |
+
|
| 7 |
+
logging:
|
| 8 |
+
enable_wandb: False
|
| 9 |
+
project: pCoMole
|
| 10 |
+
|
| 11 |
+
data:
|
| 12 |
+
# Cas9 training arrows are not shipped. Rebuild them with
|
| 13 |
+
# python cas9/data/cas9_pre_batching.py
|
| 14 |
+
# (ESM-2 tokenized, length-bucketed) or point these at your own
|
| 15 |
+
# HuggingFace load_from_disk() dataset.
|
| 16 |
+
train_path: data/cas9/train
|
| 17 |
+
val_path: data/cas9/validation
|
| 18 |
+
num_workers: 1
|
| 19 |
+
|
| 20 |
+
training:
|
| 21 |
+
batch_size: None
|
| 22 |
+
snapshot: 100
|
| 23 |
+
eval_freq: 1000
|
| 24 |
+
perplexity_freq: 100
|
| 25 |
+
seed: 42
|
| 26 |
+
grad_accumulation_steps: 2
|
| 27 |
+
loc_prop_path: False
|
| 28 |
+
lam_prop: 1
|
| 29 |
+
reparameterize: True
|
| 30 |
+
gamma_rate: 1
|
| 31 |
+
gamma_edit: 1
|
| 32 |
+
use_aux_ce: False
|
| 33 |
+
aux_ce_weight: 1.0
|
| 34 |
+
no_training: False
|
| 35 |
+
|
| 36 |
+
eval:
|
| 37 |
+
batch_size: None
|
| 38 |
+
sample_batch_size: 4
|
| 39 |
+
perplexity: False
|
| 40 |
+
perplexity_batch_size: 4
|
| 41 |
+
|
| 42 |
+
optim:
|
| 43 |
+
weight_decay: 0.03
|
| 44 |
+
optimizer: AdamW
|
| 45 |
+
lr: 1.0e-5
|
| 46 |
+
beta1: 0.9
|
| 47 |
+
beta2: 0.95
|
| 48 |
+
eps: 1.0e-8
|
| 49 |
+
grad_clip: 1.0
|
| 50 |
+
eta_min_ratio: 0.1
|
| 51 |
+
fused: false
|
| 52 |
+
n_epochs: 100
|
| 53 |
+
warmup_ratio: 0.1
|
| 54 |
+
|
| 55 |
+
flow:
|
| 56 |
+
source_distribution: uniform
|
| 57 |
+
loss_function: editflows
|
| 58 |
+
exponent: 2.0
|
| 59 |
+
scheduler_type: polynomial
|
| 60 |
+
sampling_steps: 256
|
| 61 |
+
eps_id: -1
|
| 62 |
+
|
| 63 |
+
model:
|
| 64 |
+
d_model: 768
|
| 65 |
+
max_len: 2048
|
| 66 |
+
n_layers: 12
|
| 67 |
+
n_heads: 12
|
| 68 |
+
dropout: 0.1
|
| 69 |
+
attn_dropout: 0.0
|
| 70 |
+
proj_dropout: 0.0
|
| 71 |
+
compile: False
|
| 72 |
+
esm_model_name: facebook/esm2_t33_650M_UR50D
|
| 73 |
+
freeze_esm: true
|
| 74 |
+
p_optimal: 0
|
| 75 |
+
scale_size: 0.25
|
| 76 |
+
|
| 77 |
+
work_dir: outputs/cas9
|
|
@@ -62,3 +62,67 @@ def smiles_vocab_files() -> tuple[Path, Path]:
|
|
| 62 |
|
| 63 |
def selfies_vocab_path() -> Path:
|
| 64 |
return REPO_ROOT / "smiles_tokenizer" / "selfies_vocab.json"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
|
| 63 |
def selfies_vocab_path() -> Path:
|
| 64 |
return REPO_ROOT / "smiles_tokenizer" / "selfies_vocab.json"
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
# --- Cas9 ---------------------------------------------------------------
|
| 68 |
+
|
| 69 |
+
def protein2pam_root() -> Path:
|
| 70 |
+
"""Root of the protein2pam package (provides the custom ESM loader).
|
| 71 |
+
|
| 72 |
+
Weights come from the Hub ("Profluent-Bio/protein2pam-cas9_full"); this is
|
| 73 |
+
only the Python package that defines the model class.
|
| 74 |
+
"""
|
| 75 |
+
env = os.environ.get("PROTEIN2PAM_ROOT")
|
| 76 |
+
root = Path(env).expanduser().resolve() if env else (REPO_ROOT / "cas9" / "protein2pam")
|
| 77 |
+
if not root.is_dir():
|
| 78 |
+
raise FileNotFoundError(
|
| 79 |
+
"protein2pam was not found. Install it next to the Cas9 code at "
|
| 80 |
+
f"{REPO_ROOT / 'cas9' / 'protein2pam'} or set PROTEIN2PAM_ROOT.\n"
|
| 81 |
+
" pip install protein2pam==0.2.0"
|
| 82 |
+
)
|
| 83 |
+
return root
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def cas_predictor_root() -> Path:
|
| 87 |
+
"""Root of the cas_predictor package (model.py + lightning_module.py)."""
|
| 88 |
+
env = os.environ.get("CAS_PREDICTOR_ROOT")
|
| 89 |
+
root = Path(env).expanduser().resolve() if env else (REPO_ROOT / "cas9" / "cas_predictor")
|
| 90 |
+
if not root.is_dir():
|
| 91 |
+
raise FileNotFoundError(
|
| 92 |
+
"cas_predictor was not found. Place it at "
|
| 93 |
+
f"{REPO_ROOT / 'cas9' / 'cas_predictor'} or set CAS_PREDICTOR_ROOT."
|
| 94 |
+
)
|
| 95 |
+
return root
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def cas9_classifier_ckpt() -> Path:
|
| 99 |
+
"""Cas9-likeness classifier checkpoint (hard constraint + objective)."""
|
| 100 |
+
env = os.environ.get("CAS9_CLASSIFIER_CKPT")
|
| 101 |
+
p = resolve_path(env) if env else (REPO_ROOT / "cas9" / "classifier_ckpt" / "last.ckpt")
|
| 102 |
+
if not p.is_file():
|
| 103 |
+
raise FileNotFoundError(
|
| 104 |
+
f"Cas9 classifier checkpoint not found at {p}. Download it into "
|
| 105 |
+
"cas9/classifier_ckpt/ or set CAS9_CLASSIFIER_CKPT."
|
| 106 |
+
)
|
| 107 |
+
return p
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def cas9_classifier_config() -> Path | None:
|
| 111 |
+
"""Optional classifier config; None falls back to the ckpt hyper_parameters."""
|
| 112 |
+
env = os.environ.get("CAS9_CLASSIFIER_CONFIG")
|
| 113 |
+
if env:
|
| 114 |
+
return resolve_path(env)
|
| 115 |
+
default = REPO_ROOT / "cas9" / "classifier_ckpt" / "config.yaml"
|
| 116 |
+
return default if default.is_file() else None
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def cas9_hmm_db(name: str) -> Path:
|
| 120 |
+
"""HMM database under cas9/hmm/ (e.g. 'cas9_domain_detector_RuvCmix.hmm')."""
|
| 121 |
+
env = os.environ.get("CAS9_HMM_DIR")
|
| 122 |
+
root = Path(env).expanduser().resolve() if env else (REPO_ROOT / "cas9" / "hmm")
|
| 123 |
+
p = root / name
|
| 124 |
+
if not p.is_file():
|
| 125 |
+
raise FileNotFoundError(
|
| 126 |
+
f"HMM database {name} not found at {p}. Place it in cas9/hmm/ or set CAS9_HMM_DIR."
|
| 127 |
+
)
|
| 128 |
+
return p
|
|
@@ -27,3 +27,12 @@ chemprop>=2.0.0
|
|
| 27 |
|
| 28 |
# Optional logging
|
| 29 |
# wandb
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
|
| 28 |
# Optional logging
|
| 29 |
# wandb
|
| 30 |
+
|
| 31 |
+
# Cas9
|
| 32 |
+
h5py # CICERO-format PAM datasets
|
| 33 |
+
biopython # FASTA / sequence handling
|
| 34 |
+
rapidfuzz # sequence similarity in Cas9 diagnostics
|
| 35 |
+
# HMMER (`hmmscan`) must be on PATH for the Cas9 domain / PAM-interacting
|
| 36 |
+
# detectors: conda install -c bioconda hmmer
|
| 37 |
+
# protein2pam provides the PAM oracle's model class:
|
| 38 |
+
# pip install protein2pam==0.2.0
|