TD3B-dev runnability fixes + finetune-on-target + validity samplers

#2
by chq1155 - opened
.gitignore CHANGED
@@ -1,2 +1,8 @@
1
  __pycache__/
2
  *.pyc
 
 
 
 
 
 
 
1
  __pycache__/
2
  *.pyc
3
+ results/
4
+ *.egg-info/
5
+ .ipynb_checkpoints/
6
+ /checkpoints
7
+ /data
8
+ scoring/functions/classifiers/binding-affinity.pt
CLAUDE.md ADDED
@@ -0,0 +1,250 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ ## What this is
6
+
7
+ TD3B (Transition-Directed Discrete Diffusion for Allosteric Binder Generation) is a sequence-based
8
+ generative framework that designs peptide binders with a **specified direction** — agonist or
9
+ antagonist — for a target protein. It extends **TR2-D2** (a masked discrete-diffusion peptide
10
+ generator: MDLM backbone + MCTS amortized finetuning) with three additions:
11
+
12
+ 1. a **Direction Oracle** `f_φ` that predicts agonist vs. antagonist behavior,
13
+ 2. a **soft binding-affinity gate** `g_ψ`, and
14
+ 3. a **gated reward** `R = g_ψ · σ(d*·(f_φ − 0.5)/τ)` that steers generation toward direction `d* ∈ {+1 agonist, −1 antagonist}`.
15
+
16
+ Finetuning distills MCTS-discovered high-reward sequences into the diffusion policy via a total loss
17
+ `L = L_WDCE + λ·L_ctr + β·L_KL` (weighted denoising CE + directional contrastive + KL-to-reference).
18
+
19
+ Paper: arXiv:2605.09810 (LMRL Workshop, ICLR 2026).
20
+
21
+ ## Repo layout: dev vs. OSS, and where the artifacts are
22
+
23
+ - This repo (`ChatterjeeLab/TD3B-dev`) is the **full/dev code**. The clean code-only public release is
24
+ `ChatterjeeLab/TD3B` on HuggingFace. Keep changes runnable against that OSS release.
25
+ - **No checkpoints or data are in git.** Trained checkpoints, train/test CSVs, and generated binders
26
+ ship as a single ~3.4 GB archive `td3b_dev_artifacts.zip` on Google Drive (link in README). Unzip at
27
+ repo root to populate `checkpoints/`, `data/`, `scoring/functions/classifiers/`, `generated_binders/`.
28
+ - `.gitattributes` routes all weight/data extensions (`*.ckpt *.pt *.pth *.npy *.csv? *.zip ...`) through
29
+ Git LFS. Only three XGBoost classifier JSONs (`hemolysis`, `nonfouling`, `solubility`) are in-repo;
30
+ `binding-affinity.pt` and `permeability-xgboost.json` come from the archive.
31
+
32
+ ## Environment & commands
33
+
34
+ ```bash
35
+ conda env create -f env.yml # creates env "td3b" (python 3.10, pytorch-cuda 12.1)
36
+ conda activate td3b
37
+ pip install -e . # installs the `td3b` package (setup.py)
38
+ ```
39
+
40
+ Core deps: PyTorch + Lightning 2.5.5, HuggingFace `transformers` 4.56.2, `fair-esm` 2.0.0 (ESM2),
41
+ `rdkit`, `SmilesPE`, `xgboost`, `wandb`, `hydra-core`. There is **no test suite, no linter config, and
42
+ no Makefile** — do not look for `pytest`/`tox`/`ruff`. Verify changes by actually running inference.
43
+
44
+ **Inference** (primary OSS entry point — generate directional binders):
45
+ ```bash
46
+ python inference.py \
47
+ --ckpt_path checkpoints/td3b.ckpt \
48
+ --val_csv data/test.csv \
49
+ --save_path results/ \
50
+ --seed 42 --num_pool 32 --val_samples_per_target 8 --resample_alpha 0.1
51
+ ```
52
+ For each target row it generates for **both** directions (agonist `d*=+1`, antagonist `d*=-1`), scores
53
+ with the oracle + affinity gate, applies softmax(reward/α) weighted resampling (Algorithm 2), keeps only
54
+ RDKit-valid peptides, and writes `results/td3b_results_seed{seed}.csv`.
55
+
56
+ **Training** (multi-target): edit paths in `launch_multi_target.sh` (`BASE_PATH`, checkpoints, data,
57
+ oracle, and `WANDB_ENTITY` — blank by default; set your own), then `bash launch_multi_target.sh`. It calls
58
+ `finetune_multi_target.py`. Key knobs live in the launch script: `CONTRASTIVE_WEIGHT`(λ), `KL_BETA`(β),
59
+ `SIGMOID_TEMPERATURE`(τ), `NUM_ITER`/`NUM_CHILDREN` (MCTS), `TARGETS_PER_MCTS`(K), and the cadence flags
60
+ below.
61
+
62
+ **Baselines** (CG, SMC, TDS, PepTune, Unguided): `cd baselines && ./run.sh <csv> <baseline> <device>`.
63
+ Multi-GPU via a 5th/6th arg (`torchrun`). Note this script loads `../pretrained/peptune-pretrained.ckpt`,
64
+ **not** `checkpoints/pretrained.ckpt` (see path landmines below).
65
+
66
+ **Demo**: `notebooks/TD3B_Inference_Demo.ipynb` (Colab T4).
67
+
68
+ ## Architecture — the cross-file big picture
69
+
70
+ The pieces below only make sense together; reading any one file in isolation misses the flow.
71
+
72
+ ### Diffusion backbone — `models/`
73
+ - `Diffusion(L.LightningModule)` in `models/diffusion.py` is the MDLM core (absorbing-state / masked
74
+ discrete diffusion). The denoiser is `models/roformer.py::Roformer`, a thin wrapper over HuggingFace
75
+ `RoFormerForMaskedLM` (rotary embeddings). Tokens are **SMILES** via `tokenizer/my_tokenizers.py::SMILES_SPE_Tokenizer`.
76
+ - Absorbing state = the tokenizer's `[MASK]` id (`self.mask_index`), not a constant. Generation starts
77
+ fully masked (`sample_prior` → all-mask) and reverse-diffuses. SUBS parameterization
78
+ (`subs_parameterization`) forbids predicting MASK and pins already-unmasked positions ("carry-over
79
+ unmasking"). Callers use `single_reverse_step` / `single_noise_removal` (final step guarantees no
80
+ surviving MASK); MCTS uses the `batch_mcts_reverse_step` / `mcts_reverse_step` variants which also
81
+ return per-step policy vs. pretrained log-probs for the importance log-ratio `log_rnd`.
82
+ - Config is built by `configs/finetune_config.py::DiffusionConfig`, a **shim** that synthesizes
83
+ duck-typed attribute objects (`type(...)()`) for backward compat. It fixes `parameterization='subs'`,
84
+ `T=0` (continuous-time MDLM loss), `time_conditioning=False`, and `max_position_embeddings=1035`.
85
+ It is a *partial* interface — only the finetune/eval/MCTS fields exist; pure-training paths
86
+ (`antithetic_sampling`, `noise.state_dependent`, `vocab`, `model.length`) are absent and would
87
+ `AttributeError` under it.
88
+ - `models/noise_schedule.py`: `get_noise(config)` supports geometric/loglinear/cosine/linear, but every
89
+ reverse/sampling step **asserts `loglinear`**. A second hardcoded `LogPolyNoise` (cubic) masks
90
+ peptide-bond tokens more slowly.
91
+
92
+ ### Directional reward — `td3b/td3b_scoring.py`
93
+ `TD3BRewardFunction.__call__(List[str] of peptide SMILES) -> (rewards, info)`. Internals:
94
+ `g_ψ` = `scoring/functions/binding.py::BindingAffinity` (magnitude); `f_φ` = the `DirectionalOracle`
95
+ (direction prob ∈[0,1] + confidence κ); reward = `g_ψ · σ(d*·(f_φ−0.5)/τ)`. `create_td3b_reward_function`
96
+ is the factory that builds/loads the oracle, caches the encoded protein tokens, maps `'agonist'/'antagonist'`
97
+ → `d* = +1/−1`, and returns the configured reward. `TD3BConfidenceWeighting` provides the
98
+ confidence-weighted importance weights used by MCTS (`w = κ·exp(S/α)`).
99
+
100
+ ### Direction Oracle — `td3b/direction_oracle.py`
101
+ `DirectionalOracle` wraps `ESM_TR2D2_GPCRClassifier`: **frozen ESM2** (`facebook/esm2_t33_650M_UR50D`,
102
+ 1280-d — downloads from HF unless `esm_cache_dir`/`esm_local_files_only` set) encodes the **protein**;
103
+ a **frozen TR2-D2 RoFormer** encodes the **ligand/peptide SMILES**; both project to `d_model=256`, pass
104
+ 1 self-attention layer each, then **2 stacked bidirectional cross-attention (BMCA) layers**, mean-pool,
105
+ concat, MLP → 2 logits. `predict_with_confidence` returns `f_φ = p_agonist` and `κ = max(softmax)`.
106
+ Loading needs four assets: the oracle `.pt`, the TR2-D2 ligand checkpoint, and the SMILES tokenizer
107
+ vocab+splits. The RoFormer config (768/8/8/1035) is hardcoded and must match the checkpoint.
108
+
109
+ ### Losses — `td3b/td3b_losses.py`
110
+ `TD3BTotalLoss` = `L_WDCE + λ·L_ctr + β·L_KL` (λ=`contrastive_weight`, β=`kl_beta`, both default 0.1).
111
+ `L_WDCE` is computed **externally** by `training/finetune_utils.py::loss_wdce` and passed in — it is the
112
+ policy-distillation term that reweights MCTS samples by `softmax(log_rnd)`. `L_ctr` is `ContrastiveLoss`
113
+ (margin, default) or `InfoNCELoss` over agonist/antagonist embeddings from `extract_embeddings_from_mdlm`
114
+ (reaches into `model.backbone.model`, RoFormer last hidden state, **must not** be under `no_grad`).
115
+ `L_KL` is per-position categorical KL to a **frozen deepcopy reference model** (the pretrained weights).
116
+
117
+ ### MCTS — `mcts/peptide_mcts.py` + `td3b/td3b_mcts.py`
118
+ Base `MCTS` does Pareto/multi-objective tree search: root is fully masked; `select` descends via a
119
+ non-dominated (not scalar-UCB) set with `rd.choice`; `expand` samples `num_children` one-step
120
+ unmaskings, rolls each to a full sequence, filters with `PeptideAnalyzer.is_peptide`, scores valid ones,
121
+ and maintains a **Pareto buffer** of finished trajectories (each storing `x_final`, `log_rnd`,
122
+ reward, score vector). `TD3B_MCTS` subclasses it: injects the gated `TD3BRewardFunction`, pads the (N,2)
123
+ directional score vector to (N,5) so the base Pareto machinery works, folds confidence into `log_rnd`,
124
+ and `forward`/`consolidateBuffer` return **7** values (adding `directional_labels`, `confidences`).
125
+
126
+ ### Training loop — `finetune_multi_target.py`
127
+ Self-contained inline loop (it does **not** call `td3b/td3b_finetune.py::td3b_finetune`, which is a
128
+ legacy/unused single-target loop). Per epoch it alternates:
129
+ - **MCTS generation phase** (every `resample_every_n_step` epochs, default 10): for each sampled target ×
130
+ each direction, build a per-(target,direction) reward, run a fresh `TD3B_MCTS`, push Pareto survivors
131
+ into a replay buffer with `directional_label` **forced to the intended `d*`** (not the oracle guess).
132
+ - **Gradient phase** (every epoch): shuffle the buffer, pad variable-length `x` to max-len with
133
+ `mask_index` + build an attention mask, then WDCE + KL every batch, and contrastive only when a batch
134
+ mixes both directions.
135
+
136
+ Three independent epoch-indexed cadence knobs: `resample_targets_every` (redraw K targets),
137
+ `resample_every_n_step` (MCTS phase), `reset_every_n_step` (reset vs. reuse the search tree).
138
+ `add_td3b_sampling_to_model(policy)` monkey-patches `sample_finetuned_td3b` onto the Diffusion instance —
139
+ required before validation/eval works (that method is not native to `Diffusion`).
140
+
141
+ ### Data schema — `td3b/data_utils.py`
142
+ CSVs use columns `Target_Sequence`, `Ligand_Sequence` (peptide AA string), and `label`
143
+ (`agonist`/`antagonist`, mapped to `d*`); `TD3BDataset` also reads `Action`, `Target_UniProt_ID`,
144
+ `Ligand_UniProt_ID`. Binders are converted AA→SMILES via RDKit `MolFromSequence`. The multi-target
145
+ script uses its own in-file `TargetDataset` (groups by target, stores per-direction **median binder
146
+ length** used to set generation length), reading only `Target_Sequence`/`Ligand_Sequence`/`label`.
147
+ `inference.py` also reads `Target_UniProt_ID`, `Ligand_SMILES` (for length).
148
+
149
+ ### New entry points (added 2026-07-12) — `finetune_on_target.py`, `generate_valid.py`
150
+ - **`finetune_on_target.py` (Function A)** — user-facing "bring your own target" wrapper; it does **not** reimplement training. It normalizes `--target_seq` (repeatable) / `--targets_csv` into a temp CSV (seeding a placeholder poly-G binder of `--binder_length` residues for any missing direction so `TargetDataset` can set a length prior), then **subprocess-invokes `finetune_multi_target.py`** with `--targets_per_mcts=<#targets>` + `--resample_targets_every 1` (finetune on ONLY those targets), and finally **generates in-process** reusing `inference.load_model`/`sample_sequences`/`score_sequences` + `create_td3b_reward_function` + Algorithm-2 resampling. `--direction` restricts only what is generated (finetuning always searches both). Writes `results/finetune_on_target/binders_<dir>_validity-<on|off>_seed<seed>.csv` + the finetuned ckpt under `results/<run_name>_<ts>/`. Paths default to repo root; missing heavy artifacts fail fast with a Google-Drive hint.
151
+ - **`generate_valid.py` + `sampling_strategies.py` (Function B)** — sampling-time validity boosters (no retraining) that reuse the model's own `sample_prior`/`single_reverse_step`/`single_noise_removal`/`forward` and change only token SELECTION (temperature → top-k → softmax → top-p), plus a **remask self-correction loop** (remask the lowest-confidence K% of invalid sequences and re-denoise for R rounds) and a **best-of-N** validity-guided rejection wrapper — no diffusion math is reimplemented. `sampling_strategies.generate(...)` dispatches `baseline, more_steps, top_p(=nucleus), top_k, low_temp, remask, best_of_n, nucleus_remask` (default `nucleus_remask`). `generate_valid.py` loads the real ckpt via `inference.load_model`, or falls back to `build_random_model` (random-init `Diffusion`, CPU dev) when `--ckpt_path` is absent. Output: valid-only CSV (`idx,sequence,n_chars`) + a printed valid-yield summary.
152
+ - **`inference.py --sampler`** (opt-in; default `baseline` = original behavior, byte-for-byte) selects a
153
+ Function-B sampling strategy for the candidate pool, with pass-through knobs
154
+ (`--num_steps --top_p --top_k --temperature --remask_rounds --remask_frac --best_of_n`). Non-baseline
155
+ strategies dispatch to `sampling_strategies.generate`. Function B is also available standalone via
156
+ `generate_valid.py`.
157
+
158
+ ## Landmines
159
+
160
+ A batch of path/wiring bugs that stopped the OSS release from running was fixed on 2026-07-09.
161
+ `python inference.py --help` and `python finetune_multi_target.py --help` now import cleanly (verified
162
+ in the `tr2d2-pep` conda env); a full generation run still needs the Google-Drive artifacts + a GPU.
163
+
164
+ **Fixed — do NOT reintroduce:**
165
+ - `td3b/td3b_finetune.py`: the `from plotting import ...` (no such module) is now `try/except`-guarded,
166
+ and `loss_wdce` is imported **lazily inside `td3b_finetune()`** to break a `finetune_utils ↔ td3b`
167
+ circular import. Keep both — `td3b/__init__.py` eagerly imports `td3b_finetune`, and `finetune_utils`
168
+ imports the `td3b` package at module load, so any module-level `from training.finetune_utils import …`
169
+ in `td3b_finetune.py` re-creates the cycle (it was inference.py's first crash, before plotting).
170
+ - Stale `tr2d2-pep/` prefix stripped from the tokenizer loader (`finetune_utils.load_tokenizer`), every
171
+ classifier loader (`scoring/functions/*.py`, `scoring/scoring_functions.py`, `binding.py`), the
172
+ `Diffusion` fallback tokenizer, and the training results dir. Assets now resolve to the README layout
173
+ (`tokenizer/`, `scoring/functions/classifiers/`, `results/`).
174
+ - `inference.py` reward wiring rewritten: build `MultiTargetBindingAffinity` + `DirectionalOracle` once,
175
+ wrap each target with `TargetSpecificBindingAffinity`, call `create_td3b_reward_function`. (It used to
176
+ call a non-existent `create_reward_function` signature whose `TypeError` was swallowed → empty CSV.)
177
+ - Demo notebook: `from models.diffusion import Diffusion` (was `from diffusion import …`), clone URL now
178
+ points at the OSS HF repo with `GIT_LFS_SKIP_SMUDGE=1`, `total_memory` typo fixed.
179
+ - Added `__init__.py` to the 8 dirs `find_packages()` missed; fixed corrupted `configs/peptune_config.yaml`
180
+ key (`batchinohup ng` → `batching`).
181
+
182
+ **Fixed — round 2 (runtime hardening, 2026-07-10; verified dynamically in `tr2d2-pep`):**
183
+ - `inference.py` Algorithm-2 resampling: now gates candidates by `finite-reward AND valid-peptide`
184
+ **before** sampling and draws **without replacement** (`k=min(val_samples_per_target, n_eligible)`).
185
+ Previously `replacement=True` + the peaked softmax produced duplicate rows (inflated counts, skewed
186
+ means) and validity was filtered only afterward (could save 0 samples despite valid candidates).
187
+ - Checkpoint-load guards (silent-random-weights class): `inference.py::load_model` now raises if the
188
+ backbone loaded **no** weights and warns on partial loads; `direction_oracle.py::TR2D2RoFormerEncoder`
189
+ now unwraps `model_state_dict`/`state_dict` and raises if **zero** RoFormer keys matched (was silently
190
+ leaving the ligand encoder random); `binding.py` tolerates raw/`state_dict`/`model_state_dict` ckpt
191
+ containers; `_load_state_dict_flexible` loudly flags missing **non-ESM** (trained) keys. All guards
192
+ fail only on the impossible-for-a-valid-checkpoint case, so they can't break a good load.
193
+
194
+ **Must NOT change — tied to pretrained weights:** `max_position_embeddings=1035`,
195
+ `parameterization='subs'`, `T=0`, `time_conditioning=False`. `Diffusion.forward` hard-fails if `seq_len > 1035`.
196
+
197
+ **Remaining known quirks (not inference blockers):**
198
+ - **Checkpoint generates valid SMILES only at SHORT length (verified end-to-end 2026-07-12 on real weights
199
+ from `/data1/hanqun/TD3B/checkpoints`).** Valid-peptide yield vs generation length (SMILES tokens),
200
+ `is_peptide` over 32–64 samples: L=40 → 28% baseline / 59% `best_of_n`; L=60 → ~5–30%; **L≥150 → ~0%
201
+ regardless of step count (128/256/512) or best_of_n.** Real 25–30-residue binders are 150–210 tokens, i.e.
202
+ outside the valid regime — that is why a naive `inference.py` run writes an empty CSV. Fix applied:
203
+ `inference.py` now derives length from the binder's *token* count (not char count; reads the `SMILES`
204
+ column, not the nonexistent `Ligand_SMILES`), adds `--seq_length`/`--max_seq_length`, and hints on empty
205
+ output. Working run: `--seq_length 50 --sampler best_of_n --best_of_n 6` → 16 valid oracle-scored binders,
206
+ antagonist direction-accuracy ≈ 1.0. This is a model-capability limit, not a code bug.
207
+ - **ESM2 network fetch:** `facebook/esm2_t33_650M_UR50D` is downloaded at inference twice — by the oracle
208
+ (`transformers`) and by `BindingAffinity`/`MultiTargetBindingAffinity` (`fair-esm`, no offline flag).
209
+ Offline runs need warm HF + torch-hub caches.
210
+ - **Training default paths** (`finetune_multi_target.py:551`, factory fallback `td3b_scoring.py:345`) still
211
+ resolve under `{base_path}/tr2d2-pep/...` with a wrong default oracle filename
212
+ (`best_model_tr2d2_gpcr_fixed.pt` vs. shipped `direction_oracle.pt`). `launch_multi_target.sh` overrides
213
+ all of these with explicit `checkpoints/` args, so training-via-launch-script works; a bare
214
+ `python finetune_multi_target.py` does not.
215
+ - `baselines/run.sh` loads `pretrained/peptune-pretrained.ckpt` (not `checkpoints/pretrained.ckpt`) and
216
+ defaults `CSV_PATH="To Be Added"` — pass real args.
217
+ - `td3b/td3b_finetune.py::td3b_finetune()` is legacy/unused (superseded by `finetune_multi_target.py`);
218
+ still writes to `{base}/TR2-D2/tr2d2-pep/results/...` and needs the optional `plotting` module.
219
+ - `TD3BDataset`/`load_td3b_data` (oracle-training path) require CSV columns `Action`, `Ligand_UniProt_ID`
220
+ that the inference/finetune CSVs don't carry.
221
+ - Dead/inert flags: `--contrastive_type` (loss always uses `'margin'`), `--num_epoch_for_sampling`,
222
+ `min_affinity_threshold=0.0` (down-weight branch never fires), `use_confidence_weighting` (stored but
223
+ never applied in `compute_gated_reward`).
224
+ - **`PeptideAnalyzer.is_peptide` (`utils/app.py`) false-positives** on atom-only / pure-amino-acid-letter
225
+ strings (e.g. `"CCNCCF"`, `"cccccc"` → True) because it checks AA-letter membership before RDKit. Harmless
226
+ for a well-trained model (emits real peptide SMILES) but can inflate `valid_mask` for an untrained/early
227
+ checkpoint. Shared by MCTS/baselines — do not change its semantics without checking all callers.
228
+ - Oracle confidence κ is actually ∈[0.5,1] (max of a 2-class softmax), not the documented [0,1]; unused at
229
+ inference. `TD3BConfidenceWeighting.compute_importance_weights` uses raw `exp(reward/α)` which can overflow
230
+ for large affinities — off the inference path (MCTS/training only).
231
+ - **Device coupling:** `models/roformer.py` and helpers pick their own device independently of `--device`
232
+ (some hardcode `cuda:0`); `resolve_device` in `scoring_functions.py`/`direction_oracle.py` silently falls
233
+ back to `cuda:0`/CPU. Mixed-device errors are easy to create.
234
+
235
+ **New entry points (2026-07-12) — usage caveats:**
236
+ - **The validity toggle is a FILTER, not a reward term.** `finetune_on_target.py --validity_reward {on,off}`
237
+ (default `on`) never changes the reward formula (`affinity × direction`); it toggles the
238
+ `PeptideAnalyzer.is_peptide` gate on **both** halves — the finetune-side MCTS expansion (forwarded to
239
+ `finetune_multi_target.py --validity_reward` → `args.enforce_validity`) and the generation-side Algorithm-2
240
+ resampling. `off` retains invalid samples (each output row still records `is_valid`).
241
+ `--finetune_validity_hook {on,off}` decouples the finetune-side gate from the generation-side toggle.
242
+ - **Function B benchmarks were on a RANDOM-INIT model** → only *relative* trends (which strategy helps most at
243
+ long length) are meaningful; **absolute** valid-yields need the real checkpoint. `generate_valid.py`
244
+ **silently falls back to random init** when `--ckpt_path` is missing/omitted (it prints a NOTE, but the
245
+ numbers are garbage) �� always pass a real `--ckpt_path` for reportable yields.
246
+ - `finetune_on_target.py` runs the finetune half as a **subprocess** (default `WANDB_MODE=disabled`) and locates
247
+ the produced checkpoint by diffing `results/<run_name>_*` dirs before/after (newest `model_final.ckpt`, else
248
+ newest `model_epoch_*.ckpt`); a renamed/failed run dir would break that discovery. Its own oracle/path defaults
249
+ are passed through explicitly so the subprocess never falls back to `finetune_multi_target.py`'s legacy
250
+ `{base}/tr2d2-pep/...` defaults.
README.md CHANGED
@@ -11,6 +11,8 @@
11
 
12
  TD3B is a sequence-based generative framework that designs peptide binders with specified agonist or antagonist behavior. It combines a Direction Oracle, a soft binding-affinity gate, and amortized fine-tuning of a pre-trained discrete diffusion model (MDLM).
13
 
 
 
14
  ## Installation
15
 
16
  ```bash
@@ -29,23 +31,35 @@ To run it: download the notebook from this repository, open [Google Colab](https
29
 
30
  ## Data and Checkpoints
31
 
32
- The checkpoints ship with this repository. The expected layout is:
 
 
 
 
 
 
 
 
33
 
34
  ```
35
- TD3B/
36
  ├── checkpoints/
37
- │ ├── pretrained.ckpt # Pre-trained MDLM weights
38
- │ ├── td3b.ckpt # Fine-tuned TD3B model
39
- │ └── direction_oracle.pt # Direction Oracle weights
40
  ├── scoring/functions/classifiers/
41
- │ ├── binding-affinity.pt
42
- │ ├── hemolysis-xgboost.json
43
- │ ├── nonfouling-xgboost.json
44
- │ ├── permeability-xgboost.json
45
- │ └── solubility-xgboost.json
46
- ── tokenizer/
47
- ├── new_vocab.txt
48
- ── new_splits.txt
 
 
 
 
49
  ```
50
 
51
  ## Code Structure
@@ -53,6 +67,9 @@ TD3B/
53
  ```
54
  TD3B/
55
  ├── inference.py # Generate binders (main inference entry point)
 
 
 
56
  ├── finetune_multi_target.py # Multi-target TD3B training
57
  ├── launch_multi_target.sh # Training launcher script
58
  ├── models/
@@ -95,21 +112,88 @@ python inference.py \
95
 
96
  This generates 32 candidates per (target, direction), scores them with the Direction Oracle and affinity predictor, applies Algorithm 2 weighted resampling, and saves only valid peptide samples.
97
 
98
- Output: `results/td3b_results_seed42.csv` with columns: target, sequence, direction, affinity, gated_reward, direction_oracle, direction_accuracy.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
99
 
100
  ## Training
101
 
102
  ### Multi-target TD3B
103
 
104
- 1. Edit `launch_multi_target.sh` — set paths to checkpoints, data, and oracle:
105
 
106
  ```bash
107
- BASE_PATH="/path/to/TD3B"
108
  PRETRAINED_CHECKPOINT="${BASE_PATH}/checkpoints/pretrained.ckpt"
109
  TRAIN_CSV="${BASE_PATH}/data/train.csv"
110
  ORACLE_CKPT="${BASE_PATH}/checkpoints/direction_oracle.pt"
111
  ```
112
 
 
 
113
  2. Launch training:
114
 
115
  ```bash
@@ -138,10 +222,11 @@ bash run.sh --baseline tds --device cuda:0
138
 
139
  ```bibtex
140
  @inproceedings{
141
- cao2026tdb,
142
- title={{TD3B: Transition-Directed Discrete Diffusion for Allosteric Binder Generation}},
143
- author={Hanqun Cao and Aastha Pal and Sophia Tang and Yinuo Zhang and Jingjie Zhang and Pheng-Ann Heng and Pranam Chatterjee},
144
- booktitle={Forty-third International Conference on Machine Learning (Spotlight)},
145
- year={2026}
 
146
  }
147
  ```
 
11
 
12
  TD3B is a sequence-based generative framework that designs peptide binders with specified agonist or antagonist behavior. It combines a Direction Oracle, a soft binding-affinity gate, and amortized fine-tuning of a pre-trained discrete diffusion model (MDLM).
13
 
14
+ > **Development release.** This repository (`ChatterjeeLab/TD3B-dev`) carries the full code. The heavy artifacts — trained checkpoints, training/test data, and generated binders — are distributed as a single archive on Google Drive (see [Data and Checkpoints](#data-and-checkpoints)). For the clean code-only release see [`ChatterjeeLab/TD3B`](https://huggingface.co/ChatterjeeLab/TD3B).
15
+
16
  ## Installation
17
 
18
  ```bash
 
31
 
32
  ## Data and Checkpoints
33
 
34
+ The checkpoints, datasets, and generated binders are bundled in a single archive on Google Drive:
35
+
36
+ **Download:** [`td3b_dev_artifacts.zip`](https://drive.google.com/file/d/1EU0-pcF-UoEb0OEMuO4qhodZ5RjMU4n9/view?usp=sharing) (~3.4 GB)
37
+
38
+ Unzip it at the repository root to restore the full layout:
39
+
40
+ ```bash
41
+ unzip td3b_dev_artifacts.zip -d .
42
+ ```
43
 
44
  ```
45
+ TD3B-dev/
46
  ├── checkpoints/
47
+ │ ├── pretrained.ckpt # Pre-trained MDLM weights (1.4 GB)
48
+ │ ├── td3b.ckpt # Fine-tuned TD3B model (231 MB)
49
+ │ └── direction_oracle.pt # Direction Oracle weights (2.85 GB)
50
  ├── scoring/functions/classifiers/
51
+ │ ├── binding-affinity.pt # (from archive)
52
+ │ ├── permeability-xgboost.json # (from archive)
53
+ │ ├── hemolysis-xgboost.json # (in repo)
54
+ │ ├── nonfouling-xgboost.json # (in repo)
55
+ │ └── solubility-xgboost.json # (in repo)
56
+ ── data/
57
+ ├── train.csv # Training set (target-binder pairs)
58
+ │ ├── test.csv # Test set
59
+ │ └── td3b_data_new.csv # Full labeled target/ligand pairs (agonist/antagonist)
60
+ └── generated_binders/
61
+ ├── agonist/ # 1106 generated agonist binders (.pdb + .trb), 249 targets
62
+ └── antagonist.tar.gz # Generated antagonist binders
63
  ```
64
 
65
  ## Code Structure
 
67
  ```
68
  TD3B/
69
  ├── inference.py # Generate binders (main inference entry point)
70
+ ├── finetune_on_target.py # Finetune on your own target(s) + generate (Function A)
71
+ ├── generate_valid.py # Validity-boosting sampling CLI (Function B)
72
+ ├── sampling_strategies.py # Validity-boosting sampler library (Function B)
73
  ├── finetune_multi_target.py # Multi-target TD3B training
74
  ├── launch_multi_target.sh # Training launcher script
75
  ├── models/
 
112
 
113
  This generates 32 candidates per (target, direction), scores them with the Direction Oracle and affinity predictor, applies Algorithm 2 weighted resampling, and saves only valid peptide samples.
114
 
115
+ > **Generation length matters.** The length (in SMILES tokens) comes from `--seq_length` if given, else it is
116
+ > derived from each row's reference binder (correctly tokenized). This released checkpoint reliably produces
117
+ > **valid** SMILES only at **short lengths (≲100 tokens)**; long binders (e.g. 25–30-residue peptides ≈ 150–210
118
+ > tokens) yield few or no valid samples regardless of step count. For non-empty output on such targets, pass a
119
+ > shorter `--seq_length` and a validity-boosting sampler (see below), e.g.:
120
+ > ```bash
121
+ > python inference.py --ckpt_path checkpoints/td3b.ckpt --val_csv data/test.csv \
122
+ > --seq_length 50 --sampler best_of_n --best_of_n 6 --num_pool 32 --val_samples_per_target 4
123
+ > ```
124
+ > On a real run this yields valid, oracle-scored binders in both directions (antagonist direction accuracy ≈ 1.0).
125
+
126
+ Output: `results/td3b_results_seed42.csv` with columns: target, target_uid, sequence, direction_name, target_direction, is_valid, affinity, gated_reward, direction_oracle, direction_accuracy.
127
+
128
+ ## Finetune on your own target(s)
129
+
130
+ `finetune_on_target.py` takes protein target(s) you supply, finetunes the pretrained TD3B policy on **only those target(s)**, then generates directional (agonist/antagonist) binders for them. It reuses the existing machinery: the finetune half subprocess-invokes `finetune_multi_target.py` (with `K` set to the number of targets), and the generation half runs in-process with the same reward, oracle, and Algorithm 2 resampling as `inference.py`.
131
+
132
+ ```bash
133
+ python finetune_on_target.py \
134
+ --target_seq MKTAYIAKQRQISFVKSHFSRQLEERLGLIEVQ \
135
+ --target_seq GSHMKELVLALYDYQEKSPREVTMKKGDILTLL \
136
+ --direction both \
137
+ --validity_reward on \
138
+ --num_epochs 20 \
139
+ --num_pool 32 \
140
+ --gen_samples_per_target 8 \
141
+ --device cuda:0 --seed 42
142
+ ```
143
+
144
+ Provide targets by repeating `--target_seq` and/or via `--targets_csv` (a CSV with a `Target_Sequence` column; optional `Ligand_Sequence`/`label` rows seed the per-direction length prior):
145
+
146
+ ```bash
147
+ python finetune_on_target.py --targets_csv my_targets.csv --direction agonist --binder_length 20
148
+ ```
149
+
150
+ Key flags:
151
+ - `--target_seq SEQ` (repeatable) and/or `--targets_csv` — provide at least one target.
152
+ - `--direction {agonist,antagonist,both}` (default `both`) — which binders to **generate**. Finetuning always searches both directions.
153
+ - `--validity_reward {on,off}` (default `on`) — validity gate applied to **both** halves. `on` keeps the `is_peptide` filter (invalid MCTS children are zero-rewarded during finetuning; only valid peptides are eligible for resampling during generation). `off` drops the gate on both halves (invalid samples are retained, each row keeps `is_valid`); the reward itself is always affinity × direction — validity is only ever a filter, never part of the reward formula. `--finetune_validity_hook {on,off}` overrides just the finetune-side gate.
154
+ - `--binder_length` (default 20) — placeholder binder length (residues) used to seed the generation-length prior for any target with no known binder.
155
+ - `--skip_finetune` (generate directly from `--td3b_checkpoint`) / `--skip_generate` (finetune only).
156
+ - Training knobs: `--num_epochs` (20), `--num_iter` (10) / `--num_children` (16) (MCTS), `--learning_rate` (3e-4), `--seq_length` (200). Generation knobs: `--num_pool` (32), `--gen_samples_per_target` (8), `--resample_alpha` (0.1), `--total_num_steps` (128).
157
+ - Paths default to the repo root; override `--pretrained_checkpoint`, `--direction_oracle_ckpt`, `--output_dir`, `--device` (`auto`/`cpu`/`cuda:N`), `--seed` as needed.
158
+
159
+ Output: `results/finetune_on_target/binders_<direction>_validity-<on|off>_seed<seed>.csv` with columns target, sequence, direction_name, target_direction, is_valid, affinity, gated_reward, direction_oracle, direction_accuracy, gen_length. The finetuned checkpoint is written under `results/<run_name>_<timestamp>/` (used automatically for the generation half).
160
+
161
+ ## Higher-validity sampling for long binders
162
+
163
+ As the target length grows, the fraction of decoded SMILES that pass the RDKit peptide check drops. `generate_valid.py` (backed by `sampling_strategies.py`) applies **sampling-time** strategies — no retraining — to raise the valid-peptide yield, especially at large length, and reports the valid fraction. The strategies only change token selection (temperature / top-k / top-p) and add a remask self-correction loop and best-of-N rejection on top of the model's existing diffusion primitives.
164
+
165
+ ```bash
166
+ python generate_valid.py \
167
+ --ckpt_path checkpoints/td3b.ckpt \
168
+ --length 400 --num_samples 64 \
169
+ --strategy nucleus_remask \
170
+ --device cuda:0 --seed 42 \
171
+ --save_path results/valid_len400.csv
172
+ ```
173
+
174
+ Key flags:
175
+ - `--strategy` (default `nucleus_remask`) — one of `baseline`, `more_steps`, `top_p` (a.k.a. `nucleus`), `top_k`, `low_temp`, `remask`, `best_of_n`, `nucleus_remask`. On random-init benchmarks the self-correcting strategies (`remask`, `best_of_n`, `nucleus_remask`) give the largest relative validity gains at length ≥ 200; `nucleus_remask` is the recommended default.
176
+ - `--length` (default 200, in tokens) and `--num_samples` (default 64).
177
+ - `--ckpt_path` — TD3B checkpoint. If omitted or missing, a **random-init** model is used (exercises the sampling machinery only; yields are meaningless — pass a real checkpoint for real numbers).
178
+ - Per-strategy overrides (else the preset default is used): `--top_p`, `--top_k`, `--temperature`, `--steps_per_token`, `--remask_rounds`, `--remask_frac`, `--remask_steps`, `--best_of_n`, `--num_steps`.
179
+
180
+ Output: prints the valid yield (valid / `num_samples`) with per-round counts, and writes the valid sequences to `--save_path` (default `results/valid_<strategy>_len<L>.csv`) with columns idx, sequence, n_chars.
181
 
182
  ## Training
183
 
184
  ### Multi-target TD3B
185
 
186
+ 1. Edit `launch_multi_target.sh` only if needed `BASE_PATH` auto-detects the repo root (this script's own directory), and the checkpoint, data, and oracle paths derive from it:
187
 
188
  ```bash
189
+ BASE_PATH="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # auto-detects the repo root
190
  PRETRAINED_CHECKPOINT="${BASE_PATH}/checkpoints/pretrained.ckpt"
191
  TRAIN_CSV="${BASE_PATH}/data/train.csv"
192
  ORACLE_CKPT="${BASE_PATH}/checkpoints/direction_oracle.pt"
193
  ```
194
 
195
+ `finetune_multi_target.py --base_path` also defaults to the repo root, so you only need to override these when your checkpoints or data live elsewhere.
196
+
197
  2. Launch training:
198
 
199
  ```bash
 
222
 
223
  ```bibtex
224
  @inproceedings{
225
+ cao2026tdb,
226
+ title={{TD}3B: Transition-Directed Discrete Diffusion for Allosteric Binder Generation},
227
+ author={Hanqun Cao and Aastha Pal and Sophia Tang and Yinuo Zhang and Jingjie Zhang and Pheng-Ann Heng and Pranam Chatterjee},
228
+ booktitle={Learning Meaningful Representations of Life (LMRL) Workshop at ICLR 2026},
229
+ year={2026},
230
+ url={https://openreview.net/forum?id=uMUicLylVp}
231
  }
232
  ```
baselines/run_mcts_tr2d2.py CHANGED
@@ -48,7 +48,7 @@ def _extract_state_and_config(ckpt: Dict[str, Any]) -> Dict[str, Any]:
48
 
49
  def _build_args(cfg: Dict[str, Any], cli: argparse.Namespace) -> argparse.Namespace:
50
  defaults = {
51
- "base_path": "To Be Added",
52
  "seq_length": 200,
53
  "sampling_eps": 1e-3,
54
  "total_num_steps": 128,
 
48
 
49
  def _build_args(cfg: Dict[str, Any], cli: argparse.Namespace) -> argparse.Namespace:
50
  defaults = {
51
+ "base_path": ROOT_DIR,
52
  "seq_length": 200,
53
  "sampling_eps": 1e-3,
54
  "total_num_steps": 128,
baselines/run_validation_td3b.py CHANGED
@@ -48,7 +48,7 @@ def _extract_state_and_config(ckpt: Dict[str, Any]) -> Dict[str, Any]:
48
 
49
  def _build_args(cfg: Dict[str, Any], cli: argparse.Namespace) -> argparse.Namespace:
50
  defaults = {
51
- "base_path": "To Be Added",
52
  "seq_length": 200,
53
  "sampling_eps": 1e-3,
54
  "total_num_steps": 128,
 
48
 
49
  def _build_args(cfg: Dict[str, Any], cli: argparse.Namespace) -> argparse.Namespace:
50
  defaults = {
51
+ "base_path": ROOT_DIR,
52
  "seq_length": 200,
53
  "sampling_eps": 1e-3,
54
  "total_num_steps": 128,
baselines/sampling_setup.py CHANGED
@@ -300,7 +300,7 @@ def main():
300
  raise ValueError("--prot_seq is required when --targets_csv is not provided.")
301
 
302
  base_model = load_base_model(args.ckpt_path, args.device)
303
- base_path = os.path.abspath(os.path.join(ROOT_DIR, ".."))
304
  multi_target = args.targets_csv is not None
305
  scoring_fn = load_reward_models(
306
  args.prot_seq if not multi_target else None,
 
300
  raise ValueError("--prot_seq is required when --targets_csv is not provided.")
301
 
302
  base_model = load_base_model(args.ckpt_path, args.device)
303
+ base_path = ROOT_DIR
304
  multi_target = args.targets_csv is not None
305
  scoring_fn = load_reward_models(
306
  args.prot_seq if not multi_target else None,
configs/__init__.py ADDED
File without changes
configs/peptune_config.yaml CHANGED
@@ -36,7 +36,7 @@ lr_scheduler:
36
  data:
37
  train: To Be Added
38
  valid: To Be Added
39
- batchinohup ng: wrapping # padding / wrapping
40
 
41
  loader:
42
  global_batch_size: 64
 
36
  data:
37
  train: To Be Added
38
  valid: To Be Added
39
+ batching: wrapping # padding / wrapping
40
 
41
  loader:
42
  global_batch_size: 64
finetune_multi_target.py CHANGED
@@ -397,8 +397,9 @@ def parse_args():
397
 
398
  # Paths
399
  path_group = parser.add_argument_group('Paths')
400
- path_group.add_argument('--base_path', type=str, required=True,
401
- help='Base path for TR2-D2 project')
 
402
  path_group.add_argument('--train_csv', type=str, required=True,
403
  help='Path to training CSV file')
404
  path_group.add_argument('--val_csv', type=str, default=None,
@@ -455,6 +456,12 @@ def parse_args():
455
  help='Temperature for importance weighting')
456
  mcts_group.add_argument('--exploration', type=float, default=1.0,
457
  help='UCB exploration constant')
 
 
 
 
 
 
458
 
459
  # TD3B loss hyperparameters
460
  loss_group = parser.add_argument_group('TD3B Loss')
@@ -510,8 +517,8 @@ def parse_args():
510
  log_group = parser.add_argument_group('Logging')
511
  log_group.add_argument('--wandb_project', type=str, default='TD3B-multi-target',
512
  help='W&B project name')
513
- log_group.add_argument('--wandb_entity', type=str, default='phos_zj',
514
- help='W&B entity name')
515
 
516
  # Directional oracle
517
  oracle_group = parser.add_argument_group('Directional Oracle')
@@ -570,6 +577,9 @@ def parse_args():
570
  args.time_conditioning = False
571
  args.num_obj = 5 # Must match padded score vector size
572
  args.scalarization = "sum"
 
 
 
573
 
574
  # Create save path
575
  args.save_path = create_output_directory(
 
397
 
398
  # Paths
399
  path_group = parser.add_argument_group('Paths')
400
+ path_group.add_argument('--base_path', type=str,
401
+ default=os.path.dirname(os.path.abspath(__file__)),
402
+ help='Base path for TR2-D2 project (defaults to the repo root)')
403
  path_group.add_argument('--train_csv', type=str, required=True,
404
  help='Path to training CSV file')
405
  path_group.add_argument('--val_csv', type=str, default=None,
 
456
  help='Temperature for importance weighting')
457
  mcts_group.add_argument('--exploration', type=float, default=1.0,
458
  help='UCB exploration constant')
459
+ mcts_group.add_argument('--validity_reward', choices=['on', 'off'], default='on',
460
+ help="Validity gate during MCTS expansion. 'on' (default): "
461
+ "invalid decoded peptides are gated as zero-reward nodes "
462
+ "(PeptideAnalyzer.is_peptide filter). 'off': disable the gate "
463
+ "so invalid peptides are kept and scored on the pure "
464
+ "affinity x direction reward.")
465
 
466
  # TD3B loss hyperparameters
467
  loss_group = parser.add_argument_group('TD3B Loss')
 
517
  log_group = parser.add_argument_group('Logging')
518
  log_group.add_argument('--wandb_project', type=str, default='TD3B-multi-target',
519
  help='W&B project name')
520
+ log_group.add_argument('--wandb_entity', type=str, default=None,
521
+ help='W&B entity name (default: your personal/default entity)')
522
 
523
  # Directional oracle
524
  oracle_group = parser.add_argument_group('Directional Oracle')
 
577
  args.time_conditioning = False
578
  args.num_obj = 5 # Must match padded score vector size
579
  args.scalarization = "sum"
580
+ # Validity gate toggle -> consumed by MCTS (mcts/peptide_mcts.py) via the
581
+ # args threading through create_mcts_instance/create_td3b_mcts/TD3B_MCTS.
582
+ args.enforce_validity = (args.validity_reward == "on")
583
 
584
  # Create save path
585
  args.save_path = create_output_directory(
finetune_on_target.py ADDED
@@ -0,0 +1,701 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ TD3B — Finetune-on-Target entry point (BUILD FUNCTION A).
4
+
5
+ User-facing workflow: the user supplies one or more protein *targets*; the system
6
+ (1) FINETUNES the pretrained TD3B diffusion policy on ONLY those target(s), then
7
+ (2) GENERATES directional (agonist/antagonist) peptide binders for them — with a
8
+ toggle to include or exclude the validity reward.
9
+
10
+ This script does NOT reimplement the training loop. It REUSES the existing
11
+ machinery:
12
+
13
+ * FINETUNE half -> runs ``finetune_multi_target.py`` as a subprocess on a
14
+ temporary CSV built from the provided target(s), with K (``--targets_per_mcts``)
15
+ set to the number of provided targets. That script builds
16
+ ``MultiTargetBindingAffinity`` + ``DirectionalOracle``, runs ``TD3B_MCTS`` per
17
+ (target, direction) to search candidate binders, and distills them into the
18
+ diffusion policy via WDCE + KL + contrastive losses.
19
+
20
+ * GENERATE half -> runs IN-PROCESS, reusing ``inference.py``'s
21
+ ``load_model`` / ``sample_sequences`` / ``score_sequences`` and
22
+ ``td3b.td3b_scoring.create_td3b_reward_function`` (gated reward
23
+ R = g_psi * sigma(d*(f_phi - 0.5)/tau)) plus the Algorithm-2 weighted
24
+ resampling. Running this half in-process (rather than shelling out to
25
+ ``inference.py``) is what lets us honor ``--validity_reward off`` at the
26
+ generation/scoring layer — see the VALIDITY TOGGLE section below.
27
+
28
+ --------------------------------------------------------------------------------
29
+ VALIDITY TOGGLE (``--validity_reward {on,off}``)
30
+ --------------------------------------------------------------------------------
31
+ Validity never enters the reward *formula* itself (the reward is always
32
+ affinity x direction). It enters TD3B only as a FILTER: ``PeptideAnalyzer.is_peptide``
33
+ gates MCTS expansion during finetuning and gates the Algorithm-2 resampling during
34
+ generation. This flag now controls BOTH halves:
35
+
36
+ * ``on`` (default): keep the validity gate on both halves. During finetuning,
37
+ invalid decoded children are gated as zero-reward MCTS nodes; during
38
+ generation only valid peptides are eligible for resampling (identical to
39
+ ``inference.py``).
40
+ * ``off``: drop the validity gate on both halves. FINETUNE side — forwarded as
41
+ ``--validity_reward off`` to ``finetune_multi_target.py``, which disables the
42
+ ``is_peptide`` gate in MCTS expansion (``mcts/peptide_mcts.py``) so invalid
43
+ peptides are kept and scored on the pure affinity x direction reward instead
44
+ of being zero-rewarded. GENERATION side — every finite-reward candidate is
45
+ eligible for resampling and invalid samples are retained in the output (each
46
+ row still records ``is_valid`` for inspection). The reward stays pure
47
+ affinity x direction throughout.
48
+
49
+ Use ``--finetune_validity_hook {on,off}`` to override the finetune-side gate
50
+ independently of the generation-side toggle (default: follow ``--validity_reward``).
51
+
52
+ --------------------------------------------------------------------------------
53
+ Also note (reuse-without-editing limitations):
54
+ * ``finetune_multi_target.py`` ALWAYS searches both agonist and antagonist per
55
+ target in its MCTS phase. The training CSV therefore always seeds a length
56
+ prior for BOTH directions (real binder length if provided, else a placeholder
57
+ of ``--binder_length`` residues). ``--direction`` restricts only what the
58
+ GENERATE half emits, not what finetuning trains on.
59
+
60
+ Base paths DEFAULT to the repo root (auto-detected from ``__file__``); nothing is
61
+ hardcoded to an absolute path. Heavy artifacts (checkpoints / ESM / binding
62
+ predictor) are validated up-front with a clear error if missing.
63
+ """
64
+
65
+ import argparse
66
+ import glob
67
+ import logging
68
+ import os
69
+ import subprocess
70
+ import sys
71
+ import tempfile
72
+ from typing import Dict, List, Optional, Tuple
73
+
74
+ import numpy as np
75
+ import pandas as pd
76
+
77
+ # Repo root — every default path is derived from this, never hardcoded absolute.
78
+ REPO_ROOT = os.path.dirname(os.path.abspath(__file__))
79
+ if REPO_ROOT not in sys.path:
80
+ sys.path.insert(0, REPO_ROOT)
81
+
82
+ logger = logging.getLogger("finetune_on_target")
83
+ logging.basicConfig(level=logging.INFO,
84
+ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s")
85
+
86
+ AA_SET = set("ACDEFGHIKLMNPQRSTVWY")
87
+ DIRECTIONS = {"agonist": 1.0, "antagonist": -1.0}
88
+ GDRIVE_HINT = ("These heavy artifacts ship separately (see README 'Data and "
89
+ "Checkpoints' — ~3.4 GB on Google Drive). Unzip them at the repo "
90
+ "root to restore checkpoints/ and scoring/functions/classifiers/.")
91
+
92
+
93
+ # ─────────────────────────────────────────────────────────────────────────────
94
+ # Target-set construction
95
+ # ───────────────────────────────────────────────────────────��─────────────────
96
+ def _looks_like_protein(seq: str) -> bool:
97
+ if not isinstance(seq, str):
98
+ return False
99
+ s = seq.strip().upper()
100
+ return len(s) >= 2 and all(c in AA_SET for c in s)
101
+
102
+
103
+ def make_placeholder_binder(binder_length: int, mode: str = "polyG",
104
+ rng: Optional[np.random.Generator] = None) -> str:
105
+ """Synthesize a placeholder binder purely to seed the per-direction LENGTH
106
+ prior (never scored as a real binder). Poly-glycine by default."""
107
+ n = max(2, int(binder_length))
108
+ if mode == "random":
109
+ rng = rng or np.random.default_rng(0)
110
+ return "".join(rng.choice(list(AA_SET), size=n))
111
+ return "G" * n
112
+
113
+
114
+ def build_target_training_frame(
115
+ target_seqs: List[str],
116
+ targets_csv: Optional[str],
117
+ binder_length: int,
118
+ placeholder_mode: str = "polyG",
119
+ seed: int = 42,
120
+ ) -> Tuple[pd.DataFrame, List[str]]:
121
+ """Build the normalized training frame consumed by ``finetune_multi_target``.
122
+
123
+ Columns: ``Target_Sequence``, ``Ligand_Sequence``, ``label``.
124
+
125
+ For every unique target we ensure BOTH directions have at least one row so the
126
+ downstream ``TargetDataset`` can compute a per-direction median binder length.
127
+ Real (Ligand_Sequence, label) rows from the CSV are kept as-is; any missing
128
+ direction is seeded with a placeholder binder of ``binder_length`` residues.
129
+
130
+ Returns (frame, unique_target_order).
131
+ """
132
+ rng = np.random.default_rng(seed)
133
+ # target_seq -> {'agonist': [binders...], 'antagonist': [...]}
134
+ per_target: Dict[str, Dict[str, List[str]]] = {}
135
+ order: List[str] = []
136
+
137
+ def _ensure(t: str) -> Dict[str, List[str]]:
138
+ if t not in per_target:
139
+ per_target[t] = {"agonist": [], "antagonist": []}
140
+ order.append(t)
141
+ return per_target[t]
142
+
143
+ # 1) Explicit --target_seq entries (no known binders).
144
+ for t in target_seqs or []:
145
+ t = t.strip()
146
+ if not t:
147
+ continue
148
+ if not _looks_like_protein(t):
149
+ logger.warning("Target sequence does not look like a protein (non-AA "
150
+ "characters); using it anyway: %.40s...", t)
151
+ _ensure(t)
152
+
153
+ # 2) Optional CSV with Target_Sequence (+ optional Ligand_Sequence/label).
154
+ if targets_csv:
155
+ if not os.path.isfile(targets_csv):
156
+ raise FileNotFoundError(f"--targets_csv not found: {targets_csv}")
157
+ df = pd.read_csv(targets_csv)
158
+ if "Target_Sequence" not in df.columns:
159
+ raise ValueError(f"{targets_csv} must contain a 'Target_Sequence' column "
160
+ f"(found: {list(df.columns)})")
161
+ for _, row in df.iterrows():
162
+ t = str(row["Target_Sequence"]).strip()
163
+ if not t:
164
+ continue
165
+ bucket = _ensure(t)
166
+ binder = row.get("Ligand_Sequence")
167
+ label = str(row.get("label", "")).strip().lower()
168
+ if isinstance(binder, str) and binder.strip() and label in DIRECTIONS:
169
+ bucket[label].append(binder.strip())
170
+
171
+ if not order:
172
+ raise ValueError("No targets provided. Pass at least one --target_seq or a "
173
+ "--targets_csv with a 'Target_Sequence' column.")
174
+
175
+ # 3) Materialize rows, seeding placeholders for any empty direction.
176
+ rows = []
177
+ for t in order:
178
+ for direction in ("agonist", "antagonist"):
179
+ binders = per_target[t][direction]
180
+ if not binders:
181
+ binders = [make_placeholder_binder(binder_length, placeholder_mode, rng)]
182
+ for b in binders:
183
+ rows.append({"Target_Sequence": t, "Ligand_Sequence": b, "label": direction})
184
+
185
+ frame = pd.DataFrame(rows, columns=["Target_Sequence", "Ligand_Sequence", "label"])
186
+ return frame, order
187
+
188
+
189
+ # ─────────────────────────────────────────────────────────────────────────────
190
+ # Checkpoint / artifact validation
191
+ # ─────────────────────────────────────────────────────────────────────────────
192
+ def _require_files(pairs: List[Tuple[str, str]], stage: str) -> None:
193
+ """Raise a single clear error listing every missing artifact for ``stage``."""
194
+ missing = [(label, path) for label, path in pairs if not os.path.isfile(path)]
195
+ if missing:
196
+ lines = "\n".join(f" - {label}: {path}" for label, path in missing)
197
+ raise FileNotFoundError(
198
+ f"Cannot run the {stage} stage — required artifact(s) are missing:\n"
199
+ f"{lines}\n{GDRIVE_HINT}"
200
+ )
201
+
202
+
203
+ def finetune_artifact_checks(args) -> List[Tuple[str, str]]:
204
+ return [
205
+ ("pretrained_checkpoint", args.pretrained_checkpoint),
206
+ ("direction_oracle_ckpt", args.direction_oracle_ckpt),
207
+ ("direction_oracle_tr2d2_checkpoint", args.direction_oracle_tr2d2_checkpoint),
208
+ ("direction_oracle_tokenizer_vocab", args.direction_oracle_tokenizer_vocab),
209
+ ("direction_oracle_tokenizer_splits", args.direction_oracle_tokenizer_splits),
210
+ ("binding_affinity_predictor",
211
+ os.path.join(args.base_path, "scoring", "functions", "classifiers", "binding-affinity.pt")),
212
+ ]
213
+
214
+
215
+ def generate_artifact_checks(args, ckpt_path: str) -> List[Tuple[str, str]]:
216
+ return [
217
+ ("td3b_checkpoint", ckpt_path),
218
+ ("direction_oracle_ckpt", args.direction_oracle_ckpt),
219
+ ("direction_oracle_tr2d2_checkpoint", args.direction_oracle_tr2d2_checkpoint),
220
+ ("binding_affinity_predictor",
221
+ os.path.join(args.base_path, "scoring", "functions", "classifiers", "binding-affinity.pt")),
222
+ ]
223
+
224
+
225
+ # ─────────────────────────────────────────────────────────────────────────────
226
+ # FINETUNE half (subprocess reuse of finetune_multi_target.py)
227
+ # ─────────────────────────────────────────────────────────────────────────────
228
+ def run_finetune(args, train_csv: str, num_targets: int) -> str:
229
+ """Finetune on the provided target(s) by invoking finetune_multi_target.py.
230
+
231
+ Returns the path to the finetuned checkpoint (model_final.ckpt, else the
232
+ newest model_epoch_*.ckpt) discovered in the run's output directory.
233
+ """
234
+ _require_files(finetune_artifact_checks(args), "finetune")
235
+
236
+ # Validity gate forwarded to the finetune-side MCTS. Follows --validity_reward
237
+ # unless --finetune_validity_hook explicitly overrides it (decoupling the
238
+ # finetune-side gate from the generation-side toggle).
239
+ finetune_validity = args.finetune_validity_hook or args.validity_reward
240
+
241
+ results_root = os.path.join(args.base_path, "results")
242
+ os.makedirs(results_root, exist_ok=True)
243
+ # finetune_multi_target appends a timestamp to run_name -> record pre-existing
244
+ # dirs so we can identify the NEW one it creates.
245
+ before = set(glob.glob(os.path.join(results_root, f"{args.run_name}_*")))
246
+
247
+ # K = number of provided targets (train on ONLY these).
248
+ cmd = [
249
+ sys.executable, os.path.join(args.base_path, "finetune_multi_target.py"),
250
+ "--base_path", args.base_path,
251
+ "--train_csv", train_csv,
252
+ "--pretrained_checkpoint", args.pretrained_checkpoint,
253
+ "--run_name", args.run_name,
254
+ "--device", args.device,
255
+ "--targets_per_mcts", str(num_targets),
256
+ "--resample_targets_every", "1",
257
+ "--num_epochs", str(args.num_epochs),
258
+ "--learning_rate", str(args.learning_rate),
259
+ "--train_batch_size", str(args.train_batch_size),
260
+ "--gradient_accumulation_steps", str(args.gradient_accumulation_steps),
261
+ "--resample_every_n_step", str(args.resample_every_n_step),
262
+ "--save_every_n_epochs", str(args.save_every_n_epochs),
263
+ "--reset_every_n_step", str(max(1, args.num_epochs)), # reset tree once, at epoch 0
264
+ "--num_iter", str(args.num_iter),
265
+ "--num_children", str(args.num_children),
266
+ "--buffer_size", str(args.buffer_size),
267
+ "--validity_reward", finetune_validity, # forwards the validity gate to finetune-side MCTS
268
+ "--alpha", str(args.alpha),
269
+ "--min_affinity_threshold", str(args.min_affinity_threshold),
270
+ "--sigmoid_temperature", str(args.sigmoid_temperature),
271
+ "--seq_length", str(args.seq_length),
272
+ "--wandb_project", args.wandb_project,
273
+ # oracle wiring (pass our repo-root defaults through explicitly so the
274
+ # subprocess does not fall back to its own legacy default paths)
275
+ "--direction_oracle_ckpt", args.direction_oracle_ckpt,
276
+ "--direction_oracle_tr2d2_checkpoint", args.direction_oracle_tr2d2_checkpoint,
277
+ "--direction_oracle_tokenizer_vocab", args.direction_oracle_tokenizer_vocab,
278
+ "--direction_oracle_tokenizer_splits", args.direction_oracle_tokenizer_splits,
279
+ "--direction_oracle_esm_name", args.direction_oracle_esm_name,
280
+ "--direction_oracle_d_model", str(args.direction_oracle_d_model),
281
+ "--direction_oracle_n_heads", str(args.direction_oracle_n_heads),
282
+ "--direction_oracle_n_self_attn_layers", str(args.direction_oracle_n_self_attn_layers),
283
+ "--direction_oracle_n_bmca_layers", str(args.direction_oracle_n_bmca_layers),
284
+ "--direction_oracle_dropout", str(args.direction_oracle_dropout),
285
+ ]
286
+ if args.direction_oracle_esm_cache_dir:
287
+ cmd += ["--direction_oracle_esm_cache_dir", args.direction_oracle_esm_cache_dir]
288
+ if args.direction_oracle_esm_local_files_only:
289
+ cmd += ["--direction_oracle_esm_local_files_only"]
290
+ if args.grad_clip:
291
+ cmd += ["--grad_clip", "--gradnorm_clip", str(args.gradnorm_clip)]
292
+
293
+ env = dict(os.environ)
294
+ env.setdefault("WANDB_MODE", args.wandb_mode) # default: disabled (offline, no prompt)
295
+
296
+ logger.info("Launching finetune subprocess (K=%d target(s)):\n %s",
297
+ num_targets, " ".join(cmd))
298
+ proc = subprocess.run(cmd, cwd=args.base_path, env=env)
299
+ if proc.returncode != 0:
300
+ raise RuntimeError(
301
+ f"finetune_multi_target.py exited with code {proc.returncode}. "
302
+ f"See the subprocess log above for the underlying cause (e.g. a missing "
303
+ f"checkpoint, OOM, or oracle/ESM load failure)."
304
+ )
305
+
306
+ # Locate the checkpoint produced by this run.
307
+ after = set(glob.glob(os.path.join(results_root, f"{args.run_name}_*")))
308
+ new_dirs = sorted(after - before, key=os.path.getmtime)
309
+ search_dirs = new_dirs or sorted(after, key=os.path.getmtime)
310
+ if not search_dirs:
311
+ raise RuntimeError(
312
+ f"Finetuning finished but no results dir matching "
313
+ f"{results_root}/{args.run_name}_* was found; cannot locate the "
314
+ f"finetuned checkpoint.")
315
+ run_dir = search_dirs[-1]
316
+ final_ckpt = os.path.join(run_dir, "model_final.ckpt")
317
+ if os.path.isfile(final_ckpt):
318
+ ckpt = final_ckpt
319
+ else:
320
+ epoch_ckpts = sorted(glob.glob(os.path.join(run_dir, "model_epoch_*.ckpt")),
321
+ key=os.path.getmtime)
322
+ if not epoch_ckpts:
323
+ raise RuntimeError(
324
+ f"Finetuning finished but no checkpoint (model_final.ckpt or "
325
+ f"model_epoch_*.ckpt) was found in {run_dir}.")
326
+ ckpt = epoch_ckpts[-1]
327
+ logger.info("Finetuned checkpoint: %s", ckpt)
328
+ return ckpt
329
+
330
+
331
+ # ─────────────────────────────────────────────────────────────────────────────
332
+ # GENERATE half (in-process reuse of inference.py + td3b scoring)
333
+ # ─────────────────────────────────────────────────────────────────────────────
334
+ # These three thin seams are what the smoke test monkeypatches to inject a
335
+ # random-init tiny model + stub reward (so the generation control flow can run on
336
+ # CPU without the 3.4 GB artifacts / ESM).
337
+ def build_generation_model(ckpt_path: str, device):
338
+ """Load the finetuned TD3B model. Reuses inference.load_model (which refuses
339
+ to run on an all-random backbone, guarding against silent garbage output)."""
340
+ from inference import load_model
341
+ return load_model(ckpt_path, device)
342
+
343
+
344
+ def build_reward_components(args, device, tokenizer, model):
345
+ """Build the shared (expensive) reward components ONCE: the multi-target
346
+ affinity predictor (ESM2 + binding-affinity.pt) and the Direction Oracle."""
347
+ from scoring.functions.binding import MultiTargetBindingAffinity
348
+ from td3b.direction_oracle import DirectionalOracle
349
+
350
+ multi_affinity = MultiTargetBindingAffinity(
351
+ tokenizer=tokenizer, base_path=args.base_path, device=device,
352
+ emb_model=model.backbone,
353
+ )
354
+ directional_oracle = DirectionalOracle(
355
+ model_ckpt=args.direction_oracle_ckpt,
356
+ tr2d2_checkpoint=args.direction_oracle_tr2d2_checkpoint,
357
+ tokenizer_vocab=args.direction_oracle_tokenizer_vocab,
358
+ tokenizer_splits=args.direction_oracle_tokenizer_splits,
359
+ esm_name=args.direction_oracle_esm_name,
360
+ d_model=args.direction_oracle_d_model,
361
+ n_heads=args.direction_oracle_n_heads,
362
+ n_self_attn_layers=args.direction_oracle_n_self_attn_layers,
363
+ n_bmca_layers=args.direction_oracle_n_bmca_layers,
364
+ dropout=args.direction_oracle_dropout,
365
+ max_ligand_length=args.direction_oracle_max_ligand_length,
366
+ max_protein_length=args.direction_oracle_max_protein_length,
367
+ device=device,
368
+ esm_cache_dir=args.direction_oracle_esm_cache_dir,
369
+ esm_local_files_only=args.direction_oracle_esm_local_files_only,
370
+ )
371
+ directional_oracle.eval()
372
+ return multi_affinity, directional_oracle
373
+
374
+
375
+ def make_reward_function(multi_affinity, directional_oracle, target_seq, direction,
376
+ tokenizer, device, args):
377
+ """Bind the shared reward components to (target, direction). Reuses the exact
378
+ gated reward from td3b.td3b_scoring."""
379
+ from scoring.functions.binding import TargetSpecificBindingAffinity
380
+ from td3b.td3b_scoring import create_td3b_reward_function
381
+
382
+ target_affinity = TargetSpecificBindingAffinity(multi_affinity, target_seq)
383
+ return create_td3b_reward_function(
384
+ affinity_predictor=target_affinity,
385
+ directional_oracle=directional_oracle,
386
+ target_protein_seq=target_seq,
387
+ target_direction=direction,
388
+ peptide_tokenizer=tokenizer,
389
+ device=device,
390
+ min_affinity_threshold=args.min_affinity_threshold,
391
+ temperature=args.sigmoid_temperature,
392
+ )
393
+
394
+
395
+ def generate_binders(args, model, tokenizer, multi_affinity, directional_oracle,
396
+ targets, length_provider, device) -> pd.DataFrame:
397
+ """Sample -> score -> Algorithm-2 resample, for every (target, requested
398
+ direction). Honors the validity toggle at the resampling gate.
399
+
400
+ Args:
401
+ targets: ordered list of unique target protein sequences.
402
+ length_provider: fn(target_seq, direction_name) -> int generation length.
403
+ Returns a results DataFrame (also written to disk by the caller).
404
+ """
405
+ import torch
406
+ from inference import sample_sequences, score_sequences
407
+ from utils.app import PeptideAnalyzer
408
+
409
+ analyzer = PeptideAnalyzer()
410
+ validity_on = (args.validity_reward == "on")
411
+ directions = (["agonist", "antagonist"] if args.direction == "both" else [args.direction])
412
+ columns = ["target", "target_full", "sequence", "direction_name", "target_direction",
413
+ "is_valid", "validity_reward", "affinity", "gated_reward",
414
+ "direction_oracle", "direction_accuracy", "gen_length"]
415
+ records = []
416
+
417
+ for tidx, target_seq in enumerate(targets):
418
+ for d_name in directions:
419
+ d_star = DIRECTIONS[d_name]
420
+ logger.info("[%d/%d] target=%.20s... direction=%s",
421
+ tidx + 1, len(targets), target_seq, d_name)
422
+
423
+ reward_model = make_reward_function(
424
+ multi_affinity, directional_oracle, target_seq, d_name,
425
+ tokenizer, device, args)
426
+
427
+ gen_len = int(max(1, length_provider(target_seq, d_name)))
428
+ x_pool = sample_sequences(model, args.num_pool, gen_len, args.total_num_steps)
429
+ sequences = tokenizer.batch_decode(x_pool)
430
+
431
+ valid_mask = np.array([analyzer.is_peptide(s) for s in sequences], dtype=bool)
432
+ gated_rewards, affinities, oracle_dirs, _conf = score_sequences(reward_model, sequences)
433
+ direction_accuracy = ((oracle_dirs > 0.5).astype(float) if d_star > 0
434
+ else (oracle_dirs < 0.5).astype(float))
435
+
436
+ # ── VALIDITY TOGGLE ───────────────────────────────────────────────
437
+ # on : eligible = finite reward AND valid peptide (== inference.py)
438
+ # off: eligible = finite reward only (validity gate skipped; invalid
439
+ # samples retained; reward stays pure affinity x direction)
440
+ finite = np.isfinite(gated_rewards)
441
+ eligible = (finite & valid_mask) if validity_on else finite
442
+
443
+ if eligible.any():
444
+ rewards_t = torch.as_tensor(gated_rewards[eligible], device=device,
445
+ dtype=torch.float32)
446
+ alpha = max(args.resample_alpha, 1e-6)
447
+ weights = torch.softmax(rewards_t / alpha, dim=0)
448
+ k = min(args.gen_samples_per_target, int(eligible.sum()))
449
+ idx = torch.multinomial(weights, num_samples=k, replacement=False)
450
+ chosen = np.where(eligible)[0][idx.cpu().numpy()]
451
+ else:
452
+ logger.warning("No eligible candidates for target=%.20s dir=%s "
453
+ "(validity_reward=%s).", target_seq, d_name, args.validity_reward)
454
+ chosen = np.array([], dtype=int)
455
+
456
+ for i in chosen:
457
+ records.append({
458
+ "target": target_seq[:20],
459
+ "target_full": target_seq,
460
+ "sequence": sequences[i],
461
+ "direction_name": d_name,
462
+ "target_direction": d_star,
463
+ "is_valid": bool(valid_mask[i]),
464
+ "validity_reward": args.validity_reward,
465
+ "affinity": float(affinities[i]),
466
+ "gated_reward": float(gated_rewards[i]),
467
+ "direction_oracle": float(oracle_dirs[i]),
468
+ "direction_accuracy": float(direction_accuracy[i]),
469
+ "gen_length": gen_len,
470
+ })
471
+
472
+ # Always return the defined columns so an empty result still writes a header
473
+ # row (clearer than a 0-byte file — never silently produce empty output).
474
+ return pd.DataFrame(records, columns=columns)
475
+
476
+
477
+ def run_generation(args, ckpt_path: str, train_csv: str, targets: List[str]) -> str:
478
+ """Orchestrate the generate half and write the results CSV. Returns its path."""
479
+ import torch
480
+ _require_files(generate_artifact_checks(args, ckpt_path), "generate")
481
+
482
+ device = torch.device(args.device if (args.device != "auto") else
483
+ ("cuda:0" if torch.cuda.is_available() else "cpu"))
484
+ if device.type == "cuda" and not torch.cuda.is_available():
485
+ logger.warning("CUDA requested but unavailable; using CPU.")
486
+ device = torch.device("cpu")
487
+
488
+ torch.manual_seed(args.seed)
489
+ np.random.seed(args.seed)
490
+
491
+ logger.info("Loading finetuned model: %s", ckpt_path)
492
+ model, tokenizer = build_generation_model(ckpt_path, device)
493
+
494
+ logger.info("Building reward components (affinity predictor + Direction Oracle)...")
495
+ multi_affinity, directional_oracle = build_reward_components(args, device, tokenizer, model)
496
+
497
+ # Per-direction generation length prior, reusing finetune_multi_target's
498
+ # TargetDataset (SMILES-token median length) for consistency with training.
499
+ length_provider = build_length_provider(train_csv, tokenizer, args.binder_length)
500
+
501
+ df = generate_binders(args, model, tokenizer, multi_affinity, directional_oracle,
502
+ targets, length_provider, device)
503
+
504
+ os.makedirs(args.output_dir, exist_ok=True)
505
+ out_path = os.path.join(
506
+ args.output_dir,
507
+ f"binders_{args.direction}_validity-{args.validity_reward}_seed{args.seed}.csv")
508
+ df.to_csv(out_path, index=False)
509
+
510
+ if len(df):
511
+ logger.info("=" * 60)
512
+ logger.info("Wrote %d binder(s) -> %s", len(df), out_path)
513
+ for d_name in (["agonist", "antagonist"] if args.direction == "both" else [args.direction]):
514
+ sub = df[df["direction_name"] == d_name]
515
+ if len(sub):
516
+ logger.info(" %-10s n=%d affinity=%.3f gated=%.3f dir_acc=%.3f valid_frac=%.3f",
517
+ d_name, len(sub), sub["affinity"].mean(), sub["gated_reward"].mean(),
518
+ sub["direction_accuracy"].mean(), sub["is_valid"].mean())
519
+ logger.info("=" * 60)
520
+ else:
521
+ logger.warning("No binders generated (empty results). Wrote header-only CSV -> %s", out_path)
522
+ return out_path
523
+
524
+
525
+ def build_length_provider(train_csv: str, tokenizer, fallback_binder_length: int):
526
+ """Return fn(target_seq, direction_name) -> generation length, reusing
527
+ finetune_multi_target.TargetDataset so the generated length matches the length
528
+ prior used during training. Falls back to a token-length estimate of a
529
+ placeholder of ``fallback_binder_length`` residues if a target is unknown."""
530
+ from finetune_multi_target import TargetDataset
531
+ from td3b.data_utils import peptide_seq_to_smiles, smiles_token_length
532
+
533
+ dataset = TargetDataset(train_csv, tokenizer=tokenizer)
534
+ fallback_len = smiles_token_length(
535
+ peptide_seq_to_smiles(make_placeholder_binder(fallback_binder_length)), tokenizer)
536
+
537
+ def provider(target_seq: str, direction_name: str) -> int:
538
+ try:
539
+ return dataset.get_sequence_length(target_seq, direction_name)
540
+ except KeyError:
541
+ return fallback_len
542
+
543
+ return provider
544
+
545
+
546
+ # ─────────────────────────────────────────────────────────────────────────────
547
+ # CLI
548
+ # ─────────────────────────────────────────────────────────────────────────────
549
+ def parse_args(argv=None):
550
+ p = argparse.ArgumentParser(
551
+ description="Finetune TD3B on user-provided protein target(s), then "
552
+ "generate directional binders (with a validity-reward toggle).",
553
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
554
+ )
555
+
556
+ tgt = p.add_argument_group("Targets (provide at least one)")
557
+ tgt.add_argument("--target_seq", action="append", default=None, metavar="SEQ",
558
+ help="Target protein amino-acid sequence. Repeatable for multiple targets.")
559
+ tgt.add_argument("--targets_csv", type=str, default=None,
560
+ help="CSV with at least 'Target_Sequence' (optional 'Ligand_Sequence'/'label').")
561
+
562
+ ctrl = p.add_argument_group("Control")
563
+ ctrl.add_argument("--direction", choices=["agonist", "antagonist", "both"], default="both",
564
+ help="Which directional binders to GENERATE (finetuning always trains both).")
565
+ ctrl.add_argument("--validity_reward", choices=["on", "off"], default="on",
566
+ help="on: keep the is_peptide validity gate during BOTH finetuning "
567
+ "(MCTS expansion) and generation; off: pure affinity x direction, "
568
+ "no validity gate in either phase (see module docstring).")
569
+ ctrl.add_argument("--binder_length", type=int, default=20,
570
+ help="Placeholder binder length (residues) used to seed the length prior "
571
+ "when a target has no known binder.")
572
+ ctrl.add_argument("--placeholder_mode", choices=["polyG", "random"], default="polyG",
573
+ help="Placeholder binder composition (length-prior seed only).")
574
+
575
+ stages = p.add_argument_group("Stages")
576
+ stages.add_argument("--skip_finetune", action="store_true",
577
+ help="Skip finetuning and generate from --td3b_checkpoint directly.")
578
+ stages.add_argument("--skip_generate", action="store_true",
579
+ help="Finetune only; do not generate.")
580
+
581
+ train = p.add_argument_group("Training knobs (small sane defaults)")
582
+ train.add_argument("--num_epochs", type=int, default=20)
583
+ train.add_argument("--num_iter", type=int, default=10, help="MCTS iterations per resample.")
584
+ train.add_argument("--num_children", type=int, default=16, help="Children per MCTS expansion.")
585
+ train.add_argument("--learning_rate", type=float, default=3e-4)
586
+ train.add_argument("--train_batch_size", type=int, default=8)
587
+ train.add_argument("--gradient_accumulation_steps", type=int, default=1)
588
+ train.add_argument("--resample_every_n_step", type=int, default=10)
589
+ train.add_argument("--save_every_n_epochs", type=int, default=20)
590
+ train.add_argument("--buffer_size", type=int, default=50)
591
+ train.add_argument("--alpha", type=float, default=0.1, help="Importance-weight temperature.")
592
+ train.add_argument("--min_affinity_threshold", type=float, default=0.0)
593
+ train.add_argument("--sigmoid_temperature", type=float, default=0.1)
594
+ train.add_argument("--seq_length", type=int, default=200, help="Max sequence length.")
595
+ train.add_argument("--total_num_steps", type=int, default=128, help="Diffusion steps for generation.")
596
+ train.add_argument("--grad_clip", action="store_true")
597
+ train.add_argument("--gradnorm_clip", type=float, default=1.0)
598
+ train.add_argument("--finetune_validity_hook", choices=["on", "off"], default=None,
599
+ help="Optional override for the FINETUNE-side validity gate forwarded to "
600
+ "finetune_multi_target.py's --validity_reward. Default (unset): follow "
601
+ "--validity_reward. Set to decouple the finetune-side gate from the "
602
+ "generation-side toggle.")
603
+
604
+ gen = p.add_argument_group("Generation knobs")
605
+ gen.add_argument("--num_pool", type=int, default=32, help="Candidates sampled per (target, direction).")
606
+ gen.add_argument("--gen_samples_per_target", type=int, default=8, help="Binders kept per (target, direction).")
607
+ gen.add_argument("--resample_alpha", type=float, default=0.1, help="Algorithm-2 resampling temperature.")
608
+
609
+ paths = p.add_argument_group("Paths (default to repo root, auto-detected)")
610
+ paths.add_argument("--base_path", type=str, default=REPO_ROOT,
611
+ help="Repo root; source of scoring/, tokenizer/, checkpoints/.")
612
+ paths.add_argument("--pretrained_checkpoint", type=str,
613
+ default=os.path.join(REPO_ROOT, "checkpoints", "pretrained.ckpt"))
614
+ paths.add_argument("--td3b_checkpoint", type=str, default=None,
615
+ help="Finetuned checkpoint for --skip_finetune generation. If omitted and "
616
+ "finetuning ran, the produced checkpoint is used.")
617
+ paths.add_argument("--output_dir", type=str,
618
+ default=os.path.join(REPO_ROOT, "results", "finetune_on_target"))
619
+ paths.add_argument("--run_name", type=str, default="finetune_on_target")
620
+ paths.add_argument("--device", type=str, default="auto", help="'auto', 'cpu', 'cuda', 'cuda:N'.")
621
+ paths.add_argument("--seed", type=int, default=42)
622
+ paths.add_argument("--wandb_project", type=str, default="TD3B-finetune-on-target")
623
+ paths.add_argument("--wandb_mode", type=str, default="disabled",
624
+ help="WANDB_MODE for the finetune subprocess (disabled/offline/online).")
625
+
626
+ orc = p.add_argument_group("Directional oracle")
627
+ orc.add_argument("--direction_oracle_ckpt", type=str,
628
+ default=os.path.join(REPO_ROOT, "checkpoints", "direction_oracle.pt"))
629
+ orc.add_argument("--direction_oracle_tr2d2_checkpoint", type=str,
630
+ default=os.path.join(REPO_ROOT, "checkpoints", "pretrained.ckpt"))
631
+ orc.add_argument("--direction_oracle_tokenizer_vocab", type=str,
632
+ default=os.path.join(REPO_ROOT, "tokenizer", "new_vocab.txt"))
633
+ orc.add_argument("--direction_oracle_tokenizer_splits", type=str,
634
+ default=os.path.join(REPO_ROOT, "tokenizer", "new_splits.txt"))
635
+ orc.add_argument("--direction_oracle_esm_name", type=str, default="facebook/esm2_t33_650M_UR50D")
636
+ orc.add_argument("--direction_oracle_esm_cache_dir", type=str, default=None)
637
+ orc.add_argument("--direction_oracle_esm_local_files_only", action="store_true")
638
+ orc.add_argument("--direction_oracle_max_ligand_length", type=int, default=768)
639
+ orc.add_argument("--direction_oracle_max_protein_length", type=int, default=1024)
640
+ orc.add_argument("--direction_oracle_d_model", type=int, default=256)
641
+ orc.add_argument("--direction_oracle_n_heads", type=int, default=4)
642
+ orc.add_argument("--direction_oracle_n_self_attn_layers", type=int, default=1)
643
+ orc.add_argument("--direction_oracle_n_bmca_layers", type=int, default=2)
644
+ orc.add_argument("--direction_oracle_dropout", type=float, default=0.3)
645
+
646
+ args = p.parse_args(argv)
647
+
648
+ if not args.target_seq and not args.targets_csv:
649
+ p.error("Provide at least one --target_seq or a --targets_csv.")
650
+ if args.skip_finetune and args.skip_generate:
651
+ p.error("--skip_finetune and --skip_generate together leave nothing to do.")
652
+ # Normalize base-path-derived defaults if the user overrode --base_path only.
653
+ return args
654
+
655
+
656
+ def write_temp_training_csv(frame: pd.DataFrame, output_dir: str) -> str:
657
+ os.makedirs(output_dir, exist_ok=True)
658
+ fd, path = tempfile.mkstemp(prefix="td3b_target_train_", suffix=".csv", dir=output_dir)
659
+ os.close(fd)
660
+ frame.to_csv(path, index=False)
661
+ return path
662
+
663
+
664
+ def main(argv=None):
665
+ args = parse_args(argv)
666
+ logger.info("=" * 80)
667
+ logger.info("TD3B finetune-on-target | direction=%s validity_reward=%s",
668
+ args.direction, args.validity_reward)
669
+ logger.info("=" * 80)
670
+
671
+ # 1) Build + normalize the target training set (seeds length priors).
672
+ frame, targets = build_target_training_frame(
673
+ args.target_seq, args.targets_csv, args.binder_length,
674
+ placeholder_mode=args.placeholder_mode, seed=args.seed)
675
+ logger.info("Prepared %d unique target(s); %d training row(s).", len(targets), len(frame))
676
+
677
+ train_csv = write_temp_training_csv(frame, args.output_dir)
678
+ logger.info("Temp training CSV: %s", train_csv)
679
+
680
+ if args.finetune_validity_hook is not None:
681
+ logger.info("--finetune_validity_hook=%s overrides the finetune-side validity gate "
682
+ "(generation-side stays validity_reward=%s).",
683
+ args.finetune_validity_hook, args.validity_reward)
684
+
685
+ # 2) FINETUNE half.
686
+ ckpt_path = args.td3b_checkpoint
687
+ if not args.skip_finetune:
688
+ ckpt_path = run_finetune(args, train_csv, num_targets=len(targets))
689
+ elif not ckpt_path:
690
+ raise ValueError("--skip_finetune requires --td3b_checkpoint (nothing to generate from).")
691
+
692
+ # 3) GENERATE half.
693
+ if not args.skip_generate:
694
+ out_path = run_generation(args, ckpt_path, train_csv, targets)
695
+ logger.info("Done. Results: %s", out_path)
696
+ else:
697
+ logger.info("Done (finetune only). Checkpoint: %s", ckpt_path)
698
+
699
+
700
+ if __name__ == "__main__":
701
+ main()
generate_valid.py ADDED
@@ -0,0 +1,153 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ generate_valid.py -- standalone CLI to generate peptide SMILES with a chosen
4
+ validity-boosting sampling strategy (see ``sampling_strategies.py``) and report the
5
+ fraction that pass ``utils.app.PeptideAnalyzer.is_peptide``.
6
+
7
+ Examples
8
+ --------
9
+ # Real checkpoint, long peptides, nucleus + remask self-correction (recommended default):
10
+ python generate_valid.py \
11
+ --ckpt_path checkpoints/td3b.ckpt \
12
+ --length 400 --num_samples 64 \
13
+ --strategy nucleus_remask \
14
+ --device cuda:0 --seed 42 \
15
+ --save_path results/valid_len400.csv
16
+
17
+ # No checkpoint available -> RANDOM-init model on CPU (development / API smoke test;
18
+ # absolute yields are garbage, only the sampling machinery is exercised):
19
+ python generate_valid.py --length 200 --num_samples 32 --strategy remask --device cpu
20
+
21
+ Strategies: baseline, more_steps, top_p (nucleus), top_k, low_temp, remask,
22
+ best_of_n, nucleus_remask. Per-strategy knobs below override the preset defaults.
23
+ """
24
+ import argparse
25
+ import csv
26
+ import logging
27
+ import os
28
+ import sys
29
+
30
+ import numpy as np
31
+ import torch
32
+
33
+ ROOT_DIR = os.path.dirname(os.path.abspath(__file__))
34
+ if ROOT_DIR not in sys.path:
35
+ sys.path.insert(0, ROOT_DIR)
36
+
37
+ from sampling_strategies import generate, build_random_model, available_strategies
38
+ from utils.app import PeptideAnalyzer
39
+
40
+ logger = logging.getLogger("generate_valid")
41
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
42
+
43
+
44
+ def _load_model(ckpt_path, device, base_path, hidden_size, n_layers, n_heads):
45
+ """Load the real checkpoint via ``inference.load_model`` when available; otherwise
46
+ fall back to a RANDOM-init model (imports of ``inference`` are done lazily so the
47
+ random path has no heavy dependencies)."""
48
+ if ckpt_path and os.path.isfile(ckpt_path):
49
+ logger.info("Loading real checkpoint from %s", ckpt_path)
50
+ from inference import load_model # reuse the canonical loader
51
+ model, tokenizer = load_model(ckpt_path, device)
52
+ return model, tokenizer, False
53
+ if ckpt_path:
54
+ logger.warning("Checkpoint %s not found -- falling back to RANDOM-init model.", ckpt_path)
55
+ else:
56
+ logger.warning("No --ckpt_path given -- using RANDOM-init model "
57
+ "(yields are meaningless; API/mechanism check only).")
58
+ model, tokenizer = build_random_model(
59
+ device=device, hidden_size=hidden_size, n_layers=n_layers,
60
+ n_heads=n_heads, base_path=base_path)
61
+ return model, tokenizer, True
62
+
63
+
64
+ def build_parser():
65
+ p = argparse.ArgumentParser(description="Generate valid peptides with a chosen sampling strategy.")
66
+ p.add_argument("--ckpt_path", type=str, default=None,
67
+ help="Path to TD3B checkpoint. If missing/omitted, a random-init model is used.")
68
+ p.add_argument("--base_path", type=str, default=ROOT_DIR, help="Repo root (for tokenizer files).")
69
+ p.add_argument("--length", type=int, default=200, help="Target sequence length (tokens).")
70
+ p.add_argument("--num_samples", type=int, default=64, help="Number of sequences to generate.")
71
+ p.add_argument("--strategy", type=str, default="nucleus_remask",
72
+ choices=available_strategies(), help="Sampling strategy.")
73
+ p.add_argument("--device", type=str, default="cuda:0")
74
+ p.add_argument("--seed", type=int, default=42)
75
+ p.add_argument("--save_path", type=str, default=None,
76
+ help="CSV path to save VALID sequences (default: results/valid_<strategy>_len<L>.csv).")
77
+ # strategy knobs (None -> use the strategy preset default)
78
+ p.add_argument("--num_steps", type=int, default=128, help="Base reverse-diffusion steps.")
79
+ p.add_argument("--eps", type=float, default=1e-5)
80
+ p.add_argument("--temperature", type=float, default=None, help="<1 sharpens logits (low_temp).")
81
+ p.add_argument("--top_p", type=float, default=None, help="Nucleus mass in (0,1].")
82
+ p.add_argument("--top_k", type=int, default=None, help="Top-k tokens per position.")
83
+ p.add_argument("--steps_per_token", type=float, default=None,
84
+ help="more_steps: num_steps = max(num_steps, round(steps_per_token*length)).")
85
+ p.add_argument("--remask_rounds", type=int, default=None, help="Self-correction rounds.")
86
+ p.add_argument("--remask_frac", type=float, default=None, help="Fraction of lowest-conf tokens to remask.")
87
+ p.add_argument("--remask_steps", type=int, default=None, help="Re-denoise steps per remask round.")
88
+ p.add_argument("--best_of_n", type=int, default=None, help="Oversample N per slot, keep first valid.")
89
+ # random-fallback architecture (ignored when a real checkpoint loads)
90
+ p.add_argument("--hidden_size", type=int, default=768)
91
+ p.add_argument("--n_layers", type=int, default=8)
92
+ p.add_argument("--n_heads", type=int, default=8)
93
+ return p
94
+
95
+
96
+ def main():
97
+ args = build_parser().parse_args()
98
+
99
+ torch.manual_seed(args.seed)
100
+ np.random.seed(args.seed)
101
+ device = torch.device(args.device if (args.device.startswith("cpu") or torch.cuda.is_available()) else "cpu")
102
+
103
+ model, tokenizer, is_random = _load_model(
104
+ args.ckpt_path, device, args.base_path, args.hidden_size, args.n_layers, args.n_heads)
105
+ analyzer = PeptideAnalyzer()
106
+
107
+ logger.info("Generating %d sequences of length %d with strategy=%s on %s",
108
+ args.num_samples, args.length, args.strategy, device)
109
+
110
+ tokens, sequences, valid_mask, stats = generate(
111
+ model, tokenizer, analyzer,
112
+ batch_size=args.num_samples, length=args.length, strategy=args.strategy,
113
+ num_steps=args.num_steps, eps=args.eps,
114
+ temperature=args.temperature, top_p=args.top_p, top_k=args.top_k,
115
+ steps_per_token=args.steps_per_token,
116
+ remask_rounds=args.remask_rounds, remask_frac=args.remask_frac,
117
+ remask_steps=args.remask_steps, best_of_n=args.best_of_n,
118
+ verbose=False,
119
+ )
120
+
121
+ valid_seqs = [s for s, v in zip(sequences, valid_mask) if v]
122
+
123
+ save_path = args.save_path or os.path.join(
124
+ args.base_path, "results", f"valid_{args.strategy}_len{args.length}.csv")
125
+ os.makedirs(os.path.dirname(os.path.abspath(save_path)), exist_ok=True)
126
+ with open(save_path, "w", newline="") as f:
127
+ w = csv.writer(f)
128
+ w.writerow(["idx", "sequence", "n_chars"])
129
+ for i, s in enumerate(valid_seqs):
130
+ w.writerow([i, s, len(s)])
131
+
132
+ print("\n" + "=" * 66)
133
+ print(f" strategy : {stats['strategy']}")
134
+ print(f" length : {stats['length']}")
135
+ print(f" num_samples : {stats['batch_size']}")
136
+ print(f" num_steps : {stats['num_steps']}"
137
+ f" (temp={stats['temperature']}, top_p={stats['top_p']}, top_k={stats['top_k']})")
138
+ print(f" remask : rounds={stats['remask_rounds']} frac={stats['remask_frac']} "
139
+ f"steps={stats['remask_steps']} best_of_n={stats['best_of_n']}")
140
+ if len(stats.get("round_valid_counts", [])) > 1:
141
+ print(f" valid per round : {stats['round_valid_counts']} (round 0 = before remask)")
142
+ print(f" VALID YIELD : {stats['valid_count']}/{stats['batch_size']} "
143
+ f"= {stats['valid_rate']:.1%}")
144
+ print(f" wall time : {stats['wall_time_s']}s")
145
+ print(f" saved valid seqs : {save_path} ({len(valid_seqs)} rows)")
146
+ if is_random:
147
+ print(" NOTE: RANDOM-init model -- yields are meaningless; rerun with --ckpt_path "
148
+ "<real.ckpt> for real numbers.")
149
+ print("=" * 66)
150
+
151
+
152
+ if __name__ == "__main__":
153
+ main()
inference.py CHANGED
@@ -29,10 +29,13 @@ from configs.finetune_config import (
29
  DiffusionConfig, RoFormerConfig, NoiseConfig,
30
  TrainingConfig, SamplingConfig, EvalConfig, OptimConfig, MCTSConfig,
31
  )
32
- from training.finetune_utils import load_tokenizer, create_reward_function
33
  from td3b.direction_oracle import DirectionalOracle
34
  from td3b.td3b_scoring import create_td3b_reward_function
 
 
35
  from utils.app import PeptideAnalyzer
 
36
 
37
  logger = logging.getLogger(__name__)
38
  logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s")
@@ -76,7 +79,22 @@ def load_model(ckpt_path: str, device: torch.device):
76
  )
77
 
78
  model = Diffusion(config=cfg, tokenizer=tokenizer, device=device).to(device)
79
- model.load_state_dict(state_dict, strict=False)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80
  model.eval()
81
  model.tokenizer = tokenizer
82
  return model, tokenizer
@@ -130,6 +148,30 @@ def main():
130
  parser.add_argument("--resample_alpha", type=float, default=0.1, help="Temperature for weighted resampling")
131
  parser.add_argument("--direction_oracle_ckpt", type=str, default=None)
132
  parser.add_argument("--direction_oracle_tr2d2_checkpoint", type=str, default=None)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
133
  args = parser.parse_args()
134
 
135
  # Setup
@@ -149,36 +191,68 @@ def main():
149
  df = pd.read_csv(args.val_csv)
150
  targets = []
151
  for _, row in df.iterrows():
 
 
 
 
 
 
 
 
 
 
 
152
  targets.append({
153
  "target_seq": row["Target_Sequence"],
154
  "target_uid": row.get("Target_UniProt_ID", ""),
155
  "binder_seq": row.get("Ligand_Sequence", ""),
156
  "label": row.get("label", ""),
157
- "seq_length": min(len(row.get("Ligand_SMILES", "x" * 200)), 200),
158
  })
159
 
160
- # Build reward function for each target
 
 
161
  logger.info("Building reward functions...")
162
  oracle_ckpt = args.direction_oracle_ckpt or os.path.join(ROOT_DIR, "checkpoints", "direction_oracle.pt")
163
  oracle_tr2d2 = args.direction_oracle_tr2d2_checkpoint or os.path.join(ROOT_DIR, "checkpoints", "pretrained.ckpt")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
164
 
165
  records = []
166
 
167
  for tidx, target in enumerate(targets):
 
 
168
  for d_star, d_name in [(1.0, "agonist"), (-1.0, "antagonist")]:
169
  logger.info(f"[{tidx+1}/{len(targets)}] Target {target['target_uid']} direction={d_name}")
170
 
171
- # Create reward function
172
  try:
173
- reward_model = create_reward_function(
174
- base_path=ROOT_DIR,
175
- tokenizer=tokenizer,
176
  target_protein_seq=target["target_seq"],
177
- target_direction="agonist" if d_star > 0 else "antagonist",
 
178
  device=device,
179
- emb_model=model.backbone,
180
- directional_oracle_checkpoint=oracle_ckpt,
181
- direction_oracle_tr2d2_checkpoint=oracle_tr2d2,
182
  )
183
  except Exception as e:
184
  logger.warning(f"Failed to create reward for {target['target_uid']}: {e}")
@@ -186,35 +260,48 @@ def main():
186
 
187
  # Generate pool of candidates
188
  target_length = target.get("seq_length", 200)
189
- x_pool = sample_sequences(model, args.num_pool, target_length, 128)
190
- sequences = tokenizer.batch_decode(x_pool)
191
-
192
- # Check validity
193
- valid_mask = np.array([analyzer.is_peptide(seq) for seq in sequences])
 
 
 
 
 
 
 
 
 
 
 
 
194
 
195
  # Score all
196
  gated_rewards, affinities, directions, confidences = score_sequences(reward_model, sequences)
197
  direction_accuracy = ((directions > 0.5).astype(float) if d_star > 0
198
  else (directions < 0.5).astype(float))
199
 
200
- # Weighted resampling (Algorithm 2)
201
- finite = np.isfinite(gated_rewards)
202
- if finite.any():
203
- rewards_t = torch.as_tensor(gated_rewards[finite], device=device)
 
 
 
 
 
204
  alpha = max(args.resample_alpha, 1e-6)
205
  weights = torch.softmax(rewards_t / alpha, dim=0)
206
- idx = torch.multinomial(weights, num_samples=args.val_samples_per_target, replacement=True)
207
- valid_idx = np.where(finite)[0]
208
- chosen = valid_idx[idx.cpu().numpy()]
209
  else:
210
- chosen = np.arange(min(args.val_samples_per_target, len(sequences)))
211
 
212
- # Save only VALID resampled samples
213
  for i in chosen:
214
- is_valid = bool(valid_mask[i]) if valid_mask.size else False
215
- if not is_valid:
216
- continue # Skip invalid samples
217
-
218
  records.append({
219
  "target": target["target_seq"][:20],
220
  "target_uid": target["target_uid"],
@@ -247,6 +334,9 @@ def main():
247
  logger.info(f"{'='*60}")
248
  else:
249
  logger.warning("No valid samples generated.")
 
 
 
250
 
251
 
252
  if __name__ == "__main__":
 
29
  DiffusionConfig, RoFormerConfig, NoiseConfig,
30
  TrainingConfig, SamplingConfig, EvalConfig, OptimConfig, MCTSConfig,
31
  )
32
+ from training.finetune_utils import load_tokenizer
33
  from td3b.direction_oracle import DirectionalOracle
34
  from td3b.td3b_scoring import create_td3b_reward_function
35
+ from scoring.functions.binding import MultiTargetBindingAffinity, TargetSpecificBindingAffinity
36
+ from td3b.data_utils import peptide_seq_to_smiles, smiles_token_length
37
  from utils.app import PeptideAnalyzer
38
+ import sampling_strategies
39
 
40
  logger = logging.getLogger(__name__)
41
  logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s")
 
79
  )
80
 
81
  model = Diffusion(config=cfg, tokenizer=tokenizer, device=device).to(device)
82
+ incompatible = model.load_state_dict(state_dict, strict=False)
83
+ # A correct TD3B checkpoint round-trips to 0 missing backbone keys; strict=False otherwise
84
+ # silently leaves the denoiser at random init and generates garbage with no error.
85
+ backbone_missing = [k for k in incompatible.missing_keys if k.startswith("backbone.")]
86
+ total_backbone = sum(1 for k in model.state_dict() if k.startswith("backbone."))
87
+ if backbone_missing:
88
+ msg = (f"{len(backbone_missing)}/{total_backbone} backbone weights did not load from "
89
+ f"{ckpt_path} (e.g. {backbone_missing[:3]}) — the generator would run on "
90
+ f"partially-random weights.")
91
+ if len(backbone_missing) == total_backbone:
92
+ raise RuntimeError(msg + " The backbone loaded NO weights; refusing to run — "
93
+ "check the checkpoint format/key namespace.")
94
+ logger.warning(msg)
95
+ if incompatible.unexpected_keys:
96
+ logger.warning("Checkpoint had %d unexpected keys (e.g. %s)",
97
+ len(incompatible.unexpected_keys), incompatible.unexpected_keys[:3])
98
  model.eval()
99
  model.tokenizer = tokenizer
100
  return model, tokenizer
 
148
  parser.add_argument("--resample_alpha", type=float, default=0.1, help="Temperature for weighted resampling")
149
  parser.add_argument("--direction_oracle_ckpt", type=str, default=None)
150
  parser.add_argument("--direction_oracle_tr2d2_checkpoint", type=str, default=None)
151
+ # ─── Opt-in validity-boosting sampler (default "baseline" == original behavior) ───
152
+ parser.add_argument("--sampler", type=str, default="baseline",
153
+ choices=sampling_strategies.available_strategies(),
154
+ help="Candidate-pool sampling strategy (default: baseline == original behavior)")
155
+ parser.add_argument("--num_steps", type=int, default=128,
156
+ help="Reverse-diffusion steps for non-baseline samplers")
157
+ parser.add_argument("--top_p", type=float, default=None,
158
+ help="Nucleus cumulative mass for top_p/nucleus/nucleus_remask samplers")
159
+ parser.add_argument("--top_k", type=int, default=None,
160
+ help="Top-k cutoff on clean-token logits for the top_k sampler")
161
+ parser.add_argument("--temperature", type=float, default=None,
162
+ help="Softmax temperature on clean-token logits (e.g. low_temp)")
163
+ parser.add_argument("--remask_rounds", type=int, default=None,
164
+ help="Remask self-correction rounds (remask/nucleus_remask samplers)")
165
+ parser.add_argument("--remask_frac", type=float, default=None,
166
+ help="Fraction of lowest-confidence tokens remasked each round")
167
+ parser.add_argument("--best_of_n", type=int, default=None,
168
+ help="Best-of-N validity-guided oversampling per slot (best_of_n sampler)")
169
+ parser.add_argument("--seq_length", type=int, default=None,
170
+ help="Generation length in SMILES tokens (override). If unset, derived from the "
171
+ "reference binder. NOTE: this checkpoint only yields valid SMILES at short "
172
+ "lengths (~<100 tokens) — set this (e.g. 50) for non-empty output on long binders.")
173
+ parser.add_argument("--max_seq_length", type=int, default=200,
174
+ help="Cap on the derived generation length (SMILES tokens)")
175
  args = parser.parse_args()
176
 
177
  # Setup
 
191
  df = pd.read_csv(args.val_csv)
192
  targets = []
193
  for _, row in df.iterrows():
194
+ # Generation length in SMILES *tokens*: explicit --seq_length override, else the
195
+ # reference binder's tokenized length (from a SMILES/Ligand_SMILES column, or derived
196
+ # from Ligand_Sequence), else the default. Capped at --max_seq_length.
197
+ if args.seq_length is not None:
198
+ seq_len = args.seq_length
199
+ else:
200
+ smi = row.get("SMILES") if isinstance(row.get("SMILES"), str) else row.get("Ligand_SMILES")
201
+ if not isinstance(smi, str):
202
+ lig = row.get("Ligand_Sequence")
203
+ smi = peptide_seq_to_smiles(lig) if isinstance(lig, str) else None
204
+ seq_len = smiles_token_length(smi, tokenizer) if isinstance(smi, str) else DEFAULTS["seq_length"]
205
  targets.append({
206
  "target_seq": row["Target_Sequence"],
207
  "target_uid": row.get("Target_UniProt_ID", ""),
208
  "binder_seq": row.get("Ligand_Sequence", ""),
209
  "label": row.get("label", ""),
210
+ "seq_length": max(1, min(int(seq_len) if seq_len else DEFAULTS["seq_length"], args.max_seq_length)),
211
  })
212
 
213
+ # Build the shared reward components ONCE. The affinity predictor (ESM2) and the
214
+ # Direction Oracle (ESM2 + TR2-D2) are expensive to load, so we build them a single
215
+ # time and rebind the per-target protein via lightweight wrappers inside the loop.
216
  logger.info("Building reward functions...")
217
  oracle_ckpt = args.direction_oracle_ckpt or os.path.join(ROOT_DIR, "checkpoints", "direction_oracle.pt")
218
  oracle_tr2d2 = args.direction_oracle_tr2d2_checkpoint or os.path.join(ROOT_DIR, "checkpoints", "pretrained.ckpt")
219
+ vocab_path = os.path.join(ROOT_DIR, "tokenizer", "new_vocab.txt")
220
+ splits_path = os.path.join(ROOT_DIR, "tokenizer", "new_splits.txt")
221
+
222
+ multi_affinity = MultiTargetBindingAffinity(
223
+ tokenizer=tokenizer,
224
+ base_path=ROOT_DIR,
225
+ device=device,
226
+ emb_model=model.backbone,
227
+ )
228
+ directional_oracle = DirectionalOracle(
229
+ model_ckpt=oracle_ckpt,
230
+ tr2d2_checkpoint=oracle_tr2d2,
231
+ tokenizer_vocab=vocab_path,
232
+ tokenizer_splits=splits_path,
233
+ device=device,
234
+ )
235
+ directional_oracle.eval()
236
 
237
  records = []
238
 
239
  for tidx, target in enumerate(targets):
240
+ # Bind the shared affinity predictor to this target's protein sequence.
241
+ target_affinity = TargetSpecificBindingAffinity(multi_affinity, target["target_seq"])
242
  for d_star, d_name in [(1.0, "agonist"), (-1.0, "antagonist")]:
243
  logger.info(f"[{tidx+1}/{len(targets)}] Target {target['target_uid']} direction={d_name}")
244
 
245
+ # Create reward function (reuses the preloaded oracle; only re-encodes the protein)
246
  try:
247
+ reward_model = create_td3b_reward_function(
248
+ affinity_predictor=target_affinity,
249
+ directional_oracle=directional_oracle,
250
  target_protein_seq=target["target_seq"],
251
+ target_direction=d_name,
252
+ peptide_tokenizer=tokenizer,
253
  device=device,
254
+ min_affinity_threshold=DEFAULTS["min_affinity_threshold"],
255
+ temperature=DEFAULTS["sigmoid_temperature"],
 
256
  )
257
  except Exception as e:
258
  logger.warning(f"Failed to create reward for {target['target_uid']}: {e}")
 
260
 
261
  # Generate pool of candidates
262
  target_length = target.get("seq_length", 200)
263
+ if args.sampler == "baseline":
264
+ # Original behavior, unchanged (byte-for-byte).
265
+ x_pool = sample_sequences(model, args.num_pool, target_length, 128)
266
+ sequences = tokenizer.batch_decode(x_pool)
267
+ # Check validity
268
+ valid_mask = np.array([analyzer.is_peptide(seq) for seq in sequences])
269
+ else:
270
+ # Opt-in validity-boosting sampler. Reuses its decode + is_peptide validity.
271
+ # None-valued knobs fall back to each strategy's preset inside generate().
272
+ x_pool, sequences, valid_mask, _sampler_stats = sampling_strategies.generate(
273
+ model, tokenizer, analyzer,
274
+ batch_size=args.num_pool, length=target_length,
275
+ strategy=args.sampler,
276
+ num_steps=args.num_steps, top_p=args.top_p, top_k=args.top_k,
277
+ temperature=args.temperature, remask_rounds=args.remask_rounds,
278
+ remask_frac=args.remask_frac, best_of_n=args.best_of_n,
279
+ )
280
 
281
  # Score all
282
  gated_rewards, affinities, directions, confidences = score_sequences(reward_model, sequences)
283
  direction_accuracy = ((directions > 0.5).astype(float) if d_star > 0
284
  else (directions < 0.5).astype(float))
285
 
286
+ # Weighted resampling (Algorithm 2). Resample only among candidates that are BOTH
287
+ # finite-reward AND valid peptides, and draw WITHOUT replacement so the output holds
288
+ # up to val_samples_per_target DISTINCT binders. (With replacement + the peaked
289
+ # softmax at alpha=0.1, multinomial repeatedly draws the argmax → duplicate rows that
290
+ # inflate the sample count and skew every mean metric; filtering validity only AFTER
291
+ # resampling could also drop every draw and save 0 samples despite valid candidates.)
292
+ eligible = np.isfinite(gated_rewards) & valid_mask.astype(bool)
293
+ if eligible.any():
294
+ rewards_t = torch.as_tensor(gated_rewards[eligible], device=device, dtype=torch.float32)
295
  alpha = max(args.resample_alpha, 1e-6)
296
  weights = torch.softmax(rewards_t / alpha, dim=0)
297
+ k = min(args.val_samples_per_target, int(eligible.sum()))
298
+ idx = torch.multinomial(weights, num_samples=k, replacement=False)
299
+ chosen = np.where(eligible)[0][idx.cpu().numpy()]
300
  else:
301
+ chosen = np.array([], dtype=int) # no valid finite-reward candidate → save nothing
302
 
303
+ # Save resampled samples (already gated to valid + finite above)
304
  for i in chosen:
 
 
 
 
305
  records.append({
306
  "target": target["target_seq"][:20],
307
  "target_uid": target["target_uid"],
 
334
  logger.info(f"{'='*60}")
335
  else:
336
  logger.warning("No valid samples generated.")
337
+ logger.warning("Hint: this checkpoint only produces valid SMILES at short lengths "
338
+ "(~<100 tokens). Re-run with a shorter --seq_length (e.g. 50) and/or "
339
+ "--sampler best_of_n (or nucleus_remask) to raise the valid yield.")
340
 
341
 
342
  if __name__ == "__main__":
launch_multi_target.sh CHANGED
@@ -7,8 +7,9 @@
7
  # Configuration
8
  # ============================================================================
9
 
10
- # Paths — update these to your local paths
11
- BASE_PATH="/path/to/TD3B"
 
12
  PRETRAINED_CHECKPOINT="${BASE_PATH}/checkpoints/pretrained.ckpt"
13
  TRAIN_CSV="${BASE_PATH}/data/train.csv"
14
  VAL_CSV="${BASE_PATH}/data/test.csv" # Optional: create validation split
@@ -75,7 +76,7 @@ fi
75
 
76
  # W&B (optional)
77
  WANDB_PROJECT="tr2d2-multi-target"
78
- WANDB_ENTITY="phos_zj"
79
 
80
  # ============================================================================
81
  # Launch Training
 
7
  # Configuration
8
  # ============================================================================
9
 
10
+ # Paths — BASE_PATH auto-detects the repo root (this script's own directory);
11
+ # the paths below derive from it, so they usually need no editing.
12
+ BASE_PATH="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
13
  PRETRAINED_CHECKPOINT="${BASE_PATH}/checkpoints/pretrained.ckpt"
14
  TRAIN_CSV="${BASE_PATH}/data/train.csv"
15
  VAL_CSV="${BASE_PATH}/data/test.csv" # Optional: create validation split
 
76
 
77
  # W&B (optional)
78
  WANDB_PROJECT="tr2d2-multi-target"
79
+ WANDB_ENTITY="" # set to your W&B entity/team (empty = your default entity)
80
 
81
  # ============================================================================
82
  # Launch Training
mcts/__init__.py ADDED
File without changes
mcts/peptide_mcts.py CHANGED
@@ -225,13 +225,22 @@ class MCTS:
225
  rootNode=None,
226
  reward_func=None,
227
  num_obj=None,
 
228
  ):
229
  self.timer = StepTimer(policy_model.device)
230
-
231
  self.device = policy_model.device
232
-
233
  self.args = args
234
  self.config = config
 
 
 
 
 
 
 
 
235
  self.noise = noise_schedule.get_noise(config)
236
  self.time_conditioning = args.time_conditioning
237
 
@@ -573,7 +582,11 @@ class MCTS:
573
  childSeq = childSequences[i]
574
 
575
  # check if the peptide is valid
576
- if self.analyzer.is_peptide(childSeq):
 
 
 
 
577
  valid_x_children.append(x_children[i])
578
  valid_x_final.append(x_rollout[i])
579
  validSequences.append(childSeq)
 
225
  rootNode=None,
226
  reward_func=None,
227
  num_obj=None,
228
+ enforce_validity=None,
229
  ):
230
  self.timer = StepTimer(policy_model.device)
231
+
232
  self.device = policy_model.device
233
+
234
  self.args = args
235
  self.config = config
236
+ # Validity gate toggle for MCTS expansion. Default (None) consults
237
+ # args.enforce_validity (threaded from --validity_reward), falling back
238
+ # to True so DEFAULT behavior is unchanged. When False, invalid decoded
239
+ # children are NOT zero-rewarded/filtered -- expansion proceeds on the
240
+ # pure affinity x direction reward.
241
+ if enforce_validity is None:
242
+ enforce_validity = getattr(args, "enforce_validity", True)
243
+ self.enforce_validity = bool(enforce_validity)
244
  self.noise = noise_schedule.get_noise(config)
245
  self.time_conditioning = args.time_conditioning
246
 
 
582
  childSeq = childSequences[i]
583
 
584
  # check if the peptide is valid
585
+ # When enforce_validity is False, skip the is_peptide gate so
586
+ # invalid decoded children are kept and scored on the pure
587
+ # affinity x direction reward instead of being added as
588
+ # zero-reward nodes. (short-circuits before calling is_peptide)
589
+ if (not self.enforce_validity) or self.analyzer.is_peptide(childSeq):
590
  valid_x_children.append(x_children[i])
591
  valid_x_final.append(x_rollout[i])
592
  validSequences.append(childSeq)
models/__init__.py ADDED
File without changes
models/diffusion.py CHANGED
@@ -147,8 +147,8 @@ class Diffusion(L.LightningModule):
147
 
148
  # PeptideCLM tokenizer
149
  if tokenizer is None:
150
- self.tokenizer = SMILES_SPE_Tokenizer(f'{base_path}/tr2d2-pep/tokenizer/new_vocab.txt',
151
- f'{base_path}/tr2d2-pep/tokenizer/new_splits.txt')
152
  else:
153
  self.tokenizer = tokenizer
154
 
 
147
 
148
  # PeptideCLM tokenizer
149
  if tokenizer is None:
150
+ self.tokenizer = SMILES_SPE_Tokenizer(f'{base_path}/tokenizer/new_vocab.txt',
151
+ f'{base_path}/tokenizer/new_splits.txt')
152
  else:
153
  self.tokenizer = tokenizer
154
 
notebooks/TD3B_Inference_Demo.ipynb CHANGED
@@ -1,457 +1,409 @@
1
  {
2
- "nbformat": 4,
3
- "nbformat_minor": 0,
4
- "metadata": {
5
- "colab": {
6
- "provenance": [],
7
- "gpuType": "T4"
8
- },
9
- "kernelspec": {
10
- "name": "python3",
11
- "display_name": "Python 3"
12
- },
13
- "language_info": {
14
- "name": "python"
15
- },
16
- "accelerator": "GPU"
17
  },
18
- "cells": [
19
- {
20
- "cell_type": "markdown",
21
- "source": [
22
- "# TD3B: Transition-Directed Discrete Diffusion for Allosteric Binder Generation\n",
23
- "\n",
24
- "This notebook demonstrates **TD3B inference** — generating peptide binders with specified agonist or antagonist behavior for GPCR targets.\n",
25
- "\n",
26
- "**What TD3B does:**\n",
27
- "- Takes a target protein sequence + desired direction (agonist / antagonist)\n",
28
- "- Generates peptide binder sequences using a finetuned discrete diffusion model\n",
29
- "- Scores them with a Direction Oracle and binding affinity predictor\n",
30
- "- Returns the best candidates via weighted resampling (Algorithm 2)\n",
31
- "\n",
32
- "**Requirements:** GPU runtime (T4 or better). Click **Runtime → Change runtime type → GPU**."
33
- ],
34
- "metadata": {}
35
- },
36
- {
37
- "cell_type": "markdown",
38
- "source": [
39
- "## 1. Setup"
40
- ],
41
- "metadata": {}
42
- },
43
- {
44
- "cell_type": "code",
45
- "execution_count": null,
46
- "metadata": {},
47
- "outputs": [],
48
- "source": [
49
- "# Install dependencies\n",
50
- "!pip install -q torch torchvision --index-url https://download.pytorch.org/whl/cu121\n",
51
- "!pip install -q transformers fair-esm SmilesPE rdkit-pypi scipy pandas numpy xgboost pytorch-lightning lightning hydra-core loguru timm huggingface_hub"
52
- ]
53
- },
54
- {
55
- "cell_type": "code",
56
- "execution_count": null,
57
- "metadata": {},
58
- "outputs": [],
59
- "source": [
60
- "# Clone TD3B repository and download checkpoints from HuggingFace\n",
61
- "!git clone https://github.com/chq1155/TD3B_ICML.git TD3B\n",
62
- "%cd TD3B\n",
63
- "\n",
64
- "from huggingface_hub import hf_hub_download\n",
65
- "import os\n",
66
- "\n",
67
- "REPO_ID = \"ChatterjeeLab/TD3B\"\n",
68
- "os.makedirs(\"checkpoints\", exist_ok=True)\n",
69
- "os.makedirs(\"data\", exist_ok=True)\n",
70
- "\n",
71
- "# Download checkpoints (this may take a few minutes)\n",
72
- "for fname in [\"checkpoints/td3b.ckpt\", \"checkpoints/pretrained.ckpt\",\n",
73
- " \"checkpoints/direction_oracle.pt\",\n",
74
- " \"scoring/functions/classifiers/binding-affinity.pt\",\n",
75
- " \"data/test.csv\", \"data/train.csv\"]:\n",
76
- " print(f\"Downloading {fname}...\")\n",
77
- " hf_hub_download(repo_id=REPO_ID, filename=fname, local_dir=\".\")\n",
78
- "\n",
79
- "print(\"\\nAll files downloaded!\")\n",
80
- "!ls -lh checkpoints/"
81
- ]
82
- },
83
- {
84
- "cell_type": "markdown",
85
- "source": [
86
- "## 2. Load Model and Oracle"
87
- ],
88
- "metadata": {}
89
- },
90
- {
91
- "cell_type": "code",
92
- "execution_count": null,
93
- "metadata": {},
94
- "outputs": [],
95
- "source": [
96
- "import sys\n",
97
- "sys.path.insert(0, \".\")\n",
98
- "\n",
99
- "import torch\n",
100
- "import numpy as np\n",
101
- "import pandas as pd\n",
102
- "\n",
103
- "from diffusion import Diffusion\n",
104
- "from configs.finetune_config import (\n",
105
- " DiffusionConfig, RoFormerConfig, NoiseConfig,\n",
106
- " TrainingConfig, SamplingConfig, EvalConfig, OptimConfig, MCTSConfig,\n",
107
- ")\n",
108
- "from tokenizer.my_tokenizers import SMILES_SPE_Tokenizer\n",
109
- "from td3b.direction_oracle import DirectionalOracle\n",
110
- "from td3b.td3b_scoring import TD3BRewardFunction, create_td3b_reward_function\n",
111
- "from scoring.functions.binding import BindingAffinity\n",
112
- "from utils.app import PeptideAnalyzer\n",
113
- "\n",
114
- "device = torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\")\n",
115
- "print(f\"Using device: {device}\")\n",
116
- "if torch.cuda.is_available():\n",
117
- " print(f\"GPU: {torch.cuda.get_device_name(0)}\")\n",
118
- " print(f\"Memory: {torch.cuda.get_device_properties(0).total_mem / 1e9:.1f} GB\")"
119
- ]
120
- },
121
- {
122
- "cell_type": "code",
123
- "execution_count": null,
124
- "metadata": {},
125
- "outputs": [],
126
- "source": [
127
- "# Load tokenizer\n",
128
- "tokenizer = SMILES_SPE_Tokenizer(\"tokenizer/new_vocab.txt\", \"tokenizer/new_splits.txt\")\n",
129
- "print(f\"Tokenizer vocab size: {len(tokenizer)}\")\n",
130
- "\n",
131
- "# Load diffusion model\n",
132
- "print(\"\\nLoading TD3B model...\")\n",
133
- "cfg = DiffusionConfig(\n",
134
- " roformer=RoFormerConfig(hidden_size=768, n_layers=8, n_heads=8),\n",
135
- " noise=NoiseConfig(),\n",
136
- " training=TrainingConfig(sampling_eps=1e-3),\n",
137
- " sampling=SamplingConfig(steps=128, sampling_eps=1e-3),\n",
138
- " eval_cfg=EvalConfig(), optim=OptimConfig(lr=3e-4), mcts=MCTSConfig(),\n",
139
- ")\n",
140
- "model = Diffusion(config=cfg, tokenizer=tokenizer, device=device).to(device)\n",
141
- "\n",
142
- "ckpt = torch.load(\"checkpoints/td3b.ckpt\", map_location=device, weights_only=False)\n",
143
- "state_dict = ckpt.get(\"model_state_dict\") or ckpt.get(\"state_dict\") or ckpt\n",
144
- "model.load_state_dict(state_dict, strict=False)\n",
145
- "model.eval()\n",
146
- "model.tokenizer = tokenizer\n",
147
- "print(\"TD3B model loaded!\")\n",
148
- "\n",
149
- "# Load Direction Oracle\n",
150
- "print(\"\\nLoading Direction Oracle...\")\n",
151
- "oracle = DirectionalOracle(\n",
152
- " model_ckpt=\"checkpoints/direction_oracle.pt\",\n",
153
- " tr2d2_checkpoint=\"checkpoints/pretrained.ckpt\",\n",
154
- " tokenizer_vocab=\"tokenizer/new_vocab.txt\",\n",
155
- " tokenizer_splits=\"tokenizer/new_splits.txt\",\n",
156
- " device=device,\n",
157
- ")\n",
158
- "oracle.eval()\n",
159
- "print(\"Direction Oracle loaded!\")\n",
160
- "\n",
161
- "# Load Affinity Predictor\n",
162
- "print(\"\\nLoading Affinity Predictor...\")\n",
163
- "analyzer = PeptideAnalyzer()\n",
164
- "print(\"\\nAll models loaded!\")"
165
- ]
166
- },
167
- {
168
- "cell_type": "markdown",
169
- "source": [
170
- "## 3. Define Helper Functions"
171
- ],
172
- "metadata": {}
173
- },
174
- {
175
- "cell_type": "code",
176
- "execution_count": null,
177
- "metadata": {},
178
- "outputs": [],
179
- "source": [
180
- "def sample_sequences(model, batch_size, seq_length, num_steps=128, eps=1e-5):\n",
181
- " \"\"\"Sample sequences from the diffusion model.\"\"\"\n",
182
- " x = model.sample_prior(batch_size, seq_length).to(model.device, dtype=torch.long)\n",
183
- " timesteps = torch.linspace(1, eps, num_steps + 1, device=model.device)\n",
184
- " dt = torch.tensor((1 - eps) / num_steps, device=model.device)\n",
185
- "\n",
186
- " for i in range(num_steps):\n",
187
- " t = timesteps[i] * torch.ones(x.shape[0], 1, device=model.device)\n",
188
- " _, x = model.single_reverse_step(x, t=t, dt=dt)\n",
189
- " x = x.to(model.device)\n",
190
- "\n",
191
- " mask_pos = (x == model.mask_index)\n",
192
- " if mask_pos.any():\n",
193
- " t = timesteps[-2] * torch.ones(x.shape[0], 1, device=model.device)\n",
194
- " _, x = model.single_noise_removal(x, t=t, dt=dt)\n",
195
- " return x\n",
196
- "\n",
197
- "\n",
198
- "def generate_binders(target_seq, direction=\"agonist\", num_pool=32,\n",
199
- " num_keep=8, alpha=0.1, seq_length=200):\n",
200
- " \"\"\"\n",
201
- " Generate directional binders for a target protein.\n",
202
- " \n",
203
- " Args:\n",
204
- " target_seq: Target protein amino acid sequence\n",
205
- " direction: 'agonist' or 'antagonist'\n",
206
- " num_pool: Number of candidates to generate\n",
207
- " num_keep: Number of final samples after resampling\n",
208
- " alpha: Temperature for weighted resampling\n",
209
- " seq_length: Binder sequence length (in SMILES tokens)\n",
210
- " \n",
211
- " Returns:\n",
212
- " DataFrame with generated binders and scores\n",
213
- " \"\"\"\n",
214
- " d_star = 1.0 if direction == \"agonist\" else -1.0\n",
215
- " \n",
216
- " # Build reward function\n",
217
- " affinity_pred = BindingAffinity(\n",
218
- " prot_seq=target_seq, tokenizer=tokenizer,\n",
219
- " base_path=\".\", device=device, emb_model=model.backbone\n",
220
- " )\n",
221
- " reward_fn = create_td3b_reward_function(\n",
222
- " affinity_predictor=affinity_pred,\n",
223
- " directional_oracle=oracle,\n",
224
- " target_protein_seq=target_seq,\n",
225
- " target_direction=direction,\n",
226
- " peptide_tokenizer=tokenizer,\n",
227
- " device=device,\n",
228
- " )\n",
229
- " \n",
230
- " # Generate candidates\n",
231
- " with torch.no_grad():\n",
232
- " x_pool = sample_sequences(model, num_pool, seq_length)\n",
233
- " sequences = tokenizer.batch_decode(x_pool)\n",
234
- " \n",
235
- " # Score all\n",
236
- " rewards, info = reward_fn(sequences)\n",
237
- " affinities = info[\"affinities\"]\n",
238
- " directions = info[\"directions\"]\n",
239
- " \n",
240
- " # Weighted resampling (Algorithm 2)\n",
241
- " rewards_t = torch.as_tensor(rewards, device=device)\n",
242
- " weights = torch.softmax(rewards_t / max(alpha, 1e-6), dim=0)\n",
243
- " idx = torch.multinomial(weights, num_samples=num_keep, replacement=True)\n",
244
- " chosen = idx.cpu().numpy()\n",
245
- " \n",
246
- " # Filter to valid peptides only\n",
247
- " results = []\n",
248
- " for i in chosen:\n",
249
- " is_valid = analyzer.is_peptide(sequences[i])\n",
250
- " da = float(directions[i] > 0.5) if d_star > 0 else float(directions[i] < 0.5)\n",
251
- " results.append({\n",
252
- " \"sequence\": sequences[i],\n",
253
- " \"direction\": direction,\n",
254
- " \"is_valid\": is_valid,\n",
255
- " \"affinity\": float(affinities[i]),\n",
256
- " \"gated_reward\": float(rewards[i]),\n",
257
- " \"p_agonist\": float(directions[i]),\n",
258
- " \"direction_accuracy\": da,\n",
259
- " })\n",
260
- " \n",
261
- " df = pd.DataFrame(results)\n",
262
- " return df"
263
- ]
264
- },
265
- {
266
- "cell_type": "markdown",
267
- "source": [
268
- "## 4. Generate Binders\n",
269
- "\n",
270
- "Let's generate **agonist** and **antagonist** binders for a test target and compare the Direction Oracle predictions."
271
- ],
272
- "metadata": {}
273
- },
274
- {
275
- "cell_type": "code",
276
- "execution_count": null,
277
- "metadata": {},
278
- "outputs": [],
279
- "source": [
280
- "# Load test targets\n",
281
- "test_df = pd.read_csv(\"data/test.csv\")\n",
282
- "print(f\"Test set: {len(test_df)} target-binder pairs\")\n",
283
- "\n",
284
- "# Pick first target for demo\n",
285
- "target_row = test_df.iloc[0]\n",
286
- "TARGET_SEQ = target_row[\"Target_Sequence\"]\n",
287
- "TARGET_UID = target_row[\"Target_UniProt_ID\"]\n",
288
- "print(f\"\\nTarget: {TARGET_UID}\")\n",
289
- "print(f\"Sequence length: {len(TARGET_SEQ)} aa\")\n",
290
- "print(f\"Sequence: {TARGET_SEQ[:60]}...\")"
291
- ]
292
- },
293
- {
294
- "cell_type": "code",
295
- "execution_count": null,
296
- "metadata": {},
297
- "outputs": [],
298
- "source": [
299
- "%%time\n",
300
- "# Generate AGONIST binders\n",
301
- "print(\"Generating agonist binders (d*=+1)...\")\n",
302
- "torch.manual_seed(42)\n",
303
- "np.random.seed(42)\n",
304
- "df_agonist = generate_binders(TARGET_SEQ, direction=\"agonist\", num_pool=32, num_keep=8)\n",
305
- "\n",
306
- "print(f\"\\nGenerated {len(df_agonist)} samples ({df_agonist['is_valid'].sum()} valid)\")\n",
307
- "print(f\"Mean p(agonist): {df_agonist['p_agonist'].mean():.3f}\")\n",
308
- "print(f\"Mean affinity: {df_agonist['affinity'].mean():.2f}\")\n",
309
- "print(f\"Mean gated reward: {df_agonist['gated_reward'].mean():.2f}\")"
310
- ]
311
- },
312
- {
313
- "cell_type": "code",
314
- "execution_count": null,
315
- "metadata": {},
316
- "outputs": [],
317
- "source": [
318
- "%%time\n",
319
- "# Generate ANTAGONIST binders\n",
320
- "print(\"Generating antagonist binders (d*=-1)...\")\n",
321
- "torch.manual_seed(42)\n",
322
- "np.random.seed(42)\n",
323
- "df_antagonist = generate_binders(TARGET_SEQ, direction=\"antagonist\", num_pool=32, num_keep=8)\n",
324
- "\n",
325
- "print(f\"\\nGenerated {len(df_antagonist)} samples ({df_antagonist['is_valid'].sum()} valid)\")\n",
326
- "print(f\"Mean p(agonist): {df_antagonist['p_agonist'].mean():.3f}\")\n",
327
- "print(f\"Mean affinity: {df_antagonist['affinity'].mean():.2f}\")\n",
328
- "print(f\"Mean gated reward: {df_antagonist['gated_reward'].mean():.2f}\")"
329
- ]
330
- },
331
- {
332
- "cell_type": "markdown",
333
- "source": [
334
- "## 5. Compare Directional Control"
335
- ],
336
- "metadata": {}
337
- },
338
- {
339
- "cell_type": "code",
340
- "execution_count": null,
341
- "metadata": {},
342
- "outputs": [],
343
- "source": [
344
- "import matplotlib.pyplot as plt\n",
345
- "\n",
346
- "fig, axes = plt.subplots(1, 3, figsize=(15, 4))\n",
347
- "\n",
348
- "# Plot 1: Direction Oracle p(agonist)\n",
349
- "axes[0].hist(df_agonist[\"p_agonist\"], bins=20, alpha=0.7, label=\"d*=+1 (agonist)\", color=\"#e74c3c\")\n",
350
- "axes[0].hist(df_antagonist[\"p_agonist\"], bins=20, alpha=0.7, label=\"d*=-1 (antagonist)\", color=\"#3498db\")\n",
351
- "axes[0].axvline(0.5, color=\"gray\", linestyle=\"--\", label=\"threshold\")\n",
352
- "axes[0].set_xlabel(\"p(agonist)\")\n",
353
- "axes[0].set_ylabel(\"Count\")\n",
354
- "axes[0].set_title(\"Direction Oracle Predictions\")\n",
355
- "axes[0].legend()\n",
356
- "\n",
357
- "# Plot 2: Binding Affinity\n",
358
- "axes[1].hist(df_agonist[\"affinity\"], bins=20, alpha=0.7, label=\"Agonist\", color=\"#e74c3c\")\n",
359
- "axes[1].hist(df_antagonist[\"affinity\"], bins=20, alpha=0.7, label=\"Antagonist\", color=\"#3498db\")\n",
360
- "axes[1].set_xlabel(\"Predicted Binding Affinity\")\n",
361
- "axes[1].set_ylabel(\"Count\")\n",
362
- "axes[1].set_title(\"Binding Affinity Distribution\")\n",
363
- "axes[1].legend()\n",
364
- "\n",
365
- "# Plot 3: Gated Reward\n",
366
- "axes[2].hist(df_agonist[\"gated_reward\"], bins=20, alpha=0.7, label=\"Agonist\", color=\"#e74c3c\")\n",
367
- "axes[2].hist(df_antagonist[\"gated_reward\"], bins=20, alpha=0.7, label=\"Antagonist\", color=\"#3498db\")\n",
368
- "axes[2].set_xlabel(\"Gated Reward\")\n",
369
- "axes[2].set_ylabel(\"Count\")\n",
370
- "axes[2].set_title(\"Gated Reward Distribution\")\n",
371
- "axes[2].legend()\n",
372
- "\n",
373
- "plt.tight_layout()\n",
374
- "plt.savefig(\"td3b_results.png\", dpi=150, bbox_inches=\"tight\")\n",
375
- "plt.show()\n",
376
- "\n",
377
- "print(\"\\nSummary:\")\n",
378
- "print(f\" Agonist mode: p(agonist)={df_agonist['p_agonist'].mean():.3f} Affinity={df_agonist['affinity'].mean():.2f} Gated={df_agonist['gated_reward'].mean():.2f}\")\n",
379
- "print(f\" Antagonist mode: p(agonist)={df_antagonist['p_agonist'].mean():.3f} Affinity={df_antagonist['affinity'].mean():.2f} Gated={df_antagonist['gated_reward'].mean():.2f}\")\n",
380
- "print(f\" Directional gap: Δp = {df_agonist['p_agonist'].mean() - df_antagonist['p_agonist'].mean():.3f}\")"
381
- ]
382
- },
383
- {
384
- "cell_type": "markdown",
385
- "source": [
386
- "## 6. Run on Multiple Targets\n",
387
- "\n",
388
- "Generate binders for the first 5 test targets and compute aggregate metrics."
389
- ],
390
- "metadata": {}
391
- },
392
- {
393
- "cell_type": "code",
394
- "execution_count": null,
395
- "metadata": {},
396
- "outputs": [],
397
- "source": [
398
- "N_TARGETS = 5 # Number of targets to evaluate (increase for full benchmark)\n",
399
- "\n",
400
- "all_results = []\n",
401
- "targets = test_df.drop_duplicates(\"Target_UniProt_ID\").head(N_TARGETS)\n",
402
- "\n",
403
- "for i, (_, row) in enumerate(targets.iterrows()):\n",
404
- " uid = row[\"Target_UniProt_ID\"]\n",
405
- " seq = row[\"Target_Sequence\"]\n",
406
- " print(f\"[{i+1}/{N_TARGETS}] {uid} (len={len(seq)})\")\n",
407
- " \n",
408
- " for direction in [\"agonist\", \"antagonist\"]:\n",
409
- " torch.manual_seed(42)\n",
410
- " np.random.seed(42)\n",
411
- " df = generate_binders(seq, direction=direction, num_pool=32, num_keep=8)\n",
412
- " df[\"target_uid\"] = uid\n",
413
- " all_results.append(df)\n",
414
- " \n",
415
- " d_star = 1.0 if direction == \"agonist\" else -1.0\n",
416
- " da = df[\"direction_accuracy\"].mean()\n",
417
- " print(f\" {direction:>10s}: DA={da:.2f} Aff={df['affinity'].mean():.2f} Gated={df['gated_reward'].mean():.2f} valid={df['is_valid'].sum()}/{len(df)}\")\n",
418
- "\n",
419
- "combined = pd.concat(all_results, ignore_index=True)\n",
420
- "\n",
421
- "print(f\"\\n{'='*60}\")\n",
422
- "print(f\"AGGREGATE METRICS ({N_TARGETS} targets)\")\n",
423
- "print(f\"{'='*60}\")\n",
424
- "for d_name, d_val in [(\"Agonist (d*=+1)\", \"agonist\"), (\"Antagonist (d*=-1)\", \"antagonist\")]:\n",
425
- " sub = combined[combined[\"direction\"] == d_val]\n",
426
- " valid = sub[sub[\"is_valid\"] == True]\n",
427
- " print(f\" {d_name}:\")\n",
428
- " print(f\" Affinity: {sub['affinity'].mean():.2f}\")\n",
429
- " print(f\" Direction Accuracy: {sub['direction_accuracy'].mean():.3f}\")\n",
430
- " print(f\" Gated Reward (all): {sub['gated_reward'].mean():.2f}\")\n",
431
- " if len(valid) > 0:\n",
432
- " print(f\" Gated Reward (valid): {valid['gated_reward'].mean():.2f}\")\n",
433
- " print(f\" Valid: {sub['is_valid'].sum()}/{len(sub)}\")\n",
434
- "\n",
435
- "# Save\n",
436
- "combined.to_csv(\"td3b_demo_results.csv\", index=False)\n",
437
- "print(f\"\\nResults saved to td3b_demo_results.csv\")"
438
- ]
439
- },
440
- {
441
- "cell_type": "markdown",
442
- "source": [
443
- "## Citation\n",
444
- "\n",
445
- "```bibtex\n",
446
- "@article{cao2026td3b,\n",
447
- " title={TD3B: Transition-Directed Discrete Diffusion for Allosteric Binder Generation},\n",
448
- " author={Cao, Hanqun and Pal, Aastha and Tang, Sophia and Zhang, Yinuo and Zhang, Jingjie and Heng, Pheng Ann and Chatterjee, Pranam},\n",
449
- " journal={arXiv preprint arXiv:2605.09810},\n",
450
- " year={2026}",
451
- "}\n",
452
- "```"
453
- ],
454
- "metadata": {}
455
- }
456
- ]
457
- }
 
1
  {
2
+ "nbformat": 4,
3
+ "nbformat_minor": 0,
4
+ "metadata": {
5
+ "colab": {
6
+ "provenance": [],
7
+ "gpuType": "T4"
 
 
 
 
 
 
 
 
 
8
  },
9
+ "kernelspec": {
10
+ "name": "python3",
11
+ "display_name": "Python 3"
12
+ },
13
+ "language_info": {
14
+ "name": "python"
15
+ },
16
+ "accelerator": "GPU"
17
+ },
18
+ "cells": [
19
+ {
20
+ "cell_type": "markdown",
21
+ "source": [
22
+ "# TD3B: Transition-Directed Discrete Diffusion for Allosteric Binder Generation\n",
23
+ "\n",
24
+ "This notebook demonstrates **TD3B inference** — generating peptide binders with specified agonist or antagonist behavior for GPCR targets.\n",
25
+ "\n",
26
+ "**What TD3B does:**\n",
27
+ "- Takes a target protein sequence + desired direction (agonist / antagonist)\n",
28
+ "- Generates peptide binder sequences using a finetuned discrete diffusion model\n",
29
+ "- Scores them with a Direction Oracle and binding affinity predictor\n",
30
+ "- Returns the best candidates via weighted resampling (Algorithm 2)\n",
31
+ "\n",
32
+ "**Requirements:** GPU runtime (T4 or better). Click **Runtime → Change runtime type → GPU**."
33
+ ],
34
+ "metadata": {}
35
+ },
36
+ {
37
+ "cell_type": "markdown",
38
+ "source": [
39
+ "## 1. Setup"
40
+ ],
41
+ "metadata": {}
42
+ },
43
+ {
44
+ "cell_type": "code",
45
+ "execution_count": null,
46
+ "metadata": {},
47
+ "outputs": [],
48
+ "source": [
49
+ "# Install dependencies\n",
50
+ "!pip install -q torch torchvision --index-url https://download.pytorch.org/whl/cu121\n",
51
+ "!pip install -q transformers fair-esm SmilesPE rdkit-pypi scipy pandas numpy xgboost pytorch-lightning lightning hydra-core loguru timm huggingface_hub"
52
+ ]
53
+ },
54
+ {
55
+ "cell_type": "code",
56
+ "execution_count": null,
57
+ "metadata": {},
58
+ "outputs": [],
59
+ "source": "# Clone the TD3B code and download checkpoints from HuggingFace.\n# Skip LFS blobs on clone (the large checkpoints are pulled explicitly below).\n!GIT_LFS_SKIP_SMUDGE=1 git clone https://huggingface.co/ChatterjeeLab/TD3B TD3B\n%cd TD3B\n\nfrom huggingface_hub import hf_hub_download\nimport os\n\nREPO_ID = \"ChatterjeeLab/TD3B\"\nos.makedirs(\"checkpoints\", exist_ok=True)\nos.makedirs(\"data\", exist_ok=True)\n\n# Download checkpoints (this may take a few minutes)\nfor fname in [\"checkpoints/td3b.ckpt\", \"checkpoints/pretrained.ckpt\",\n \"checkpoints/direction_oracle.pt\",\n \"scoring/functions/classifiers/binding-affinity.pt\",\n \"data/test.csv\", \"data/train.csv\"]:\n print(f\"Downloading {fname}...\")\n hf_hub_download(repo_id=REPO_ID, filename=fname, local_dir=\".\")\n\nprint(\"\\nAll files downloaded!\")\n!ls -lh checkpoints/"
60
+ },
61
+ {
62
+ "cell_type": "markdown",
63
+ "source": [
64
+ "## 2. Load Model and Oracle"
65
+ ],
66
+ "metadata": {}
67
+ },
68
+ {
69
+ "cell_type": "code",
70
+ "execution_count": null,
71
+ "metadata": {},
72
+ "outputs": [],
73
+ "source": "import sys\nsys.path.insert(0, \".\")\n\nimport torch\nimport numpy as np\nimport pandas as pd\n\nfrom models.diffusion import Diffusion\nfrom configs.finetune_config import (\n DiffusionConfig, RoFormerConfig, NoiseConfig,\n TrainingConfig, SamplingConfig, EvalConfig, OptimConfig, MCTSConfig,\n)\nfrom tokenizer.my_tokenizers import SMILES_SPE_Tokenizer\nfrom td3b.direction_oracle import DirectionalOracle\nfrom td3b.td3b_scoring import TD3BRewardFunction, create_td3b_reward_function\nfrom scoring.functions.binding import BindingAffinity\nfrom utils.app import PeptideAnalyzer\n\ndevice = torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\")\nprint(f\"Using device: {device}\")\nif torch.cuda.is_available():\n print(f\"GPU: {torch.cuda.get_device_name(0)}\")\n print(f\"Memory: {torch.cuda.get_device_properties(0).total_memory / 1e9:.1f} GB\")"
74
+ },
75
+ {
76
+ "cell_type": "code",
77
+ "execution_count": null,
78
+ "metadata": {},
79
+ "outputs": [],
80
+ "source": [
81
+ "# Load tokenizer\n",
82
+ "tokenizer = SMILES_SPE_Tokenizer(\"tokenizer/new_vocab.txt\", \"tokenizer/new_splits.txt\")\n",
83
+ "print(f\"Tokenizer vocab size: {len(tokenizer)}\")\n",
84
+ "\n",
85
+ "# Load diffusion model\n",
86
+ "print(\"\\nLoading TD3B model...\")\n",
87
+ "cfg = DiffusionConfig(\n",
88
+ " roformer=RoFormerConfig(hidden_size=768, n_layers=8, n_heads=8),\n",
89
+ " noise=NoiseConfig(),\n",
90
+ " training=TrainingConfig(sampling_eps=1e-3),\n",
91
+ " sampling=SamplingConfig(steps=128, sampling_eps=1e-3),\n",
92
+ " eval_cfg=EvalConfig(), optim=OptimConfig(lr=3e-4), mcts=MCTSConfig(),\n",
93
+ ")\n",
94
+ "model = Diffusion(config=cfg, tokenizer=tokenizer, device=device).to(device)\n",
95
+ "\n",
96
+ "ckpt = torch.load(\"checkpoints/td3b.ckpt\", map_location=device, weights_only=False)\n",
97
+ "state_dict = ckpt.get(\"model_state_dict\") or ckpt.get(\"state_dict\") or ckpt\n",
98
+ "model.load_state_dict(state_dict, strict=False)\n",
99
+ "model.eval()\n",
100
+ "model.tokenizer = tokenizer\n",
101
+ "print(\"TD3B model loaded!\")\n",
102
+ "\n",
103
+ "# Load Direction Oracle\n",
104
+ "print(\"\\nLoading Direction Oracle...\")\n",
105
+ "oracle = DirectionalOracle(\n",
106
+ " model_ckpt=\"checkpoints/direction_oracle.pt\",\n",
107
+ " tr2d2_checkpoint=\"checkpoints/pretrained.ckpt\",\n",
108
+ " tokenizer_vocab=\"tokenizer/new_vocab.txt\",\n",
109
+ " tokenizer_splits=\"tokenizer/new_splits.txt\",\n",
110
+ " device=device,\n",
111
+ ")\n",
112
+ "oracle.eval()\n",
113
+ "print(\"Direction Oracle loaded!\")\n",
114
+ "\n",
115
+ "# Load Affinity Predictor\n",
116
+ "print(\"\\nLoading Affinity Predictor...\")\n",
117
+ "analyzer = PeptideAnalyzer()\n",
118
+ "print(\"\\nAll models loaded!\")"
119
+ ]
120
+ },
121
+ {
122
+ "cell_type": "markdown",
123
+ "source": [
124
+ "## 3. Define Helper Functions"
125
+ ],
126
+ "metadata": {}
127
+ },
128
+ {
129
+ "cell_type": "code",
130
+ "execution_count": null,
131
+ "metadata": {},
132
+ "outputs": [],
133
+ "source": [
134
+ "def sample_sequences(model, batch_size, seq_length, num_steps=128, eps=1e-5):\n",
135
+ " \"\"\"Sample sequences from the diffusion model.\"\"\"\n",
136
+ " x = model.sample_prior(batch_size, seq_length).to(model.device, dtype=torch.long)\n",
137
+ " timesteps = torch.linspace(1, eps, num_steps + 1, device=model.device)\n",
138
+ " dt = torch.tensor((1 - eps) / num_steps, device=model.device)\n",
139
+ "\n",
140
+ " for i in range(num_steps):\n",
141
+ " t = timesteps[i] * torch.ones(x.shape[0], 1, device=model.device)\n",
142
+ " _, x = model.single_reverse_step(x, t=t, dt=dt)\n",
143
+ " x = x.to(model.device)\n",
144
+ "\n",
145
+ " mask_pos = (x == model.mask_index)\n",
146
+ " if mask_pos.any():\n",
147
+ " t = timesteps[-2] * torch.ones(x.shape[0], 1, device=model.device)\n",
148
+ " _, x = model.single_noise_removal(x, t=t, dt=dt)\n",
149
+ " return x\n",
150
+ "\n",
151
+ "\n",
152
+ "def generate_binders(target_seq, direction=\"agonist\", num_pool=32,\n",
153
+ " num_keep=8, alpha=0.1, seq_length=200):\n",
154
+ " \"\"\"\n",
155
+ " Generate directional binders for a target protein.\n",
156
+ " \n",
157
+ " Args:\n",
158
+ " target_seq: Target protein amino acid sequence\n",
159
+ " direction: 'agonist' or 'antagonist'\n",
160
+ " num_pool: Number of candidates to generate\n",
161
+ " num_keep: Number of final samples after resampling\n",
162
+ " alpha: Temperature for weighted resampling\n",
163
+ " seq_length: Binder sequence length (in SMILES tokens)\n",
164
+ " \n",
165
+ " Returns:\n",
166
+ " DataFrame with generated binders and scores\n",
167
+ " \"\"\"\n",
168
+ " d_star = 1.0 if direction == \"agonist\" else -1.0\n",
169
+ " \n",
170
+ " # Build reward function\n",
171
+ " affinity_pred = BindingAffinity(\n",
172
+ " prot_seq=target_seq, tokenizer=tokenizer,\n",
173
+ " base_path=\".\", device=device, emb_model=model.backbone\n",
174
+ " )\n",
175
+ " reward_fn = create_td3b_reward_function(\n",
176
+ " affinity_predictor=affinity_pred,\n",
177
+ " directional_oracle=oracle,\n",
178
+ " target_protein_seq=target_seq,\n",
179
+ " target_direction=direction,\n",
180
+ " peptide_tokenizer=tokenizer,\n",
181
+ " device=device,\n",
182
+ " )\n",
183
+ " \n",
184
+ " # Generate candidates\n",
185
+ " with torch.no_grad():\n",
186
+ " x_pool = sample_sequences(model, num_pool, seq_length)\n",
187
+ " sequences = tokenizer.batch_decode(x_pool)\n",
188
+ " \n",
189
+ " # Score all\n",
190
+ " rewards, info = reward_fn(sequences)\n",
191
+ " affinities = info[\"affinities\"]\n",
192
+ " directions = info[\"directions\"]\n",
193
+ " \n",
194
+ " # Weighted resampling (Algorithm 2)\n",
195
+ " rewards_t = torch.as_tensor(rewards, device=device)\n",
196
+ " weights = torch.softmax(rewards_t / max(alpha, 1e-6), dim=0)\n",
197
+ " idx = torch.multinomial(weights, num_samples=num_keep, replacement=True)\n",
198
+ " chosen = idx.cpu().numpy()\n",
199
+ " \n",
200
+ " # Filter to valid peptides only\n",
201
+ " results = []\n",
202
+ " for i in chosen:\n",
203
+ " is_valid = analyzer.is_peptide(sequences[i])\n",
204
+ " da = float(directions[i] > 0.5) if d_star > 0 else float(directions[i] < 0.5)\n",
205
+ " results.append({\n",
206
+ " \"sequence\": sequences[i],\n",
207
+ " \"direction\": direction,\n",
208
+ " \"is_valid\": is_valid,\n",
209
+ " \"affinity\": float(affinities[i]),\n",
210
+ " \"gated_reward\": float(rewards[i]),\n",
211
+ " \"p_agonist\": float(directions[i]),\n",
212
+ " \"direction_accuracy\": da,\n",
213
+ " })\n",
214
+ " \n",
215
+ " df = pd.DataFrame(results)\n",
216
+ " return df"
217
+ ]
218
+ },
219
+ {
220
+ "cell_type": "markdown",
221
+ "source": [
222
+ "## 4. Generate Binders\n",
223
+ "\n",
224
+ "Let's generate **agonist** and **antagonist** binders for a test target and compare the Direction Oracle predictions."
225
+ ],
226
+ "metadata": {}
227
+ },
228
+ {
229
+ "cell_type": "code",
230
+ "execution_count": null,
231
+ "metadata": {},
232
+ "outputs": [],
233
+ "source": [
234
+ "# Load test targets\n",
235
+ "test_df = pd.read_csv(\"data/test.csv\")\n",
236
+ "print(f\"Test set: {len(test_df)} target-binder pairs\")\n",
237
+ "\n",
238
+ "# Pick first target for demo\n",
239
+ "target_row = test_df.iloc[0]\n",
240
+ "TARGET_SEQ = target_row[\"Target_Sequence\"]\n",
241
+ "TARGET_UID = target_row[\"Target_UniProt_ID\"]\n",
242
+ "print(f\"\\nTarget: {TARGET_UID}\")\n",
243
+ "print(f\"Sequence length: {len(TARGET_SEQ)} aa\")\n",
244
+ "print(f\"Sequence: {TARGET_SEQ[:60]}...\")"
245
+ ]
246
+ },
247
+ {
248
+ "cell_type": "code",
249
+ "execution_count": null,
250
+ "metadata": {},
251
+ "outputs": [],
252
+ "source": [
253
+ "%%time\n",
254
+ "# Generate AGONIST binders\n",
255
+ "print(\"Generating agonist binders (d*=+1)...\")\n",
256
+ "torch.manual_seed(42)\n",
257
+ "np.random.seed(42)\n",
258
+ "df_agonist = generate_binders(TARGET_SEQ, direction=\"agonist\", num_pool=32, num_keep=8)\n",
259
+ "\n",
260
+ "print(f\"\\nGenerated {len(df_agonist)} samples ({df_agonist['is_valid'].sum()} valid)\")\n",
261
+ "print(f\"Mean p(agonist): {df_agonist['p_agonist'].mean():.3f}\")\n",
262
+ "print(f\"Mean affinity: {df_agonist['affinity'].mean():.2f}\")\n",
263
+ "print(f\"Mean gated reward: {df_agonist['gated_reward'].mean():.2f}\")"
264
+ ]
265
+ },
266
+ {
267
+ "cell_type": "code",
268
+ "execution_count": null,
269
+ "metadata": {},
270
+ "outputs": [],
271
+ "source": [
272
+ "%%time\n",
273
+ "# Generate ANTAGONIST binders\n",
274
+ "print(\"Generating antagonist binders (d*=-1)...\")\n",
275
+ "torch.manual_seed(42)\n",
276
+ "np.random.seed(42)\n",
277
+ "df_antagonist = generate_binders(TARGET_SEQ, direction=\"antagonist\", num_pool=32, num_keep=8)\n",
278
+ "\n",
279
+ "print(f\"\\nGenerated {len(df_antagonist)} samples ({df_antagonist['is_valid'].sum()} valid)\")\n",
280
+ "print(f\"Mean p(agonist): {df_antagonist['p_agonist'].mean():.3f}\")\n",
281
+ "print(f\"Mean affinity: {df_antagonist['affinity'].mean():.2f}\")\n",
282
+ "print(f\"Mean gated reward: {df_antagonist['gated_reward'].mean():.2f}\")"
283
+ ]
284
+ },
285
+ {
286
+ "cell_type": "markdown",
287
+ "source": [
288
+ "## 5. Compare Directional Control"
289
+ ],
290
+ "metadata": {}
291
+ },
292
+ {
293
+ "cell_type": "code",
294
+ "execution_count": null,
295
+ "metadata": {},
296
+ "outputs": [],
297
+ "source": [
298
+ "import matplotlib.pyplot as plt\n",
299
+ "\n",
300
+ "fig, axes = plt.subplots(1, 3, figsize=(15, 4))\n",
301
+ "\n",
302
+ "# Plot 1: Direction Oracle p(agonist)\n",
303
+ "axes[0].hist(df_agonist[\"p_agonist\"], bins=20, alpha=0.7, label=\"d*=+1 (agonist)\", color=\"#e74c3c\")\n",
304
+ "axes[0].hist(df_antagonist[\"p_agonist\"], bins=20, alpha=0.7, label=\"d*=-1 (antagonist)\", color=\"#3498db\")\n",
305
+ "axes[0].axvline(0.5, color=\"gray\", linestyle=\"--\", label=\"threshold\")\n",
306
+ "axes[0].set_xlabel(\"p(agonist)\")\n",
307
+ "axes[0].set_ylabel(\"Count\")\n",
308
+ "axes[0].set_title(\"Direction Oracle Predictions\")\n",
309
+ "axes[0].legend()\n",
310
+ "\n",
311
+ "# Plot 2: Binding Affinity\n",
312
+ "axes[1].hist(df_agonist[\"affinity\"], bins=20, alpha=0.7, label=\"Agonist\", color=\"#e74c3c\")\n",
313
+ "axes[1].hist(df_antagonist[\"affinity\"], bins=20, alpha=0.7, label=\"Antagonist\", color=\"#3498db\")\n",
314
+ "axes[1].set_xlabel(\"Predicted Binding Affinity\")\n",
315
+ "axes[1].set_ylabel(\"Count\")\n",
316
+ "axes[1].set_title(\"Binding Affinity Distribution\")\n",
317
+ "axes[1].legend()\n",
318
+ "\n",
319
+ "# Plot 3: Gated Reward\n",
320
+ "axes[2].hist(df_agonist[\"gated_reward\"], bins=20, alpha=0.7, label=\"Agonist\", color=\"#e74c3c\")\n",
321
+ "axes[2].hist(df_antagonist[\"gated_reward\"], bins=20, alpha=0.7, label=\"Antagonist\", color=\"#3498db\")\n",
322
+ "axes[2].set_xlabel(\"Gated Reward\")\n",
323
+ "axes[2].set_ylabel(\"Count\")\n",
324
+ "axes[2].set_title(\"Gated Reward Distribution\")\n",
325
+ "axes[2].legend()\n",
326
+ "\n",
327
+ "plt.tight_layout()\n",
328
+ "plt.savefig(\"td3b_results.png\", dpi=150, bbox_inches=\"tight\")\n",
329
+ "plt.show()\n",
330
+ "\n",
331
+ "print(\"\\nSummary:\")\n",
332
+ "print(f\" Agonist mode: p(agonist)={df_agonist['p_agonist'].mean():.3f} Affinity={df_agonist['affinity'].mean():.2f} Gated={df_agonist['gated_reward'].mean():.2f}\")\n",
333
+ "print(f\" Antagonist mode: p(agonist)={df_antagonist['p_agonist'].mean():.3f} Affinity={df_antagonist['affinity'].mean():.2f} Gated={df_antagonist['gated_reward'].mean():.2f}\")\n",
334
+ "print(f\" Directional gap: Δp = {df_agonist['p_agonist'].mean() - df_antagonist['p_agonist'].mean():.3f}\")"
335
+ ]
336
+ },
337
+ {
338
+ "cell_type": "markdown",
339
+ "source": [
340
+ "## 6. Run on Multiple Targets\n",
341
+ "\n",
342
+ "Generate binders for the first 5 test targets and compute aggregate metrics."
343
+ ],
344
+ "metadata": {}
345
+ },
346
+ {
347
+ "cell_type": "code",
348
+ "execution_count": null,
349
+ "metadata": {},
350
+ "outputs": [],
351
+ "source": [
352
+ "N_TARGETS = 5 # Number of targets to evaluate (increase for full benchmark)\n",
353
+ "\n",
354
+ "all_results = []\n",
355
+ "targets = test_df.drop_duplicates(\"Target_UniProt_ID\").head(N_TARGETS)\n",
356
+ "\n",
357
+ "for i, (_, row) in enumerate(targets.iterrows()):\n",
358
+ " uid = row[\"Target_UniProt_ID\"]\n",
359
+ " seq = row[\"Target_Sequence\"]\n",
360
+ " print(f\"[{i+1}/{N_TARGETS}] {uid} (len={len(seq)})\")\n",
361
+ " \n",
362
+ " for direction in [\"agonist\", \"antagonist\"]:\n",
363
+ " torch.manual_seed(42)\n",
364
+ " np.random.seed(42)\n",
365
+ " df = generate_binders(seq, direction=direction, num_pool=32, num_keep=8)\n",
366
+ " df[\"target_uid\"] = uid\n",
367
+ " all_results.append(df)\n",
368
+ " \n",
369
+ " d_star = 1.0 if direction == \"agonist\" else -1.0\n",
370
+ " da = df[\"direction_accuracy\"].mean()\n",
371
+ " print(f\" {direction:>10s}: DA={da:.2f} Aff={df['affinity'].mean():.2f} Gated={df['gated_reward'].mean():.2f} valid={df['is_valid'].sum()}/{len(df)}\")\n",
372
+ "\n",
373
+ "combined = pd.concat(all_results, ignore_index=True)\n",
374
+ "\n",
375
+ "print(f\"\\n{'='*60}\")\n",
376
+ "print(f\"AGGREGATE METRICS ({N_TARGETS} targets)\")\n",
377
+ "print(f\"{'='*60}\")\n",
378
+ "for d_name, d_val in [(\"Agonist (d*=+1)\", \"agonist\"), (\"Antagonist (d*=-1)\", \"antagonist\")]:\n",
379
+ " sub = combined[combined[\"direction\"] == d_val]\n",
380
+ " valid = sub[sub[\"is_valid\"] == True]\n",
381
+ " print(f\" {d_name}:\")\n",
382
+ " print(f\" Affinity: {sub['affinity'].mean():.2f}\")\n",
383
+ " print(f\" Direction Accuracy: {sub['direction_accuracy'].mean():.3f}\")\n",
384
+ " print(f\" Gated Reward (all): {sub['gated_reward'].mean():.2f}\")\n",
385
+ " if len(valid) > 0:\n",
386
+ " print(f\" Gated Reward (valid): {valid['gated_reward'].mean():.2f}\")\n",
387
+ " print(f\" Valid: {sub['is_valid'].sum()}/{len(sub)}\")\n",
388
+ "\n",
389
+ "# Save\n",
390
+ "combined.to_csv(\"td3b_demo_results.csv\", index=False)\n",
391
+ "print(f\"\\nResults saved to td3b_demo_results.csv\")"
392
+ ]
393
+ },
394
+ {
395
+ "cell_type": "markdown",
396
+ "source": [
397
+ "## Citation\n",
398
+ "\n",
399
+ "```bibtex\n",
400
+ "@article{caotd3b,\n",
401
+ " title={TD3B: Transition-Directed Discrete Diffusion for Allosteric Binder Generation},\n",
402
+ " author={Cao, Hanqun and Pal, Aastha and Tang, Sophia and Zhang, Yinuo and Zhang, Jingjie and Heng, Pheng-Ann and Chatterjee, Pranam}\n",
403
+ "}\n",
404
+ "```"
405
+ ],
406
+ "metadata": {}
407
+ }
408
+ ]
409
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
sampling_strategies.py ADDED
@@ -0,0 +1,461 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ sampling_strategies.py -- TD3B / MDLM validity-boosting samplers (FUNCTION B).
4
+
5
+ Goal
6
+ ----
7
+ As the target peptide length grows, the fraction of decoded SMILES that pass
8
+ ``utils.app.PeptideAnalyzer.is_peptide`` (RDKit) collapses. This module supplies a
9
+ library of *sampling-time* techniques (no retraining) that raise that valid-yield,
10
+ especially at long length.
11
+
12
+ All samplers REUSE the model's existing masked-diffusion primitives:
13
+ * ``Diffusion.sample_prior`` -> fully masked start
14
+ * ``Diffusion.single_reverse_step`` -> one reverse (denoising) step (returns log_p, x_next)
15
+ * ``Diffusion.single_noise_removal``-> final step guaranteeing no surviving [MASK]
16
+ * ``Diffusion.forward`` -> per-position log p(x0) (SUBS-parameterised logits)
17
+
18
+ We DO NOT reimplement the diffusion math. The reverse-posterior construction
19
+ (``q_xs = p_x0 * (t - (t-dt))`` with the mask-stay probability, and the carry-over
20
+ copy_flag) is left entirely to ``single_reverse_step`` / ``single_noise_removal``.
21
+ We only change **token SELECTION** -- i.e. we transform the model's clean-token
22
+ distribution ``p_x0`` (temperature / top-k / top-p) that we feed back into
23
+ ``single_reverse_step(..., p_x0=...)`` -- and add a **remask self-correction loop**
24
+ and a **best-of-N validity-guided rejection** wrapper on top.
25
+
26
+ Common entry point
27
+ ------------------
28
+ generate(model, tokenizer, analyzer, batch_size, length, strategy="baseline", **kw)
29
+ -> (tokens, sequences, valid_mask, stats)
30
+
31
+ Strategies (``strategy=``):
32
+ baseline : reverse diffusion identical to inference.sample_sequences (num_steps=128)
33
+ more_steps : same, but num_steps scales with length (steps_per_token)
34
+ top_p : nucleus -- restrict p_x0 to the smallest set with cumulative mass >= p
35
+ nucleus : alias of top_p
36
+ top_k : restrict p_x0 to the k most-probable clean tokens
37
+ low_temp : temperature < 1 on the clean-token logits (sharper, more valid)
38
+ remask : self-correction -- generate, then remask the lowest-confidence K%%
39
+ of tokens of INVALID sequences and re-denoise, for R rounds
40
+ best_of_n : oversample N per slot, keep the first valid decode
41
+ nucleus_remask : nucleus + remask (recommended default for long length)
42
+
43
+ Any preset can be combined with explicit kwargs, e.g.
44
+ generate(..., strategy="remask", top_p=0.9, remask_rounds=4)
45
+
46
+ The library is model-agnostic: it works with a RANDOM-init ``Diffusion`` (for CPU
47
+ development / relative benchmarking) and, unchanged, with the real checkpoint.
48
+ """
49
+
50
+ from __future__ import annotations
51
+
52
+ import time
53
+ from typing import Callable, Dict, List, Optional, Tuple
54
+
55
+ import numpy as np
56
+ import torch
57
+
58
+ # --------------------------------------------------------------------------- #
59
+ # Distribution transforms (token SELECTION only -- no diffusion math here) #
60
+ # --------------------------------------------------------------------------- #
61
+ _NEG_INF = -1e9
62
+
63
+
64
+ def _apply_top_k(logits: torch.Tensor, k: int) -> torch.Tensor:
65
+ """Keep the k largest logits per position, push the rest to -inf."""
66
+ if k is None or k <= 0 or k >= logits.shape[-1]:
67
+ return logits
68
+ kth = torch.topk(logits, k, dim=-1).values[..., -1, None] # (..., 1) k-th largest
69
+ return logits.masked_fill(logits < kth, _NEG_INF)
70
+
71
+
72
+ def _apply_top_p(probs: torch.Tensor, p: float) -> torch.Tensor:
73
+ """Nucleus filter: zero out the low-mass tail so the kept set has cumulative
74
+ mass >= p (always keeps at least the argmax), then renormalise."""
75
+ if p is None or p >= 1.0:
76
+ return probs
77
+ sorted_probs, sorted_idx = torch.sort(probs, dim=-1, descending=True)
78
+ cumsum = sorted_probs.cumsum(dim=-1)
79
+ # a token is DROPPED if the mass strictly *before* it already reached p
80
+ drop_sorted = (cumsum - sorted_probs) > p
81
+ sorted_probs = sorted_probs.masked_fill(drop_sorted, 0.0)
82
+ new_probs = torch.zeros_like(probs).scatter_(-1, sorted_idx, sorted_probs)
83
+ return new_probs / new_probs.sum(dim=-1, keepdim=True).clamp_min(1e-12)
84
+
85
+
86
+ def make_transform(temperature: float = 1.0,
87
+ top_p: Optional[float] = None,
88
+ top_k: Optional[int] = None) -> Optional[Callable[[torch.Tensor], torch.Tensor]]:
89
+ """Build a callable ``log_p (B,L,V) -> p_x0 (B,L,V)`` implementing
90
+ temperature -> top_k (logit space) -> softmax -> top_p (prob space).
91
+
92
+ Returns ``None`` when the transform is the identity (baseline path), so the
93
+ caller can take the cheaper single-``single_reverse_step`` route.
94
+ """
95
+ temp_on = temperature is not None and abs(temperature - 1.0) > 1e-8
96
+ if not temp_on and top_p is None and top_k is None:
97
+ return None
98
+
99
+ def _transform(log_p: torch.Tensor) -> torch.Tensor:
100
+ v = log_p
101
+ if temp_on:
102
+ v = v / float(temperature)
103
+ if top_k is not None:
104
+ v = _apply_top_k(v, int(top_k))
105
+ probs = torch.softmax(v, dim=-1)
106
+ if top_p is not None:
107
+ probs = _apply_top_p(probs, float(top_p))
108
+ return probs
109
+
110
+ return _transform
111
+
112
+
113
+ # --------------------------------------------------------------------------- #
114
+ # Core reverse-diffusion loop (delegates all diffusion math to the model) #
115
+ # --------------------------------------------------------------------------- #
116
+ def _reverse_step(model, x: torch.Tensor, t: torch.Tensor, dt: torch.Tensor,
117
+ attn_mask: torch.Tensor,
118
+ transform: Optional[Callable]) -> Tuple[torch.Tensor, torch.Tensor]:
119
+ """One reverse step. Returns (log_p, x_next).
120
+
121
+ * transform is None -> plain ``single_reverse_step`` (one forward, returns log_p).
122
+ * transform given -> one ``forward`` to get log p_x0, transform it, then hand the
123
+ modified p_x0 to ``single_reverse_step`` which still builds the reverse posterior
124
+ and samples exactly as in the original math.
125
+ """
126
+ if transform is None:
127
+ log_p, x_next = model.single_reverse_step(x, t=t, dt=dt, attn_mask=attn_mask)
128
+ return log_p, x_next
129
+ # lower-level logits (SUBS-parameterised); sigma comes from the model's own schedule
130
+ sigma_t, _ = model.noise(t)
131
+ log_p = model.forward(x, attn_mask=attn_mask, sigma=sigma_t)
132
+ p_x0 = transform(log_p)
133
+ _, x_next = model.single_reverse_step(x, t=t, dt=dt, p_x0=p_x0, attn_mask=attn_mask)
134
+ return log_p, x_next
135
+
136
+
137
+ def _update_confidence(conf: torch.Tensor, x_prev: torch.Tensor, x_next: torch.Tensor,
138
+ log_p: torch.Tensor, mask_index: int) -> torch.Tensor:
139
+ """Record, for every position that transitions mask->token at this step, the model's
140
+ probability of the chosen token. Because of carry-over unmasking each position is
141
+ written exactly once (when it first unmasks); higher conf == model more certain."""
142
+ if log_p is None:
143
+ return conf
144
+ newly = (x_prev == mask_index) & (x_next != mask_index)
145
+ if not newly.any():
146
+ return conf
147
+ probs = log_p.exp()
148
+ chosen = x_next.clamp(0, probs.shape[-1] - 1).unsqueeze(-1)
149
+ step_conf = probs.gather(-1, chosen).squeeze(-1)
150
+ return torch.where(newly, step_conf, conf)
151
+
152
+
153
+ def _reverse_diffusion(model, x: torch.Tensor, num_steps: int,
154
+ t_start: float = 1.0, eps: float = 1e-5,
155
+ transform: Optional[Callable] = None,
156
+ attn_mask: Optional[torch.Tensor] = None,
157
+ conf: Optional[torch.Tensor] = None,
158
+ noise_removal: bool = True) -> Tuple[torch.Tensor, torch.Tensor]:
159
+ """Reverse-diffuse ``x`` from time ``t_start`` down to ``eps`` over ``num_steps``.
160
+
161
+ Mirrors ``inference.sample_sequences`` (linspace timesteps, final noise-removal to
162
+ guarantee no surviving [MASK]) but supports (a) an arbitrary starting time -- so the
163
+ remask loop can resume from a partially-masked state -- and (b) a p_x0 ``transform``.
164
+ Already-unmasked positions are preserved by the model's copy_flag (carry-over).
165
+ Returns (x, confidence) with confidence in [0, 1] per position.
166
+ """
167
+ device = model.device
168
+ x = x.to(device, dtype=torch.long)
169
+ if attn_mask is None:
170
+ attn_mask = torch.ones_like(x, device=device, dtype=torch.long)
171
+ if conf is None:
172
+ conf = torch.zeros(x.shape, device=device, dtype=torch.float32)
173
+ mask_index = model.mask_index
174
+
175
+ num_steps = max(int(num_steps), 1)
176
+ timesteps = torch.linspace(t_start, eps, num_steps + 1, device=device)
177
+ dt = torch.tensor((t_start - eps) / num_steps, device=device)
178
+
179
+ for i in range(num_steps):
180
+ t = timesteps[i] * torch.ones(x.shape[0], 1, device=device)
181
+ log_p, x_next = _reverse_step(model, x, t, dt, attn_mask, transform)
182
+ conf = _update_confidence(conf, x, x_next, log_p, mask_index)
183
+ x = x_next.to(device)
184
+
185
+ if noise_removal and (x == mask_index).any():
186
+ t = timesteps[-2] * torch.ones(x.shape[0], 1, device=device)
187
+ log_p, x_next = model.single_noise_removal(x, t=t, dt=dt, attn_mask=attn_mask)
188
+ conf = _update_confidence(conf, x, x_next, log_p, mask_index)
189
+ x = x_next.to(device)
190
+
191
+ return x, conf
192
+
193
+
194
+ # --------------------------------------------------------------------------- #
195
+ # Decode / validity helpers #
196
+ # --------------------------------------------------------------------------- #
197
+ def decode_and_validate(tokenizer, analyzer, x: torch.Tensor) -> Tuple[List[str], np.ndarray]:
198
+ """Decode token ids to SMILES and test each with ``analyzer.is_peptide``."""
199
+ sequences = tokenizer.batch_decode(x)
200
+ valid = np.fromiter((bool(analyzer.is_peptide(s)) for s in sequences),
201
+ dtype=bool, count=len(sequences))
202
+ return sequences, valid
203
+
204
+
205
+ # --------------------------------------------------------------------------- #
206
+ # Remask self-correction (the key technique for long sequences) #
207
+ # --------------------------------------------------------------------------- #
208
+ def _remask_and_redenoise(model, x: torch.Tensor, conf: torch.Tensor,
209
+ rows: torch.Tensor, remask_frac: float, remask_steps: int,
210
+ transform: Optional[Callable], eps: float,
211
+ attn_mask: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]:
212
+ """For the selected ``rows`` (typically the invalid sequences): set the lowest-
213
+ confidence ``remask_frac`` of their positions back to [MASK], then re-denoise ONLY
214
+ those rows from t=remask_frac down to eps. Returns updated (x, conf) copies.
215
+
216
+ The high-confidence tokens are kept (carry-over), so re-denoising only re-samples
217
+ the positions the model was least sure about -- a cheap, targeted second attempt.
218
+ """
219
+ x = x.clone()
220
+ conf = conf.clone()
221
+ sub_x = x[rows].clone()
222
+ sub_conf = conf[rows].clone()
223
+ n, L = sub_x.shape
224
+ k = max(1, int(round(remask_frac * L)))
225
+ k = min(k, L)
226
+
227
+ # lowest-confidence k positions per row -> back to MASK
228
+ low_idx = torch.topk(sub_conf, k, dim=-1, largest=False).indices # (n, k)
229
+ sub_x.scatter_(1, low_idx, model.mask_index)
230
+ sub_conf.scatter_(1, low_idx, 0.0) # will be recomputed when re-unmasked
231
+
232
+ # resume masked-diffusion from a time consistent with the masked fraction (~k/L)
233
+ t_start = float(min(max(k / L, 2 * eps), 1.0))
234
+ sub_attn = attn_mask[rows]
235
+ sub_x, sub_conf = _reverse_diffusion(
236
+ model, sub_x, num_steps=remask_steps, t_start=t_start, eps=eps,
237
+ transform=transform, attn_mask=sub_attn, conf=sub_conf, noise_removal=True)
238
+
239
+ x[rows] = sub_x
240
+ conf[rows] = sub_conf
241
+ return x, conf
242
+
243
+
244
+ # --------------------------------------------------------------------------- #
245
+ # Single generation pass (+ optional remask rounds) #
246
+ # --------------------------------------------------------------------------- #
247
+ @torch.no_grad()
248
+ def _generate_once(model, tokenizer, analyzer, batch_size: int, length: int,
249
+ num_steps: int, eps: float,
250
+ transform: Optional[Callable],
251
+ remask_rounds: int, remask_frac: float, remask_steps: int
252
+ ) -> Tuple[torch.Tensor, List[str], np.ndarray, torch.Tensor, List[int]]:
253
+ device = model.device
254
+ x = model.sample_prior(batch_size, length).to(device, dtype=torch.long)
255
+ attn_mask = torch.ones_like(x, device=device, dtype=torch.long)
256
+
257
+ x, conf = _reverse_diffusion(model, x, num_steps=num_steps, t_start=1.0, eps=eps,
258
+ transform=transform, attn_mask=attn_mask,
259
+ noise_removal=True)
260
+ sequences, valid = decode_and_validate(tokenizer, analyzer, x)
261
+ round_valid = [int(valid.sum())] # valid count after each stage (round 0 == initial)
262
+
263
+ for _ in range(int(remask_rounds)):
264
+ if valid.all():
265
+ break
266
+ rows = torch.from_numpy(np.where(~valid)[0]).to(device=device, dtype=torch.long)
267
+ x, conf = _remask_and_redenoise(model, x, conf, rows, remask_frac, remask_steps,
268
+ transform, eps, attn_mask)
269
+ # re-decode / re-validate only the rows we touched
270
+ new_seqs = tokenizer.batch_decode(x[rows])
271
+ for j, gi in enumerate(rows.tolist()):
272
+ sequences[gi] = new_seqs[j]
273
+ valid[gi] = bool(analyzer.is_peptide(new_seqs[j]))
274
+ round_valid.append(int(valid.sum()))
275
+
276
+ return x, sequences, valid, conf, round_valid
277
+
278
+
279
+ # --------------------------------------------------------------------------- #
280
+ # Best-of-N validity-guided rejection wrapper #
281
+ # --------------------------------------------------------------------------- #
282
+ @torch.no_grad()
283
+ def _generate_best_of_n(model, tokenizer, analyzer, batch_size: int, length: int,
284
+ num_steps: int, eps: float, transform: Optional[Callable],
285
+ remask_rounds: int, remask_frac: float, remask_steps: int,
286
+ best_of_n: int
287
+ ) -> Tuple[torch.Tensor, List[str], np.ndarray, Dict]:
288
+ """Draw up to ``best_of_n`` independent candidates per slot; keep the first valid
289
+ decode for each slot (fall back to the last draw if none is valid)."""
290
+ device = model.device
291
+ tokens = None
292
+ sequences: List[str] = [""] * batch_size
293
+ valid = np.zeros(batch_size, dtype=bool)
294
+ draws_used = 0
295
+ for n in range(max(int(best_of_n), 1)):
296
+ draws_used = n + 1
297
+ xg, seqs, vmask, _conf, _rv = _generate_once(
298
+ model, tokenizer, analyzer, batch_size, length, num_steps, eps,
299
+ transform, remask_rounds, remask_frac, remask_steps)
300
+ if tokens is None:
301
+ tokens = xg.clone()
302
+ for i in range(batch_size):
303
+ sequences[i] = seqs[i]
304
+ valid = vmask.copy()
305
+ else:
306
+ # accept this draw only for slots not yet valid
307
+ take = (~valid) & vmask
308
+ if take.any():
309
+ idx = np.where(take)[0]
310
+ tokens[idx] = xg[idx]
311
+ for i in idx:
312
+ sequences[i] = seqs[i]
313
+ valid[idx] = True
314
+ # for still-invalid slots, keep the freshest candidate (so tokens stay consistent)
315
+ still = np.where(~valid)[0]
316
+ if len(still):
317
+ tokens[still] = xg[still]
318
+ for i in still:
319
+ sequences[i] = seqs[i]
320
+ if valid.all():
321
+ break
322
+ stats = {"best_of_n_draws_used": draws_used}
323
+ return tokens, sequences, valid, stats
324
+
325
+
326
+ # --------------------------------------------------------------------------- #
327
+ # Public dispatch #
328
+ # --------------------------------------------------------------------------- #
329
+ STRATEGY_PRESETS: Dict[str, Dict] = {
330
+ "baseline": dict(),
331
+ "more_steps": dict(steps_per_token=1.0),
332
+ "top_p": dict(top_p=0.9),
333
+ "nucleus": dict(top_p=0.9),
334
+ "top_k": dict(top_k=20),
335
+ "low_temp": dict(temperature=0.7),
336
+ "remask": dict(remask_rounds=3, remask_frac=0.25, remask_steps=32),
337
+ "best_of_n": dict(best_of_n=4),
338
+ "nucleus_remask": dict(top_p=0.9, remask_rounds=3, remask_frac=0.25, remask_steps=32),
339
+ }
340
+
341
+
342
+ def available_strategies() -> List[str]:
343
+ return list(STRATEGY_PRESETS.keys())
344
+
345
+
346
+ @torch.no_grad()
347
+ def generate(model, tokenizer, analyzer, batch_size: int, length: int,
348
+ strategy: str = "baseline",
349
+ num_steps: int = 128, eps: float = 1e-5,
350
+ temperature: Optional[float] = None,
351
+ top_p: Optional[float] = None,
352
+ top_k: Optional[int] = None,
353
+ steps_per_token: Optional[float] = None,
354
+ remask_rounds: Optional[int] = None,
355
+ remask_frac: Optional[float] = None,
356
+ remask_steps: Optional[int] = None,
357
+ best_of_n: Optional[int] = None,
358
+ verbose: bool = False,
359
+ ) -> Tuple[torch.Tensor, List[str], np.ndarray, Dict]:
360
+ """Generate ``batch_size`` peptides of ``length`` tokens with the chosen strategy.
361
+
362
+ Returns
363
+ -------
364
+ tokens : LongTensor (batch_size, length) final token ids
365
+ sequences : list[str] decoded SMILES
366
+ valid_mask : np.ndarray[bool] (batch_size,) analyzer.is_peptide per sequence
367
+ stats : dict metrics (valid_rate, timing, knobs, ...)
368
+ """
369
+ if strategy not in STRATEGY_PRESETS:
370
+ raise ValueError(f"unknown strategy {strategy!r}; choose from {available_strategies()}")
371
+
372
+ # preset provides defaults; any explicitly-passed (non-None) kwarg overrides it
373
+ cfg = dict(temperature=1.0, top_p=None, top_k=None, steps_per_token=None,
374
+ remask_rounds=0, remask_frac=0.25, remask_steps=32, best_of_n=1)
375
+ cfg.update(STRATEGY_PRESETS[strategy])
376
+ explicit = dict(temperature=temperature, top_p=top_p, top_k=top_k,
377
+ steps_per_token=steps_per_token, remask_rounds=remask_rounds,
378
+ remask_frac=remask_frac, remask_steps=remask_steps, best_of_n=best_of_n)
379
+ for key, val in explicit.items():
380
+ if val is not None:
381
+ cfg[key] = val
382
+
383
+ # steps scale with length for `more_steps` (>= base num_steps)
384
+ eff_steps = int(num_steps)
385
+ if cfg["steps_per_token"] is not None:
386
+ eff_steps = max(eff_steps, int(round(cfg["steps_per_token"] * length)))
387
+
388
+ transform = make_transform(cfg["temperature"], cfg["top_p"], cfg["top_k"])
389
+
390
+ t0 = time.time()
391
+ if int(cfg["best_of_n"]) > 1:
392
+ tokens, sequences, valid, extra = _generate_best_of_n(
393
+ model, tokenizer, analyzer, batch_size, length, eff_steps, eps, transform,
394
+ int(cfg["remask_rounds"]), float(cfg["remask_frac"]), int(cfg["remask_steps"]),
395
+ int(cfg["best_of_n"]))
396
+ round_valid = [int(valid.sum())]
397
+ else:
398
+ tokens, sequences, valid, _conf, round_valid = _generate_once(
399
+ model, tokenizer, analyzer, batch_size, length, eff_steps, eps, transform,
400
+ int(cfg["remask_rounds"]), float(cfg["remask_frac"]), int(cfg["remask_steps"]))
401
+ extra = {}
402
+ wall = time.time() - t0
403
+
404
+ valid_count = int(valid.sum())
405
+ stats = {
406
+ "strategy": strategy,
407
+ "length": int(length),
408
+ "batch_size": int(batch_size),
409
+ "num_steps": int(eff_steps),
410
+ "temperature": float(cfg["temperature"]),
411
+ "top_p": cfg["top_p"],
412
+ "top_k": cfg["top_k"],
413
+ "remask_rounds": int(cfg["remask_rounds"]),
414
+ "remask_frac": float(cfg["remask_frac"]),
415
+ "remask_steps": int(cfg["remask_steps"]),
416
+ "best_of_n": int(cfg["best_of_n"]),
417
+ "valid_count": valid_count,
418
+ "valid_rate": valid_count / max(batch_size, 1),
419
+ "round_valid_counts": round_valid, # valid count after each remask round (shows mechanism)
420
+ "wall_time_s": round(wall, 2),
421
+ }
422
+ stats.update(extra)
423
+ if verbose:
424
+ print(f"[{strategy}] L={length} steps={eff_steps} "
425
+ f"valid={valid_count}/{batch_size} ({stats['valid_rate']:.1%}) "
426
+ f"rounds={round_valid} {wall:.1f}s")
427
+ return tokens, sequences, valid, stats
428
+
429
+
430
+ # --------------------------------------------------------------------------- #
431
+ # Model construction helpers #
432
+ # --------------------------------------------------------------------------- #
433
+ def build_random_model(device="cpu", hidden_size: int = 768, n_layers: int = 8,
434
+ n_heads: int = 8, tokenizer=None, base_path: Optional[str] = None):
435
+ """Construct a RANDOM-init ``Diffusion`` (no checkpoint) for CPU development /
436
+ relative benchmarking. Same architecture/config path as ``inference.load_model``
437
+ minus the weight load, so samplers exercised here transfer unchanged to the real
438
+ checkpoint. Reduce hidden_size/n_layers for fast CPU benchmarks (yields are
439
+ garbage either way with random init -- only relative trends are meaningful).
440
+ """
441
+ import os
442
+ from configs.finetune_config import (DiffusionConfig, RoFormerConfig, NoiseConfig,
443
+ TrainingConfig, SamplingConfig, EvalConfig,
444
+ OptimConfig, MCTSConfig)
445
+ from models.diffusion import Diffusion
446
+ if tokenizer is None:
447
+ from training.finetune_utils import load_tokenizer
448
+ # default to this module's own directory (repo root) so it works from any cwd
449
+ if base_path is None:
450
+ base_path = os.path.dirname(os.path.abspath(__file__))
451
+ tokenizer = load_tokenizer(base_path)
452
+ dev = torch.device(device)
453
+ cfg = DiffusionConfig(
454
+ roformer=RoFormerConfig(hidden_size=hidden_size, n_layers=n_layers, n_heads=n_heads),
455
+ noise=NoiseConfig(), training=TrainingConfig(sampling_eps=1e-3),
456
+ sampling=SamplingConfig(steps=128, sampling_eps=1e-3), eval_cfg=EvalConfig(),
457
+ optim=OptimConfig(lr=3e-4), mcts=MCTSConfig())
458
+ model = Diffusion(config=cfg, tokenizer=tokenizer, device=dev).to(dev)
459
+ model.eval()
460
+ model.tokenizer = tokenizer
461
+ return model, tokenizer
scoring/__init__.py ADDED
File without changes
scoring/functions/__init__.py ADDED
File without changes
scoring/functions/binding.py CHANGED
@@ -154,10 +154,12 @@ class BindingAffinity:
154
  self.max_pep_len = getattr(self.pep_model.config, "max_position_embeddings", None)
155
 
156
  self.model = ImprovedBindingPredictor().to(self.device)
157
- checkpoint = torch.load(f'{base_path}/tr2d2-pep/scoring/functions/classifiers/binding-affinity.pt',
158
  map_location=self.device,
159
  weights_only=False)
160
- self.model.load_state_dict(checkpoint['model_state_dict'])
 
 
161
 
162
  self.model.eval()
163
 
@@ -268,10 +270,12 @@ class MultiTargetBindingAffinity:
268
 
269
  # Binding affinity prediction model
270
  self.model = ImprovedBindingPredictor().to(self.device)
271
- checkpoint = torch.load(f'{base_path}/tr2d2-pep/scoring/functions/classifiers/binding-affinity.pt',
272
  map_location=self.device,
273
  weights_only=False)
274
- self.model.load_state_dict(checkpoint['model_state_dict'])
 
 
275
  self.model.eval()
276
 
277
  # Protein (ESM) model
 
154
  self.max_pep_len = getattr(self.pep_model.config, "max_position_embeddings", None)
155
 
156
  self.model = ImprovedBindingPredictor().to(self.device)
157
+ checkpoint = torch.load(f'{base_path}/scoring/functions/classifiers/binding-affinity.pt',
158
  map_location=self.device,
159
  weights_only=False)
160
+ _bind_sd = checkpoint.get('model_state_dict', checkpoint.get('state_dict', checkpoint)) \
161
+ if isinstance(checkpoint, dict) else checkpoint
162
+ self.model.load_state_dict(_bind_sd)
163
 
164
  self.model.eval()
165
 
 
270
 
271
  # Binding affinity prediction model
272
  self.model = ImprovedBindingPredictor().to(self.device)
273
+ checkpoint = torch.load(f'{base_path}/scoring/functions/classifiers/binding-affinity.pt',
274
  map_location=self.device,
275
  weights_only=False)
276
+ _bind_sd = checkpoint.get('model_state_dict', checkpoint.get('state_dict', checkpoint)) \
277
+ if isinstance(checkpoint, dict) else checkpoint
278
+ self.model.load_state_dict(_bind_sd)
279
  self.model.eval()
280
 
281
  # Protein (ESM) model
scoring/functions/hemolysis.py CHANGED
@@ -15,7 +15,7 @@ class Hemolysis:
15
 
16
  def __init__(self, tokenizer, base_path, device=None, emb_model=None):
17
  self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu") if device is None else device
18
- self.predictor = xgb.Booster(model_file=f'{base_path}/tr2d2-pep/scoring/functions/classifiers/hemolysis-xgboost.json')
19
  self.emb_model = emb_model if emb_model is not None else AutoModelForMaskedLM.from_pretrained('aaronfeller/PeptideCLM-23M-all').roformer.to(device).eval()
20
  self.tokenizer = tokenizer
21
 
 
15
 
16
  def __init__(self, tokenizer, base_path, device=None, emb_model=None):
17
  self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu") if device is None else device
18
+ self.predictor = xgb.Booster(model_file=f'{base_path}/scoring/functions/classifiers/hemolysis-xgboost.json')
19
  self.emb_model = emb_model if emb_model is not None else AutoModelForMaskedLM.from_pretrained('aaronfeller/PeptideCLM-23M-all').roformer.to(device).eval()
20
  self.tokenizer = tokenizer
21
 
scoring/functions/nonfouling.py CHANGED
@@ -18,7 +18,7 @@ class Nonfouling:
18
 
19
  def __init__(self, tokenizer, base_path, device=None, emb_model=None):
20
  self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu") if device is None else device
21
- self.predictor = xgb.Booster(model_file=f'{base_path}/tr2d2-pep/scoring/functions/classifiers/nonfouling-xgboost.json')
22
  self.emb_model = emb_model if emb_model is not None else AutoModelForMaskedLM.from_pretrained('aaronfeller/PeptideCLM-23M-all').roformer.to(device).eval()
23
  self.tokenizer = tokenizer
24
 
 
18
 
19
  def __init__(self, tokenizer, base_path, device=None, emb_model=None):
20
  self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu") if device is None else device
21
+ self.predictor = xgb.Booster(model_file=f'{base_path}/scoring/functions/classifiers/nonfouling-xgboost.json')
22
  self.emb_model = emb_model if emb_model is not None else AutoModelForMaskedLM.from_pretrained('aaronfeller/PeptideCLM-23M-all').roformer.to(device).eval()
23
  self.tokenizer = tokenizer
24
 
scoring/functions/permeability.py CHANGED
@@ -100,7 +100,7 @@ class Permeability:
100
 
101
  def __init__(self, tokenizer, base_path, device=None, emb_model=None):
102
  self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu") if device is None else device
103
- self.predictor = xgb.Booster(model_file=f'{base_path}/tr2d2-pep/scoring/functions/classifiers/permeability-xgboost.json')
104
  if emb_model is not None:
105
  self.emb_model = emb_model.to(self.device).eval()
106
  else:
 
100
 
101
  def __init__(self, tokenizer, base_path, device=None, emb_model=None):
102
  self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu") if device is None else device
103
+ self.predictor = xgb.Booster(model_file=f'{base_path}/scoring/functions/classifiers/permeability-xgboost.json')
104
  if emb_model is not None:
105
  self.emb_model = emb_model.to(self.device).eval()
106
  else:
scoring/functions/solubility.py CHANGED
@@ -14,7 +14,7 @@ warnings.filterwarnings("ignore", category=FutureWarning)
14
  class Solubility:
15
  def __init__(self, tokenizer, base_path, device=None, emb_model=None):
16
  self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu") if device is None else device
17
- self.predictor = xgb.Booster(model_file=f'{base_path}/tr2d2-pep/scoring/functions/classifiers/solubility-xgboost.json')
18
  if emb_model is not None:
19
  self.emb_model = emb_model.to(self.device).eval()
20
  else:
 
14
  class Solubility:
15
  def __init__(self, tokenizer, base_path, device=None, emb_model=None):
16
  self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu") if device is None else device
17
+ self.predictor = xgb.Booster(model_file=f'{base_path}/scoring/functions/classifiers/solubility-xgboost.json')
18
  if emb_model is not None:
19
  self.emb_model = emb_model.to(self.device).eval()
20
  else:
scoring/scoring_functions.py CHANGED
@@ -47,8 +47,8 @@ class ScoringFunctions:
47
  emb_model = AutoModelForMaskedLM.from_pretrained(
48
  'aaronfeller/PeptideCLM-23M-all'
49
  ).roformer.to(device).eval()
50
- tokenizer = SMILES_SPE_Tokenizer(f'{base_path}/tr2d2-pep/tokenizer/new_vocab.txt',
51
- f'{base_path}/tr2d2-pep/tokenizer/new_splits.txt')
52
  prot_seqs = prot_seqs if prot_seqs is not None else []
53
 
54
  if score_func_names is None:
 
47
  emb_model = AutoModelForMaskedLM.from_pretrained(
48
  'aaronfeller/PeptideCLM-23M-all'
49
  ).roformer.to(device).eval()
50
+ tokenizer = SMILES_SPE_Tokenizer(f'{base_path}/tokenizer/new_vocab.txt',
51
+ f'{base_path}/tokenizer/new_splits.txt')
52
  prot_seqs = prot_seqs if prot_seqs is not None else []
53
 
54
  if score_func_names is None:
td3b/direction_oracle.py CHANGED
@@ -114,14 +114,28 @@ class TR2D2RoFormerEncoder(nn.Module):
114
  if checkpoint_path:
115
  print(f" Loading TR2-D2 checkpoint...")
116
  ckpt = torch.load(checkpoint_path, map_location=device, weights_only=False)
117
- state_dict = ckpt.get("state_dict", ckpt)
 
 
 
 
 
118
  roformer_state = {
119
  k.replace("model.", "").replace("backbone.", ""): v
120
  for k, v in state_dict.items()
121
  if "roformer" in k or "encoder" in k or "backbone" in k
122
  }
123
- self.encoder.model.load_state_dict(roformer_state, strict=False)
124
- print(" TR2-D2 checkpoint loaded")
 
 
 
 
 
 
 
 
 
125
 
126
  for p in self.encoder.parameters():
127
  p.requires_grad = False
@@ -289,6 +303,14 @@ def _load_state_dict_flexible(model: nn.Module, state_dict: Dict, strict: bool =
289
  len(state_dict)
290
  )
291
  incompatible = model.load_state_dict(filtered, strict=False)
 
 
 
 
 
 
 
 
292
  if incompatible.missing_keys:
293
  logger.warning("Missing keys (first 10): %s", incompatible.missing_keys[:10])
294
  if incompatible.unexpected_keys:
 
114
  if checkpoint_path:
115
  print(f" Loading TR2-D2 checkpoint...")
116
  ckpt = torch.load(checkpoint_path, map_location=device, weights_only=False)
117
+ # Unwrap common checkpoint containers (raw / "state_dict" / "model_state_dict").
118
+ state_dict = ckpt
119
+ for _key in ("state_dict", "model_state_dict"):
120
+ if isinstance(state_dict, dict) and _key in state_dict:
121
+ state_dict = state_dict[_key]
122
+ break
123
  roformer_state = {
124
  k.replace("model.", "").replace("backbone.", ""): v
125
  for k, v in state_dict.items()
126
  if "roformer" in k or "encoder" in k or "backbone" in k
127
  }
128
+ if not roformer_state:
129
+ raise RuntimeError(
130
+ f"No RoFormer/encoder keys matched in TR2-D2 checkpoint {checkpoint_path} "
131
+ f"(top-level keys: {list(state_dict.keys())[:8]}). The ligand encoder would be "
132
+ f"left randomly initialized, producing meaningless direction scores."
133
+ )
134
+ incompatible = self.encoder.model.load_state_dict(roformer_state, strict=False)
135
+ if incompatible.missing_keys:
136
+ print(f" [warn] TR2-D2 encoder: {len(incompatible.missing_keys)} keys not in "
137
+ f"checkpoint (e.g. {incompatible.missing_keys[:3]})")
138
+ print(f" TR2-D2 checkpoint loaded ({len(roformer_state)} tensors)")
139
 
140
  for p in self.encoder.parameters():
141
  p.requires_grad = False
 
303
  len(state_dict)
304
  )
305
  incompatible = model.load_state_dict(filtered, strict=False)
306
+ # ESM keys are expected to be absent (ESM2 is re-loaded pretrained in __init__); any
307
+ # missing NON-esm key is a trained head left at random init — surface it loudly.
308
+ trained_missing = [k for k in incompatible.missing_keys if not k.startswith("esm")]
309
+ if trained_missing:
310
+ logger.warning(
311
+ "%d TRAINED (non-ESM) oracle keys did not load and remain randomly initialized: %s",
312
+ len(trained_missing), trained_missing[:10]
313
+ )
314
  if incompatible.missing_keys:
315
  logger.warning("Missing keys (first 10): %s", incompatible.missing_keys[:10])
316
  if incompatible.unexpected_keys:
td3b/td3b_finetune.py CHANGED
@@ -7,11 +7,19 @@ import numpy as np
7
  import torch
8
  import wandb
9
  import os
10
- from training.finetune_utils import loss_wdce
 
 
11
  from .td3b_losses import TD3BTotalLoss, extract_embeddings_from_mdlm
12
  from tqdm import tqdm
13
  import pandas as pd
14
- from plotting import plot_data_with_distribution_seaborn, plot_data
 
 
 
 
 
 
15
 
16
 
17
  def td3b_finetune(
@@ -234,7 +242,8 @@ def td3b_finetune(
234
  directional_labels = directional_labels_saved
235
  confidences = confidences_saved
236
 
237
- # Compute WDCE loss
 
238
  wdce_loss = loss_wdce(
239
  policy_model,
240
  log_rnd,
 
7
  import torch
8
  import wandb
9
  import os
10
+ # NOTE: `loss_wdce` is imported lazily inside td3b_finetune() (see below) to avoid a
11
+ # circular import: training.finetune_utils imports the td3b package at module load,
12
+ # so importing it here at module level would deadlock the finetune_utils <-> td3b cycle.
13
  from .td3b_losses import TD3BTotalLoss, extract_embeddings_from_mdlm
14
  from tqdm import tqdm
15
  import pandas as pd
16
+ try:
17
+ from plotting import plot_data_with_distribution_seaborn, plot_data
18
+ except ImportError:
19
+ # `plotting` is an optional dev-only helper used solely by the legacy
20
+ # single-target loop below. Guard the import so that `import td3b` (and the
21
+ # inference/training entry points that depend on it) work without it.
22
+ plot_data_with_distribution_seaborn = plot_data = None
23
 
24
 
25
  def td3b_finetune(
 
242
  directional_labels = directional_labels_saved
243
  confidences = confidences_saved
244
 
245
+ # Compute WDCE loss (lazy import breaks the finetune_utils <-> td3b cycle)
246
+ from training.finetune_utils import loss_wdce
247
  wdce_loss = loss_wdce(
248
  policy_model,
249
  log_rnd,
td3b/td3b_mcts.py CHANGED
@@ -26,7 +26,8 @@ class TD3B_MCTS(BaseMCTS):
26
  mask_index: int,
27
  buffer_size: int = 100,
28
  noise=None,
29
- tokenizer=None
 
30
  ):
31
  """
32
  Args:
@@ -38,6 +39,10 @@ class TD3B_MCTS(BaseMCTS):
38
  buffer_size: Maximum buffer size
39
  noise: Noise schedule
40
  tokenizer: Peptide tokenizer
 
 
 
 
41
  """
42
  # Initialize base MCTS (will set self.rewardFunc later)
43
  # Note: base MCTS expects 'policy_model' not 'diffusion_model'
@@ -56,7 +61,8 @@ class TD3B_MCTS(BaseMCTS):
56
  config=config,
57
  policy_model=diffusion_model,
58
  pretrained=diffusion_model, # Use same model
59
- score_func_names=['affinity', 'gated_reward', 'placeholder1', 'placeholder2', 'placeholder3'] # 5 objectives
 
60
  )
61
 
62
  # Set TD3B-specific attributes
@@ -284,7 +290,10 @@ def create_td3b_mcts(
284
  diffusion_model: MDLM model
285
  td3b_reward_function: TD3BRewardFunction instance
286
  alpha: Temperature for importance weighting
287
- **kwargs: Additional MCTS arguments
 
 
 
288
 
289
  Returns:
290
  mcts: TD3B_MCTS instance
 
26
  mask_index: int,
27
  buffer_size: int = 100,
28
  noise=None,
29
+ tokenizer=None,
30
+ enforce_validity=None,
31
  ):
32
  """
33
  Args:
 
39
  buffer_size: Maximum buffer size
40
  noise: Noise schedule
41
  tokenizer: Peptide tokenizer
42
+ enforce_validity: Validity gate toggle for MCTS expansion. None
43
+ (default) consults args.enforce_validity, falling back to True
44
+ (unchanged behavior). False disables the is_peptide gate so
45
+ invalid peptides are kept/scored on pure affinity x direction.
46
  """
47
  # Initialize base MCTS (will set self.rewardFunc later)
48
  # Note: base MCTS expects 'policy_model' not 'diffusion_model'
 
61
  config=config,
62
  policy_model=diffusion_model,
63
  pretrained=diffusion_model, # Use same model
64
+ score_func_names=['affinity', 'gated_reward', 'placeholder1', 'placeholder2', 'placeholder3'], # 5 objectives
65
+ enforce_validity=enforce_validity,
66
  )
67
 
68
  # Set TD3B-specific attributes
 
290
  diffusion_model: MDLM model
291
  td3b_reward_function: TD3BRewardFunction instance
292
  alpha: Temperature for importance weighting
293
+ **kwargs: Additional MCTS arguments (forwarded verbatim to TD3B_MCTS).
294
+ This includes ``enforce_validity`` (validity gate toggle); when not
295
+ passed here it defaults to None inside TD3B_MCTS and is resolved from
296
+ ``args.enforce_validity`` (i.e. the --validity_reward flag).
297
 
298
  Returns:
299
  mcts: TD3B_MCTS instance
tokenizer/__init__.py ADDED
File without changes
training/__init__.py ADDED
File without changes
training/finetune_utils.py CHANGED
@@ -192,8 +192,8 @@ def load_tokenizer(base_path: str) -> SMILES_SPE_Tokenizer:
192
  >>> tokenizer = load_tokenizer('To Be Added')
193
  """
194
  base_path = Path(base_path)
195
- vocab_path = base_path / "tr2d2-pep" / "tokenizer" / "new_vocab.txt"
196
- spe_path = base_path / "tr2d2-pep" / "tokenizer" / "new_splits.txt"
197
 
198
  if not vocab_path.exists():
199
  raise FileNotFoundError(f"Vocabulary file not found: {vocab_path}")
@@ -340,7 +340,7 @@ def create_output_directory(base_path: str, run_name: str, add_timestamp: bool =
340
  else:
341
  dir_name = run_name
342
 
343
- output_dir = os.path.join(base_path, "tr2d2-pep", "results", dir_name)
344
  os.makedirs(output_dir, exist_ok=True)
345
 
346
  logger.info("Created output directory: %s", output_dir)
 
192
  >>> tokenizer = load_tokenizer('To Be Added')
193
  """
194
  base_path = Path(base_path)
195
+ vocab_path = base_path / "tokenizer" / "new_vocab.txt"
196
+ spe_path = base_path / "tokenizer" / "new_splits.txt"
197
 
198
  if not vocab_path.exists():
199
  raise FileNotFoundError(f"Vocabulary file not found: {vocab_path}")
 
340
  else:
341
  dir_name = run_name
342
 
343
+ output_dir = os.path.join(base_path, "results", dir_name)
344
  os.makedirs(output_dir, exist_ok=True)
345
 
346
  logger.info("Created output directory: %s", output_dir)
utils/__init__.py ADDED
File without changes