CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
What this is
TD3B (Transition-Directed Discrete Diffusion for Allosteric Binder Generation) is a sequence-based generative framework that designs peptide binders with a specified direction β agonist or antagonist β for a target protein. It extends TR2-D2 (a masked discrete-diffusion peptide generator: MDLM backbone + MCTS amortized finetuning) with three additions:
- a Direction Oracle
f_Οthat predicts agonist vs. antagonist behavior, - a soft binding-affinity gate
g_Ο, and - a gated reward
R = g_Ο Β· Ο(d*Β·(f_Ο β 0.5)/Ο)that steers generation toward directiond* β {+1 agonist, β1 antagonist}.
Finetuning distills MCTS-discovered high-reward sequences into the diffusion policy via a total loss
L = L_WDCE + λ·L_ctr + β·L_KL (weighted denoising CE + directional contrastive + KL-to-reference).
Paper: arXiv:2605.09810 (LMRL Workshop, ICLR 2026).
Repo layout: dev vs. OSS, and where the artifacts are
- This repo (
ChatterjeeLab/TD3B-dev) is the full/dev code. The clean code-only public release isChatterjeeLab/TD3Bon HuggingFace. Keep changes runnable against that OSS release. - No checkpoints or data are in git. Trained checkpoints, train/test CSVs, and generated binders
ship as a single ~3.4 GB archive
td3b_dev_artifacts.zipon Google Drive (link in README). Unzip at repo root to populatecheckpoints/,data/,scoring/functions/classifiers/,generated_binders/. .gitattributesroutes all weight/data extensions (*.ckpt *.pt *.pth *.npy *.csv? *.zip ...) through Git LFS. Only three XGBoost classifier JSONs (hemolysis,nonfouling,solubility) are in-repo;binding-affinity.ptandpermeability-xgboost.jsoncome from the archive.
Environment & commands
conda env create -f env.yml # creates env "td3b" (python 3.10, pytorch-cuda 12.1)
conda activate td3b
pip install -e . # installs the `td3b` package (setup.py)
Core deps: PyTorch + Lightning 2.5.5, HuggingFace transformers 4.56.2, fair-esm 2.0.0 (ESM2),
rdkit, SmilesPE, xgboost, wandb, hydra-core. There is no test suite, no linter config, and
no Makefile β do not look for pytest/tox/ruff. Verify changes by actually running inference.
Inference (primary OSS entry point β generate directional binders):
python inference.py \
--ckpt_path checkpoints/td3b.ckpt \
--val_csv data/test.csv \
--save_path results/ \
--seed 42 --num_pool 32 --val_samples_per_target 8 --resample_alpha 0.1
For each target row it generates for both directions (agonist d*=+1, antagonist d*=-1), scores
with the oracle + affinity gate, applies softmax(reward/Ξ±) weighted resampling (Algorithm 2), keeps only
RDKit-valid peptides, and writes results/td3b_results_seed{seed}.csv.
Training (multi-target): edit paths in launch_multi_target.sh (BASE_PATH, checkpoints, data,
oracle, and WANDB_ENTITY β blank by default; set your own), then bash launch_multi_target.sh. It calls
finetune_multi_target.py. Key knobs live in the launch script: CONTRASTIVE_WEIGHT(Ξ»), KL_BETA(Ξ²),
SIGMOID_TEMPERATURE(Ο), NUM_ITER/NUM_CHILDREN (MCTS), TARGETS_PER_MCTS(K), and the cadence flags
below.
Baselines (CG, SMC, TDS, PepTune, Unguided): cd baselines && ./run.sh <csv> <baseline> <device>.
Multi-GPU via a 5th/6th arg (torchrun). Note this script loads ../pretrained/peptune-pretrained.ckpt,
not checkpoints/pretrained.ckpt (see path landmines below).
Demo: notebooks/TD3B_Inference_Demo.ipynb (Colab T4).
Architecture β the cross-file big picture
The pieces below only make sense together; reading any one file in isolation misses the flow.
Diffusion backbone β models/
Diffusion(L.LightningModule)inmodels/diffusion.pyis the MDLM core (absorbing-state / masked discrete diffusion). The denoiser ismodels/roformer.py::Roformer, a thin wrapper over HuggingFaceRoFormerForMaskedLM(rotary embeddings). Tokens are SMILES viatokenizer/my_tokenizers.py::SMILES_SPE_Tokenizer.- Absorbing state = the tokenizer's
[MASK]id (self.mask_index), not a constant. Generation starts fully masked (sample_priorβ all-mask) and reverse-diffuses. SUBS parameterization (subs_parameterization) forbids predicting MASK and pins already-unmasked positions ("carry-over unmasking"). Callers usesingle_reverse_step/single_noise_removal(final step guarantees no surviving MASK); MCTS uses thebatch_mcts_reverse_step/mcts_reverse_stepvariants which also return per-step policy vs. pretrained log-probs for the importance log-ratiolog_rnd. - Config is built by
configs/finetune_config.py::DiffusionConfig, a shim that synthesizes duck-typed attribute objects (type(...)()) for backward compat. It fixesparameterization='subs',T=0(continuous-time MDLM loss),time_conditioning=False, andmax_position_embeddings=1035. It is a partial interface β only the finetune/eval/MCTS fields exist; pure-training paths (antithetic_sampling,noise.state_dependent,vocab,model.length) are absent and wouldAttributeErrorunder it. models/noise_schedule.py:get_noise(config)supports geometric/loglinear/cosine/linear, but every reverse/sampling step assertsloglinear. A second hardcodedLogPolyNoise(cubic) masks peptide-bond tokens more slowly.
Directional reward β td3b/td3b_scoring.py
TD3BRewardFunction.__call__(List[str] of peptide SMILES) -> (rewards, info). Internals:
g_Ο = scoring/functions/binding.py::BindingAffinity (magnitude); f_Ο = the DirectionalOracle
(direction prob β[0,1] + confidence ΞΊ); reward = g_Ο Β· Ο(d*Β·(f_Οβ0.5)/Ο). create_td3b_reward_function
is the factory that builds/loads the oracle, caches the encoded protein tokens, maps 'agonist'/'antagonist'
β d* = +1/β1, and returns the configured reward. TD3BConfidenceWeighting provides the
confidence-weighted importance weights used by MCTS (w = ΞΊΒ·exp(S/Ξ±)).
Direction Oracle β td3b/direction_oracle.py
DirectionalOracle wraps ESM_TR2D2_GPCRClassifier: frozen ESM2 (facebook/esm2_t33_650M_UR50D,
1280-d β downloads from HF unless esm_cache_dir/esm_local_files_only set) encodes the protein;
a frozen TR2-D2 RoFormer encodes the ligand/peptide SMILES; both project to d_model=256, pass
1 self-attention layer each, then 2 stacked bidirectional cross-attention (BMCA) layers, mean-pool,
concat, MLP β 2 logits. predict_with_confidence returns f_Ο = p_agonist and ΞΊ = max(softmax).
Loading needs four assets: the oracle .pt, the TR2-D2 ligand checkpoint, and the SMILES tokenizer
vocab+splits. The RoFormer config (768/8/8/1035) is hardcoded and must match the checkpoint.
Losses β td3b/td3b_losses.py
TD3BTotalLoss = L_WDCE + λ·L_ctr + β·L_KL (λ=contrastive_weight, β=kl_beta, both default 0.1).
L_WDCE is computed externally by training/finetune_utils.py::loss_wdce and passed in β it is the
policy-distillation term that reweights MCTS samples by softmax(log_rnd). L_ctr is ContrastiveLoss
(margin, default) or InfoNCELoss over agonist/antagonist embeddings from extract_embeddings_from_mdlm
(reaches into model.backbone.model, RoFormer last hidden state, must not be under no_grad).
L_KL is per-position categorical KL to a frozen deepcopy reference model (the pretrained weights).
MCTS β mcts/peptide_mcts.py + td3b/td3b_mcts.py
Base MCTS does Pareto/multi-objective tree search: root is fully masked; select descends via a
non-dominated (not scalar-UCB) set with rd.choice; expand samples num_children one-step
unmaskings, rolls each to a full sequence, filters with PeptideAnalyzer.is_peptide, scores valid ones,
and maintains a Pareto buffer of finished trajectories (each storing x_final, log_rnd,
reward, score vector). TD3B_MCTS subclasses it: injects the gated TD3BRewardFunction, pads the (N,2)
directional score vector to (N,5) so the base Pareto machinery works, folds confidence into log_rnd,
and forward/consolidateBuffer return 7 values (adding directional_labels, confidences).
Training loop β finetune_multi_target.py
Self-contained inline loop (it does not call td3b/td3b_finetune.py::td3b_finetune, which is a
legacy/unused single-target loop). Per epoch it alternates:
- MCTS generation phase (every
resample_every_n_stepepochs, default 10): for each sampled target Γ each direction, build a per-(target,direction) reward, run a freshTD3B_MCTS, push Pareto survivors into a replay buffer withdirectional_labelforced to the intendedd*(not the oracle guess). - Gradient phase (every epoch): shuffle the buffer, pad variable-length
xto max-len withmask_index+ build an attention mask, then WDCE + KL every batch, and contrastive only when a batch mixes both directions.
Three independent epoch-indexed cadence knobs: resample_targets_every (redraw K targets),
resample_every_n_step (MCTS phase), reset_every_n_step (reset vs. reuse the search tree).
add_td3b_sampling_to_model(policy) monkey-patches sample_finetuned_td3b onto the Diffusion instance β
required before validation/eval works (that method is not native to Diffusion).
Data schema β td3b/data_utils.py
CSVs use columns Target_Sequence, Ligand_Sequence (peptide AA string), and label
(agonist/antagonist, mapped to d*); TD3BDataset also reads Action, Target_UniProt_ID,
Ligand_UniProt_ID. Binders are converted AAβSMILES via RDKit MolFromSequence. The multi-target
script uses its own in-file TargetDataset (groups by target, stores per-direction median binder
length used to set generation length), reading only Target_Sequence/Ligand_Sequence/label.
inference.py also reads Target_UniProt_ID, Ligand_SMILES (for length).
New entry points (added 2026-07-12) β finetune_on_target.py, generate_valid.py
finetune_on_target.py(Function A) β user-facing "bring your own target" wrapper; it does not reimplement training. It normalizes--target_seq(repeatable) /--targets_csvinto a temp CSV (seeding a placeholder poly-G binder of--binder_lengthresidues for any missing direction soTargetDatasetcan set a length prior), then subprocess-invokesfinetune_multi_target.pywith--targets_per_mcts=<#targets>+--resample_targets_every 1(finetune on ONLY those targets), and finally generates in-process reusinginference.load_model/sample_sequences/score_sequences+create_td3b_reward_function+ Algorithm-2 resampling.--directionrestricts only what is generated (finetuning always searches both). Writesresults/finetune_on_target/binders_<dir>_validity-<on|off>_seed<seed>.csv+ the finetuned ckpt underresults/<run_name>_<ts>/. Paths default to repo root; missing heavy artifacts fail fast with a Google-Drive hint.generate_valid.py+sampling_strategies.py(Function B) β sampling-time validity boosters (no retraining) that reuse the model's ownsample_prior/single_reverse_step/single_noise_removal/forwardand 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(...)dispatchesbaseline, more_steps, top_p(=nucleus), top_k, low_temp, remask, best_of_n, nucleus_remask(defaultnucleus_remask).generate_valid.pyloads the real ckpt viainference.load_model, or falls back tobuild_random_model(random-initDiffusion, CPU dev) when--ckpt_pathis absent. Output: valid-only CSV (idx,sequence,n_chars) + a printed valid-yield summary.inference.py --sampler(opt-in; defaultbaseline= original behavior, byte-for-byte) selects a Function-B sampling strategy for the candidate pool, with pass-through knobs (--num_steps --top_p --top_k --temperature --remask_rounds --remask_frac --best_of_n). Non-baseline strategies dispatch tosampling_strategies.generate. Function B is also available standalone viagenerate_valid.py.
Landmines
A batch of path/wiring bugs that stopped the OSS release from running was fixed on 2026-07-09.
python inference.py --help and python finetune_multi_target.py --help now import cleanly (verified
in the tr2d2-pep conda env); a full generation run still needs the Google-Drive artifacts + a GPU.
Fixed β do NOT reintroduce:
td3b/td3b_finetune.py: thefrom plotting import ...(no such module) is nowtry/except-guarded, andloss_wdceis imported lazily insidetd3b_finetune()to break afinetune_utils β td3bcircular import. Keep both βtd3b/__init__.pyeagerly importstd3b_finetune, andfinetune_utilsimports thetd3bpackage at module load, so any module-levelfrom training.finetune_utils import β¦intd3b_finetune.pyre-creates the cycle (it was inference.py's first crash, before plotting).- Stale
tr2d2-pep/prefix stripped from the tokenizer loader (finetune_utils.load_tokenizer), every classifier loader (scoring/functions/*.py,scoring/scoring_functions.py,binding.py), theDiffusionfallback tokenizer, and the training results dir. Assets now resolve to the README layout (tokenizer/,scoring/functions/classifiers/,results/). inference.pyreward wiring rewritten: buildMultiTargetBindingAffinity+DirectionalOracleonce, wrap each target withTargetSpecificBindingAffinity, callcreate_td3b_reward_function. (It used to call a non-existentcreate_reward_functionsignature whoseTypeErrorwas swallowed β empty CSV.)- Demo notebook:
from models.diffusion import Diffusion(wasfrom diffusion import β¦), clone URL now points at the OSS HF repo withGIT_LFS_SKIP_SMUDGE=1,total_memorytypo fixed. - Added
__init__.pyto the 8 dirsfind_packages()missed; fixed corruptedconfigs/peptune_config.yamlkey (batchinohup ngβbatching).
Fixed β round 2 (runtime hardening, 2026-07-10; verified dynamically in tr2d2-pep):
inference.pyAlgorithm-2 resampling: now gates candidates byfinite-reward AND valid-peptidebefore sampling and draws without replacement (k=min(val_samples_per_target, n_eligible)). Previouslyreplacement=True+ the peaked softmax produced duplicate rows (inflated counts, skewed means) and validity was filtered only afterward (could save 0 samples despite valid candidates).- Checkpoint-load guards (silent-random-weights class):
inference.py::load_modelnow raises if the backbone loaded no weights and warns on partial loads;direction_oracle.py::TR2D2RoFormerEncodernow unwrapsmodel_state_dict/state_dictand raises if zero RoFormer keys matched (was silently leaving the ligand encoder random);binding.pytolerates raw/state_dict/model_state_dictckpt containers;_load_state_dict_flexibleloudly flags missing non-ESM (trained) keys. All guards fail only on the impossible-for-a-valid-checkpoint case, so they can't break a good load.
Must NOT change β tied to pretrained weights: max_position_embeddings=1035,
parameterization='subs', T=0, time_conditioning=False. Diffusion.forward hard-fails if seq_len > 1035.
Remaining known quirks (not inference blockers):
- Checkpoint generates valid SMILES only at SHORT length (verified end-to-end 2026-07-12 on real weights
from
/data1/hanqun/TD3B/checkpoints). Valid-peptide yield vs generation length (SMILES tokens),is_peptideover 32β64 samples: L=40 β 28% baseline / 59%best_of_n; L=60 β ~5β30%; Lβ₯150 β ~0% regardless of step count (128/256/512) or best_of_n. Real 25β30-residue binders are 150β210 tokens, i.e. outside the valid regime β that is why a naiveinference.pyrun writes an empty CSV. Fix applied:inference.pynow derives length from the binder's token count (not char count; reads theSMILEScolumn, not the nonexistentLigand_SMILES), adds--seq_length/--max_seq_length, and hints on empty output. Working run:--seq_length 50 --sampler best_of_n --best_of_n 6β 16 valid oracle-scored binders, antagonist direction-accuracy β 1.0. This is a model-capability limit, not a code bug. - ESM2 network fetch:
facebook/esm2_t33_650M_UR50Dis downloaded at inference twice β by the oracle (transformers) and byBindingAffinity/MultiTargetBindingAffinity(fair-esm, no offline flag). Offline runs need warm HF + torch-hub caches. - Training default paths (
finetune_multi_target.py:551, factory fallbacktd3b_scoring.py:345) still resolve under{base_path}/tr2d2-pep/...with a wrong default oracle filename (best_model_tr2d2_gpcr_fixed.ptvs. shippeddirection_oracle.pt).launch_multi_target.shoverrides all of these with explicitcheckpoints/args, so training-via-launch-script works; a barepython finetune_multi_target.pydoes not. baselines/run.shloadspretrained/peptune-pretrained.ckpt(notcheckpoints/pretrained.ckpt) and defaultsCSV_PATH="To Be Added"β pass real args.td3b/td3b_finetune.py::td3b_finetune()is legacy/unused (superseded byfinetune_multi_target.py); still writes to{base}/TR2-D2/tr2d2-pep/results/...and needs the optionalplottingmodule.TD3BDataset/load_td3b_data(oracle-training path) require CSV columnsAction,Ligand_UniProt_IDthat the inference/finetune CSVs don't carry.- Dead/inert flags:
--contrastive_type(loss always uses'margin'),--num_epoch_for_sampling,min_affinity_threshold=0.0(down-weight branch never fires),use_confidence_weighting(stored but never applied incompute_gated_reward). PeptideAnalyzer.is_peptide(utils/app.py) false-positives on atom-only / pure-amino-acid-letter strings (e.g."CCNCCF","cccccc"β True) because it checks AA-letter membership before RDKit. Harmless for a well-trained model (emits real peptide SMILES) but can inflatevalid_maskfor an untrained/early checkpoint. Shared by MCTS/baselines β do not change its semantics without checking all callers.- Oracle confidence ΞΊ is actually β[0.5,1] (max of a 2-class softmax), not the documented [0,1]; unused at
inference.
TD3BConfidenceWeighting.compute_importance_weightsuses rawexp(reward/Ξ±)which can overflow for large affinities β off the inference path (MCTS/training only). - Device coupling:
models/roformer.pyand helpers pick their own device independently of--device(some hardcodecuda:0);resolve_deviceinscoring_functions.py/direction_oracle.pysilently falls back tocuda:0/CPU. Mixed-device errors are easy to create.
New entry points (2026-07-12) β usage caveats:
- The validity toggle is a FILTER, not a reward term.
finetune_on_target.py --validity_reward {on,off}(defaulton) never changes the reward formula (affinity Γ direction); it toggles thePeptideAnalyzer.is_peptidegate on both halves β the finetune-side MCTS expansion (forwarded tofinetune_multi_target.py --validity_rewardβargs.enforce_validity) and the generation-side Algorithm-2 resampling.offretains invalid samples (each output row still recordsis_valid).--finetune_validity_hook {on,off}decouples the finetune-side gate from the generation-side toggle. - Function B benchmarks were on a RANDOM-INIT model β only relative trends (which strategy helps most at
long length) are meaningful; absolute valid-yields need the real checkpoint.
generate_valid.pysilently falls back to random init when--ckpt_pathis missing/omitted (it prints a NOTE, but the numbers are garbage) β always pass a real--ckpt_pathfor reportable yields. finetune_on_target.pyruns the finetune half as a subprocess (defaultWANDB_MODE=disabled) and locates the produced checkpoint by diffingresults/<run_name>_*dirs before/after (newestmodel_final.ckpt, else newestmodel_epoch_*.ckpt); a renamed/failed run dir would break that discovery. Its own oracle/path defaults are passed through explicitly so the subprocess never falls back tofinetune_multi_target.py's legacy{base}/tr2d2-pep/...defaults.