Add Lecture 2 flow matching and diffusion guidance examples
Browse filesStandalone PyTorch examples, synthetic sequence data, setup instructions, mathematical notes, and offline tests.
- .gitignore +20 -0
- LICENSE +21 -0
- README.md +346 -0
- lecture_02/GUIDANCE_NOTES.md +156 -0
- lecture_02/esm2_diffusion_guidance.py +264 -0
- lecture_02/esm2_example.csv +65 -0
- lecture_02/esm2_flow_guidance.py +251 -0
- requirements.txt +3 -0
- tests/test_examples.py +97 -0
.gitignore
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Local Python environments and caches.
|
| 2 |
+
.venv*/
|
| 3 |
+
venv/
|
| 4 |
+
__pycache__/
|
| 5 |
+
*.py[cod]
|
| 6 |
+
.pytest_cache/
|
| 7 |
+
.DS_Store
|
| 8 |
+
.esm2_cache/
|
| 9 |
+
|
| 10 |
+
# Generated results and downloaded/trained weights.
|
| 11 |
+
outputs/
|
| 12 |
+
esm2_flow_outputs/
|
| 13 |
+
esm2_diffusion_outputs/
|
| 14 |
+
*.pt
|
| 15 |
+
*.pth
|
| 16 |
+
*.safetensors
|
| 17 |
+
|
| 18 |
+
# Local secrets must never be committed.
|
| 19 |
+
.env
|
| 20 |
+
.env.*
|
LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2026 Chatterjee Lab
|
| 4 |
+
|
| 5 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 6 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 7 |
+
in the Software without restriction, including without limitation the rights
|
| 8 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 9 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 10 |
+
furnished to do so, subject to the following conditions:
|
| 11 |
+
|
| 12 |
+
The above copyright notice and this permission notice shall be included in all
|
| 13 |
+
copies or substantial portions of the Software.
|
| 14 |
+
|
| 15 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 16 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 17 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 18 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 19 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 20 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 21 |
+
SOFTWARE.
|
README.md
CHANGED
|
@@ -1,3 +1,349 @@
|
|
| 1 |
---
|
| 2 |
license: mit
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
---
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
license: mit
|
| 3 |
+
language:
|
| 4 |
+
- en
|
| 5 |
+
tags:
|
| 6 |
+
- education
|
| 7 |
+
- pytorch
|
| 8 |
+
- flow-matching
|
| 9 |
+
- diffusion
|
| 10 |
+
- esm2
|
| 11 |
+
- protein-generation
|
| 12 |
+
- guidance
|
| 13 |
---
|
| 14 |
+
|
| 15 |
+
# CIS 6270 · Generative Modeling
|
| 16 |
+
|
| 17 |
+
### Lecture 2: Continuous Generative Models
|
| 18 |
+
|
| 19 |
+
**Chatterjee Lab | Executable teaching examples in PyTorch**
|
| 20 |
+
|
| 21 |
+
This repository accompanies the CIS 6270 lectures. We connect the mathematics
|
| 22 |
+
of continuous generative models to working code, using frozen ESM-2 protein
|
| 23 |
+
representations to compare **flow matching** and **DDPM diffusion**.
|
| 24 |
+
|
| 25 |
+
Each example includes data loading, latent and property normalization, a small
|
| 26 |
+
conditional model, training, classifier-free guidance, single-objective reward
|
| 27 |
+
steering, weighted multi-objective steering, and final sequence decoding with a
|
| 28 |
+
hard residue-count constraint.
|
| 29 |
+
|
| 30 |
+
> This is a source-code repository. The included dataset is synthetic, and no
|
| 31 |
+
> trained generator weights are distributed here. These examples teach the
|
| 32 |
+
> mechanics of guidance; they do not establish protein activity or solubility.
|
| 33 |
+
|
| 34 |
+
## Start here
|
| 35 |
+
|
| 36 |
+
| Example | What the model learns | Sampling |
|
| 37 |
+
| --- | --- | --- |
|
| 38 |
+
| [Flow matching](lecture_02/esm2_flow_guidance.py) | A velocity field between noise and clean ESM-2 latents | Euler integration from noise to data |
|
| 39 |
+
| [Diffusion](lecture_02/esm2_diffusion_guidance.py) | The noise added to clean ESM-2 latents | A 1,000-step DDPM reverse chain |
|
| 40 |
+
| [Mathematical and implementation notes](lecture_02/GUIDANCE_NOTES.md) | Normalization, guidance conventions, decoding, and limitations | Companion reading for both scripts |
|
| 41 |
+
|
| 42 |
+
The scripts are independent. Neither imports the other, and each contains the
|
| 43 |
+
complete example in one file. The same decoder appears in both so either can
|
| 44 |
+
run on its own.
|
| 45 |
+
|
| 46 |
+
## Quick start
|
| 47 |
+
|
| 48 |
+
Use **Python 3.11**, or another compatible Python version at least 3.10. A fresh
|
| 49 |
+
virtual environment is recommended. The dependencies are pinned to the versions
|
| 50 |
+
used to test these examples: PyTorch 2.9.1 and Transformers 4.57.6.
|
| 51 |
+
|
| 52 |
+
### 1. Download and install
|
| 53 |
+
|
| 54 |
+
```bash
|
| 55 |
+
git clone https://huggingface.co/ChatterjeeLab/CIS6270
|
| 56 |
+
cd CIS6270
|
| 57 |
+
|
| 58 |
+
python3 -m venv .venv
|
| 59 |
+
source .venv/bin/activate
|
| 60 |
+
python -m pip install --upgrade pip
|
| 61 |
+
python -m pip install -r requirements.txt
|
| 62 |
+
```
|
| 63 |
+
|
| 64 |
+
On Windows PowerShell, use `python -m venv .venv` and activate with
|
| 65 |
+
`.venv\Scripts\Activate.ps1`.
|
| 66 |
+
|
| 67 |
+
### 2. Train and sample
|
| 68 |
+
|
| 69 |
+
Run either example, or both:
|
| 70 |
+
|
| 71 |
+
```bash
|
| 72 |
+
python lecture_02/esm2_flow_guidance.py --epochs 200 --samples 8
|
| 73 |
+
python lecture_02/esm2_diffusion_guidance.py --epochs 200 --samples 8
|
| 74 |
+
```
|
| 75 |
+
|
| 76 |
+
Each command trains a generator and a property predictor from scratch, then
|
| 77 |
+
generates sequences using three guidance settings. The first run downloads the
|
| 78 |
+
public [ESM-2 8M checkpoint](https://huggingface.co/facebook/esm2_t6_8M_UR50D)
|
| 79 |
+
into `lecture_02/.esm2_cache/`. Later runs reuse those downloaded files. No
|
| 80 |
+
Hugging Face login is required to read this public repository or checkpoint.
|
| 81 |
+
|
| 82 |
+
The scripts use CUDA when available and CPU otherwise. Apple Silicon currently
|
| 83 |
+
uses the CPU path. Runtime depends on hardware and the first-run download.
|
| 84 |
+
|
| 85 |
+
For a short end-to-end installation check:
|
| 86 |
+
|
| 87 |
+
```bash
|
| 88 |
+
python lecture_02/esm2_flow_guidance.py --epochs 2 --samples 2 --output outputs/flow_smoke
|
| 89 |
+
python lecture_02/esm2_diffusion_guidance.py --epochs 2 --samples 2 --output outputs/diffusion_smoke
|
| 90 |
+
```
|
| 91 |
+
|
| 92 |
+
Two epochs check execution, not sample quality.
|
| 93 |
+
|
| 94 |
+
### 3. Find the generated sequences
|
| 95 |
+
|
| 96 |
+
By default, outputs are written beside the scripts:
|
| 97 |
+
|
| 98 |
+
```text
|
| 99 |
+
lecture_02/
|
| 100 |
+
├── esm2_flow_outputs/
|
| 101 |
+
│ ├── cfg.fasta
|
| 102 |
+
│ ├── single.fasta
|
| 103 |
+
│ ├── multi.fasta
|
| 104 |
+
│ └── results.pt
|
| 105 |
+
└── esm2_diffusion_outputs/
|
| 106 |
+
├── cfg.fasta
|
| 107 |
+
├── single.fasta
|
| 108 |
+
├── multi.fasta
|
| 109 |
+
└── results.pt
|
| 110 |
+
```
|
| 111 |
+
|
| 112 |
+
The FASTA files contain decoded sequences. `results.pt` contains generated
|
| 113 |
+
standardized latents, generator and reward-predictor state dictionaries,
|
| 114 |
+
normalization statistics, the ESM checkpoint name, latent dimensions, and
|
| 115 |
+
constraint settings. The terminal also prints re-evaluated composition
|
| 116 |
+
properties of the decoded sequences.
|
| 117 |
+
|
| 118 |
+
Repeating a command with the same output directory replaces those output files.
|
| 119 |
+
Choose a new `--output` directory to keep separate experiments. The scripts do
|
| 120 |
+
not automatically resume training or reload saved generators.
|
| 121 |
+
|
| 122 |
+
## What happens in each example?
|
| 123 |
+
|
| 124 |
+
1. **Encode sequences.** Use frozen ESM-2 to obtain one 320-dimensional vector
|
| 125 |
+
per residue, retaining position information and removing BOS/EOS tokens.
|
| 126 |
+
2. **Normalize.** Standardize latent coordinates and each property using
|
| 127 |
+
training-set statistics.
|
| 128 |
+
3. **Train.** Learn a conditional generative field and a time-conditioned
|
| 129 |
+
predictor of the clean sequence's standardized properties.
|
| 130 |
+
4. **Guide generation.** Compare CFG, one reward, and a weighted reward sum.
|
| 131 |
+
5. **Decode once at the end.** Undo latent normalization, apply the frozen ESM-2
|
| 132 |
+
language-model head, and enforce the selected residue-count constraint.
|
| 133 |
+
6. **Re-evaluate.** Calculate the composition properties on the actual decoded
|
| 134 |
+
sequences. A higher surrogate prediction need not survive decoding.
|
| 135 |
+
|
| 136 |
+
## Where do the rewards come from?
|
| 137 |
+
|
| 138 |
+
The [example CSV](lecture_02/esm2_example.csv) contains **64 synthetic sequences
|
| 139 |
+
of length 24**. The labels are transparent composition calculations:
|
| 140 |
+
|
| 141 |
+
| Column | Definition | Interpretation |
|
| 142 |
+
| --- | --- | --- |
|
| 143 |
+
| `sequence` | A canonical amino-acid sequence | Input to frozen ESM-2 |
|
| 144 |
+
| `r1` | `(count(K) + count(R) - count(D) - count(E)) / length` | A simple charge-count proxy |
|
| 145 |
+
| `r2` | `count(residues in DEHKNQRST) / length` | A defined polar/charged fraction |
|
| 146 |
+
| `c` | `1` if `r1 > 0`, otherwise `0` | The binary conditioning class |
|
| 147 |
+
|
| 148 |
+
The first reward is not a full pH-dependent charge calculation. The second is
|
| 149 |
+
not an experimental solubility measurement. The synthetic sequences are
|
| 150 |
+
teaching data, not validated natural proteins or functional peptides.
|
| 151 |
+
|
| 152 |
+
### Use your own data
|
| 153 |
+
|
| 154 |
+
Supply a CSV with `sequence,c,r1,r2` columns:
|
| 155 |
+
|
| 156 |
+
```csv
|
| 157 |
+
sequence,c,r1,r2
|
| 158 |
+
QYWSDSWWESQMMSPWYPMSPLSV,0,-0.08333333,0.41666667
|
| 159 |
+
CKSEFQPPHLMGHDFFACEMRNFK,0,0.00000000,0.45833333
|
| 160 |
+
WPYGEHMLADNNVVKKRLQQWCFI,1,0.04166667,0.41666667
|
| 161 |
+
KVVGALPIESFYTAKMESIAVEVI,0,-0.04166667,0.33333333
|
| 162 |
+
```
|
| 163 |
+
|
| 164 |
+
```bash
|
| 165 |
+
python lecture_02/esm2_flow_guidance.py --data my_sequences.csv --output outputs/my_flow
|
| 166 |
+
python lecture_02/esm2_diffusion_guidance.py --data my_sequences.csv --output outputs/my_diffusion
|
| 167 |
+
```
|
| 168 |
+
|
| 169 |
+
- Include at least four sequences and both class labels, 0 and 1.
|
| 170 |
+
- Use one fixed sequence length, at most 128 residues, and the 20 canonical
|
| 171 |
+
amino acids. The compact models do not implement variable-length masking.
|
| 172 |
+
- Supply finite property values and orient each objective so **larger is better**.
|
| 173 |
+
For a quantity to minimize, negate it before writing the CSV.
|
| 174 |
+
- If both `r1` and `r2` are omitted, `composition_proxies()` calculates the
|
| 175 |
+
two example properties directly. Supplied reward columns override them.
|
| 176 |
+
- Experimental measurements or scores from a separate predictor can fill the
|
| 177 |
+
reward columns. The code trains a differentiable latent-space surrogate; it
|
| 178 |
+
does not differentiate through the original measurement or sequence scorer.
|
| 179 |
+
- The printed composition proxies always retain their defined count-based
|
| 180 |
+
meaning, even when custom reward labels are supplied. Re-evaluate custom
|
| 181 |
+
objectives with the corresponding assay or scorer after decoding.
|
| 182 |
+
|
| 183 |
+
For a research experiment, split and sequence-cluster data before fitting
|
| 184 |
+
normalization statistics or models. The bundled demonstration uses all 64
|
| 185 |
+
sequences for training and contains no held-out performance claim.
|
| 186 |
+
|
| 187 |
+
## Normalize first, then express preferences
|
| 188 |
+
|
| 189 |
+
Each objective is standardized independently:
|
| 190 |
+
|
| 191 |
+
```text
|
| 192 |
+
r̃ₘ(s) = [rₘ(s) − μₘ] / max(σₘ, 10⁻⁶)
|
| 193 |
+
```
|
| 194 |
+
|
| 195 |
+
Here μₘ and σₘ are the mean and standard deviation of property m in the training
|
| 196 |
+
data. Normalization prevents the numerical units of one property from
|
| 197 |
+
determining its importance in a weighted sum. The saved training statistics
|
| 198 |
+
must also be used for new examples; do not refit them on generated samples.
|
| 199 |
+
|
| 200 |
+
The reward predictor estimates standardized clean-sequence properties from an
|
| 201 |
+
intermediate latent and its time. During sampling, we combine its predictions:
|
| 202 |
+
|
| 203 |
+
```text
|
| 204 |
+
Rλ(z,t) = λ₁ r̂₁(z,t) + λ₂ r̂₂(z,t)
|
| 205 |
+
λ₁ ≥ 0, λ₂ ≥ 0, λ₁ + λ₂ = 1
|
| 206 |
+
```
|
| 207 |
+
|
| 208 |
+
The nonnegative tradeoff weights λ are normalized to sum to one. Overall
|
| 209 |
+
steering strength η is a separate parameter.
|
| 210 |
+
|
| 211 |
+
## Compare the three guidance modes
|
| 212 |
+
|
| 213 |
+
Both scripts run these settings in `main()`:
|
| 214 |
+
|
| 215 |
+
| Output | CFG strength `w` | Reward strength `eta` | Property weights `lambdas` |
|
| 216 |
+
| --- | ---: | ---: | --- |
|
| 217 |
+
| `cfg.fasta` | 2.0, class 1 | 0.0 | Unused |
|
| 218 |
+
| `single.fasta` | 0.0 | 1.0 | `(1.0, 0.0)` |
|
| 219 |
+
| `multi.fasta` | 0.0 | 1.0 | `(0.7, 0.3)` |
|
| 220 |
+
|
| 221 |
+
**Classifier-free guidance.** During training, 20% of class labels are replaced
|
| 222 |
+
by null class index 2. Sampling combines the conditional and unconditional
|
| 223 |
+
predictions as `F_uncond + w * (F_cond - F_uncond)`. Thus `w=0` is unconditional,
|
| 224 |
+
`w=1` is ordinary conditional sampling, and `w>1` amplifies the conditional
|
| 225 |
+
difference. F is a velocity for flow matching and predicted noise for DDPM.
|
| 226 |
+
|
| 227 |
+
**Reward steering.** Freeze both networks, enable gradients only for the
|
| 228 |
+
current latent, calculate the scalarized reward, and differentiate it with
|
| 229 |
+
respect to that latent. Set `(1, 0)` for the first objective alone or change
|
| 230 |
+
the weights to express a tradeoff. CFG alone does not use the reward predictor.
|
| 231 |
+
|
| 232 |
+
To change these settings, edit the three `sample(...)` calls in `main()`.
|
| 233 |
+
To combine CFG with reward steering, set both strengths:
|
| 234 |
+
|
| 235 |
+
```python
|
| 236 |
+
latent = sample(model, reward_model, n=8, c=1,
|
| 237 |
+
w=2.0, eta=1.0, lambdas=(0.7, 0.3))
|
| 238 |
+
```
|
| 239 |
+
|
| 240 |
+
Each call resets the sampling seed to compare methods using the same random
|
| 241 |
+
draws for the same batch size. Hardware and library differences can still
|
| 242 |
+
change numerical results.
|
| 243 |
+
|
| 244 |
+
### How the guidance enters the dynamics
|
| 245 |
+
|
| 246 |
+
| | Flow matching | DDPM diffusion |
|
| 247 |
+
| --- | --- | --- |
|
| 248 |
+
| Clean-data endpoint | Z₁ | Z₀ |
|
| 249 |
+
| Training target | Velocity Z₁ − Z₀ | Injected Gaussian noise ε |
|
| 250 |
+
| Generation direction | t = 0 → 1 | k = 1000 → 1 |
|
| 251 |
+
| Reward correction | Add κ(t)∇Rλ to velocity | Subtract η√(1−ᾱₖ)∇Rλ from predicted noise |
|
| 252 |
+
| Numerical step | Euler ODE update | Reverse mean plus posterior Gaussian noise |
|
| 253 |
+
|
| 254 |
+
For the flow, `κ(t)=4ηt(1−t)` is a chosen steering schedule. It is not an
|
| 255 |
+
automatically derived probability-flow ODE coefficient. For DDPM, the sign and
|
| 256 |
+
scale follow the score-to-noise relation `s=−ε/√(1−ᾱₖ)`.
|
| 257 |
+
|
| 258 |
+
The DDPM predictor includes a Gaussian-reference skip plus a learned correction
|
| 259 |
+
so the small MLP can carry high-dimensional noise. The training target remains
|
| 260 |
+
noise, and the sampler uses the standard DDPM reverse equations.
|
| 261 |
+
|
| 262 |
+
These are **surrogate-based steering examples**. A predicted expected property
|
| 263 |
+
is not the exact log likelihood or log exponential-reward expectation needed
|
| 264 |
+
for exact conditional or reward-tilted sampling. Neither example establishes
|
| 265 |
+
such an exact sampling guarantee.
|
| 266 |
+
|
| 267 |
+
## A hard constraint at decoding
|
| 268 |
+
|
| 269 |
+
The default decoder requires **at least 12 of the 24 output residues** to
|
| 270 |
+
belong to:
|
| 271 |
+
|
| 272 |
+
```text
|
| 273 |
+
𝒫 = {D, E, H, K, N, Q, R, S, T}
|
| 274 |
+
```
|
| 275 |
+
|
| 276 |
+
Starting from the highest-logit residue at each position, the decoder counts
|
| 277 |
+
members of 𝒫. If more are needed, it selects the smallest logit-cost substitutions
|
| 278 |
+
into that set. This exactly maximizes the sum of the fixed per-position logits
|
| 279 |
+
under the minimum-count constraint. It does not require differentiation through
|
| 280 |
+
the final amino-acid choices.
|
| 281 |
+
|
| 282 |
+
```bash
|
| 283 |
+
# Require at least 16 selected residues.
|
| 284 |
+
python lecture_02/esm2_flow_guidance.py --min-polar 16
|
| 285 |
+
|
| 286 |
+
# Remove the minimum-count constraint.
|
| 287 |
+
python lecture_02/esm2_diffusion_guidance.py --min-polar 0
|
| 288 |
+
```
|
| 289 |
+
|
| 290 |
+
Choose a minimum between zero and the sequence length. A hard composition rule
|
| 291 |
+
is a guarantee about the output sequence's count. It is not a guarantee of
|
| 292 |
+
physical solubility, folding, stability, safety, or biological function.
|
| 293 |
+
|
| 294 |
+
## Tests and limitations
|
| 295 |
+
|
| 296 |
+
Run the offline unit checks after installing dependencies:
|
| 297 |
+
|
| 298 |
+
```bash
|
| 299 |
+
python -m unittest discover -s tests -v
|
| 300 |
+
```
|
| 301 |
+
|
| 302 |
+
These check dataset annotations, scalarization weights, reward input gradients,
|
| 303 |
+
DDPM schedule indexing, and the constrained decoder against exhaustive solutions
|
| 304 |
+
on small examples. They do not download ESM-2 or evaluate biological quality.
|
| 305 |
+
|
| 306 |
+
The teaching scripts were also run end to end with the actual ESM-2 checkpoint,
|
| 307 |
+
200 training epochs, and all three guidance modes. All 48 decoded sequences in
|
| 308 |
+
that smoke test met the hard count constraint. Some single- and multi-objective
|
| 309 |
+
outputs decoded identically, and surrogate improvements did not consistently
|
| 310 |
+
translate into improved decoded properties.
|
| 311 |
+
|
| 312 |
+
Important boundaries:
|
| 313 |
+
|
| 314 |
+
- Tiny synthetic data and narrow MLPs are intended for instruction.
|
| 315 |
+
- The frozen ESM-2 head is not an exact inverse of the encoder; generated
|
| 316 |
+
latents may be off the encoding manifold.
|
| 317 |
+
- Increasing guidance can exploit surrogate errors. Validate the predictor
|
| 318 |
+
and re-evaluate generated sequences using the original objective.
|
| 319 |
+
- Constrained substitutions can change other properties and do not preserve
|
| 320 |
+
an exact conditioned generative distribution.
|
| 321 |
+
- No biological or clinical claims follow from these examples.
|
| 322 |
+
|
| 323 |
+
## Troubleshooting
|
| 324 |
+
|
| 325 |
+
- **Download failure:** the first run needs internet access to the ESM-2
|
| 326 |
+
checkpoint. Cached subsequent runs can use `HF_HUB_OFFLINE=1` once all files
|
| 327 |
+
have been downloaded.
|
| 328 |
+
- **Dependency conflicts:** use a clean virtual environment and the pinned
|
| 329 |
+
requirements. For a specific CUDA build, follow the official
|
| 330 |
+
[PyTorch installation instructions](https://pytorch.org/get-started/locally/).
|
| 331 |
+
- **Input validation error:** check equal lengths, canonical residues, both
|
| 332 |
+
class labels, finite rewards, and a minimum count no greater than the length.
|
| 333 |
+
- **Weak or repetitive samples:** the bundled experiment is an execution demo.
|
| 334 |
+
Use suitable data, validate the decoder and predictor, and assess held-out
|
| 335 |
+
quality before interpreting guidance effects.
|
| 336 |
+
|
| 337 |
+
## References
|
| 338 |
+
|
| 339 |
+
- [ESM-2 checkpoint](https://huggingface.co/facebook/esm2_t6_8M_UR50D)
|
| 340 |
+
and [Transformers ESM documentation](https://huggingface.co/docs/transformers/model_doc/esm).
|
| 341 |
+
- [Flow Matching for Generative Modeling](https://arxiv.org/abs/2210.02747).
|
| 342 |
+
- [Denoising Diffusion Probabilistic Models](https://arxiv.org/abs/2006.11239).
|
| 343 |
+
- [Classifier-Free Diffusion Guidance](https://arxiv.org/abs/2207.12598).
|
| 344 |
+
|
| 345 |
+
## License
|
| 346 |
+
|
| 347 |
+
Repository code is distributed under the [MIT License](LICENSE), matching the
|
| 348 |
+
repository's license setting. ESM-2 weights are downloaded separately and remain
|
| 349 |
+
subject to their original distribution terms.
|
lecture_02/GUIDANCE_NOTES.md
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ESM-2 flow matching and diffusion guidance
|
| 2 |
+
|
| 3 |
+
Two independent teaching scripts, each with encoding, normalization, a small
|
| 4 |
+
conditional generator, a time-conditioned reward predictor, training, three
|
| 5 |
+
guidance examples, and constrained sequence decoding. Neither imports the other.
|
| 6 |
+
|
| 7 |
+
## Run
|
| 8 |
+
|
| 9 |
+
Install Python 3.10+ and the dependencies in your own environment:
|
| 10 |
+
|
| 11 |
+
```sh
|
| 12 |
+
pip install torch transformers==4.57.6
|
| 13 |
+
python esm2_flow_guidance.py --epochs 200 --samples 8
|
| 14 |
+
python esm2_diffusion_guidance.py --epochs 200 --samples 8
|
| 15 |
+
```
|
| 16 |
+
|
| 17 |
+
Keep both scripts and `esm2_example.csv` together. Their default paths are
|
| 18 |
+
relative to the scripts, so the commands also work with absolute script paths.
|
| 19 |
+
The first run downloads the public `facebook/esm2_t6_8M_UR50D` checkpoint into
|
| 20 |
+
`.esm2_cache`. CUDA is used when available; otherwise the scripts use CPU.
|
| 21 |
+
These examples were executed with PyTorch 2.9.1 and Transformers 4.57.6.
|
| 22 |
+
|
| 23 |
+
## Motivation
|
| 24 |
+
|
| 25 |
+
We will generate protein-sequence representations with a flow and a diffusion
|
| 26 |
+
model, then guide generation toward specified properties. Using frozen ESM-2
|
| 27 |
+
residue embeddings, we can compare classifier-free conditioning with
|
| 28 |
+
single-objective and weighted multi-objective reward steering in the same latent
|
| 29 |
+
space. After generation, we decode the latents into amino acids and enforce an
|
| 30 |
+
explicit residue-count constraint. This separates learning the sequence
|
| 31 |
+
distribution, expressing preferences, and guaranteeing a discrete output rule.
|
| 32 |
+
|
| 33 |
+
## Where the rewards come from
|
| 34 |
+
|
| 35 |
+
The bundled dataset contains 64 synthetic sequences of length 24. These are
|
| 36 |
+
teaching examples, not natural proteins or experimentally validated peptides.
|
| 37 |
+
|
| 38 |
+
For sequence s of length L, `composition_proxies()` computes:
|
| 39 |
+
|
| 40 |
+
- r1 = (number of K and R minus number of D and E) / L.
|
| 41 |
+
This is a side-chain charge-count proxy, not a complete pH-dependent charge model.
|
| 42 |
+
- r2 = number of residues in `DEHKNQRST` / L.
|
| 43 |
+
This is a defined polar/charged composition fraction, not measured solubility.
|
| 44 |
+
- The bundled class label c is 1 when r1 > 0, and 0 otherwise.
|
| 45 |
+
|
| 46 |
+
CSV columns are `sequence,c,r1,r2`. Both classes 0 and 1 must be present. If r1
|
| 47 |
+
and r2 are omitted, the scripts compute the composition proxies directly.
|
| 48 |
+
Supplied r1/r2 columns override the proxy calculation, allowing measured
|
| 49 |
+
objectives or external predictor outputs. Orient each objective so larger is
|
| 50 |
+
better before supplying it. For an undesirable quantity, negate it first.
|
| 51 |
+
|
| 52 |
+
All sequences in one run must have the same length, between 1 and 128, using
|
| 53 |
+
the 20 canonical amino acids. The default hard minimum is 12, so shorter
|
| 54 |
+
sequences require a smaller `--min-polar` value.
|
| 55 |
+
|
| 56 |
+
## Normalization and reward prediction
|
| 57 |
+
|
| 58 |
+
- Preserve one ESM-2 embedding per residue, giving [N, L, 320]. Remove BOS/EOS.
|
| 59 |
+
- Standardize each latent feature using its mean and standard deviation over
|
| 60 |
+
training sequences and residue positions.
|
| 61 |
+
- Standardize each property separately: r_tilde = (r - mean) / std.
|
| 62 |
+
- Save these statistics. Do not refit them on generated, validation, or test data.
|
| 63 |
+
- Train a small predictor on intermediate latents and their clean-sequence
|
| 64 |
+
standardized property labels. Its two outputs estimate expected endpoint
|
| 65 |
+
rewards from the current state and time.
|
| 66 |
+
|
| 67 |
+
The examples use all 64 sequences for training and do not claim held-out quality.
|
| 68 |
+
For research, split and cluster data before fitting statistics or networks, then
|
| 69 |
+
assess prediction and sequence reconstruction on held-out sequences.
|
| 70 |
+
|
| 71 |
+
## Three sampling modes in each script
|
| 72 |
+
|
| 73 |
+
1. CFG: `w=2`, `eta=0`. Combine unconditional and class-conditioned predictions
|
| 74 |
+
as F_uncond + w * (F_cond - F_uncond). Here w=0 is unconditional and w=1 is
|
| 75 |
+
ordinary conditional sampling. Drop the class to null index 2 in 20% of
|
| 76 |
+
training examples. CFG does not use the external reward predictor.
|
| 77 |
+
2. Single objective: `w=0`, `eta=1`, `lambdas=(1,0)`.
|
| 78 |
+
3. Multiple objectives: `w=0`, `eta=1`, `lambdas=(0.7,0.3)`.
|
| 79 |
+
|
| 80 |
+
The weights are normalized to sum to one. Lambda controls the relative
|
| 81 |
+
tradeoff in standardized property units; eta controls overall steering strength.
|
| 82 |
+
Set both w and eta positive to combine CFG and reward steering. Edit the three
|
| 83 |
+
calls in `main()` to change the demonstration settings.
|
| 84 |
+
|
| 85 |
+
Flow training uses Z_0=noise, Z_1=data, a straight conditional path, and target
|
| 86 |
+
velocity Z_1-Z_0. Sampling integrates from t=0 to t=1 with Euler steps. Reward
|
| 87 |
+
steering adds kappa(t) times the reward gradient to the velocity, with the chosen
|
| 88 |
+
schedule kappa(t)=4*eta*t*(1-t). This is heuristic velocity steering, not a claim
|
| 89 |
+
of exact sampling from a reward-tilted density.
|
| 90 |
+
|
| 91 |
+
DDPM training uses Z_0=data and predicts the Gaussian noise used to construct
|
| 92 |
+
Z_k. Sampling runs k=1000 down to 1. The small noise predictor includes the
|
| 93 |
+
Gaussian-reference skip sqrt(1-alpha_bar_k)*Z_k and learns an additive correction
|
| 94 |
+
scaled by sqrt(alpha_bar_k). This is a parameterization of the noise predictor;
|
| 95 |
+
the target and DDPM equations remain noise-prediction equations. It lets the
|
| 96 |
+
small network pass through high-dimensional noise without reconstructing every
|
| 97 |
+
coordinate through its narrow hidden layer.
|
| 98 |
+
|
| 99 |
+
For DDPM steering, epsilon_guided = epsilon_CFG - eta *
|
| 100 |
+
sqrt(1-alpha_bar_k) * gradient(R_lambda). This follows the score-to-noise
|
| 101 |
+
conversion s = -epsilon / sqrt(1-alpha_bar_k). The posterior standard deviation
|
| 102 |
+
used for the reverse random increment is a different quantity.
|
| 103 |
+
|
| 104 |
+
In both scripts, gradient ascent uses a learned expected-reward predictor.
|
| 105 |
+
It is not the exact log conditional likelihood or log exponential-reward
|
| 106 |
+
expectation needed for exact conditional or reward-tilted sampling. The
|
| 107 |
+
generator and reward weights are frozen during sampling; gradients are enabled
|
| 108 |
+
only for the current latent. No gradients through a discrete sequence scorer
|
| 109 |
+
are required. A research implementation must validate surrogate quality and
|
| 110 |
+
recheck the true objectives after decoding.
|
| 111 |
+
|
| 112 |
+
## One decoder at the end
|
| 113 |
+
|
| 114 |
+
The identical `decode()` function appears in both files to keep each script
|
| 115 |
+
standalone. Both samplers use this same final decoding operation.
|
| 116 |
+
|
| 117 |
+
1. Undo latent standardization.
|
| 118 |
+
2. Apply ESM-2's frozen language-model head and retain the 20 amino-acid logits.
|
| 119 |
+
3. Take the highest-logit residue at each position.
|
| 120 |
+
4. If fewer than M positions contain a residue in `DEHKNQRST`, replace exactly
|
| 121 |
+
the missing number using the lowest logit-cost changes to that set.
|
| 122 |
+
|
| 123 |
+
For the default M=12, every decoded 24-residue output contains at least 12
|
| 124 |
+
members of the selected set. `--min-polar 0` removes the constraint. The procedure
|
| 125 |
+
maximizes the sum of the fixed per-position logits subject to this minimum
|
| 126 |
+
count. It does not enforce the constraint along the latent trajectory, preserve
|
| 127 |
+
an exact conditioned generative distribution, or guarantee physical solubility.
|
| 128 |
+
|
| 129 |
+
The language-model head is a simple available decoder, not a mathematically
|
| 130 |
+
exact inverse of ESM-2. Generated latents can be off the encoding manifold.
|
| 131 |
+
For serious protein generation, validate decoding and consider training a
|
| 132 |
+
dedicated sequence decoder. Property improvements in the surrogate need not
|
| 133 |
+
survive discretization or the constrained substitutions.
|
| 134 |
+
|
| 135 |
+
## Outputs and checks
|
| 136 |
+
|
| 137 |
+
Each script writes `cfg.fasta`, `single.fasta`, `multi.fasta`, and `results.pt`
|
| 138 |
+
to its own output directory. The tensor file contains standardized generated
|
| 139 |
+
latents, both networks' state dictionaries, normalization statistics, ESM model
|
| 140 |
+
name, latent shape, and constraint settings. The terminal prints example
|
| 141 |
+
sequences and re-evaluated mean composition proxies.
|
| 142 |
+
|
| 143 |
+
Both examples have been executed through actual ESM-2 encoding, 200 training
|
| 144 |
+
epochs, all three guidance modes, and final decoding. Checks included finite
|
| 145 |
+
outputs, reward input gradients, normalization, schedule indexing, and the
|
| 146 |
+
hard constraint. For small test cases, constrained decoding was compared with
|
| 147 |
+
exhaustive search over polar/nonpolar assignments. These checks establish code
|
| 148 |
+
mechanics, not biological validity or reliable property optimization.
|
| 149 |
+
|
| 150 |
+
## Primary references
|
| 151 |
+
|
| 152 |
+
- ESM model: https://huggingface.co/facebook/esm2_t6_8M_UR50D
|
| 153 |
+
- ESM implementation: https://huggingface.co/docs/transformers/model_doc/esm
|
| 154 |
+
- Flow Matching: https://arxiv.org/abs/2210.02747
|
| 155 |
+
- DDPM: https://arxiv.org/abs/2006.11239
|
| 156 |
+
- Classifier-Free Diffusion Guidance: https://arxiv.org/abs/2207.12598
|
lecture_02/esm2_diffusion_guidance.py
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Standalone ESM-2 diffusion guidance example for CIS 6270.
|
| 2 |
+
Input CSV: sequence,c,r1,r2. Sequences have one fixed length; c is 0 or 1.
|
| 3 |
+
Both objectives are oriented so larger is better. The bundled CSV is synthetic.
|
| 4 |
+
Run: python esm2_diffusion_guidance.py --data esm2_example.csv --epochs 200
|
| 5 |
+
Install: pip install torch transformers==4.57.6
|
| 6 |
+
Outputs: guided residue latents, model weights, and decoded amino-acid sequences.
|
| 7 |
+
"""
|
| 8 |
+
import argparse
|
| 9 |
+
import csv
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
|
| 12 |
+
import torch
|
| 13 |
+
from torch import nn
|
| 14 |
+
import torch.nn.functional as F
|
| 15 |
+
from torch.utils.data import DataLoader, TensorDataset
|
| 16 |
+
from transformers import AutoTokenizer, EsmForMaskedLM
|
| 17 |
+
|
| 18 |
+
ROOT = Path(__file__).resolve().parent
|
| 19 |
+
DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 20 |
+
ESM_NAME = "facebook/esm2_t6_8M_UR50D"
|
| 21 |
+
AMINO_ACIDS = "ACDEFGHIKLMNPQRSTVWY"
|
| 22 |
+
POLAR_RESIDUES = "DEHKNQRST" # Operational polar/charged set; not a solubility assay.
|
| 23 |
+
BATCH_SIZE, HIDDEN, LEARNING_RATE = 16, 128, 1e-3
|
| 24 |
+
CONDITION_DROP = 0.2 # Drop c during training so the same model learns the null condition.
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def composition_proxies(sequences):
|
| 28 |
+
# Transparent teaching rewards; these are not measured activity or solubility.
|
| 29 |
+
return torch.tensor([
|
| 30 |
+
[(sum(a in "KR" for a in s) - sum(a in "DE" for a in s)) / len(s),
|
| 31 |
+
sum(a in POLAR_RESIDUES for a in s) / len(s)]
|
| 32 |
+
for s in sequences
|
| 33 |
+
], dtype=torch.float32)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
# 1. Load annotated sequences and encode frozen ESM-2 residue vectors.
|
| 37 |
+
@torch.no_grad()
|
| 38 |
+
def load_data(path):
|
| 39 |
+
with Path(path).open(newline="") as handle:
|
| 40 |
+
rows = list(csv.DictReader(handle))
|
| 41 |
+
sequences = [row["sequence"].strip().upper() for row in rows]
|
| 42 |
+
if len(rows) < 4 or any(not s or set(s) - set(AMINO_ACIDS) for s in sequences):
|
| 43 |
+
raise ValueError("Supply at least four sequences using the 20 standard amino acids")
|
| 44 |
+
lengths = {len(s) for s in sequences}
|
| 45 |
+
if len(lengths) != 1 or max(lengths) > 128:
|
| 46 |
+
raise ValueError("This compact example requires one fixed sequence length, at most 128")
|
| 47 |
+
c = torch.tensor([int(row["c"]) for row in rows], dtype=torch.long)
|
| 48 |
+
# With sequence,c only, compute example rewards directly from residue counts.
|
| 49 |
+
# Optional r1/r2 columns override them with supplied labels, such as measurements.
|
| 50 |
+
if {"r1", "r2"}.issubset(rows[0]):
|
| 51 |
+
r = torch.tensor([[float(row["r1"]), float(row["r2"])] for row in rows])
|
| 52 |
+
else:
|
| 53 |
+
r = composition_proxies(sequences)
|
| 54 |
+
if set(c.tolist()) != {0, 1} or not torch.isfinite(r).all():
|
| 55 |
+
raise ValueError("Include both c=0 and c=1, with finite r1 and r2")
|
| 56 |
+
|
| 57 |
+
tokenizer = AutoTokenizer.from_pretrained(ESM_NAME, cache_dir=ROOT / ".esm2_cache")
|
| 58 |
+
esm = EsmForMaskedLM.from_pretrained(
|
| 59 |
+
ESM_NAME, cache_dir=ROOT / ".esm2_cache", use_safetensors=True
|
| 60 |
+
).to(DEVICE).eval().requires_grad_(False)
|
| 61 |
+
encoded = []
|
| 62 |
+
for start in range(0, len(sequences), BATCH_SIZE):
|
| 63 |
+
tokens = tokenizer(sequences[start:start + BATCH_SIZE], return_tensors="pt")
|
| 64 |
+
tokens = {key: value.to(DEVICE) for key, value in tokens.items()}
|
| 65 |
+
hidden = esm.esm(**tokens).last_hidden_state
|
| 66 |
+
encoded.append(hidden[:, 1:-1].cpu()) # Remove BOS/EOS; retain all L residue positions.
|
| 67 |
+
z = torch.cat(encoded) # [N, L, D], with D=320 for this checkpoint.
|
| 68 |
+
z_mean = z.mean((0, 1), keepdim=True)
|
| 69 |
+
z_std = z.std((0, 1), correction=0, keepdim=True).clamp_min(1e-4)
|
| 70 |
+
r_mean, r_std = r.mean(0), r.std(0, correction=0).clamp_min(1e-6)
|
| 71 |
+
dataset = TensorDataset((z - z_mean) / z_std, c, (r - r_mean) / r_std)
|
| 72 |
+
stats = {"z_mean": z_mean, "z_std": z_std, "r_mean": r_mean, "r_std": r_std}
|
| 73 |
+
return dataset, esm, tokenizer, stats
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
# 2. Small model classes: flattening lets each output depend on the entire sequence.
|
| 77 |
+
class DiffusionModel(nn.Module):
|
| 78 |
+
def __init__(self, length, dim):
|
| 79 |
+
super().__init__()
|
| 80 |
+
self.length, self.dim = length, dim
|
| 81 |
+
self.time = nn.Sequential(nn.Linear(1, 32), nn.SiLU(), nn.Linear(32, 32))
|
| 82 |
+
self.condition = nn.Embedding(3, 16) # Indices 0,1 are classes; 2 is the null class.
|
| 83 |
+
self.net = nn.Sequential(
|
| 84 |
+
nn.Linear(length * dim + 48, HIDDEN), nn.SiLU(),
|
| 85 |
+
nn.Linear(HIDDEN, HIDDEN), nn.SiLU(),
|
| 86 |
+
nn.Linear(HIDDEN, length * dim),
|
| 87 |
+
)
|
| 88 |
+
|
| 89 |
+
def forward(self, z, t, c):
|
| 90 |
+
time = self.time(t[:, None])
|
| 91 |
+
inputs = torch.cat([z.flatten(1), time, self.condition(c)], dim=1)
|
| 92 |
+
k = (t * K).round().long().clamp(0, K)
|
| 93 |
+
a = alpha_bars[k, None, None]
|
| 94 |
+
return (1 - a).sqrt() * z + a.sqrt() * self.net(inputs).reshape_as(z)
|
| 95 |
+
# Gaussian-reference noise prediction plus a learned correction.
|
| 96 |
+
# The skip carries all coordinates; the correction vanishes near pure noise.
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
class RewardModel(nn.Module):
|
| 100 |
+
def __init__(self, length, dim):
|
| 101 |
+
super().__init__()
|
| 102 |
+
self.net = nn.Sequential(
|
| 103 |
+
nn.Linear(length * dim + 1, HIDDEN), nn.SiLU(),
|
| 104 |
+
nn.Linear(HIDDEN, HIDDEN), nn.SiLU(), nn.Linear(HIDDEN, 2),
|
| 105 |
+
)
|
| 106 |
+
|
| 107 |
+
def forward(self, z, t):
|
| 108 |
+
return self.net(torch.cat([z.flatten(1), t[:, None]], dim=1))
|
| 109 |
+
# Two predicted standardized endpoint objectives, given the current latent and time.
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
# The same DDPM schedule as Section 7; k=0 denotes the clean latent.
|
| 113 |
+
K = 1000
|
| 114 |
+
betas = torch.cat([torch.zeros(1), torch.linspace(1e-4, 0.02, K)]).to(DEVICE)
|
| 115 |
+
alphas = 1.0 - betas
|
| 116 |
+
alpha_bars = alphas.cumprod(0)
|
| 117 |
+
previous = torch.cat([torch.ones(1, device=DEVICE), alpha_bars[:-1]])
|
| 118 |
+
posterior_variances = betas * (1 - previous) / (1 - alpha_bars).clamp_min(1e-20)
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
# 3. DDPM: corrupt Z_0 at step k and regress the exact noise epsilon.
|
| 122 |
+
def train(dataset, epochs):
|
| 123 |
+
_, length, dim = dataset.tensors[0].shape
|
| 124 |
+
loader = DataLoader(dataset, batch_size=BATCH_SIZE, shuffle=True)
|
| 125 |
+
model = DiffusionModel(length, dim).to(DEVICE)
|
| 126 |
+
reward_model = RewardModel(length, dim).to(DEVICE)
|
| 127 |
+
optimizer = torch.optim.Adam(list(model.parameters()) + list(reward_model.parameters()), lr=LEARNING_RATE)
|
| 128 |
+
for epoch in range(epochs):
|
| 129 |
+
total = 0.0
|
| 130 |
+
for z0, c, r_tilde in loader:
|
| 131 |
+
z0, c, r_tilde = z0.to(DEVICE), c.to(DEVICE), r_tilde.to(DEVICE)
|
| 132 |
+
k = torch.randint(1, K + 1, (len(z0),), device=DEVICE)
|
| 133 |
+
t = k.float() / K # Normalized k is only the network's time input.
|
| 134 |
+
a = alpha_bars[k, None, None]
|
| 135 |
+
epsilon = torch.randn_like(z0)
|
| 136 |
+
zk = a.sqrt() * z0 + (1 - a).sqrt() * epsilon
|
| 137 |
+
dropped = c.masked_fill(torch.rand(len(c), device=DEVICE) < CONDITION_DROP, 2)
|
| 138 |
+
loss_diffusion = F.mse_loss(model(zk, t, dropped), epsilon)
|
| 139 |
+
loss_reward = F.mse_loss(reward_model(zk, t), r_tilde)
|
| 140 |
+
loss = loss_diffusion + loss_reward
|
| 141 |
+
optimizer.zero_grad(set_to_none=True)
|
| 142 |
+
loss.backward()
|
| 143 |
+
optimizer.step()
|
| 144 |
+
total += loss.item()
|
| 145 |
+
if (epoch + 1) % 50 == 0 or epoch + 1 == epochs:
|
| 146 |
+
print(f"epoch {epoch+1}: combined training loss {total / len(loader):.4f}")
|
| 147 |
+
for network in (model, reward_model):
|
| 148 |
+
network.eval().requires_grad_(False)
|
| 149 |
+
for parameter in network.parameters():
|
| 150 |
+
parameter.grad = None
|
| 151 |
+
return model, reward_model
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
# 4. R_lambda = sum_m lambda_m * r_tilde_m; differentiate the current latent only.
|
| 155 |
+
def reward_gradient(reward_model, z, t, lambdas):
|
| 156 |
+
with torch.enable_grad(): # Re-enable input gradients inside sampling.
|
| 157 |
+
state = z.detach().requires_grad_(True)
|
| 158 |
+
R_lambda = (reward_model(state, t) * lambdas).sum(dim=1)
|
| 159 |
+
grad = torch.autograd.grad(R_lambda.sum(), state)[0]
|
| 160 |
+
return grad.detach() # Do not retain graphs across sampling steps.
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
def normalize_weights(lambdas):
|
| 164 |
+
values = torch.as_tensor(lambdas, dtype=torch.float32, device=DEVICE)
|
| 165 |
+
if values.shape != (2,) or not torch.isfinite(values).all() or (values < 0).any() or values.sum() <= 0:
|
| 166 |
+
raise ValueError("Use two finite, nonnegative weights with a positive sum")
|
| 167 |
+
return values / values.sum() # lambda controls tradeoffs, eta controls strength.
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
# 5. Sampling: CFG first; optional reward gradient then changes predicted noise.
|
| 171 |
+
@torch.no_grad()
|
| 172 |
+
def sample(model, reward_model, n=8, c=1, w=0.0, eta=0.0, lambdas=(1.0, 0.0)):
|
| 173 |
+
if c not in (0, 1) or w < 0 or eta < 0:
|
| 174 |
+
raise ValueError("Use c=0/1 and nonnegative w and eta")
|
| 175 |
+
lambdas = normalize_weights(lambdas)
|
| 176 |
+
torch.manual_seed(123) # Same starting and reverse noise across comparisons.
|
| 177 |
+
z = torch.randn(n, model.length, model.dim, device=DEVICE)
|
| 178 |
+
null = torch.full((n,), 2, dtype=torch.long, device=DEVICE)
|
| 179 |
+
condition = torch.full((n,), c, dtype=torch.long, device=DEVICE)
|
| 180 |
+
for k in range(K, 0, -1):
|
| 181 |
+
t = torch.full((n,), k / K, device=DEVICE)
|
| 182 |
+
eps_uncond = model(z, t, null)
|
| 183 |
+
eps = eps_uncond
|
| 184 |
+
if w != 0:
|
| 185 |
+
eps = eps_uncond + w * (model(z, t, condition) - eps_uncond)
|
| 186 |
+
sigma = (1 - alpha_bars[k]).sqrt() # FORWARD corruption standard deviation.
|
| 187 |
+
if eta != 0:
|
| 188 |
+
grad_R = reward_gradient(reward_model, z, t, lambdas)
|
| 189 |
+
eps = eps - eta * sigma * grad_R # s_guided=s_theta+eta*grad R; s=-epsilon/sigma.
|
| 190 |
+
mean = (z - betas[k] * eps / sigma) / alphas[k].sqrt()
|
| 191 |
+
z = mean + posterior_variances[k].sqrt() * torch.randn_like(z) if k > 1 else mean
|
| 192 |
+
return z # No fresh noise at k=1.
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
# 6. One decoder, used only AFTER the flow or diffusion trajectory is complete.
|
| 196 |
+
@torch.no_grad()
|
| 197 |
+
def decode(z, esm, tokenizer, stats, min_polar=12):
|
| 198 |
+
latent = z * stats["z_std"].to(z.device) + stats["z_mean"].to(z.device)
|
| 199 |
+
logits = esm.lm_head(latent) # Frozen head produces one vocabulary distribution per residue.
|
| 200 |
+
aa_ids = torch.tensor(tokenizer.convert_tokens_to_ids(list(AMINO_ACIDS)), device=z.device)
|
| 201 |
+
logits = logits.index_select(-1, aa_ids) # Restrict the vocabulary to the 20 amino acids.
|
| 202 |
+
if not 0 <= min_polar <= z.shape[1]:
|
| 203 |
+
raise ValueError("min_polar must lie between zero and the sequence length")
|
| 204 |
+
polar = torch.tensor([a in POLAR_RESIDUES for a in AMINO_ACIDS], device=z.device)
|
| 205 |
+
polar_ids = polar.nonzero().flatten()
|
| 206 |
+
best_scores, choices = logits.max(dim=-1) # Start from unrestricted amino-acid argmax.
|
| 207 |
+
polar_scores, local = logits[..., polar_ids].max(dim=-1)
|
| 208 |
+
polar_choices = polar_ids[local] # Best polar/charged amino acid at each position.
|
| 209 |
+
for i in range(len(z)):
|
| 210 |
+
already_polar = polar[choices[i]]
|
| 211 |
+
missing = max(0, min_polar - int(already_polar.sum()))
|
| 212 |
+
if missing:
|
| 213 |
+
cost = (best_scores[i] - polar_scores[i]).masked_fill(already_polar, float("inf"))
|
| 214 |
+
positions = cost.topk(missing, largest=False).indices
|
| 215 |
+
choices[i, positions] = polar_choices[i, positions]
|
| 216 |
+
return ["".join(AMINO_ACIDS[i] for i in row) for row in choices.cpu().tolist()]
|
| 217 |
+
# Exact maximum-logit decode subject to at least min_polar selected residues.
|
| 218 |
+
# This enforces composition, not measured solubility; no gradient through argmax.
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
# 7. Run CFG, single-objective steering, and scalarized multi-objective steering in order.
|
| 222 |
+
def main():
|
| 223 |
+
parser = argparse.ArgumentParser(description=__doc__)
|
| 224 |
+
parser.add_argument("--data", type=Path, default=ROOT / "esm2_example.csv")
|
| 225 |
+
parser.add_argument("--epochs", type=int, default=200)
|
| 226 |
+
parser.add_argument("--samples", type=int, default=8)
|
| 227 |
+
parser.add_argument("--min-polar", type=int, default=12)
|
| 228 |
+
parser.add_argument("--output", type=Path, default=ROOT / "esm2_diffusion_outputs")
|
| 229 |
+
args = parser.parse_args()
|
| 230 |
+
if args.epochs < 1 or args.samples < 1:
|
| 231 |
+
parser.error("epochs and samples must be positive")
|
| 232 |
+
torch.manual_seed(7)
|
| 233 |
+
if DEVICE.type == "cpu":
|
| 234 |
+
torch.set_num_threads(2)
|
| 235 |
+
dataset, esm, tokenizer, stats = load_data(args.data)
|
| 236 |
+
if not 0 <= args.min_polar <= dataset.tensors[0].shape[1]:
|
| 237 |
+
parser.error("min-polar must lie between zero and the sequence length")
|
| 238 |
+
model, reward_model = train(dataset, args.epochs)
|
| 239 |
+
outputs = {
|
| 240 |
+
"cfg": sample(model, reward_model, args.samples, c=1, w=2.0),
|
| 241 |
+
"single": sample(model, reward_model, args.samples, eta=1.0, lambdas=(1.0, 0.0)),
|
| 242 |
+
"multi": sample(model, reward_model, args.samples, eta=1.0, lambdas=(0.7, 0.3)),
|
| 243 |
+
}
|
| 244 |
+
args.output.mkdir(parents=True, exist_ok=True)
|
| 245 |
+
for name, latent in outputs.items():
|
| 246 |
+
if not torch.isfinite(latent).all():
|
| 247 |
+
raise RuntimeError(f"Nonfinite {name} output; reduce guidance or check training")
|
| 248 |
+
sequences = decode(latent, esm, tokenizer, stats, args.min_polar)
|
| 249 |
+
assert all(sum(a in POLAR_RESIDUES for a in s) >= args.min_polar for s in sequences)
|
| 250 |
+
fasta = "".join(f">{name}_{i+1}\n{seq}\n" for i, seq in enumerate(sequences))
|
| 251 |
+
(args.output / f"{name}.fasta").write_text(fasta)
|
| 252 |
+
count = sum(a in POLAR_RESIDUES for a in sequences[0])
|
| 253 |
+
print(f"{name}: {sequences[0]} polar/charged residues={count}")
|
| 254 |
+
print(" decoded mean composition proxies:", composition_proxies(sequences).mean(0).tolist())
|
| 255 |
+
torch.save({"standardized_latents": {k: v.cpu() for k, v in outputs.items()},
|
| 256 |
+
"model": model.state_dict(), "reward_model": reward_model.state_dict(),
|
| 257 |
+
"stats": stats, "esm_name": ESM_NAME,
|
| 258 |
+
"length": model.length, "dim": model.dim, "min_polar": args.min_polar,
|
| 259 |
+
"polar_residues": POLAR_RESIDUES}, args.output / "results.pt")
|
| 260 |
+
print(f"Saved latent tensors and FASTA sequences to {args.output}")
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
if __name__ == "__main__":
|
| 264 |
+
main()
|
lecture_02/esm2_example.csv
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
sequence,c,r1,r2
|
| 2 |
+
QYWSDSWWESQMMSPWYPMSPLSV,0,-0.08333333,0.41666667
|
| 3 |
+
CKSEFQPPHLMGHDFFACEMRNFK,0,0.00000000,0.45833333
|
| 4 |
+
WPYGEHMLADNNVVKKRLQQWCFI,1,0.04166667,0.41666667
|
| 5 |
+
KVVGALPIESFYTAKMESIAVEVI,0,-0.04166667,0.33333333
|
| 6 |
+
MEFHAYSLGPASRWSSRHGYFTNL,1,0.04166667,0.45833333
|
| 7 |
+
TCYLFCLAQIVMFGRAYVGGYWAK,1,0.08333333,0.16666667
|
| 8 |
+
VFSHIVACCEIETRDTCVWNHMYM,0,-0.08333333,0.41666667
|
| 9 |
+
QRNQTEIRTLIYMPYGNKTYLRGS,1,0.12500000,0.54166667
|
| 10 |
+
WDNQRIKTALAGHTGVDSGWMHNF,0,0.00000000,0.50000000
|
| 11 |
+
PPVCRGWQMPRAALWVMRCRFPRQ,1,0.20833333,0.29166667
|
| 12 |
+
HPNAKNYGETIFFGLWGNDRKNLV,1,0.04166667,0.45833333
|
| 13 |
+
CMKEIYESFLDETTIWVIHMIAAR,0,-0.08333333,0.41666667
|
| 14 |
+
RWDTGMWGKDCIHRPIQKKDPTWA,1,0.08333333,0.50000000
|
| 15 |
+
WCCQLMAYPPAKMGHPTFDHPLPK,1,0.04166667,0.29166667
|
| 16 |
+
QPVRDFVLEVGQSILESHSLKVPS,0,-0.04166667,0.50000000
|
| 17 |
+
GREMRGFQKTPNATKKEGTYKPAP,1,0.16666667,0.54166667
|
| 18 |
+
ENEWNGSRHYWCVRWLGTLKILTF,1,0.04166667,0.45833333
|
| 19 |
+
AVSQIAPHARQQASSQENKHGETT,0,0.00000000,0.66666667
|
| 20 |
+
NADTREKMPSNDLSWWLYNMFMPA,0,-0.04166667,0.45833333
|
| 21 |
+
SLWFAIMPYVAKYVIYGHVEPTAT,0,0.00000000,0.25000000
|
| 22 |
+
YGWHYTLLMWYDERCGFLGRGRAQ,1,0.04166667,0.33333333
|
| 23 |
+
EEQEKNLDDMNSLDFSQNLSLFEY,0,-0.25000000,0.66666667
|
| 24 |
+
ETWDHYPQYKCVKCKNNHCPHVAY,1,0.04166667,0.50000000
|
| 25 |
+
YHIMRKVPSTNEFRPLAHNNCTLQ,1,0.08333333,0.54166667
|
| 26 |
+
DFRMNQYGSKDYPKTTGAREHLFM,1,0.04166667,0.54166667
|
| 27 |
+
IQWWSDVQTMQCMADEGMHHQYFV,0,-0.12500000,0.45833333
|
| 28 |
+
WAQMMMVRQIDCRNFIRHTVYSNF,1,0.08333333,0.45833333
|
| 29 |
+
DMTFGHSSDAILAKVYDSKNCPQL,0,-0.04166667,0.50000000
|
| 30 |
+
DRSICNKIPPSQTIPFMGPFISQN,1,0.04166667,0.45833333
|
| 31 |
+
QNHLSDAEIKYCIKWNVLNGKMLP,1,0.04166667,0.45833333
|
| 32 |
+
RQYCPEPSWSQHTNKPACMKVEEC,0,0.00000000,0.54166667
|
| 33 |
+
IWWWHVNCQCRDDHEHCPLCYAPI,0,-0.08333333,0.37500000
|
| 34 |
+
SDKSVTHIMERDIWPIIAWLRDRV,0,0.00000000,0.50000000
|
| 35 |
+
WSDAWDVYIWILAMNCEVGRMYYC,0,-0.08333333,0.25000000
|
| 36 |
+
VAKELIRNYGDEFCWLVNSSMKED,0,-0.08333333,0.50000000
|
| 37 |
+
VTWANPHINITFCDPMHIIRIQCQ,0,0.00000000,0.41666667
|
| 38 |
+
GYYWVTHLVHMVDIWFSDMPIHYE,0,-0.12500000,0.33333333
|
| 39 |
+
TRALRHDDVWVDYYFIWTALTYQF,0,-0.04166667,0.41666667
|
| 40 |
+
CGNGSDFDTFIQMWICACGMCIDK,0,-0.08333333,0.33333333
|
| 41 |
+
NGQMACTQENWAWSVFHFEWWCPK,0,-0.04166667,0.41666667
|
| 42 |
+
GSRETKHEVTADCPFQWNGRALKK,1,0.08333333,0.58333333
|
| 43 |
+
CHGKYTFTFGMEIRDMPAWYGHFV,0,0.00000000,0.33333333
|
| 44 |
+
GSYDNPLAIKWQMVCNSSRMDWTM,0,0.00000000,0.45833333
|
| 45 |
+
FRIAQIRSKIGYLQNIFVMSLLLR,1,0.16666667,0.37500000
|
| 46 |
+
SCCDWRQMQDRVGPAMMKEWACGE,0,-0.04166667,0.41666667
|
| 47 |
+
FFSELMKCHYHYCYAWRRPIAKDW,1,0.08333333,0.37500000
|
| 48 |
+
AWGPVLDFNIQSDIFYPCRTDMGL,0,-0.08333333,0.33333333
|
| 49 |
+
PHTCRRIMMMEQDLTNFPLAYCTQ,0,0.00000000,0.45833333
|
| 50 |
+
DQINDGSAHENQYCQNPSDPFVWK,0,-0.12500000,0.58333333
|
| 51 |
+
QMRSHFQYGCGWMITPMRFPKNMA,1,0.12500000,0.37500000
|
| 52 |
+
EECYALAGDYDFKVRIQHAQCTWQ,0,-0.08333333,0.45833333
|
| 53 |
+
ITKQMWVNDVWDTEVRHHSFMTLF,0,-0.04166667,0.54166667
|
| 54 |
+
EHAKYVKNSNCYNKENNVDLFYCN,0,0.00000000,0.58333333
|
| 55 |
+
SYYVPKTKQRLTTSRCGLKGVVKA,1,0.25000000,0.50000000
|
| 56 |
+
TDAKGWHYNWHWPLHQFCHLPVHA,0,0.00000000,0.41666667
|
| 57 |
+
YIVRMSDSFHSGHDHDKKNLIMYH,0,0.00000000,0.58333333
|
| 58 |
+
CTHVQNPNSIETNCHEHYDFGTSM,0,-0.12500000,0.62500000
|
| 59 |
+
RWWYQNHLMCYNSKDACYHPWINM,1,0.04166667,0.41666667
|
| 60 |
+
RKSNGGKSLIQNAGITCVKWCYGV,1,0.16666667,0.41666667
|
| 61 |
+
WWNQITTPHWCKWWASHWMQYSRW,1,0.08333333,0.45833333
|
| 62 |
+
IDTICWQVCILVQESDAKDVFEGN,0,-0.16666667,0.45833333
|
| 63 |
+
NFHEWVLWELTGQWRVWSRQHSKE,0,0.00000000,0.58333333
|
| 64 |
+
VTYCMMAKFAGMDYEQKICVHVAC,0,0.00000000,0.29166667
|
| 65 |
+
KVKTTLKVEREAPPYTRMSLSAWT,1,0.12500000,0.54166667
|
lecture_02/esm2_flow_guidance.py
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Standalone ESM-2 flow matching guidance example for CIS 6270.
|
| 2 |
+
Input CSV: sequence,c,r1,r2. Sequences have one fixed length; c is 0 or 1.
|
| 3 |
+
Both objectives are oriented so larger is better. The bundled CSV is synthetic.
|
| 4 |
+
Run: python esm2_flow_guidance.py --data esm2_example.csv --epochs 200
|
| 5 |
+
Install: pip install torch transformers==4.57.6
|
| 6 |
+
Outputs: guided residue latents, model weights, and decoded amino-acid sequences.
|
| 7 |
+
"""
|
| 8 |
+
import argparse
|
| 9 |
+
import csv
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
|
| 12 |
+
import torch
|
| 13 |
+
from torch import nn
|
| 14 |
+
import torch.nn.functional as F
|
| 15 |
+
from torch.utils.data import DataLoader, TensorDataset
|
| 16 |
+
from transformers import AutoTokenizer, EsmForMaskedLM
|
| 17 |
+
|
| 18 |
+
ROOT = Path(__file__).resolve().parent
|
| 19 |
+
DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 20 |
+
ESM_NAME = "facebook/esm2_t6_8M_UR50D"
|
| 21 |
+
AMINO_ACIDS = "ACDEFGHIKLMNPQRSTVWY"
|
| 22 |
+
POLAR_RESIDUES = "DEHKNQRST" # Operational polar/charged set; not a solubility assay.
|
| 23 |
+
BATCH_SIZE, HIDDEN, LEARNING_RATE = 16, 128, 1e-3
|
| 24 |
+
CONDITION_DROP = 0.2 # Drop c during training so the same model learns the null condition.
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def composition_proxies(sequences):
|
| 28 |
+
# Transparent teaching rewards; these are not measured activity or solubility.
|
| 29 |
+
return torch.tensor([
|
| 30 |
+
[(sum(a in "KR" for a in s) - sum(a in "DE" for a in s)) / len(s),
|
| 31 |
+
sum(a in POLAR_RESIDUES for a in s) / len(s)]
|
| 32 |
+
for s in sequences
|
| 33 |
+
], dtype=torch.float32)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
# 1. Load annotated sequences and encode frozen ESM-2 residue vectors.
|
| 37 |
+
@torch.no_grad()
|
| 38 |
+
def load_data(path):
|
| 39 |
+
with Path(path).open(newline="") as handle:
|
| 40 |
+
rows = list(csv.DictReader(handle))
|
| 41 |
+
sequences = [row["sequence"].strip().upper() for row in rows]
|
| 42 |
+
if len(rows) < 4 or any(not s or set(s) - set(AMINO_ACIDS) for s in sequences):
|
| 43 |
+
raise ValueError("Supply at least four sequences using the 20 standard amino acids")
|
| 44 |
+
lengths = {len(s) for s in sequences}
|
| 45 |
+
if len(lengths) != 1 or max(lengths) > 128:
|
| 46 |
+
raise ValueError("This compact example requires one fixed sequence length, at most 128")
|
| 47 |
+
c = torch.tensor([int(row["c"]) for row in rows], dtype=torch.long)
|
| 48 |
+
# With sequence,c only, compute example rewards directly from residue counts.
|
| 49 |
+
# Optional r1/r2 columns override them with supplied labels, such as measurements.
|
| 50 |
+
if {"r1", "r2"}.issubset(rows[0]):
|
| 51 |
+
r = torch.tensor([[float(row["r1"]), float(row["r2"])] for row in rows])
|
| 52 |
+
else:
|
| 53 |
+
r = composition_proxies(sequences)
|
| 54 |
+
if set(c.tolist()) != {0, 1} or not torch.isfinite(r).all():
|
| 55 |
+
raise ValueError("Include both c=0 and c=1, with finite r1 and r2")
|
| 56 |
+
|
| 57 |
+
tokenizer = AutoTokenizer.from_pretrained(ESM_NAME, cache_dir=ROOT / ".esm2_cache")
|
| 58 |
+
esm = EsmForMaskedLM.from_pretrained(
|
| 59 |
+
ESM_NAME, cache_dir=ROOT / ".esm2_cache", use_safetensors=True
|
| 60 |
+
).to(DEVICE).eval().requires_grad_(False)
|
| 61 |
+
encoded = []
|
| 62 |
+
for start in range(0, len(sequences), BATCH_SIZE):
|
| 63 |
+
tokens = tokenizer(sequences[start:start + BATCH_SIZE], return_tensors="pt")
|
| 64 |
+
tokens = {key: value.to(DEVICE) for key, value in tokens.items()}
|
| 65 |
+
hidden = esm.esm(**tokens).last_hidden_state
|
| 66 |
+
encoded.append(hidden[:, 1:-1].cpu()) # Remove BOS/EOS; retain all L residue positions.
|
| 67 |
+
z = torch.cat(encoded) # [N, L, D], with D=320 for this checkpoint.
|
| 68 |
+
z_mean = z.mean((0, 1), keepdim=True)
|
| 69 |
+
z_std = z.std((0, 1), correction=0, keepdim=True).clamp_min(1e-4)
|
| 70 |
+
r_mean, r_std = r.mean(0), r.std(0, correction=0).clamp_min(1e-6)
|
| 71 |
+
dataset = TensorDataset((z - z_mean) / z_std, c, (r - r_mean) / r_std)
|
| 72 |
+
stats = {"z_mean": z_mean, "z_std": z_std, "r_mean": r_mean, "r_std": r_std}
|
| 73 |
+
return dataset, esm, tokenizer, stats
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
# 2. Small model classes: flattening lets each output depend on the entire sequence.
|
| 77 |
+
class FlowModel(nn.Module):
|
| 78 |
+
def __init__(self, length, dim):
|
| 79 |
+
super().__init__()
|
| 80 |
+
self.length, self.dim = length, dim
|
| 81 |
+
self.time = nn.Sequential(nn.Linear(1, 32), nn.SiLU(), nn.Linear(32, 32))
|
| 82 |
+
self.skip = nn.Linear(32, 1) # Preserve full-dimensional state/noise through a time gate.
|
| 83 |
+
self.condition = nn.Embedding(3, 16) # Indices 0,1 are classes; 2 is the null class.
|
| 84 |
+
self.net = nn.Sequential(
|
| 85 |
+
nn.Linear(length * dim + 48, HIDDEN), nn.SiLU(),
|
| 86 |
+
nn.Linear(HIDDEN, HIDDEN), nn.SiLU(),
|
| 87 |
+
nn.Linear(HIDDEN, length * dim),
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
def forward(self, z, t, c):
|
| 91 |
+
time = self.time(t[:, None])
|
| 92 |
+
inputs = torch.cat([z.flatten(1), time, self.condition(c)], dim=1)
|
| 93 |
+
return self.skip(time)[:, :, None] * z + self.net(inputs).reshape_as(z)
|
| 94 |
+
# A time-dependent linear part plus a learned nonlinear velocity correction.
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
class RewardModel(nn.Module):
|
| 98 |
+
def __init__(self, length, dim):
|
| 99 |
+
super().__init__()
|
| 100 |
+
self.net = nn.Sequential(
|
| 101 |
+
nn.Linear(length * dim + 1, HIDDEN), nn.SiLU(),
|
| 102 |
+
nn.Linear(HIDDEN, HIDDEN), nn.SiLU(), nn.Linear(HIDDEN, 2),
|
| 103 |
+
)
|
| 104 |
+
|
| 105 |
+
def forward(self, z, t):
|
| 106 |
+
return self.net(torch.cat([z.flatten(1), t[:, None]], dim=1))
|
| 107 |
+
# Two predicted standardized endpoint objectives, given the current latent and time.
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
# 3. Flow matching: Z_t=(1-t)Z_0+tZ_1; target velocity is Z_1-Z_0.
|
| 111 |
+
def train(dataset, epochs):
|
| 112 |
+
_, length, dim = dataset.tensors[0].shape
|
| 113 |
+
loader = DataLoader(dataset, batch_size=BATCH_SIZE, shuffle=True)
|
| 114 |
+
model = FlowModel(length, dim).to(DEVICE)
|
| 115 |
+
reward_model = RewardModel(length, dim).to(DEVICE)
|
| 116 |
+
optimizer = torch.optim.Adam(list(model.parameters()) + list(reward_model.parameters()), lr=LEARNING_RATE)
|
| 117 |
+
for epoch in range(epochs):
|
| 118 |
+
total = 0.0
|
| 119 |
+
for z1, c, r_tilde in loader:
|
| 120 |
+
z1, c, r_tilde = z1.to(DEVICE), c.to(DEVICE), r_tilde.to(DEVICE)
|
| 121 |
+
z0 = torch.randn_like(z1) # Flow convention: Z_0=noise, Z_1=clean latent.
|
| 122 |
+
t = torch.rand(len(z1), device=DEVICE)
|
| 123 |
+
zt = (1 - t[:, None, None]) * z0 + t[:, None, None] * z1
|
| 124 |
+
dropped = c.masked_fill(torch.rand(len(c), device=DEVICE) < CONDITION_DROP, 2)
|
| 125 |
+
loss_flow = F.mse_loss(model(zt, t, dropped), z1 - z0)
|
| 126 |
+
loss_reward = F.mse_loss(reward_model(zt, t), r_tilde)
|
| 127 |
+
loss = loss_flow + loss_reward # Independent parameter sets; one optimizer suffices.
|
| 128 |
+
optimizer.zero_grad(set_to_none=True)
|
| 129 |
+
loss.backward()
|
| 130 |
+
optimizer.step()
|
| 131 |
+
total += loss.item()
|
| 132 |
+
if (epoch + 1) % 50 == 0 or epoch + 1 == epochs:
|
| 133 |
+
print(f"epoch {epoch+1}: combined training loss {total / len(loader):.4f}")
|
| 134 |
+
for network in (model, reward_model):
|
| 135 |
+
network.eval().requires_grad_(False) # Freeze weights; still allow gradients of input z.
|
| 136 |
+
for parameter in network.parameters():
|
| 137 |
+
parameter.grad = None
|
| 138 |
+
return model, reward_model
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
# 4. R_lambda = sum_m lambda_m * r_tilde_m; differentiate the current latent only.
|
| 142 |
+
def reward_gradient(reward_model, z, t, lambdas):
|
| 143 |
+
with torch.enable_grad(): # Re-enable input gradients inside sampling.
|
| 144 |
+
state = z.detach().requires_grad_(True)
|
| 145 |
+
R_lambda = (reward_model(state, t) * lambdas).sum(dim=1)
|
| 146 |
+
grad = torch.autograd.grad(R_lambda.sum(), state)[0]
|
| 147 |
+
return grad.detach() # Do not retain graphs across sampling steps.
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def normalize_weights(lambdas):
|
| 151 |
+
values = torch.as_tensor(lambdas, dtype=torch.float32, device=DEVICE)
|
| 152 |
+
if values.shape != (2,) or not torch.isfinite(values).all() or (values < 0).any() or values.sum() <= 0:
|
| 153 |
+
raise ValueError("Use two finite, nonnegative weights with a positive sum")
|
| 154 |
+
return values / values.sum() # lambda controls tradeoffs, eta controls strength.
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
# 5. Sampling: CFG first; optional reward gradient then changes the velocity.
|
| 158 |
+
@torch.no_grad()
|
| 159 |
+
def sample(model, reward_model, n=8, c=1, w=0.0, eta=0.0, lambdas=(1.0, 0.0), steps=200):
|
| 160 |
+
if c not in (0, 1) or steps < 1 or w < 0 or eta < 0:
|
| 161 |
+
raise ValueError("Use c=0/1, positive steps, and nonnegative w and eta")
|
| 162 |
+
lambdas = normalize_weights(lambdas)
|
| 163 |
+
torch.manual_seed(123) # Compare methods from the same initial noise.
|
| 164 |
+
z = torch.randn(n, model.length, model.dim, device=DEVICE)
|
| 165 |
+
null = torch.full((n,), 2, dtype=torch.long, device=DEVICE)
|
| 166 |
+
condition = torch.full((n,), c, dtype=torch.long, device=DEVICE)
|
| 167 |
+
dt = 1.0 / steps
|
| 168 |
+
for step in range(steps):
|
| 169 |
+
t = torch.full((n,), step * dt, device=DEVICE)
|
| 170 |
+
v_uncond = model(z, t, null)
|
| 171 |
+
v = v_uncond
|
| 172 |
+
if w != 0:
|
| 173 |
+
v = v_uncond + w * (model(z, t, condition) - v_uncond)
|
| 174 |
+
if eta != 0:
|
| 175 |
+
grad_R = reward_gradient(reward_model, z, t, lambdas)
|
| 176 |
+
kappa = eta * 4 * t[:, None, None] * (1 - t[:, None, None])
|
| 177 |
+
v = v + kappa * grad_R # Chosen steering rule; not exact conditional transport.
|
| 178 |
+
z = z + dt * v # Integrate forward from t=0 to t=1.
|
| 179 |
+
return z
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
# 6. One decoder, used only AFTER the flow or diffusion trajectory is complete.
|
| 183 |
+
@torch.no_grad()
|
| 184 |
+
def decode(z, esm, tokenizer, stats, min_polar=12):
|
| 185 |
+
latent = z * stats["z_std"].to(z.device) + stats["z_mean"].to(z.device)
|
| 186 |
+
logits = esm.lm_head(latent) # Frozen head produces one vocabulary distribution per residue.
|
| 187 |
+
aa_ids = torch.tensor(tokenizer.convert_tokens_to_ids(list(AMINO_ACIDS)), device=z.device)
|
| 188 |
+
logits = logits.index_select(-1, aa_ids) # Restrict the vocabulary to the 20 amino acids.
|
| 189 |
+
if not 0 <= min_polar <= z.shape[1]:
|
| 190 |
+
raise ValueError("min_polar must lie between zero and the sequence length")
|
| 191 |
+
polar = torch.tensor([a in POLAR_RESIDUES for a in AMINO_ACIDS], device=z.device)
|
| 192 |
+
polar_ids = polar.nonzero().flatten()
|
| 193 |
+
best_scores, choices = logits.max(dim=-1) # Start from unrestricted amino-acid argmax.
|
| 194 |
+
polar_scores, local = logits[..., polar_ids].max(dim=-1)
|
| 195 |
+
polar_choices = polar_ids[local] # Best polar/charged amino acid at each position.
|
| 196 |
+
for i in range(len(z)):
|
| 197 |
+
already_polar = polar[choices[i]]
|
| 198 |
+
missing = max(0, min_polar - int(already_polar.sum()))
|
| 199 |
+
if missing:
|
| 200 |
+
cost = (best_scores[i] - polar_scores[i]).masked_fill(already_polar, float("inf"))
|
| 201 |
+
positions = cost.topk(missing, largest=False).indices
|
| 202 |
+
choices[i, positions] = polar_choices[i, positions]
|
| 203 |
+
return ["".join(AMINO_ACIDS[i] for i in row) for row in choices.cpu().tolist()]
|
| 204 |
+
# Exact maximum-logit decode subject to at least min_polar selected residues.
|
| 205 |
+
# This enforces composition, not measured solubility; no gradient through argmax.
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
# 7. Run CFG, single-objective steering, and scalarized multi-objective steering in order.
|
| 209 |
+
def main():
|
| 210 |
+
parser = argparse.ArgumentParser(description=__doc__)
|
| 211 |
+
parser.add_argument("--data", type=Path, default=ROOT / "esm2_example.csv")
|
| 212 |
+
parser.add_argument("--epochs", type=int, default=200)
|
| 213 |
+
parser.add_argument("--samples", type=int, default=8)
|
| 214 |
+
parser.add_argument("--min-polar", type=int, default=12)
|
| 215 |
+
parser.add_argument("--output", type=Path, default=ROOT / "esm2_flow_outputs")
|
| 216 |
+
args = parser.parse_args()
|
| 217 |
+
if args.epochs < 1 or args.samples < 1:
|
| 218 |
+
parser.error("epochs and samples must be positive")
|
| 219 |
+
torch.manual_seed(7)
|
| 220 |
+
if DEVICE.type == "cpu":
|
| 221 |
+
torch.set_num_threads(2)
|
| 222 |
+
dataset, esm, tokenizer, stats = load_data(args.data)
|
| 223 |
+
if not 0 <= args.min_polar <= dataset.tensors[0].shape[1]:
|
| 224 |
+
parser.error("min-polar must lie between zero and the sequence length")
|
| 225 |
+
model, reward_model = train(dataset, args.epochs)
|
| 226 |
+
outputs = {
|
| 227 |
+
"cfg": sample(model, reward_model, args.samples, c=1, w=2.0),
|
| 228 |
+
"single": sample(model, reward_model, args.samples, eta=1.0, lambdas=(1.0, 0.0)),
|
| 229 |
+
"multi": sample(model, reward_model, args.samples, eta=1.0, lambdas=(0.7, 0.3)),
|
| 230 |
+
}
|
| 231 |
+
args.output.mkdir(parents=True, exist_ok=True)
|
| 232 |
+
for name, latent in outputs.items():
|
| 233 |
+
if not torch.isfinite(latent).all():
|
| 234 |
+
raise RuntimeError(f"Nonfinite {name} output; reduce guidance or check training")
|
| 235 |
+
sequences = decode(latent, esm, tokenizer, stats, args.min_polar)
|
| 236 |
+
assert all(sum(a in POLAR_RESIDUES for a in s) >= args.min_polar for s in sequences)
|
| 237 |
+
fasta = "".join(f">{name}_{i+1}\n{seq}\n" for i, seq in enumerate(sequences))
|
| 238 |
+
(args.output / f"{name}.fasta").write_text(fasta)
|
| 239 |
+
count = sum(a in POLAR_RESIDUES for a in sequences[0])
|
| 240 |
+
print(f"{name}: {sequences[0]} polar/charged residues={count}")
|
| 241 |
+
print(" decoded mean composition proxies:", composition_proxies(sequences).mean(0).tolist())
|
| 242 |
+
torch.save({"standardized_latents": {k: v.cpu() for k, v in outputs.items()},
|
| 243 |
+
"model": model.state_dict(), "reward_model": reward_model.state_dict(),
|
| 244 |
+
"stats": stats, "esm_name": ESM_NAME,
|
| 245 |
+
"length": model.length, "dim": model.dim, "min_polar": args.min_polar,
|
| 246 |
+
"polar_residues": POLAR_RESIDUES}, args.output / "results.pt")
|
| 247 |
+
print(f"Saved latent tensors and FASTA sequences to {args.output}")
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
if __name__ == "__main__":
|
| 251 |
+
main()
|
requirements.txt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Tested teaching environment: Python 3.11.
|
| 2 |
+
torch==2.9.1
|
| 3 |
+
transformers==4.57.6
|
tests/test_examples.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Offline checks for the Lecture 2 examples; no model download is required."""
|
| 2 |
+
import csv
|
| 3 |
+
import itertools
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
import sys
|
| 6 |
+
from types import SimpleNamespace
|
| 7 |
+
import unittest
|
| 8 |
+
|
| 9 |
+
import torch
|
| 10 |
+
|
| 11 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 12 |
+
sys.path.insert(0, str(ROOT / "lecture_02"))
|
| 13 |
+
import esm2_flow_guidance as flow
|
| 14 |
+
import esm2_diffusion_guidance as diffusion
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class GuidanceTests(unittest.TestCase):
|
| 18 |
+
@classmethod
|
| 19 |
+
def setUpClass(cls):
|
| 20 |
+
torch.set_num_threads(2)
|
| 21 |
+
|
| 22 |
+
def test_example_annotations(self):
|
| 23 |
+
with (ROOT / "lecture_02" / "esm2_example.csv").open(newline="") as handle:
|
| 24 |
+
rows = list(csv.DictReader(handle))
|
| 25 |
+
self.assertEqual(len(rows), 64)
|
| 26 |
+
self.assertEqual({len(row["sequence"]) for row in rows}, {24})
|
| 27 |
+
labels = torch.tensor([[float(row["r1"]), float(row["r2"])] for row in rows])
|
| 28 |
+
for module in (flow, diffusion):
|
| 29 |
+
computed = module.composition_proxies([row["sequence"] for row in rows])
|
| 30 |
+
torch.testing.assert_close(computed, labels, atol=1e-7, rtol=0)
|
| 31 |
+
for row in rows:
|
| 32 |
+
self.assertEqual(int(row["c"]), int(float(row["r1"]) > 0))
|
| 33 |
+
|
| 34 |
+
def test_scalarization_weights(self):
|
| 35 |
+
for module in (flow, diffusion):
|
| 36 |
+
with self.subTest(module=module.__name__):
|
| 37 |
+
weights = module.normalize_weights((7, 3)).cpu()
|
| 38 |
+
torch.testing.assert_close(weights, torch.tensor([0.7, 0.3]))
|
| 39 |
+
for invalid in ((0, 0), (-1, 2), (1,), (float("nan"), 1)):
|
| 40 |
+
with self.assertRaises(ValueError):
|
| 41 |
+
module.normalize_weights(invalid)
|
| 42 |
+
|
| 43 |
+
def test_reward_gradient_without_weight_updates(self):
|
| 44 |
+
for module in (flow, diffusion):
|
| 45 |
+
with self.subTest(module=module.__name__):
|
| 46 |
+
torch.manual_seed(3)
|
| 47 |
+
reward = module.RewardModel(4, 5).to(module.DEVICE)
|
| 48 |
+
reward.eval().requires_grad_(False)
|
| 49 |
+
state = torch.randn(2, 4, 5, device=module.DEVICE)
|
| 50 |
+
time = torch.tensor([0.3, 0.7], device=module.DEVICE)
|
| 51 |
+
weights = module.normalize_weights((0.7, 0.3))
|
| 52 |
+
gradient = module.reward_gradient(reward, state, time, weights)
|
| 53 |
+
delta = torch.zeros_like(state)
|
| 54 |
+
delta[0, 1, 2] = 1e-3
|
| 55 |
+
difference = reward(state + delta, time) - reward(state - delta, time)
|
| 56 |
+
estimate = (difference * weights).sum() / (2e-3)
|
| 57 |
+
torch.testing.assert_close(gradient[0, 1, 2], estimate, atol=3e-5, rtol=3e-3)
|
| 58 |
+
self.assertFalse(gradient.requires_grad)
|
| 59 |
+
self.assertTrue(torch.isfinite(gradient).all())
|
| 60 |
+
self.assertTrue(all(p.grad is None for p in reward.parameters()))
|
| 61 |
+
|
| 62 |
+
def test_ddpm_schedule(self):
|
| 63 |
+
self.assertEqual(diffusion.alpha_bars[0].item(), 1.0)
|
| 64 |
+
self.assertLess(diffusion.alpha_bars[-1].item(), 1e-4)
|
| 65 |
+
self.assertEqual(diffusion.posterior_variances[1].item(), 0.0)
|
| 66 |
+
self.assertTrue((diffusion.posterior_variances >= 0).all())
|
| 67 |
+
self.assertTrue(torch.isfinite(diffusion.posterior_variances).all())
|
| 68 |
+
|
| 69 |
+
def test_constrained_decoder_against_exhaustive_search(self):
|
| 70 |
+
torch.manual_seed(11)
|
| 71 |
+
scores = torch.randn(3, 4, 20)
|
| 72 |
+
tokenizer = SimpleNamespace(convert_tokens_to_ids=lambda tokens: list(range(20)))
|
| 73 |
+
esm = SimpleNamespace(lm_head=torch.nn.Identity())
|
| 74 |
+
stats = {"z_mean": torch.zeros(1, 1, 20), "z_std": torch.ones(1, 1, 20)}
|
| 75 |
+
for module in (flow, diffusion):
|
| 76 |
+
polar = [i for i, a in enumerate(module.AMINO_ACIDS) if a in module.POLAR_RESIDUES]
|
| 77 |
+
nonpolar = [i for i in range(20) if i not in polar]
|
| 78 |
+
for minimum in range(5):
|
| 79 |
+
sequences = module.decode(scores, esm, tokenizer, stats, min_polar=minimum)
|
| 80 |
+
for batch, sequence in enumerate(sequences):
|
| 81 |
+
count = sum(a in module.POLAR_RESIDUES for a in sequence)
|
| 82 |
+
self.assertGreaterEqual(count, minimum)
|
| 83 |
+
actual = sum(float(scores[batch, j, module.AMINO_ACIDS.index(a)])
|
| 84 |
+
for j, a in enumerate(sequence))
|
| 85 |
+
optimal = max(
|
| 86 |
+
sum(float(scores[batch, j, polar if flag else nonpolar].max())
|
| 87 |
+
for j, flag in enumerate(assignment))
|
| 88 |
+
for assignment in itertools.product((0, 1), repeat=4)
|
| 89 |
+
if sum(assignment) >= minimum
|
| 90 |
+
)
|
| 91 |
+
self.assertAlmostEqual(actual, optimal, places=5)
|
| 92 |
+
with self.assertRaises(ValueError):
|
| 93 |
+
module.decode(scores, esm, tokenizer, stats, min_polar=5)
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
if __name__ == "__main__":
|
| 97 |
+
unittest.main()
|