TD3B-dev runnability fixes + finetune-on-target + validity samplers
#2
by chq1155 - opened
- .gitignore +6 -0
- CLAUDE.md +250 -0
- README.md +106 -21
- baselines/run_mcts_tr2d2.py +1 -1
- baselines/run_validation_td3b.py +1 -1
- baselines/sampling_setup.py +1 -1
- configs/__init__.py +0 -0
- configs/peptune_config.yaml +1 -1
- finetune_multi_target.py +14 -4
- finetune_on_target.py +701 -0
- generate_valid.py +153 -0
- inference.py +120 -30
- launch_multi_target.sh +4 -3
- mcts/__init__.py +0 -0
- mcts/peptide_mcts.py +16 -3
- models/__init__.py +0 -0
- models/diffusion.py +2 -2
- notebooks/TD3B_Inference_Demo.ipynb +407 -455
- sampling_strategies.py +461 -0
- scoring/__init__.py +0 -0
- scoring/functions/__init__.py +0 -0
- scoring/functions/binding.py +8 -4
- scoring/functions/hemolysis.py +1 -1
- scoring/functions/nonfouling.py +1 -1
- scoring/functions/permeability.py +1 -1
- scoring/functions/solubility.py +1 -1
- scoring/scoring_functions.py +2 -2
- td3b/direction_oracle.py +25 -3
- td3b/td3b_finetune.py +12 -3
- td3b/td3b_mcts.py +12 -3
- tokenizer/__init__.py +0 -0
- training/__init__.py +0 -0
- training/finetune_utils.py +3 -3
- utils/__init__.py +0 -0
.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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
|
| 34 |
```
|
| 35 |
-
TD3B/
|
| 36 |
├── checkpoints/
|
| 37 |
-
│ ├── pretrained.ckpt
|
| 38 |
-
│ ├── td3b.ckpt
|
| 39 |
-
│ └── direction_oracle.pt
|
| 40 |
├── scoring/functions/classifiers/
|
| 41 |
-
│ ├── binding-affinity.pt
|
| 42 |
-
│ ├──
|
| 43 |
-
│ ├──
|
| 44 |
-
│ ├──
|
| 45 |
-
│ └── solubility-xgboost.json
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
|
| 100 |
## Training
|
| 101 |
|
| 102 |
### Multi-target TD3B
|
| 103 |
|
| 104 |
-
1. Edit `launch_multi_target.sh` —
|
| 105 |
|
| 106 |
```bash
|
| 107 |
-
BASE_PATH="
|
| 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 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
|
|
|
| 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":
|
| 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":
|
| 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 =
|
| 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 |
-
|
| 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,
|
| 401 |
-
|
|
|
|
| 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=
|
| 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
|
| 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(
|
| 158 |
})
|
| 159 |
|
| 160 |
-
# Build reward
|
|
|
|
|
|
|
| 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 =
|
| 174 |
-
|
| 175 |
-
|
| 176 |
target_protein_seq=target["target_seq"],
|
| 177 |
-
target_direction=
|
|
|
|
| 178 |
device=device,
|
| 179 |
-
|
| 180 |
-
|
| 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 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 202 |
-
|
| 203 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 204 |
alpha = max(args.resample_alpha, 1e-6)
|
| 205 |
weights = torch.softmax(rewards_t / alpha, dim=0)
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
chosen =
|
| 209 |
else:
|
| 210 |
-
chosen = np.
|
| 211 |
|
| 212 |
-
# Save
|
| 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 —
|
| 11 |
-
|
|
|
|
| 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="
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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}/
|
| 151 |
-
f'{base_path}/
|
| 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 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
},
|
| 9 |
-
"kernelspec": {
|
| 10 |
-
"name": "python3",
|
| 11 |
-
"display_name": "Python 3"
|
| 12 |
-
},
|
| 13 |
-
"language_info": {
|
| 14 |
-
"name": "python"
|
| 15 |
-
},
|
| 16 |
-
"accelerator": "GPU"
|
| 17 |
},
|
| 18 |
-
"
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
"
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
},
|
| 392 |
-
{
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 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}/
|
| 158 |
map_location=self.device,
|
| 159 |
weights_only=False)
|
| 160 |
-
|
|
|
|
|
|
|
| 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}/
|
| 272 |
map_location=self.device,
|
| 273 |
weights_only=False)
|
| 274 |
-
|
|
|
|
|
|
|
| 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}/
|
| 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}/
|
| 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}/
|
| 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}/
|
| 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}/
|
| 51 |
-
f'{base_path}/
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 124 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 11 |
from .td3b_losses import TD3BTotalLoss, extract_embeddings_from_mdlm
|
| 12 |
from tqdm import tqdm
|
| 13 |
import pandas as pd
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 / "
|
| 196 |
-
spe_path = base_path / "
|
| 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, "
|
| 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
|