Maximilian Holsman Claude Opus 5 commited on
Commit
12fea4a
·
1 Parent(s): 84ee494

Add Cas9 task

Browse files

Adds 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 CHANGED
@@ -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 is not part of this release.
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
cas9/__init__.py ADDED
File without changes
cas9/ckpt/README.md ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ Tier B artifact slot — not yet populated.
2
+
3
+ See the repository README, section 4, for what belongs here.
cas9/classifier_ckpt/README.md ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ Tier B artifact slot — not yet populated.
2
+
3
+ See the repository README, section 4, for what belongs here.
cas9/constraints.py ADDED
@@ -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)
cas9/data/__init__.py ADDED
@@ -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"]
cas9/data/cas9_pre_batching.py ADDED
@@ -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}")
cas9/data/cas9_pre_batching_csv.py ADDED
@@ -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}")
cas9/data/data.py ADDED
@@ -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)
cas9/data/match_cas9_length_distribution.py ADDED
@@ -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()
cas9/data/match_cas9_length_distribution_filtered.py ADDED
@@ -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()
cas9/data/orthologs.fasta ADDED
@@ -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
cas9/data/tokenizer.py ADDED
@@ -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
cas9/data/uniref_pre_batching.py ADDED
@@ -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}")
cas9/data/utils.py ADDED
@@ -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]
cas9/domain_detector.py ADDED
@@ -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]
cas9/evaluate_val.py ADDED
@@ -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()
cas9/evaluate_val_multi_sample.py ADDED
@@ -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()
cas9/generate.py ADDED
@@ -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]
cas9/generate_cas9_csvs.py ADDED
@@ -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()
cas9/hmm/README.md ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ Tier B artifact slot — not yet populated.
2
+
3
+ See the repository README, section 4, for what belongs here.
cas9/logic/__init__.py ADDED
@@ -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.
cas9/logic/evaluate.py ADDED
@@ -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
+ )
cas9/logic/flow.py ADDED
@@ -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")
cas9/logic/generate.py ADDED
@@ -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
cas9/logic/state.py ADDED
@@ -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)
cas9/logic/training.py ADDED
@@ -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()
cas9/model/__init__.py ADDED
@@ -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
+ ]
cas9/model/base_models.py ADDED
@@ -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
+
cas9/model/rotary.py ADDED
@@ -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
+
cas9/model/transformer.py ADDED
@@ -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)
cas9/model/utils.py ADDED
@@ -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
cas9/objectives.py ADDED
@@ -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
+
cas9/pam_detector_hmm.py ADDED
@@ -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
cas9/pcomole.py ADDED
The diff for this file is too large to render. See raw diff
 
cas9/predict_pam_distribution.py ADDED
@@ -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()
cas9/scripts/evaluate.sh ADDED
@@ -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
+ "$@"
cas9/scripts/generate.sh ADDED
@@ -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
+ "$@"
cas9/scripts/pcomole.sh ADDED
@@ -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
+ "$@"
cas9/scripts/train.sh ADDED
@@ -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 "$@"
cas9/train.py ADDED
@@ -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)
configs/config_cas9.yaml ADDED
@@ -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
paths.py CHANGED
@@ -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
requirements.txt CHANGED
@@ -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