pranamanam commited on
Commit
014bc67
·
verified ·
1 Parent(s): 99538c9

Add Lecture 2 flow matching and diffusion guidance examples

Browse files

Standalone PyTorch examples, synthetic sequence data, setup instructions, mathematical notes, and offline tests.

.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()