Delete lecture_4
Browse files- lecture_4/README.md +0 -108
- lecture_4/block.py +0 -180
- lecture_4/ctmc.py +0 -278
- lecture_4/d3pm.py +0 -234
- lecture_4/guidance.py +0 -308
- lecture_4/mdlm.py +0 -190
- lecture_4/planner.py +0 -238
- lecture_4/search.py +0 -250
- lecture_4/sedd.py +0 -176
- lecture_4/simplex.py +0 -246
- lecture_4/uniform.py +0 -240
lecture_4/README.md
DELETED
|
@@ -1,108 +0,0 @@
|
|
| 1 |
-
# Lecture 4: Discrete Diffusion
|
| 2 |
-
|
| 3 |
-
Chapter 4 keeps every state inside a finite vocabulary and replaces the continuous displacement of
|
| 4 |
-
Chapter 3 by a decision about which tokens to erase, reveal or replace. The chapter builds masked
|
| 5 |
-
diffusion by hand from a survival schedule, then rebuilds it as one member of a family: a rate
|
| 6 |
-
matrix says what a token can do in the next instant, the master equation follows the marginals, the
|
| 7 |
-
matrix exponential gives the corruption kernel at any time, and time reversal turns the forward
|
| 8 |
-
rates into the rates that generate. Seven published models come out of that family by changing the
|
| 9 |
-
rate matrix, the time variable, what the network predicts, or the state space itself, and a
|
| 10 |
-
further group keeps the rates and changes the order in which positions are filled, the weighting
|
| 11 |
-
of the loss, or the distribution to sample from. Every file here runs on the shared synthetic DNA
|
| 12 |
-
of `cis6270.data`, with `t = 0` clean and `t = 1` all masks, survival probability `alpha_t`, and
|
| 13 |
-
rates `R_t(x, y)` in the row convention `p_dot = p R`.
|
| 14 |
-
|
| 15 |
-
## The files
|
| 16 |
-
|
| 17 |
-
| File | Command | What it trains and generates |
|
| 18 |
-
| --- | --- | --- |
|
| 19 |
-
| `ctmc.py` | `python lecture_4/ctmc.py` | Nothing is trained. Works every number of the CTMC sections: the two rules, one row for DNA, waiting times, the master equation, the matrix exponential, the `R_pi` family, eigenvalues, detailed balance and time reversal. |
|
| 20 |
-
| `mdlm.py` | `python lecture_4/mdlm.py` | Trains the denoiser on the weighted cross-entropy at masked positions, generates DNA from all masks. The chapter's thirty-line model. |
|
| 21 |
-
| `uniform.py` | `python lecture_4/uniform.py` | Trains against the rate loss under uniform replacement, generates by tau-leaping from uniformly random tokens. |
|
| 22 |
-
| `d3pm.py` | `python lecture_4/d3pm.py` | Trains against the discrete-time variational bound plus the auxiliary cross-entropy, generates by `T` ancestral steps. |
|
| 23 |
-
| `sedd.py` | `python lecture_4/sedd.py` | Trains a network to output the concrete score through denoising score entropy, generates from the predicted ratios. |
|
| 24 |
-
| `block.py` | `python lecture_4/block.py` | Trains masked diffusion inside blocks with a clean prefix, generates block by block. `--block` moves the dial between MDLM and autoregression. |
|
| 25 |
-
| `simplex.py` | `python lecture_4/simplex.py` | Trains a denoiser that reads a Dirichlet belief at every position, generates by thinning the belief, drawing an innovation and mixing the two. |
|
| 26 |
-
| `guidance.py` | `python lecture_4/guidance.py` | Trains a label-dropout denoiser and a classifier on noisy sequences, then samples unguided, with exact classifier guidance, with its gradient approximation, and classifier free. |
|
| 27 |
-
| `planner.py` | `python lecture_4/planner.py` | Trains with the planner weights inside the bound, samples the same weights in a uniform order and in the planner's order with a remasking corrector. |
|
| 28 |
-
| `search.py` | `python lecture_4/search.py` | Trains masked diffusion, then searches the tree of partial sequences under the h-transform and keeps a Pareto archive over two objectives. |
|
| 29 |
-
|
| 30 |
-
Two constructions of the chapter have no file here, and the chapter develops both in full. Per-token
|
| 31 |
-
schedules, MD4 and GenMD4, give every clean token its own rate `beta_a(t)` and change nothing but
|
| 32 |
-
the weight, which becomes `w_a(t) = -alpha_dot_t(a)/(1 - alpha_t(a))` inside the sum over tokens.
|
| 33 |
-
A2D2
|
| 34 |
-
lets the length change by adding an insertion jump beside the unmasking jump, which enlarges the
|
| 35 |
-
state space and keeps the master equation, the reversal and the rate loss exactly as they are.
|
| 36 |
-
|
| 37 |
-
Every file takes the shared flags of `cis6270.runner.common_parser`, so `--steps`, `--samples`,
|
| 38 |
-
`--sample-steps`, `--seed` and `--quiet` mean the same thing everywhere. `ctmc.py` trains nothing,
|
| 39 |
-
so its `--steps` is the number of Euler steps in the matrix-exponential comparison and its
|
| 40 |
-
`--samples` the number of simulated paths. `d3pm.py` takes
|
| 41 |
-
`T` from `--timesteps`, since the same number fixes the corruption and the ancestral sampler.
|
| 42 |
-
`search.py` reads `--sample-steps` as the length of one rollout, which is short because every leaf
|
| 43 |
-
of the tree is scored by its own rollouts.
|
| 44 |
-
|
| 45 |
-
## What a default run produces
|
| 46 |
-
|
| 47 |
-
All numbers below come from `--seed 0` on a CPU. The worked numbers are exact and repeat on any
|
| 48 |
-
machine; the trained numbers move a little with the hardware's floating point, and the sample
|
| 49 |
-
statistics move with the seed. The training data have GC fraction 0.577 and motif fraction 0.777,
|
| 50 |
-
and a sampler drawing its bases uniformly would land near 0.05 on the motif, since ACGT fits in a
|
| 51 |
-
length-16 sequence at thirteen places and each one has probability `1/256`.
|
| 52 |
-
|
| 53 |
-
| File | Worked numbers it prints | Trained numbers |
|
| 54 |
-
| --- | --- | --- |
|
| 55 |
-
| `ctmc.py` | exit rate 4 out of A, `P(A, A) = 0.6` at `h = 0.1`, Euler step `(0.16, 0.22, 0.28, 0.34)`, `exp(tau R_pi)` at `0.625` and `0.125`, Euler products `0.480`, `0.570`, `0.616`, reverse rates `2` and `0.5` out of C | none |
|
| 56 |
-
| `mdlm.py` | `r = 0.5`, reverse law `(0.3, 0.2, 0, 0, 0.5)`, `w(0.5) = 2`, loss term `2.0996`, rate error `1.0217` | bound 1.25 nats per token, GC 0.56, motif 0.69 |
|
| 57 |
-
| `uniform.py` | kernel `0.625` and `0.125`, clean factor 5, posterior `(0.375, 0.625)`, conditional rates 5 and 0.2, posterior average **2**, model rates `2.76` and `1.16`, rate error `0.754` | rate loss 19.1 nats per sequence, GC 0.56, motif 0.50 |
|
| 58 |
-
| `d3pm.py` | `alpha_bar_2 = 0.56`, `Q_bar_2` at `0.67` and `0.11`, the step-1 posterior `(0.5795, 0.3523, 0.0341, 0.0341)`, Gaussian row `0.4088` and `0.0912`, `exp(0.3 R)` first row `(0.6747, 0.1964, 0.1122, 0.0167)` | bound 18.2 nats per sequence, GC 0.57, motif 0.72 |
|
| 59 |
-
| `sedd.py` | target ratio 2, score entropy `0.0107`, `0.3863`, `0.1891` | score entropy 18.8 nats per sequence, mean predicted ratio 1.26 toward a conditional target of 5, GC 0.55, motif 0.33 |
|
| 60 |
-
| `block.py` | worked bound `2.7567` nats on ACGTAC, `integral of w(t)(1 - alpha_t) = 1` | bound 1.21 nats per token at `L' = 4` and 1.29 at `L' = 1`, GC 0.57, motif 0.67 |
|
| 61 |
-
| `simplex.py` | `c_t = 8` and `c_s = 20`, parameters `(5, 1, 1, 1)` and `(17, 1, 1, 1)`, standard deviation `0.161` on A, increment `(12, 0, 0, 0)`, `W ~ Beta(8, 12)` with mean `0.4`, simulated standard deviation `0.0779` against the exact `0.0779` | cross-entropy 0.42 nats per token, GC 0.57, motif 0.34 |
|
| 62 |
-
| `guidance.py` | `h = (2, 1, 0.5, 0.5)`, guided rates `(1, 1.5, 0.5)`, at `gamma = 2` rates `(0.5, 4.5, 0.5)` with exit rate `5.5` and share `0.8182` on G | GC 0.58 unguided, 0.68 with the exact classifier, 0.63 with the gradient approximation, 0.74 classifier free |
|
| 63 |
-
| `planner.py` | reveals positions 3 and 6, remasks position 5, both corrector fluxes 1, PAPL weights `(0.4, 2.0, 0.6, 1.0)`, inner sums `2.9` and `2.12` | motif 0.61 in a uniform order against 0.86 in the planner's order, GC 0.58 and 0.51 |
|
| 64 |
-
| `search.py` | `h(z) = 0.35`, `h(z') = 0.75`, factor `2.143`, a rate of `1.2` becomes `2.571`, the archive keeps P1 and P2 | 144 rollouts scored, an archive of 3 sequences, best GC 0.88 and best motif match 1.0 |
|
| 65 |
-
|
| 66 |
-
A default run of `ctmc.py` takes about two seconds, and every other file takes between thirty and
|
| 67 |
-
forty seconds of processor time on one core. `--steps 20` runs each file in a few seconds.
|
| 68 |
-
|
| 69 |
-
`mdlm.py --steps 3000` lowers the bound from 1.25 to 1.22 nats per token in about three times the
|
| 70 |
-
time, and leaves the motif fraction inside the spread it already has across seeds, which at seeds
|
| 71 |
-
0, 1 and 2 is 0.69, 0.45 and 0.72. The one-position bound of `block.py --block 1` is the exact
|
| 72 |
-
autoregressive log-likelihood with no slack, and at this budget it still measures above the
|
| 73 |
-
`L' = 4` bound, because the estimator draws one block of sixteen and multiplies by sixteen, which
|
| 74 |
-
is sixteen times noisier at the same number of draws, and because a denoiser restricted to a left
|
| 75 |
-
prefix has a harder prediction problem than one that reads both sides.
|
| 76 |
-
`sedd.py` sits below the other samplers on the motif because its sampler stops at the time floor,
|
| 77 |
-
as the list below says. `simplex.py --sample-steps 256` raises its motif fraction from 0.34 to
|
| 78 |
-
0.38, so the reverse grid is not what holds that number down.
|
| 79 |
-
|
| 80 |
-
## What is simplified
|
| 81 |
-
|
| 82 |
-
- The time weight `w(t) = 1/t` is unbounded at `t = 0`, so every file draws its times above a small
|
| 83 |
-
floor. The chapter's integrand is correct as written; the floor keeps one draw from dominating a
|
| 84 |
-
batch.
|
| 85 |
-
- The samplers that use tau-leaping rescale a step whose total jump probability would exceed one.
|
| 86 |
-
`uniform.py` then finishes with the exact posterior step down to `t = 0` that the chapter derives
|
| 87 |
-
for the `pi` family. `sedd.py` predicts ratios, with no posterior over clean tokens to step
|
| 88 |
-
through, so it stops at the time floor, where `alpha_t = 0.92` leaves about eight percent of the
|
| 89 |
-
positions redrawn from `pi`.
|
| 90 |
-
- `uniform.py` and `sedd.py` use the constant schedule `beta(t) = 4`, which is the schedule of the
|
| 91 |
-
chapter's worked example and reaches `alpha_t = 1/2` at `t = (log 2)/4`. Its survival probability
|
| 92 |
-
at `t = 1` is `0.018` and not zero, and that residue is the `KL(q_1 || p_1)` term of the bound.
|
| 93 |
-
- `d3pm.py` samples one step index per sequence and multiplies by `T`, which estimates the sum over
|
| 94 |
-
steps without evaluating all of it.
|
| 95 |
-
- `block.py` samples one block per sequence and multiplies by the number of blocks, for the same
|
| 96 |
-
reason. Training a block of length `L'` hides the suffix behind the mask in place of a
|
| 97 |
-
block-causal attention mask.
|
| 98 |
-
- `simplex.py` holds the concentration constant at `gamma = 1`, realizes the sum over clean tokens
|
| 99 |
-
by one categorical draw as the chapter does, and reads the final token as the corner the belief
|
| 100 |
-
has reached. Its loss is the chapter's cross-entropy, which bounds no likelihood, so its nats
|
| 101 |
-
per token do not compare with the bounds above.
|
| 102 |
-
- `guidance.py` uses a mean-pooling classifier on one-hot inputs, because the gradient
|
| 103 |
-
approximation needs a derivative with respect to the sequence and the label of this data is a
|
| 104 |
-
thresholded mean over positions.
|
| 105 |
-
- `planner.py` reveals the number of positions the schedule calls for at each step, which is the
|
| 106 |
-
deterministic version of the chapter's independent reveal draws, and it remasks a fixed count.
|
| 107 |
-
- `search.py` scores `--sample-steps` rollout steps per completion and expands a fixed number of
|
| 108 |
-
children, so the tree stays small enough to build in a few seconds.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
lecture_4/block.py
DELETED
|
@@ -1,180 +0,0 @@
|
|
| 1 |
-
"""Block diffusion on DNA, with the block size as the dial between diffusion and autoregression.
|
| 2 |
-
|
| 3 |
-
The chain rule over blocks is exact, and each factor is a distribution over one block of tokens
|
| 4 |
-
conditioned on a clean prefix, which is the kind of distribution masked diffusion models. At one
|
| 5 |
-
block the bound is the MDLM loss, at blocks of a single position it is the autoregressive negative
|
| 6 |
-
log-likelihood with no slack, and every size in between interpolates.
|
| 7 |
-
|
| 8 |
-
Chapter 4, "Blocks between diffusion and autoregression" and "The block size as a dial between two
|
| 9 |
-
models". Time runs from t = 0 clean to t = 1 all masks, the survival probability is
|
| 10 |
-
alpha_t = 1 - t, and the rates R_t(x, y) act on probability rows, p_dot = p R. Trained on the
|
| 11 |
-
synthetic DNA of cis6270.data. Prints the worked bound of 2.7567 nats, the one-position identity,
|
| 12 |
-
and the sample statistics.
|
| 13 |
-
|
| 14 |
-
python lecture_4/block.py # train and generate with blocks of four
|
| 15 |
-
python lecture_4/block.py --block 1 # the autoregressive end of the dial
|
| 16 |
-
python lecture_4/block.py --steps 20 # a few seconds, for a first look
|
| 17 |
-
"""
|
| 18 |
-
|
| 19 |
-
# %% 01. Imports and configuration
|
| 20 |
-
import math # The worked bound is a sum of logarithms.
|
| 21 |
-
|
| 22 |
-
import torch # Tensors, gradients and the categorical draws.
|
| 23 |
-
import torch.nn.functional as F # Cross-entropy at every position.
|
| 24 |
-
|
| 25 |
-
from cis6270.data import DNA_SIZE, decode_dna, dna_splits # ACGT, K = 4 and the shared sequences.
|
| 26 |
-
from cis6270.metrics import gc_fraction, motif_fraction, token_entropy, unique_fraction
|
| 27 |
-
from cis6270.nets import SequenceDenoiser, count_parameters # The denoiser d_theta(. | z, t).
|
| 28 |
-
from cis6270.runner import common_parser, pick_device, print_report, save_run, set_seed, train
|
| 29 |
-
|
| 30 |
-
MASK = DNA_SIZE # A, C, G, T are 0 to 3, and token 4 is the mask m.
|
| 31 |
-
VOCAB = DNA_SIZE + 1 # The masked alphabet V_m has K + 1 = 5 symbols.
|
| 32 |
-
TIME_FLOOR = 1e-3 # The reveal rate w(t) = 1/t is unbounded at t = 0.
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
# %% 02. One block, with a clean prefix and a masked suffix
|
| 36 |
-
#
|
| 37 |
-
# The rate matrix acts only on the current block. Everything before it is clean and conditions the
|
| 38 |
-
# prediction, and everything after it is hidden, so the denoiser never reads the future.
|
| 39 |
-
def block_input(x0: torch.Tensor, block: torch.Tensor, size: int, t: torch.Tensor) -> torch.Tensor:
|
| 40 |
-
"""The network input for one block: clean prefix, corrupted block, masked suffix. [B, L]."""
|
| 41 |
-
position = torch.arange(x0.shape[1], device=x0.device)[None, :] # [1, L] the column index.
|
| 42 |
-
start = (block * size)[:, None] # [B, 1] where the current block begins.
|
| 43 |
-
inside = (position >= start) & (position < start + size) # [B, L] the current block.
|
| 44 |
-
keep = torch.rand(x0.shape, device=x0.device) < (1.0 - t) # [B, L] the survival draw.
|
| 45 |
-
corrupted = torch.where(keep, x0, MASK) # [B, L] masking applied inside the block.
|
| 46 |
-
return torch.where(position < start, x0, torch.where(inside, corrupted, MASK)) # [B, L].
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
def block_loss(model: torch.nn.Module, x0: torch.Tensor, size: int) -> torch.Tensor:
|
| 50 |
-
"""One Monte Carlo sample of the block bound, with one block and one time per sequence."""
|
| 51 |
-
blocks = math.ceil(x0.shape[1] / size) # B, the number of blocks the sequence splits into.
|
| 52 |
-
block = torch.randint(0, blocks, (x0.shape[0],), device=x0.device) # [B] which factor to sample.
|
| 53 |
-
t = torch.rand(x0.shape[0], device=x0.device).clamp(min=TIME_FLOOR) # [B] one noise level each.
|
| 54 |
-
z = block_input(x0, block, size, t[:, None]) # [B, L] the denoiser's input.
|
| 55 |
-
position = torch.arange(x0.shape[1], device=x0.device)[None, :] # [1, L].
|
| 56 |
-
start = (block * size)[:, None] # [B, 1].
|
| 57 |
-
inside = (position >= start) & (position < start + size) # [B, L] the current block.
|
| 58 |
-
logits = model(z, t) # [B, L, 5] the clean-token prediction at every position.
|
| 59 |
-
logits[..., MASK] = -1e9 # The zero-mask rule: no posterior mass belongs on m.
|
| 60 |
-
ce = F.cross_entropy(logits.transpose(1, 2), x0, reduction="none") # [B, L].
|
| 61 |
-
scored = ce * ((z == MASK) & inside) # [B, L] only the masked positions of the current block.
|
| 62 |
-
# Drawing one block uniformly estimates a sum over blocks, so the term is scaled by that count.
|
| 63 |
-
return (blocks * (1.0 / t)[:, None] * scored).sum(-1).mean() # Nats per sequence.
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
# %% 03. The block size as a dial
|
| 67 |
-
#
|
| 68 |
-
# At a block of one position the time integral collapses. The position is masked with probability
|
| 69 |
-
# 1 - alpha_t and weighted by w(t) = -alpha_dot/(1 - alpha_t), so the integrand is -alpha_dot times
|
| 70 |
-
# the cross-entropy, and the remaining integral is alpha_0 - alpha_1 = 1.
|
| 71 |
-
def one_position_integral(grid: int = 2048) -> float:
|
| 72 |
-
"""The integral of w(t)(1 - alpha_t) over [0, 1], which the collapse makes exactly one."""
|
| 73 |
-
t = (torch.arange(grid).double() + 0.5) / grid # [grid] midpoints of a uniform partition.
|
| 74 |
-
return float(((1.0 / t) * t).mean()) # w(t)(1 - alpha_t) = 1 at every t on the linear schedule.
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
def one_position_estimate(probability: float, draws: int, generator) -> float:
|
| 78 |
-
"""The same integral by Monte Carlo, which is how the training loss evaluates it."""
|
| 79 |
-
t = torch.rand(draws, generator=generator).clamp(min=TIME_FLOOR) # [N] uniform times.
|
| 80 |
-
masked = torch.rand(draws, generator=generator) < t # [N] the single position is masked or not.
|
| 81 |
-
return float(((1.0 / t) * masked * -math.log(probability)).mean()) # Should land on -log d.
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
# %% 04. Generating block by block
|
| 85 |
-
@torch.no_grad() # Generation needs no gradient tracking.
|
| 86 |
-
def sample(model: torch.nn.Module, batch: int, length: int, size: int,
|
| 87 |
-
steps: int = 32) -> torch.Tensor:
|
| 88 |
-
"""Fill one block at a time by masked diffusion, with the finished blocks as a clean prefix."""
|
| 89 |
-
device = next(model.parameters()).device
|
| 90 |
-
z = torch.full((batch, length), MASK, device=device) # [B, L] every position starts masked.
|
| 91 |
-
ts = torch.linspace(1.0, TIME_FLOOR, steps + 1, device=device) # The reverse grid per block.
|
| 92 |
-
position = torch.arange(length, device=device)[None, :] # [1, L] the column index.
|
| 93 |
-
for start in range(0, length, size): # Blocks are completed in order, left to right.
|
| 94 |
-
inside = (position >= start) & (position < start + size) # [1, L] the current block.
|
| 95 |
-
for t, s in zip(ts[:-1], ts[1:]):
|
| 96 |
-
logits = model(z, t.expand(batch)) # [B, L, 5] the prefix conditions every prediction.
|
| 97 |
-
logits[..., MASK] = -1e9 # The zero-mask rule, at sampling time.
|
| 98 |
-
x0 = torch.distributions.Categorical(logits=logits).sample() # [B, L] candidate tokens.
|
| 99 |
-
unmask = torch.rand(z.shape, device=device) < (t - s) / t # [B, L] the reveal draw.
|
| 100 |
-
z = torch.where((z == MASK) & unmask & inside, x0, z) # Reveal inside this block only.
|
| 101 |
-
logits = model(z, ts[-1].expand(batch)) # The last step of the block runs down to s = 0.
|
| 102 |
-
logits[..., MASK] = -1e9
|
| 103 |
-
final = torch.distributions.Categorical(logits=logits).sample() # [B, L].
|
| 104 |
-
z = torch.where((z == MASK) & inside, final, z) # The block is clean before the next starts.
|
| 105 |
-
return z # [B, L] clean DNA.
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
# %% 05. The report
|
| 109 |
-
def main() -> None:
|
| 110 |
-
parser = common_parser(__doc__.split("\n")[0])
|
| 111 |
-
parser.add_argument("--block", type=int, default=4, help="L', the block length")
|
| 112 |
-
# Generation runs the whole reverse grid once per block, so --sample-steps stays at 16 while
|
| 113 |
-
# training rises to 1000 steps and the report measures 64 sequences, inside about a minute.
|
| 114 |
-
parser.set_defaults(steps=1000, sample_steps=16, samples=64)
|
| 115 |
-
args = parser.parse_args()
|
| 116 |
-
set_seed(args.seed)
|
| 117 |
-
device = pick_device(args.device)
|
| 118 |
-
talk = (lambda *a: None) if args.quiet else print # --quiet keeps only the report block.
|
| 119 |
-
|
| 120 |
-
# Section 02, one draw of the bound on ACGTAC with blocks of two at t = 0.5.
|
| 121 |
-
weight = 1.0 / 0.5 # w(0.5) = 2 on the linear schedule.
|
| 122 |
-
contributions = [ # Block 1 masked twice, block 2 once, block 3 once.
|
| 123 |
-
weight * (-math.log(0.5) - math.log(0.8)),
|
| 124 |
-
weight * -math.log(0.7),
|
| 125 |
-
weight * -math.log(0.9),
|
| 126 |
-
]
|
| 127 |
-
talk(f"worked block bound {[round(v, 4) for v in contributions]} total "
|
| 128 |
-
f"{sum(contributions):.4f} nats on ACGTAC")
|
| 129 |
-
|
| 130 |
-
# Section 03, the one-position identity that makes blocks of length one autoregressive.
|
| 131 |
-
generator = torch.Generator().manual_seed(args.seed) # A fixed stream for the estimate.
|
| 132 |
-
exact = one_position_integral() # The integral of w(t)(1 - alpha_t), which is one.
|
| 133 |
-
estimate = one_position_estimate(0.7, 20000, generator) # The same number by Monte Carlo.
|
| 134 |
-
talk(f"one-position dial integral {exact:.4f} Monte Carlo bound {estimate:.4f} against "
|
| 135 |
-
f"-log 0.7 = {-math.log(0.7):.4f}")
|
| 136 |
-
|
| 137 |
-
# Train the denoiser on the shared synthetic DNA at the chosen block size.
|
| 138 |
-
splits = dna_splits(num_sequences=512, length=args.length, seed=args.seed)
|
| 139 |
-
train_tokens = splits["train_tokens"].to(device) # [N, L] clean sequences over ACGT.
|
| 140 |
-
valid_tokens = splits["valid_tokens"].to(device) # [M, L] held out for the bound below.
|
| 141 |
-
model = SequenceDenoiser(VOCAB, args.length, width=args.width).to(device)
|
| 142 |
-
blocks = math.ceil(args.length / args.block) # How many factors the chain rule has.
|
| 143 |
-
talk(f"device={device} parameters={count_parameters(model):,} "
|
| 144 |
-
f"blocks={blocks} of length {args.block}")
|
| 145 |
-
|
| 146 |
-
def step_loss(step: int) -> torch.Tensor:
|
| 147 |
-
rows = torch.randint(0, len(train_tokens), (args.batch_size,), device=device)
|
| 148 |
-
return block_loss(model, train_tokens[rows], args.block) # One clean batch into one scalar.
|
| 149 |
-
|
| 150 |
-
losses = train(model, step_loss, steps=args.steps, lr=args.lr, quiet=args.quiet)
|
| 151 |
-
with torch.no_grad():
|
| 152 |
-
bound = sum(float(block_loss(model, valid_tokens, args.block)) for _ in range(8)) / 8.0
|
| 153 |
-
|
| 154 |
-
torch.manual_seed(args.seed + 1) # Fix the reveal draws, so the samples repeat.
|
| 155 |
-
samples = sample(model, args.samples, args.length, args.block, args.sample_steps)
|
| 156 |
-
report = {
|
| 157 |
-
"worked bound on ACGTAC": sum(contributions),
|
| 158 |
-
"first block of the worked bound": contributions[0],
|
| 159 |
-
"integral of w(t)(1 - alpha_t)": exact,
|
| 160 |
-
"one-position Monte Carlo bound": estimate,
|
| 161 |
-
"blocks": blocks,
|
| 162 |
-
"block length": args.block,
|
| 163 |
-
"training bound, last 50": sum(losses[-50:]) / len(losses[-50:]),
|
| 164 |
-
"validation bound, nats/seq": bound,
|
| 165 |
-
"bound, nats per token": bound / args.length,
|
| 166 |
-
"GC fraction": gc_fraction(samples),
|
| 167 |
-
"GC fraction in the data": gc_fraction(train_tokens),
|
| 168 |
-
"motif fraction": motif_fraction(samples),
|
| 169 |
-
"motif fraction in the data": motif_fraction(train_tokens),
|
| 170 |
-
"distinct samples": unique_fraction(samples),
|
| 171 |
-
"letter entropy, nats": token_entropy(samples, DNA_SIZE),
|
| 172 |
-
}
|
| 173 |
-
print_report(f"block: diffusion in blocks of {args.block}", report, decode_dna(samples))
|
| 174 |
-
save_run(args.out, model=model, config=vars(args), report=report,
|
| 175 |
-
losses=losses, samples=decode_dna(samples))
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
# %% 06. Run the script
|
| 179 |
-
if __name__ == "__main__": # Execute when launched directly.
|
| 180 |
-
main() # Work the block bound, train at one block size, and generate block by block.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
lecture_4/ctmc.py
DELETED
|
@@ -1,278 +0,0 @@
|
|
| 1 |
-
"""Continuous-time Markov chains on one DNA base, the mathematics with no learning in it.
|
| 2 |
-
|
| 3 |
-
A rate matrix says what a base can do in the next instant, and the master equation, the matrix
|
| 4 |
-
exponential, the stationary distribution and the time reversal all follow from it. Nothing here is
|
| 5 |
-
trained: every number this file prints is a number the chapter works by hand.
|
| 6 |
-
|
| 7 |
-
Chapter 4, "The two rules a rate matrix obeys" through "Running the jumps backward". Time runs from
|
| 8 |
-
t = 0 clean to t = 1 all masks, the survival probability is alpha_t, and the rates R_t(x, y) act on
|
| 9 |
-
probability rows, p_dot = p R. Prints every worked quantity of those sections.
|
| 10 |
-
|
| 11 |
-
python lecture_4/ctmc.py # the worked numbers, 10 Euler steps
|
| 12 |
-
python lecture_4/ctmc.py --steps 20 # a finer Euler grid in Section 09
|
| 13 |
-
"""
|
| 14 |
-
|
| 15 |
-
# %% 01. Imports and configuration
|
| 16 |
-
import math # Logarithms and exponentials of the worked numbers.
|
| 17 |
-
|
| 18 |
-
import torch # Matrices, eigenvalues and the matrix exponential.
|
| 19 |
-
|
| 20 |
-
from cis6270.data import DNA, DNA_SIZE # The alphabet ACGT and K = 4.
|
| 21 |
-
from cis6270.runner import common_parser, print_report, save_run, set_seed # Flags and the report.
|
| 22 |
-
|
| 23 |
-
MASK_STATES = DNA_SIZE + 1 # A, C, G, T and m, the five states of the masked alphabet.
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
# %% 02. The two rules a rate matrix obeys
|
| 27 |
-
def rate_matrix(off_diagonal: torch.Tensor) -> torch.Tensor:
|
| 28 |
-
"""Fill the diagonal of a nonnegative array so that R_t(x, x) = -sum_{y != x} R_t(x, y)."""
|
| 29 |
-
R = off_diagonal.clone().double() # [K, K] the chosen jump rates.
|
| 30 |
-
R.fill_diagonal_(0.0) # The diagonal adds no information of its own.
|
| 31 |
-
return R - torch.diag(R.sum(dim=1)) # [K, K] every row now sums to zero.
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
def obeys_the_two_rules(R: torch.Tensor, tol: float = 1e-9) -> bool:
|
| 35 |
-
"""Nonnegative off the diagonal, and zero row sums, which is the definition."""
|
| 36 |
-
off = R - torch.diag(torch.diag(R)) # [K, K] the diagonal removed.
|
| 37 |
-
return bool((off >= -tol).all()) and bool(R.sum(dim=1).abs().max() < tol)
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
def exit_rates(R: torch.Tensor) -> torch.Tensor:
|
| 41 |
-
"""lambda_t(x) = -R_t(x, x), the total rate of leaving each state."""
|
| 42 |
-
return -torch.diag(R) # [K] one exit rate per state.
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
# %% 03. One row of a rate matrix for DNA
|
| 46 |
-
#
|
| 47 |
-
# A jumps to C at rate 2 and to G and to T at rate 1 each. The chapter gives that one row; the
|
| 48 |
-
# other three repeat the pattern with the next base in ACGT order taking the rate 2.
|
| 49 |
-
DNA_RATES = rate_matrix(torch.tensor([
|
| 50 |
-
[0.0, 2.0, 1.0, 1.0], # A: rate 2 toward C, rate 1 toward G and T.
|
| 51 |
-
[1.0, 0.0, 2.0, 1.0], # C: rate 2 toward G.
|
| 52 |
-
[1.0, 1.0, 0.0, 2.0], # G: rate 2 toward T.
|
| 53 |
-
[2.0, 1.0, 1.0, 0.0], # T: rate 2 toward A.
|
| 54 |
-
]))
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
def short_interval_row(R: torch.Tensor, state: int, h: float) -> torch.Tensor:
|
| 58 |
-
"""One row of P_{t,t+h} = I + h R_t + o(h), the first-order transition probabilities."""
|
| 59 |
-
identity_row = torch.zeros(R.shape[0], dtype=R.dtype) # [K] the row of I at this state.
|
| 60 |
-
identity_row[state] = 1.0 # Staying put is the only certainty at h = 0.
|
| 61 |
-
return identity_row + h * R[state] # [K] probabilities, valid while the diagonal stays positive.
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
def largest_valid_step(R: torch.Tensor) -> float:
|
| 65 |
-
"""The bound h * sum_{y != x} R_t(x, y) <= 1, read at the state with the largest exit rate."""
|
| 66 |
-
return float(1.0 / exit_rates(R).max()) # One bad state is enough to give a negative entry.
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
# %% 04. Waiting times and destinations
|
| 70 |
-
#
|
| 71 |
-
# A state x holds for an exponential time of rate lambda(x), because S(s + h) = S(s)(1 - lambda h)
|
| 72 |
-
# gives dS/ds = -lambda S. The destination is then the row of rates normalized to sum to one.
|
| 73 |
-
def sample_path(R: torch.Tensor, start: int, horizon: float, generator) -> list[tuple[float, int]]:
|
| 74 |
-
"""Gillespie simulation: an exponential wait, then a destination drawn from the normalized row."""
|
| 75 |
-
time_now, state, path = 0.0, start, [(0.0, start)] # The path begins at the starting base.
|
| 76 |
-
while True:
|
| 77 |
-
rate = float(exit_rates(R)[state]) # lambda(x) for the base we are sitting on.
|
| 78 |
-
u = float(torch.rand((), generator=generator)) # One uniform variate for the wait.
|
| 79 |
-
time_now += -math.log(u) / rate # Inverting S(s) = exp(-lambda s) at u.
|
| 80 |
-
if time_now > horizon:
|
| 81 |
-
return path # The last jump falls outside the interval we are watching.
|
| 82 |
-
row = R[state].clamp(min=0.0) # [K] the nonnegative rates out of this base.
|
| 83 |
-
state = int(torch.multinomial(row / row.sum(), 1, generator=generator)) # The destination.
|
| 84 |
-
path.append((time_now, state)) # Record when the jump happened and where it went.
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
# %% 05. The master equation
|
| 88 |
-
def master_equation(p: torch.Tensor, R: torch.Tensor) -> torch.Tensor:
|
| 89 |
-
"""p_dot = p R, the Kolmogorov forward equation written on probability rows."""
|
| 90 |
-
return p @ R # [K] the y-th entry collects the y-th column of R, every rate pointing into y.
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
def arrivals_and_departures(p: torch.Tensor, R: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
|
| 94 |
-
"""The same derivative split into the flux j_t(x -> y) arriving at y and the flux leaving y."""
|
| 95 |
-
flux = p[:, None] * R # [K, K] j_t(x -> y) = p_t(x) R_t(x, y), off the diagonal.
|
| 96 |
-
arriving = flux.sum(dim=0) - torch.diag(flux) # [K] sum over x != y of p_t(x) R_t(x, y).
|
| 97 |
-
leaving = -torch.diag(flux) # [K] p_t(y) times the exit rate of y.
|
| 98 |
-
return arriving, leaving # Their difference is the master equation.
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
# %% 06. A worked evolution of four DNA probabilities
|
| 102 |
-
#
|
| 103 |
-
# Every base jumps to every other at rate 1, so each diagonal entry is -3 and each coordinate obeys
|
| 104 |
-
# p_dot(y) = 1 - 4 p(y), whose solution is 1/4 + (p_0(y) - 1/4) exp(-4t).
|
| 105 |
-
UNIT_RATES = rate_matrix(torch.ones(DNA_SIZE, DNA_SIZE)) # [4, 4] all pairwise rates equal to 1.
|
| 106 |
-
START = torch.tensor([0.1, 0.2, 0.3, 0.4], dtype=torch.float64) # p_t over (A, C, G, T).
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
def exact_evolution(p0: torch.Tensor, t: float) -> torch.Tensor:
|
| 110 |
-
"""The closed-form solution of p_dot(y) = 1 - 4 p(y) for the all-ones rate matrix."""
|
| 111 |
-
return 0.25 + (p0 - 0.25) * math.exp(-4.0 * t) # [K] each coordinate decays toward 1/4.
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
# %% 07. The matrix exponential and its composition over two intervals
|
| 115 |
-
def transition_matrix(R: torch.Tensor, elapsed: float) -> torch.Tensor:
|
| 116 |
-
"""P_{0,t} = exp(t R), the solution of the master equation for a fixed rate matrix."""
|
| 117 |
-
return torch.matrix_exp(elapsed * R) # [K, K] rows sum to one and no entry is negative.
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
def euler_product(R: torch.Tensor, elapsed: float, n: int) -> torch.Tensor:
|
| 121 |
-
"""(I + (t/n) R)^n, the product of n short-interval steps, which approaches exp(t R)."""
|
| 122 |
-
step = torch.eye(R.shape[0], dtype=R.dtype) + (elapsed / n) * R # [K, K] one first-order step.
|
| 123 |
-
return torch.linalg.matrix_power(step, n) # [K, K] the steps compose by matrix multiplication.
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
# %% 08. The R_pi family and its closed-form kernel
|
| 127 |
-
#
|
| 128 |
-
# At rate one the current token is discarded and a fresh one drawn from pi, so every row of the rate
|
| 129 |
-
# matrix is pi with the current state subtracted. Because M = 1 pi^T is idempotent, the exponential
|
| 130 |
-
# collapses to a mixture of staying put and drawing afresh.
|
| 131 |
-
def r_pi(pi: torch.Tensor) -> torch.Tensor:
|
| 132 |
-
"""R_pi = 1 pi^T - I, the one-free-distribution family."""
|
| 133 |
-
return torch.ones(len(pi), 1, dtype=pi.dtype) @ pi[None, :] - torch.eye(len(pi), dtype=pi.dtype)
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
def pi_kernel(pi: torch.Tensor, alpha: float) -> torch.Tensor:
|
| 137 |
-
"""Q(alpha) = alpha I + (1 - alpha) 1 pi^T, the kernel at survival probability alpha."""
|
| 138 |
-
M = torch.ones(len(pi), 1, dtype=pi.dtype) @ pi[None, :] # [K, K] every row equals pi.
|
| 139 |
-
return alpha * torch.eye(len(pi), dtype=pi.dtype) + (1.0 - alpha) * M # [K, K] a convex mixture.
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
# %% 09. Eigenvalues, stationarity and detailed balance
|
| 143 |
-
def detailed_balance_gap(R: torch.Tensor, pi: torch.Tensor) -> float:
|
| 144 |
-
"""The largest mismatch in pi(x) R(x, y) = pi(y) R(y, x), zero when the pair is reversible."""
|
| 145 |
-
flux = pi[:, None] * R # [K, K] the stationary flux along each edge.
|
| 146 |
-
return float((flux - flux.T).abs().max()) # Zero exactly when every edge balances.
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
CYCLE = rate_matrix(torch.tensor([ # A -> C -> G -> A at rate 1, with no jumps the other way.
|
| 150 |
-
[0.0, 1.0, 0.0],
|
| 151 |
-
[0.0, 0.0, 1.0],
|
| 152 |
-
[1.0, 0.0, 0.0],
|
| 153 |
-
]))
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
# %% 10. Time reversal
|
| 157 |
-
def reverse_rates(R: torch.Tensor, p: torch.Tensor) -> torch.Tensor:
|
| 158 |
-
"""R_hat_t(y, x) = R_t(x, y) p_t(x) / p_t(y), the rates of the process watched backward."""
|
| 159 |
-
reversed_rates = (R * p[:, None] / p[None, :]).T # [K, K] transpose moves (x, y) to (y, x).
|
| 160 |
-
reversed_rates.fill_diagonal_(0.0) # The diagonal is fixed by the row-sum rule, not by the ratio.
|
| 161 |
-
return reversed_rates - torch.diag(reversed_rates.sum(dim=1)) # [K, K] a rate matrix again.
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
# %% 11. The report
|
| 165 |
-
def main() -> None:
|
| 166 |
-
parser = common_parser(__doc__.split("\n")[0])
|
| 167 |
-
# Nothing trains here, so --steps is the Euler grid of Section 09 and --samples the number of
|
| 168 |
-
# Gillespie paths of Section 04. The flags that describe a network go unread.
|
| 169 |
-
parser.set_defaults(steps=10, samples=512)
|
| 170 |
-
args = parser.parse_args()
|
| 171 |
-
set_seed(args.seed) # The same seed gives the same sampled path and the same histogram.
|
| 172 |
-
generator = torch.Generator().manual_seed(args.seed) # Local stream for the simulations.
|
| 173 |
-
talk = (lambda *a: None) if args.quiet else print # --quiet keeps only the report block.
|
| 174 |
-
|
| 175 |
-
# Sections 02 and 03, the row for A and its first-order transition probabilities.
|
| 176 |
-
row_probabilities = short_interval_row(DNA_RATES, 0, 0.1) # [4] at h = 0.1.
|
| 177 |
-
exact_stay = math.exp(-4.0 * 0.1) # The probability of never leaving A over that interval.
|
| 178 |
-
talk(f"rate row for A {[round(float(v), 2) for v in DNA_RATES[0]]} rules hold "
|
| 179 |
-
f"{obeys_the_two_rules(DNA_RATES)} exit rate {float(exit_rates(DNA_RATES)[0]):.0f}")
|
| 180 |
-
talk(f"P(A, .) at h = 0.1 {[round(float(v), 2) for v in row_probabilities]} "
|
| 181 |
-
f"exact stay {exact_stay:.4f} valid while h <= {largest_valid_step(DNA_RATES):.2f}, "
|
| 182 |
-
f"since at h = 0.3 the staying entry is "
|
| 183 |
-
f"{float(short_interval_row(DNA_RATES, 0, 0.3)[0]):.1f}")
|
| 184 |
-
|
| 185 |
-
# Section 04, the waiting time and the destination, first from the two uniforms of the chapter.
|
| 186 |
-
wait = -math.log(0.42) / 4.0 # u = 0.42 inverted through S(s) = exp(-4s).
|
| 187 |
-
destination = DNA[int(torch.cumsum(DNA_RATES[0].clamp(min=0) / 4.0, 0).lt(0.31).sum())]
|
| 188 |
-
path = sample_path(DNA_RATES, 0, 1.0, generator) # One sampled trajectory over the unit interval.
|
| 189 |
-
ends = torch.tensor([sample_path(DNA_RATES, 0, 1.0, generator)[-1][1]
|
| 190 |
-
for _ in range(args.samples)]) # [N] the base each path finishes on.
|
| 191 |
-
histogram = torch.bincount(ends, minlength=DNA_SIZE).double() / args.samples # [4] proportions.
|
| 192 |
-
exact_end = transition_matrix(DNA_RATES, 1.0)[0] # [4] the exact law of the base at t = 1.
|
| 193 |
-
talk(f"wait from u = 0.42 {wait:.4f} destination from v = 0.31 {destination} "
|
| 194 |
-
f"mean {1 / 4:.2f} median {math.log(2) / 4:.4f}")
|
| 195 |
-
talk(f"sampled path {' -> '.join(f'{DNA[s]}@{time:.3f}' for time, s in path)}")
|
| 196 |
-
talk(f"{args.samples} paths at t = 1 {[round(float(v), 3) for v in histogram]} against the "
|
| 197 |
-
f"exact {[round(float(v), 3) for v in exact_end]}")
|
| 198 |
-
|
| 199 |
-
# Sections 05 and 06, the master equation and the worked evolution.
|
| 200 |
-
derivative = master_equation(START, UNIT_RATES) # [4] p_dot = (0.6, 0.2, -0.2, -0.6).
|
| 201 |
-
arriving, leaving = arrivals_and_departures(START, UNIT_RATES) # The two halves of that number.
|
| 202 |
-
euler_step = START + 0.1 * derivative # One step of size h = 0.1, inside the bound h <= 1/3.
|
| 203 |
-
talk(f"p_dot at A {float(arriving[0]):.1f} in minus {float(leaving[0]):.1f} out "
|
| 204 |
-
f"= {float(derivative[0]):.1f} p_dot {[round(float(v), 1) for v in derivative]}")
|
| 205 |
-
talk(f"Euler step h = 0.1 {[round(float(v), 2) for v in euler_step]} exact "
|
| 206 |
-
f"{[round(float(v), 4) for v in exact_evolution(START, 0.1)]} "
|
| 207 |
-
f"total change {float(derivative.sum()):.0e}")
|
| 208 |
-
talk(f"exact at t = 1 {[round(float(v), 4) for v in exact_evolution(START, 1.0)]}")
|
| 209 |
-
|
| 210 |
-
# Section 07, the matrix exponential and the Chapman-Kolmogorov composition.
|
| 211 |
-
whole = transition_matrix(UNIT_RATES, 0.7) # P_{s,u} over an interval of length 0.7.
|
| 212 |
-
first, second = transition_matrix(UNIT_RATES, 0.3), transition_matrix(UNIT_RATES, 0.4)
|
| 213 |
-
halves = first @ second # P_{s,t} P_{t,u}, the two intervals composed.
|
| 214 |
-
talk(f"exp(tR) rows sum to {float(whole.sum(1).min()):.6f}, smallest entry "
|
| 215 |
-
f"{float(whole.min()):.6f}, composition gap {float((whole - halves).abs().max()):.2e}")
|
| 216 |
-
|
| 217 |
-
# Section 08, the R_pi family, its closed form and the multiplication of survival probabilities.
|
| 218 |
-
uniform = torch.full((DNA_SIZE,), 1.0 / DNA_SIZE, dtype=torch.float64) # pi uniform over ACGT.
|
| 219 |
-
tau = math.log(2.0) # The clock the linear schedule reaches at t = 0.5, where alpha = 1/2.
|
| 220 |
-
closed_form = pi_kernel(uniform, math.exp(-tau)) # alpha I + (1 - alpha) 1 pi^T.
|
| 221 |
-
series = transition_matrix(r_pi(uniform), tau) # The same matrix from the series.
|
| 222 |
-
composed = pi_kernel(uniform, 0.8) @ pi_kernel(uniform, 0.5 / 0.8) # Q(0.8) Q(0.625).
|
| 223 |
-
talk(f"closed form vs exp gap {float((closed_form - series).abs().max()):.2e} "
|
| 224 |
-
f"Q(0.8) Q(0.625) at (A, A) {float(composed[0, 0]):.5f} = Q(0.5) entry "
|
| 225 |
-
f"{float(pi_kernel(uniform, 0.5)[0, 0]):.5f}")
|
| 226 |
-
|
| 227 |
-
# Section 09, the matrix exponential worked for DNA, and the masking kernel beside it.
|
| 228 |
-
euler_entries = {n: float(euler_product(r_pi(uniform), tau, n)[0, 0]) for n in (1, 2, args.steps)}
|
| 229 |
-
mask_pi = torch.zeros(MASK_STATES, dtype=torch.float64) # pi = delta_m on the five states.
|
| 230 |
-
mask_pi[DNA_SIZE] = 1.0 # All of the target probability sits on the mask.
|
| 231 |
-
mask_kernel = pi_kernel(mask_pi, 0.5) # [5, 5] the masking kernel at alpha = 1/2.
|
| 232 |
-
talk(f"exp(tau R_pi) diagonal {float(closed_form[0, 0]):.3f}, off-diagonal "
|
| 233 |
-
f"{float(closed_form[0, 1]):.3f}, Euler " +
|
| 234 |
-
", ".join(f"n={n}: {v:.3f}" for n, v in euler_entries.items()) +
|
| 235 |
-
f"; masking A row {[round(float(v), 2) for v in mask_kernel[0]]}, m row "
|
| 236 |
-
f"{[round(float(v), 2) for v in mask_kernel[DNA_SIZE]]}")
|
| 237 |
-
|
| 238 |
-
# Section 10, the eigenvalues and the stationary distribution, then detailed balance.
|
| 239 |
-
eigenvalues = sorted(float(v.real) for v in torch.linalg.eigvals(r_pi(uniform))) # 0 and -1.
|
| 240 |
-
cycle_pi = torch.full((3,), 1.0 / 3.0, dtype=torch.float64) # Uniform over the three bases.
|
| 241 |
-
talk(f"eigenvalues of R_pi {[round(v, 4) for v in eigenvalues]}, pi R_pi "
|
| 242 |
-
f"{float((uniform @ r_pi(uniform)).abs().max()):.2e}, uniform flux "
|
| 243 |
-
f"{float((uniform[:, None] * r_pi(uniform))[0, 1]):.4f} each way with gap "
|
| 244 |
-
f"{detailed_balance_gap(r_pi(uniform), uniform):.2e}")
|
| 245 |
-
talk(f"cycle of three stationary gap {float((cycle_pi @ CYCLE).abs().max()):.2e}, "
|
| 246 |
-
f"edge gap {detailed_balance_gap(CYCLE, cycle_pi):.4f}")
|
| 247 |
-
|
| 248 |
-
# Section 11, the reverse rates and the marginals they retrace.
|
| 249 |
-
marginal = torch.tensor([0.5, 0.25, 0.125, 0.125], dtype=torch.float64) # p_t at alpha_t = 1/2.
|
| 250 |
-
unit_forward = rate_matrix(torch.ones(DNA_SIZE, DNA_SIZE)) # Forward rate 1 between any two bases.
|
| 251 |
-
backward = reverse_rates(unit_forward, marginal) # [4, 4] R_hat_t(y, x).
|
| 252 |
-
talk(f"reverse row for C {[round(float(v), 3) for v in backward[1]]} exit rate "
|
| 253 |
-
f"{float(exit_rates(backward)[1]):.1f} mean holding time "
|
| 254 |
-
f"{1 / float(exit_rates(backward)[1]):.4f}")
|
| 255 |
-
|
| 256 |
-
report = {
|
| 257 |
-
"exit rate out of A": float(exit_rates(DNA_RATES)[0]),
|
| 258 |
-
"P(A, A) at h = 0.1": float(row_probabilities[0]),
|
| 259 |
-
"largest valid step": largest_valid_step(DNA_RATES),
|
| 260 |
-
"wait from u = 0.42": wait,
|
| 261 |
-
"p_dot at A": float(derivative[0]),
|
| 262 |
-
"Euler step at A": float(euler_step[0]),
|
| 263 |
-
"composition gap": float((whole - halves).abs().max()),
|
| 264 |
-
"exp(tau R_pi) at (A, A)": float(closed_form[0, 0]),
|
| 265 |
-
"exp(tau R_pi) off diagonal": float(closed_form[0, 1]),
|
| 266 |
-
f"Euler product, n = {args.steps}": euler_entries[args.steps],
|
| 267 |
-
"detailed balance gap, cycle": detailed_balance_gap(CYCLE, cycle_pi),
|
| 268 |
-
"reverse rate C to A": float(backward[1, 0]),
|
| 269 |
-
"reverse rate C to G": float(backward[1, 2]),
|
| 270 |
-
"p R_hat plus p R": float((marginal @ backward + marginal @ unit_forward).abs().max()),
|
| 271 |
-
}
|
| 272 |
-
print_report("ctmc: rates, marginals and their reversal", report)
|
| 273 |
-
save_run(args.out, config=vars(args), report=report)
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
# %% 12. Run the script
|
| 277 |
-
if __name__ == "__main__": # Execute when launched directly.
|
| 278 |
-
main() # Work every number of the sections above.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
lecture_4/d3pm.py
DELETED
|
@@ -1,234 +0,0 @@
|
|
| 1 |
-
"""D3PM on DNA, discrete time with one transition matrix per step.
|
| 2 |
-
|
| 3 |
-
Corruption multiplies a one-hot token by a transition matrix once per step, and the product of
|
| 4 |
-
those matrices stays inside the pi family, so the cumulative kernel is closed form. Reversing one
|
| 5 |
-
finite step is Bayes' rule on three factors, and the continuous-time rate matrices of the rest of
|
| 6 |
-
the chapter are the limit of these products.
|
| 7 |
-
|
| 8 |
-
Chapter 4, "Discrete time, as in D3PM", "Reversing one finite step" and "Rate matrices chosen for
|
| 9 |
-
the data". Time runs from t = 0 clean to t = T noise, the cumulative survival probability is
|
| 10 |
-
alpha_bar_t, and rates act on probability rows, p_dot = p R. Trained on the synthetic DNA of
|
| 11 |
-
cis6270.data. Prints the worked cumulative kernel, the worked reverse step, and the sample
|
| 12 |
-
statistics.
|
| 13 |
-
|
| 14 |
-
python lecture_4/d3pm.py # train and generate
|
| 15 |
-
python lecture_4/d3pm.py --steps 20 # a few seconds, for a first look
|
| 16 |
-
"""
|
| 17 |
-
|
| 18 |
-
# %% 01. Imports and configuration
|
| 19 |
-
import math # The continuous-time limit is an exponential.
|
| 20 |
-
|
| 21 |
-
import torch # Tensors, gradients and the categorical draws.
|
| 22 |
-
|
| 23 |
-
from cis6270.data import DNA_SIZE, decode_dna, dna_splits # ACGT, K = 4 and the shared sequences.
|
| 24 |
-
from cis6270.metrics import gc_fraction, motif_fraction, token_entropy, unique_fraction
|
| 25 |
-
from cis6270.nets import SequenceDenoiser, count_parameters # The denoiser over clean tokens.
|
| 26 |
-
from cis6270.runner import common_parser, pick_device, print_report, save_run, set_seed, train
|
| 27 |
-
|
| 28 |
-
AUXILIARY = 0.01 # lambda_aux, the coefficient of the cross-entropy D3PM adds to the bound.
|
| 29 |
-
UNIFORM = torch.full((DNA_SIZE,), 1.0 / DNA_SIZE) # pi uniform over the four bases.
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
# %% 02. The transition matrices and their product
|
| 33 |
-
#
|
| 34 |
-
# Q_t = (1 - beta_t) I + beta_t 1 pi^T is the one-step version of the pi family, and two members
|
| 35 |
-
# multiply to a third whose survival probability is the product of the two.
|
| 36 |
-
def one_step(beta: float) -> torch.Tensor:
|
| 37 |
-
"""Q_t, with beta the probability of discarding the token and redrawing from pi. Returns [K, K]."""
|
| 38 |
-
return (1.0 - beta) * torch.eye(DNA_SIZE) + beta * UNIFORM.expand(DNA_SIZE, DNA_SIZE)
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
def cumulative(alpha_bar: float) -> torch.Tensor:
|
| 42 |
-
"""Q_bar_t = alpha_bar_t I + (1 - alpha_bar_t) 1 pi^T, the product of the first t matrices."""
|
| 43 |
-
return alpha_bar * torch.eye(DNA_SIZE) + (1.0 - alpha_bar) * UNIFORM.expand(DNA_SIZE, DNA_SIZE)
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
def schedule(timesteps: int) -> tuple[torch.Tensor, torch.Tensor]:
|
| 47 |
-
"""A linear cumulative survival alpha_bar_t = 1 - t/T, and the per-step beta_t it implies."""
|
| 48 |
-
alpha_bar = 1.0 - torch.arange(timesteps + 1).to(torch.get_default_dtype()) / timesteps # [T + 1] with alpha_bar_0 = 1.
|
| 49 |
-
beta = 1.0 - alpha_bar[1:] / alpha_bar[:-1].clamp(min=1e-12) # [T] the redraw probability.
|
| 50 |
-
return alpha_bar, beta # The two descriptions of the same corruption.
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
# %% 03. Reversing one finite step
|
| 54 |
-
#
|
| 55 |
-
# The numerator holds the two halves of the path through the candidate x_{t-1}, one from the clean
|
| 56 |
-
# token forward to the candidate and one from the candidate forward to the observation.
|
| 57 |
-
def exact_posterior(observed: torch.Tensor, clean_distribution: torch.Tensor,
|
| 58 |
-
step_matrix: torch.Tensor, previous: torch.Tensor) -> torch.Tensor:
|
| 59 |
-
"""q(x_{t-1} | x_t, x_0) when clean_distribution is a point mass, averaged over it otherwise.
|
| 60 |
-
|
| 61 |
-
observed: [B, L] token ids. clean_distribution: [B, L, K] over candidate clean tokens.
|
| 62 |
-
step_matrix and previous: [B, K, K], one Q_t and one Q_bar_{t-1} per sequence.
|
| 63 |
-
Returns [B, L, K], normalized over the candidate token at step t - 1.
|
| 64 |
-
"""
|
| 65 |
-
length = observed.shape[1]
|
| 66 |
-
columns = step_matrix[:, None].expand(-1, length, -1, -1) # [B, L, K, K] the step matrix.
|
| 67 |
-
to_observation = columns.gather( # [B, L, K] the column of Q_t at the observed token.
|
| 68 |
-
3, observed[:, :, None, None].expand(-1, -1, DNA_SIZE, 1)).squeeze(-1)
|
| 69 |
-
from_clean = torch.bmm(clean_distribution, previous) # [B, L, K] the row of Q_bar_{t-1}.
|
| 70 |
-
joint = to_observation * from_clean # [B, L, K] the two halves of the path, multiplied.
|
| 71 |
-
return joint / joint.sum(-1, keepdim=True).clamp(min=1e-20) # [B, L, K] a distribution.
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
# %% 04. Rate matrices chosen for the data
|
| 75 |
-
#
|
| 76 |
-
# Nothing in the five facts requires that every pair of tokens be treated alike. A discretized
|
| 77 |
-
# Gaussian keeps a pixel near its own intensity, and a nearest-neighbour graph does the same for
|
| 78 |
-
# tokens.
|
| 79 |
-
def gaussian_row(levels: int = 5, center: int = 2, sigma: float = 1.0) -> torch.Tensor:
|
| 80 |
-
"""The off-diagonal weights out of one intensity level, normalized to a total rate of one."""
|
| 81 |
-
offsets = torch.arange(levels).to(torch.get_default_dtype()) - center # [levels] the distance to each level.
|
| 82 |
-
weights = torch.exp(-offsets ** 2 / (2.0 * sigma ** 2)) # [levels] nearby levels weigh more.
|
| 83 |
-
weights[center] = 0.0 # The diagonal is fixed by the row-sum rule.
|
| 84 |
-
return weights / weights.sum() # [levels] a row of rates summing to one.
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
def graph_rates(adjacency: torch.Tensor) -> torch.Tensor:
|
| 88 |
-
"""R(i, j) = A_ij off the diagonal and R(i, i) = -sum_l A_il, from a token-similarity graph."""
|
| 89 |
-
R = adjacency.clone() # [K, K] the graph weights.
|
| 90 |
-
R.fill_diagonal_(0.0) # Only the off-diagonal entries are rates.
|
| 91 |
-
return R - torch.diag(R.sum(dim=1)) # [K, K] every row sums to zero.
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
# %% 05. The discrete-time bound
|
| 95 |
-
#
|
| 96 |
-
# The loss is the variational bound of Section "A variational bound for the whole path", one KL
|
| 97 |
-
# divergence per step and per position, plus the auxiliary cross-entropy on the clean tokens.
|
| 98 |
-
def d3pm_loss(model: torch.nn.Module, x0: torch.Tensor, matrices: tuple) -> torch.Tensor:
|
| 99 |
-
"""One sample of L_vb + lambda_aux CE, with one step index and one corruption per sequence."""
|
| 100 |
-
steps, cumulatives, alpha_bar = matrices # [T, K, K], [T + 1, K, K] and [T + 1].
|
| 101 |
-
timesteps = len(steps)
|
| 102 |
-
index = torch.randint(1, timesteps + 1, (x0.shape[0],), device=x0.device) # [B] the step t.
|
| 103 |
-
probabilities = cumulatives[index][ # [B, L, K] the row of Q_bar_t at each clean token.
|
| 104 |
-
torch.arange(len(x0), device=x0.device)[:, None], x0]
|
| 105 |
-
xt = torch.distributions.Categorical(probs=probabilities).sample() # [B, L] the noisy tokens.
|
| 106 |
-
|
| 107 |
-
time = index.to(torch.get_default_dtype()) / timesteps # [B] the step index as a time in [0, 1] for the network.
|
| 108 |
-
denoiser = model(xt, time).softmax(-1) # [B, L, K] the posterior over clean tokens.
|
| 109 |
-
one_hot = torch.nn.functional.one_hot(x0, DNA_SIZE).to(torch.get_default_dtype()) # [B, L, K] the true clean token.
|
| 110 |
-
step_matrix, previous = steps[index - 1], cumulatives[index - 1] # [B, K, K] each.
|
| 111 |
-
target = exact_posterior(xt, one_hot, step_matrix, previous) # [B, L, K] the exact reverse step.
|
| 112 |
-
predicted = exact_posterior(xt, denoiser, step_matrix, previous) # [B, L, K] the model's own.
|
| 113 |
-
divergence = (target * (target.clamp(min=1e-20) # KL(q || p_theta) at every position.
|
| 114 |
-
/ predicted.clamp(min=1e-20)).log()).sum((1, 2)) # [B].
|
| 115 |
-
cross_entropy = -(one_hot * denoiser.clamp(min=1e-20).log()).sum((1, 2)) # [B] the auxiliary term.
|
| 116 |
-
# Drawing one step index uniformly estimates a sum of T terms, so the divergence is scaled by T.
|
| 117 |
-
return (timesteps * divergence + AUXILIARY * cross_entropy).mean() # Nats per sequence.
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
# %% 06. Ancestral sampling
|
| 121 |
-
@torch.no_grad() # Generation needs no gradient tracking.
|
| 122 |
-
def sample(model: torch.nn.Module, batch: int, length: int, matrices: tuple) -> torch.Tensor:
|
| 123 |
-
"""Walk the chain from x_T drawn from pi down to x_0. Returns [B, L] over ACGT."""
|
| 124 |
-
steps, cumulatives, _ = matrices
|
| 125 |
-
device = next(model.parameters()).device
|
| 126 |
-
timesteps = len(steps)
|
| 127 |
-
x = torch.distributions.Categorical( # [B, L] the prior, which is pi at every position.
|
| 128 |
-
probs=UNIFORM.to(device).expand(batch, length, DNA_SIZE)).sample()
|
| 129 |
-
for t in range(timesteps, 0, -1): # One finite reverse step at a time.
|
| 130 |
-
time = torch.full((batch,), t / timesteps, device=device) # [B] the step as a time.
|
| 131 |
-
denoiser = model(x, time).softmax(-1) # [B, L, K] the prediction of the clean token.
|
| 132 |
-
posterior = exact_posterior( # [B, L, K] the reverse step, averaged over the denoiser.
|
| 133 |
-
x, denoiser, steps[t - 1].expand(batch, -1, -1), cumulatives[t - 1].expand(batch, -1, -1))
|
| 134 |
-
x = torch.distributions.Categorical(probs=posterior).sample() # [B, L] the earlier tokens.
|
| 135 |
-
return x # [B, L] clean DNA.
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
# %% 07. The report
|
| 139 |
-
def main() -> None:
|
| 140 |
-
parser = common_parser(__doc__.split("\n")[0])
|
| 141 |
-
parser.add_argument("--timesteps", type=int, default=32, help="T, the number of discrete steps")
|
| 142 |
-
# T fixes both the corruption and the ancestral sampler, so this file reads it from --timesteps
|
| 143 |
-
# and leaves --sample-steps alone. A default run trains 1000 steps on 64 sequences, inside
|
| 144 |
-
# about a minute.
|
| 145 |
-
parser.set_defaults(steps=1000, samples=64)
|
| 146 |
-
args = parser.parse_args()
|
| 147 |
-
set_seed(args.seed)
|
| 148 |
-
device = pick_device(args.device)
|
| 149 |
-
talk = (lambda *a: None) if args.quiet else print # --quiet keeps only the report block.
|
| 150 |
-
|
| 151 |
-
# Section 02, the worked cumulative kernel with beta_1 = 0.2 and beta_2 = 0.3.
|
| 152 |
-
first, second = one_step(0.2), one_step(0.3) # [K, K] the two transition matrices.
|
| 153 |
-
product = first @ second # [K, K] the cumulative kernel after two steps.
|
| 154 |
-
alpha_bar_two = 0.8 * 0.7 # The product of the per-step survival probabilities.
|
| 155 |
-
talk(f"Q_bar_2 alpha_bar {alpha_bar_two:.2f} diagonal {float(product[0, 0]):.2f} "
|
| 156 |
-
f"off-diagonal {float(product[0, 1]):.2f} closed-form gap "
|
| 157 |
-
f"{float((product - cumulative(alpha_bar_two)).abs().max()):.2e}")
|
| 158 |
-
|
| 159 |
-
# Section 02 again, the continuous-time limit of the same product at a constant rate.
|
| 160 |
-
limits = {n: (1.0 - 1.0 / n) ** n for n in (10, 100, 1000)} # The products climb toward exp(-1).
|
| 161 |
-
talk("constant rate 1 " + " ".join(f"T={n}: {v:.4f}" for n, v in limits.items()) +
|
| 162 |
-
f" exp(-1) = {math.exp(-1):.4f}")
|
| 163 |
-
|
| 164 |
-
# Section 03, reversing one finite step from an observed C under a clean A.
|
| 165 |
-
clean_a = torch.tensor([[[1.0, 0.0, 0.0, 0.0]]]) # [1, 1, K] the clean base A.
|
| 166 |
-
observed_c = torch.tensor([[1]]) # [1, 1] the base observed at step 2.
|
| 167 |
-
history = exact_posterior(observed_c, clean_a, second[None], first[None])[0, 0] # [K] at step 1.
|
| 168 |
-
talk(f"observed C, clean A column of Q_2 {[round(float(v), 3) for v in second.T[1]]} row of "
|
| 169 |
-
f"Q_bar_1 {[round(float(v), 2) for v in first[0]]}")
|
| 170 |
-
talk(f"q(x_1 | x_2, x_0) {[round(float(v), 4) for v in history]} "
|
| 171 |
-
f"evidence {float(product[0, 1]):.2f}")
|
| 172 |
-
|
| 173 |
-
# Section 04, two rate matrices built for the data they corrupt.
|
| 174 |
-
neighbours = gaussian_row() # [5] the row out of a mid-grey pixel.
|
| 175 |
-
adjacency = torch.tensor([ # A to C, C to G and G to T at weight 1, A to G at weight 0.5.
|
| 176 |
-
[0.0, 1.0, 0.5, 0.0], [1.0, 0.0, 1.0, 0.0],
|
| 177 |
-
[0.5, 1.0, 0.0, 1.0], [0.0, 0.0, 1.0, 0.0]])
|
| 178 |
-
R = graph_rates(adjacency) # [K, K] the rate matrix of the token graph.
|
| 179 |
-
graph_kernel = torch.matrix_exp(0.3 * R) # [K, K] its transition matrix at clock 0.3.
|
| 180 |
-
flux = UNIFORM[:, None] * R # [K, K] the stationary flux along each edge.
|
| 181 |
-
talk(f"gaussian row neighbour {float(neighbours[1]):.4f} two away "
|
| 182 |
-
f"{float(neighbours[0]):.4f} ratio {float(neighbours[1] / neighbours[0]):.1f}")
|
| 183 |
-
talk(f"exp(0.3 R) first row {[round(float(v), 4) for v in graph_kernel[0]]} detailed balance "
|
| 184 |
-
f"gap {float((flux - flux.T).abs().max()):.2e}")
|
| 185 |
-
|
| 186 |
-
# Train the denoiser against the discrete-time bound.
|
| 187 |
-
alpha_bar, beta = schedule(args.timesteps)
|
| 188 |
-
matrices = (torch.stack([one_step(float(b)) for b in beta]).to(device),
|
| 189 |
-
torch.stack([cumulative(float(a)) for a in alpha_bar]).to(device),
|
| 190 |
-
alpha_bar.to(device))
|
| 191 |
-
splits = dna_splits(num_sequences=512, length=args.length, seed=args.seed)
|
| 192 |
-
train_tokens = splits["train_tokens"].to(device) # [N, L] clean sequences over ACGT.
|
| 193 |
-
valid_tokens = splits["valid_tokens"].to(device) # [M, L] held out for the bound below.
|
| 194 |
-
model = SequenceDenoiser(DNA_SIZE, args.length, width=args.width).to(device)
|
| 195 |
-
talk(f"device={device} parameters={count_parameters(model):,} T={args.timesteps}")
|
| 196 |
-
|
| 197 |
-
def step_loss(step: int) -> torch.Tensor:
|
| 198 |
-
rows = torch.randint(0, len(train_tokens), (args.batch_size,), device=device)
|
| 199 |
-
return d3pm_loss(model, train_tokens[rows], matrices) # One clean batch into one scalar.
|
| 200 |
-
|
| 201 |
-
losses = train(model, step_loss, steps=args.steps, lr=args.lr, quiet=args.quiet)
|
| 202 |
-
with torch.no_grad():
|
| 203 |
-
held_out = sum(float(d3pm_loss(model, valid_tokens, matrices)) for _ in range(4)) / 4.0
|
| 204 |
-
|
| 205 |
-
torch.manual_seed(args.seed + 1) # Fix the ancestral draws, so the samples repeat.
|
| 206 |
-
samples = sample(model, args.samples, args.length, matrices) # [B, L] over ACGT.
|
| 207 |
-
report = {
|
| 208 |
-
"alpha_bar after two steps": alpha_bar_two,
|
| 209 |
-
"Q_bar_2 on the diagonal": float(product[0, 0]),
|
| 210 |
-
"Q_bar_2 off the diagonal": float(product[0, 1]),
|
| 211 |
-
"product at T = 1000": limits[1000],
|
| 212 |
-
"q(x_1 = A | C, A)": float(history[0]),
|
| 213 |
-
"q(x_1 = C | C, A)": float(history[1]),
|
| 214 |
-
"gaussian neighbour weight": float(neighbours[1]),
|
| 215 |
-
"gaussian far weight": float(neighbours[0]),
|
| 216 |
-
"exp(0.3 R) at (A, C)": float(graph_kernel[0, 1]),
|
| 217 |
-
"graph detailed balance gap": float((flux - flux.T).abs().max()),
|
| 218 |
-
"training bound, last 50": sum(losses[-50:]) / len(losses[-50:]),
|
| 219 |
-
"validation bound, nats/seq": held_out,
|
| 220 |
-
"GC fraction": gc_fraction(samples),
|
| 221 |
-
"GC fraction in the data": gc_fraction(train_tokens),
|
| 222 |
-
"motif fraction": motif_fraction(samples),
|
| 223 |
-
"motif fraction in the data": motif_fraction(train_tokens),
|
| 224 |
-
"distinct samples": unique_fraction(samples),
|
| 225 |
-
"letter entropy, nats": token_entropy(samples, DNA_SIZE),
|
| 226 |
-
}
|
| 227 |
-
print_report("d3pm: discrete-time diffusion on DNA", report, decode_dna(samples))
|
| 228 |
-
save_run(args.out, model=model, config=vars(args), report=report,
|
| 229 |
-
losses=losses, samples=decode_dna(samples))
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
# %% 08. Run the script
|
| 233 |
-
if __name__ == "__main__": # Execute when launched directly.
|
| 234 |
-
main() # Work the finite-step numbers, train against the bound, and generate.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
lecture_4/guidance.py
DELETED
|
@@ -1,308 +0,0 @@
|
|
| 1 |
-
"""Guiding masked diffusion on DNA with Doob's h-transform.
|
| 2 |
-
|
| 3 |
-
Conditioning a jump process on an event observed at the end of its path multiplies every rate by
|
| 4 |
-
the ratio of the value function at the destination to the value at the current state. The reverse
|
| 5 |
-
process is itself such a transform, with h_t = p_t/pi, and a second transform by a classifier aims
|
| 6 |
-
generation at a property. Three estimates of that classifier appear here: the exact one, its
|
| 7 |
-
first-order expansion in the one-hot input, and the conditional denoiser of classifier-free
|
| 8 |
-
guidance.
|
| 9 |
-
|
| 10 |
-
Chapter 4, "Conditioning a jump process: Doob's h-transform", "The reverse process as an
|
| 11 |
-
h-transform" and "Guiding generation toward a property". Time runs from t = 0 clean to t = 1 all
|
| 12 |
-
masks, the survival probability is alpha_t = 1 - t, and the rates R_t(x, y) act on probability
|
| 13 |
-
rows, p_dot = p R. Trained on the synthetic DNA of cis6270.data, whose label is a GC fraction of at
|
| 14 |
-
least 0.6. Prints the worked guided jump and the GC fraction under each kind of guidance.
|
| 15 |
-
|
| 16 |
-
python lecture_4/guidance.py # train, then sample four ways
|
| 17 |
-
python lecture_4/guidance.py --gamma 3 # a sharper transform
|
| 18 |
-
python lecture_4/guidance.py --steps 20 # a few seconds, for a first look
|
| 19 |
-
"""
|
| 20 |
-
|
| 21 |
-
# %% 01. Imports and configuration
|
| 22 |
-
import torch # Tensors, gradients and the categorical draws.
|
| 23 |
-
import torch.nn as nn # The classifier's layers.
|
| 24 |
-
import torch.nn.functional as F # Cross-entropy and the one-hot encoding.
|
| 25 |
-
|
| 26 |
-
from cis6270.data import DNA_SIZE, decode_dna, dna_splits # ACGT, K = 4 and the shared sequences.
|
| 27 |
-
from cis6270.metrics import gc_fraction, motif_fraction, token_entropy, unique_fraction
|
| 28 |
-
from cis6270.nets import TimeEmbedding, SequenceDenoiser, count_parameters
|
| 29 |
-
from cis6270.runner import common_parser, pick_device, print_report, save_run, set_seed, train
|
| 30 |
-
|
| 31 |
-
MASK = DNA_SIZE # A, C, G, T are 0 to 3, and token 4 is the mask m.
|
| 32 |
-
VOCAB = DNA_SIZE + 1 # The masked alphabet V_m has K + 1 = 5 symbols.
|
| 33 |
-
NULL = 2 # The null label the conditional denoiser drops to, one past the two real labels.
|
| 34 |
-
DROP = 0.1 # How often training replaces the label by the null one.
|
| 35 |
-
TIME_FLOOR = 1e-3 # The reveal rate w(t) = 1/t is unbounded at t = 0.
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
# %% 02. Doob's h-transform
|
| 39 |
-
#
|
| 40 |
-
# R^h_t(x, y) = R_t(x, y) h_t(y)/h_t(x). A jump that raises the chance of the event is faster by
|
| 41 |
-
# the factor by which it raises it, the graph of allowed jumps is unchanged, and a constant h
|
| 42 |
-
# changes nothing, which is the statement that conditioning on a certain event does nothing.
|
| 43 |
-
def h_transform(R: torch.Tensor, h: torch.Tensor) -> torch.Tensor:
|
| 44 |
-
"""Reweight every off-diagonal rate by h(y)/h(x), then refill the diagonal. Returns [K, K]."""
|
| 45 |
-
transformed = R * (h[None, :] / h[:, None]) # [K, K] one ratio per edge.
|
| 46 |
-
transformed.fill_diagonal_(0.0) # The diagonal is fixed by the row-sum rule.
|
| 47 |
-
return transformed - torch.diag(transformed.sum(dim=1)) # [K, K] a rate matrix again.
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
# %% 03. The reverse process as an h-transform
|
| 51 |
-
#
|
| 52 |
-
# For a forward process in detailed balance with pi, the reverse rate equals the forward rate times
|
| 53 |
-
# h_t(x)/h_t(y) with h_t = p_t/pi, so generation is the noise process aimed at the states that are
|
| 54 |
-
# more likely under the data than under pure noise.
|
| 55 |
-
def value_from_marginal(p: torch.Tensor, pi: torch.Tensor) -> torch.Tensor:
|
| 56 |
-
"""h_t = p_t / pi, defined wherever pi is positive, as uniform noise is and masking is not."""
|
| 57 |
-
return p / pi # [K] the excess of the data marginal over the noise distribution.
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
# %% 04. A classifier on noisy sequences
|
| 61 |
-
#
|
| 62 |
-
# The value function for a property is h_t(x) = p(c | X_t = x), estimated by a classifier trained at
|
| 63 |
-
# every noise level. The input is a one-hot sequence, so a gradient with respect to it exists and
|
| 64 |
-
# Section 06 can take one.
|
| 65 |
-
class NoisyClassifier(nn.Module):
|
| 66 |
-
"""A small network from a one-hot noisy sequence and a time to the logit of p(c | z, t)."""
|
| 67 |
-
|
| 68 |
-
def __init__(self, length: int, width: int = 128):
|
| 69 |
-
super().__init__()
|
| 70 |
-
self.embed = nn.Linear(VOCAB, width, bias=False) # One-hot in, so the input has a gradient.
|
| 71 |
-
self.position = nn.Embedding(length, width) # Where each token sits.
|
| 72 |
-
self.time = TimeEmbedding(width) # How far along the corruption we are.
|
| 73 |
-
self.body = nn.Sequential(nn.Linear(width, width), nn.SiLU(), nn.Linear(width, width),
|
| 74 |
-
nn.SiLU())
|
| 75 |
-
self.head = nn.Linear(width, 1) # One logit per sequence.
|
| 76 |
-
|
| 77 |
-
def forward(self, one_hot: torch.Tensor, t: torch.Tensor) -> torch.Tensor:
|
| 78 |
-
"""one_hot: [B, L, V], t: [B] -> [B] the logit of the property."""
|
| 79 |
-
positions = torch.arange(one_hot.shape[1], device=one_hot.device) # [L].
|
| 80 |
-
h = (self.embed(one_hot) + self.position(positions)[None]
|
| 81 |
-
+ self.time(t)[:, None, :]) # [B, L, width].
|
| 82 |
-
return self.head(self.body(h).mean(dim=1))[:, 0] # [B] pooled over the positions.
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
def as_one_hot(z: torch.Tensor) -> torch.Tensor:
|
| 86 |
-
"""The sequence over the masked alphabet as floats, so a gradient in the input exists."""
|
| 87 |
-
return F.one_hot(z, VOCAB).to(torch.get_default_dtype()) # [B, L, V] one row per position.
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
def classifier_loss(classifier: nn.Module, x0: torch.Tensor, labels: torch.Tensor) -> torch.Tensor:
|
| 91 |
-
"""Binary cross-entropy on sequences corrupted to a random noise level."""
|
| 92 |
-
t = torch.rand(x0.shape[0], device=x0.device).clamp(min=TIME_FLOOR) # [B] one level each.
|
| 93 |
-
keep = torch.rand(x0.shape, device=x0.device) < (1.0 - t)[:, None] # [B, L] the survival draw.
|
| 94 |
-
z = torch.where(keep, x0, MASK) # [B, L] a partially masked sequence.
|
| 95 |
-
logit = classifier(as_one_hot(z), t) # [B] the predicted log odds of the property.
|
| 96 |
-
return F.binary_cross_entropy_with_logits(logit, labels.to(torch.get_default_dtype()))
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
# %% 05. Exact classifier guidance
|
| 100 |
-
#
|
| 101 |
-
# The guided rate has p(c | z^{i <- a}) in it at every position and every token, so the exact
|
| 102 |
-
# version costs one classifier evaluation per candidate. A length-16 DNA sequence has 64 of them.
|
| 103 |
-
def exact_ratio(classifier: nn.Module, z: torch.Tensor, t: torch.Tensor) -> torch.Tensor:
|
| 104 |
-
"""p(c | z^{i <- a}) / p(c | z) at every position and clean token. Returns [B, L, K]."""
|
| 105 |
-
batch, length = z.shape
|
| 106 |
-
one_hot = as_one_hot(z) # [B, L, V] the current state.
|
| 107 |
-
here = torch.sigmoid(classifier(one_hot, t))[:, None, None] # [B, 1, 1] the value at z.
|
| 108 |
-
candidates = one_hot[:, None, None].repeat(1, length, DNA_SIZE, 1, 1) # [B, L, K, L, V].
|
| 109 |
-
index = torch.arange(length, device=z.device) # [L] the position being changed.
|
| 110 |
-
candidates[:, index, :, index] = torch.eye(VOCAB, device=z.device)[:DNA_SIZE] # Set position i.
|
| 111 |
-
flat = candidates.reshape(batch * length * DNA_SIZE, length, VOCAB) # One batch of candidates.
|
| 112 |
-
time = t[:, None, None].expand(batch, length, DNA_SIZE).reshape(-1) # [B L K] the same time.
|
| 113 |
-
value = torch.sigmoid(classifier(flat, time)).view(batch, length, DNA_SIZE) # [B, L, K].
|
| 114 |
-
return value / here.clamp(min=1e-8) # [B, L, K] the h-transform factor on each jump.
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
# %% 06. The gradient approximation
|
| 118 |
-
#
|
| 119 |
-
# One backward pass through the classifier gives every ratio at once, because a first-order
|
| 120 |
-
# expansion of log p(c | .) in the one-hot input differs between two candidates only in the two
|
| 121 |
-
# coordinates that change.
|
| 122 |
-
def gradient_ratio(classifier: nn.Module, z: torch.Tensor, t: torch.Tensor) -> torch.Tensor:
|
| 123 |
-
"""exp(g_{i,a} - g_{i,z^i}) with g the gradient of log p(c | z) in the one-hot. [B, L, K]."""
|
| 124 |
-
one_hot = as_one_hot(z).requires_grad_(True) # [B, L, V] the point of the expansion.
|
| 125 |
-
with torch.enable_grad(): # The sampler runs under no_grad, and this one pass needs gradients.
|
| 126 |
-
log_value = F.logsigmoid(classifier(one_hot, t)).sum() # One backward covers the batch.
|
| 127 |
-
gradient, = torch.autograd.grad(log_value, one_hot) # [B, L, V] the expansion's slopes.
|
| 128 |
-
current = gradient.gather(2, z[:, :, None]) # [B, L, 1] the coordinate that is being left.
|
| 129 |
-
return (gradient[..., :DNA_SIZE] - current).exp() # [B, L, K] the approximated ratio.
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
# %% 07. The guided sampler
|
| 133 |
-
#
|
| 134 |
-
# The reveal rate at a masked position is w(t) d_theta(a), so the h-transform multiplies each of
|
| 135 |
-
# those rates by its own ratio. Both the probability of revealing anything and the letter that
|
| 136 |
-
# appears change, and a ratio of one at every token returns the unguided sampler exactly.
|
| 137 |
-
def denoiser_probabilities(model: nn.Module, z: torch.Tensor, time: torch.Tensor,
|
| 138 |
-
labels: torch.Tensor | None) -> torch.Tensor:
|
| 139 |
-
"""d_theta(. | z, t) over the four bases, with the zero-mask rule applied. Returns [B, L, K]."""
|
| 140 |
-
logits = model(z, time, labels) # [B, L, 5] the clean-token prediction.
|
| 141 |
-
logits[..., MASK] = -1e9 # The zero-mask rule: no posterior mass belongs on m.
|
| 142 |
-
return logits.softmax(-1)[..., :DNA_SIZE] # [B, L, K], which sums to one over ACGT.
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
def guided_weights(model: nn.Module, z: torch.Tensor, time: torch.Tensor,
|
| 146 |
-
labels: torch.Tensor | None, ratio_fn, gamma: float) -> torch.Tensor:
|
| 147 |
-
"""The reveal rates over w(t): the denoiser times its h-transform ratio. Returns [B, L, K]."""
|
| 148 |
-
weights = denoiser_probabilities(model, z, time, labels) # [B, L, K] the unguided rates.
|
| 149 |
-
if ratio_fn is None:
|
| 150 |
-
return weights # [B, L, K] summing to one, which is the unguided exit rate w(t).
|
| 151 |
-
return weights * ratio_fn(z, time).clamp(min=1e-8) ** gamma # [B, L, K] the h-transform.
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
@torch.no_grad() # Only the gradient ratio reopens the graph, and it does so locally.
|
| 155 |
-
def sample(model: nn.Module, labels: torch.Tensor | None, batch: int, length: int,
|
| 156 |
-
steps: int = 32, ratio_fn=None, gamma: float = 1.0) -> torch.Tensor:
|
| 157 |
-
"""Generate from all masks, with the reveal rates reweighted by ratio_fn. Returns [B, L]."""
|
| 158 |
-
device = next(model.parameters()).device
|
| 159 |
-
z = torch.full((batch, length), MASK, device=device) # [B, L] the all-mask start.
|
| 160 |
-
ts = torch.linspace(1.0, TIME_FLOOR, steps + 1, device=device) # The reverse grid.
|
| 161 |
-
for t, s in zip(ts[:-1], ts[1:]):
|
| 162 |
-
time = t.expand(batch) # [B] the shared time of this step.
|
| 163 |
-
weights = guided_weights(model, z, time, labels, ratio_fn, gamma) # [B, L, K].
|
| 164 |
-
total = weights.sum(-1, keepdim=True) # [B, L, 1] the transformed exit rate over w(t).
|
| 165 |
-
reveal = ((t - s) / t * total).clamp(max=1.0) # [B, L, 1] the probability of acting at all.
|
| 166 |
-
drawn = torch.distributions.Categorical(probs=weights / total).sample() # [B, L].
|
| 167 |
-
open_now = torch.rand(z.shape, device=device)[:, :, None] < reveal # [B, L, 1].
|
| 168 |
-
z = torch.where((z == MASK) & open_now[..., 0], drawn, z) # Carry-over unmasking.
|
| 169 |
-
# The last step runs down to s = 0, where the schedule reveals every remaining mask. The same
|
| 170 |
-
# transform picks the letter, so the guidance reaches the positions that waited to the end.
|
| 171 |
-
last = ts[-1].expand(batch) # [B] the final time of the grid.
|
| 172 |
-
weights = guided_weights(model, z, last, labels, ratio_fn, gamma) # [B, L, K].
|
| 173 |
-
drawn = torch.distributions.Categorical( # [B, L] one letter per position.
|
| 174 |
-
probs=weights / weights.sum(-1, keepdim=True)).sample()
|
| 175 |
-
return torch.where(z == MASK, drawn, z) # [B, L] clean DNA.
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
# %% 08. The report
|
| 179 |
-
def main() -> None:
|
| 180 |
-
parser = common_parser(__doc__.split("\n")[0])
|
| 181 |
-
parser.add_argument("--gamma", type=float, default=2.0, help="exponent on the h ratio")
|
| 182 |
-
# Two networks train and four samplers run, so each network gets 1000 steps and each sampler
|
| 183 |
-
# measures 64 sequences, which is the longest default run of the lecture.
|
| 184 |
-
parser.set_defaults(steps=1000, sample_steps=32, samples=64)
|
| 185 |
-
args = parser.parse_args()
|
| 186 |
-
set_seed(args.seed)
|
| 187 |
-
device = pick_device(args.device)
|
| 188 |
-
talk = (lambda *a: None) if args.quiet else print # --quiet keeps only the report block.
|
| 189 |
-
|
| 190 |
-
# Sections 02 and 03, the reverse rates read off h_t = p_t / pi at alpha_t = 1/2.
|
| 191 |
-
marginal = torch.tensor([0.5, 0.25, 0.125, 0.125]) # p_t over (A, C, G, T).
|
| 192 |
-
pi = torch.full((DNA_SIZE,), 0.25) # Uniform noise.
|
| 193 |
-
unit = torch.ones(DNA_SIZE, DNA_SIZE) - torch.diag(torch.full((DNA_SIZE,), DNA_SIZE * 1.0))
|
| 194 |
-
values = value_from_marginal(marginal, pi) # [4] h_t = (2, 1, 0.5, 0.5).
|
| 195 |
-
reverse = h_transform(unit, values) # [4, 4] the reverse rates, at unit forward rates.
|
| 196 |
-
conditioned = value_from_marginal(torch.tensor([0.625, 0.125, 0.125, 0.125]), pi) # Clean A.
|
| 197 |
-
talk(f"h from the marginal {[round(float(v), 2) for v in values]} reverse row for C "
|
| 198 |
-
f"{[round(float(v), 2) for v in reverse[1]]}")
|
| 199 |
-
talk(f"h given a clean A {[round(float(v), 2) for v in conditioned]} rate C to A "
|
| 200 |
-
f"{float(conditioned[0] / conditioned[1]):.0f} given a clean C "
|
| 201 |
-
f"{float(conditioned[1] / conditioned[0]):.1f}")
|
| 202 |
-
|
| 203 |
-
# Section 05 on paper, the worked guided jump out of C with a classifier on the four bases.
|
| 204 |
-
unguided = torch.tensor([2.0, 0.5, 0.5]) # The reverse rates toward A, G and T.
|
| 205 |
-
classifier_value = torch.tensor([0.1, 0.6, 0.2]) / 0.2 # p(c | destination) over p(c | C).
|
| 206 |
-
guided = unguided * classifier_value # [3] the guided rates.
|
| 207 |
-
sharper = unguided * classifier_value ** 2 # [3] the same transform at gamma = 2.
|
| 208 |
-
shares = [round(float(v), 4) for v in guided / guided.sum()] # Where the jump most likely goes.
|
| 209 |
-
talk(f"guided jump {[round(float(v), 2) for v in guided]} exit "
|
| 210 |
-
f"{float(guided.sum()):.0f} destinations {shares}")
|
| 211 |
-
talk(f"at gamma = 2 {[round(float(v), 2) for v in sharper]} exit "
|
| 212 |
-
f"{float(sharper.sum()):.1f} destinations "
|
| 213 |
-
f"{[round(float(v), 4) for v in sharper / sharper.sum()]} holding "
|
| 214 |
-
f"{1 / float(sharper.sum()):.4f}")
|
| 215 |
-
|
| 216 |
-
# Train the conditional denoiser and the classifier on the shared synthetic DNA.
|
| 217 |
-
splits = dna_splits(num_sequences=512, length=args.length, seed=args.seed)
|
| 218 |
-
tokens = splits["train_tokens"].to(device) # [N, L] clean sequences over ACGT.
|
| 219 |
-
labels = splits["train_labels"].to(device) # [N] one when the GC fraction is at least 0.6.
|
| 220 |
-
model = SequenceDenoiser(VOCAB, args.length, width=args.width, num_classes=2).to(device)
|
| 221 |
-
classifier = NoisyClassifier(args.length, args.width).to(device)
|
| 222 |
-
share = float(labels.to(torch.get_default_dtype()).mean()) # How much of the data is GC-rich.
|
| 223 |
-
talk(f"device={device} denoiser={count_parameters(model):,} "
|
| 224 |
-
f"classifier={count_parameters(classifier):,} labelled {share:.2f}")
|
| 225 |
-
|
| 226 |
-
def denoiser_loss(step: int) -> torch.Tensor:
|
| 227 |
-
"""The MDLM loss, with the label replaced by the null one a tenth of the time."""
|
| 228 |
-
rows = torch.randint(0, len(tokens), (args.batch_size,), device=device)
|
| 229 |
-
x0, y = tokens[rows], labels[rows].clone()
|
| 230 |
-
y[torch.rand(len(y), device=device) < DROP] = NULL # Dropping the label trains both heads.
|
| 231 |
-
t = torch.rand(len(x0), device=device).clamp(min=TIME_FLOOR) # [B] one noise level each.
|
| 232 |
-
zt = torch.where(torch.rand(x0.shape, device=device) < (1.0 - t)[:, None], x0, MASK)
|
| 233 |
-
logits = model(zt, t, y) # [B, L, 5].
|
| 234 |
-
logits[..., MASK] = -1e9 # The zero-mask rule.
|
| 235 |
-
ce = F.cross_entropy(logits.transpose(1, 2), x0, reduction="none") # [B, L].
|
| 236 |
-
return ((1.0 / t)[:, None] * ce * (zt == MASK)).sum(-1).mean()
|
| 237 |
-
|
| 238 |
-
def labelled_batch(step: int) -> torch.Tensor:
|
| 239 |
-
"""One batch of clean sequences and their labels, for the classifier."""
|
| 240 |
-
rows = torch.randint(0, len(tokens), (args.batch_size,), device=device)
|
| 241 |
-
return classifier_loss(classifier, tokens[rows], labels[rows])
|
| 242 |
-
|
| 243 |
-
losses = train(model, denoiser_loss, steps=args.steps, lr=args.lr, quiet=args.quiet)
|
| 244 |
-
classifier_losses = train(classifier, labelled_batch, steps=args.steps, lr=args.lr,
|
| 245 |
-
quiet=args.quiet)
|
| 246 |
-
|
| 247 |
-
# Section 06, the exact ratios against their first-order approximation on one noisy batch.
|
| 248 |
-
probe_t = torch.full((32,), 0.5, device=device) # [32] halfway through the corruption.
|
| 249 |
-
probe = torch.where(torch.rand(tokens[:32].shape, device=device) < 0.5, tokens[:32], MASK)
|
| 250 |
-
with torch.no_grad():
|
| 251 |
-
exact = exact_ratio(classifier, probe, probe_t) # [32, L, K] every candidate evaluated.
|
| 252 |
-
approximate = gradient_ratio(classifier, probe, probe_t) # [32, L, K] one backward pass.
|
| 253 |
-
gap = exact.clamp(min=1e-6).log() - approximate.clamp(min=1e-6).log() # [32, L, K] log ratios.
|
| 254 |
-
agreement = float(gap.abs().mean()) # How far the expansion sits from the exact ratio.
|
| 255 |
-
|
| 256 |
-
# Sample four ways, from the same weights and the same starting state.
|
| 257 |
-
wanted = torch.ones(args.samples, dtype=torch.long, device=device) # Ask for the GC-rich label.
|
| 258 |
-
null = torch.full_like(wanted, NULL) # The label the unconditional head reads.
|
| 259 |
-
|
| 260 |
-
def free_ratio(z: torch.Tensor, t: torch.Tensor) -> torch.Tensor:
|
| 261 |
-
"""d_theta(a | z, c) / d_theta(a | z), which is the classifier ratio implied by Bayes."""
|
| 262 |
-
# Both denoisers go through the zero-mask rule first. The loss overwrites the mask logit,
|
| 263 |
-
# so it never trains, and a softmax that kept it would leave a stray factor in the ratio.
|
| 264 |
-
conditional = denoiser_probabilities(model, z, t, wanted) # [B, L, K] with the label.
|
| 265 |
-
unconditional = denoiser_probabilities(model, z, t, null) # [B, L, K] with the null label.
|
| 266 |
-
return conditional / unconditional.clamp(min=1e-8) # [B, L, K] the h-transform factor.
|
| 267 |
-
|
| 268 |
-
runs = {}
|
| 269 |
-
for name, ratio in (
|
| 270 |
-
("unguided", None),
|
| 271 |
-
("exact classifier", lambda z, t: exact_ratio(classifier, z, t)),
|
| 272 |
-
("gradient approximation", lambda z, t: gradient_ratio(classifier, z, t)),
|
| 273 |
-
("classifier free", free_ratio),
|
| 274 |
-
):
|
| 275 |
-
torch.manual_seed(args.seed + 1) # The same reveal draws for every run.
|
| 276 |
-
runs[name] = sample(model, null, args.samples, args.length, args.sample_steps,
|
| 277 |
-
ratio, args.gamma)
|
| 278 |
-
|
| 279 |
-
report = {
|
| 280 |
-
"h at A from the marginal": float(values[0]),
|
| 281 |
-
"reverse rate C to A": float(reverse[1, 0]),
|
| 282 |
-
"guided rate C to A": float(guided[0]),
|
| 283 |
-
"guided rate C to G": float(guided[1]),
|
| 284 |
-
"guided exit rate": float(guided.sum()),
|
| 285 |
-
"guided exit rate at gamma = 2": float(sharper.sum()),
|
| 286 |
-
"share on G at gamma = 2": float((sharper / sharper.sum())[1]),
|
| 287 |
-
"denoiser loss, last 50": sum(losses[-50:]) / len(losses[-50:]),
|
| 288 |
-
"classifier loss, last 50": sum(classifier_losses[-50:]) / len(classifier_losses[-50:]),
|
| 289 |
-
"mean log-ratio gap, exact vs gradient": agreement,
|
| 290 |
-
"GC fraction in the data": gc_fraction(tokens),
|
| 291 |
-
"motif fraction in the data": motif_fraction(tokens),
|
| 292 |
-
"gamma": args.gamma,
|
| 293 |
-
}
|
| 294 |
-
for name, drawn in runs.items():
|
| 295 |
-
report[f"GC fraction, {name}"] = gc_fraction(drawn)
|
| 296 |
-
# The label is a GC threshold, so the motif is the structure guidance leaves alone.
|
| 297 |
-
report[f"motif fraction, {name}"] = motif_fraction(drawn)
|
| 298 |
-
report[f"distinct, {name}"] = unique_fraction(drawn)
|
| 299 |
-
report["letter entropy, exact classifier"] = token_entropy(runs["exact classifier"], DNA_SIZE)
|
| 300 |
-
print_report("guidance: h-transforms of masked diffusion", report,
|
| 301 |
-
decode_dna(runs["exact classifier"]))
|
| 302 |
-
save_run(args.out, model=model, config=vars(args), report=report, losses=losses,
|
| 303 |
-
samples=decode_dna(runs["exact classifier"]))
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
# %% 09. Run the script
|
| 307 |
-
if __name__ == "__main__": # Execute when launched directly.
|
| 308 |
-
main() # Work the guided jump, train the two networks, and sample four ways.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
lecture_4/mdlm.py
DELETED
|
@@ -1,190 +0,0 @@
|
|
| 1 |
-
"""Masked diffusion on DNA, the whole model in thirty lines.
|
| 2 |
-
|
| 3 |
-
Corruption replaces each base by the mask with probability 1 - alpha_t, a network reads the masked
|
| 4 |
-
sequence and predicts the clean token at every position, and generation reveals masked positions
|
| 5 |
-
with the probability the schedule allows. The corruption, the loss and the sampler below are the
|
| 6 |
-
three listings of Section "The whole model for DNA in thirty lines", expanded with comments.
|
| 7 |
-
|
| 8 |
-
Chapter 4, "Masked diffusion on one token" through "The whole model for DNA in thirty lines". Time
|
| 9 |
-
runs from t = 0 clean to t = 1 all masks, the survival probability is alpha_t = 1 - t, and the rates
|
| 10 |
-
R_t(x, y) act on probability rows, p_dot = p R. Trained on the synthetic DNA of cis6270.data. Prints
|
| 11 |
-
the chapter's worked reverse step and loss term, then the training loss and the sample statistics.
|
| 12 |
-
|
| 13 |
-
python lecture_4/mdlm.py # train and generate, about a minute on a CPU
|
| 14 |
-
python lecture_4/mdlm.py --steps 20 # a few seconds, for a first look
|
| 15 |
-
"""
|
| 16 |
-
|
| 17 |
-
# %% 01. Imports and configuration
|
| 18 |
-
import math # The worked numbers of the chapter are logarithms.
|
| 19 |
-
|
| 20 |
-
import torch # Tensors, gradients and the categorical draws.
|
| 21 |
-
import torch.nn.functional as F # Cross-entropy at every position.
|
| 22 |
-
from torch.distributions import Categorical # One draw per position from the denoiser.
|
| 23 |
-
|
| 24 |
-
from cis6270.data import DNA_SIZE, decode_dna, dna_splits # ACGT, K = 4 and the shared sequences.
|
| 25 |
-
from cis6270.metrics import gc_fraction, motif_fraction, token_entropy, unique_fraction
|
| 26 |
-
from cis6270.nets import SequenceDenoiser, count_parameters # The denoiser d_theta(. | z, t).
|
| 27 |
-
from cis6270.runner import common_parser, pick_device, print_report, save_run, set_seed, train
|
| 28 |
-
|
| 29 |
-
MASK = DNA_SIZE # A, C, G, T are 0 to 3, and token 4 is the mask m.
|
| 30 |
-
VOCAB = DNA_SIZE + 1 # The masked alphabet V_m has K + 1 = 5 symbols.
|
| 31 |
-
TIME_FLOOR = 1e-3 # The weight 1/t is unbounded at t = 0, so draws start just above it.
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
# %% 02. The corruption
|
| 35 |
-
#
|
| 36 |
-
# Fact 2 of the construction: the forward marginal q_t(z^i | x_0^i) = alpha_t delta(z^i, x_0^i) +
|
| 37 |
-
# (1 - alpha_t) delta(z^i, m) is closed form, so any noise level is reachable in one draw.
|
| 38 |
-
def corrupt(x0: torch.Tensor, t: torch.Tensor) -> torch.Tensor:
|
| 39 |
-
"""Mask each position independently with probability 1 - alpha_t. x0: [B, L], t: [B, 1]."""
|
| 40 |
-
alpha = 1.0 - t # The linear schedule, alpha_0 = 1 and alpha_1 = 0.
|
| 41 |
-
keep = torch.rand(x0.shape, device=x0.device) < alpha # [B, L] one Bernoulli survival draw.
|
| 42 |
-
return torch.where(keep, x0, MASK) # [B, L] the clean token where it survived, m where it did not.
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
# %% 03. The weighted cross-entropy at masked positions
|
| 46 |
-
#
|
| 47 |
-
# The per-step KL divergence collapses to -r log d_theta(a), and summing the steps into an integral
|
| 48 |
-
# replaces r by the reveal rate w(t) = -alpha_dot / (1 - alpha) = 1/t on the linear schedule.
|
| 49 |
-
def loss(model: torch.nn.Module, x0: torch.Tensor) -> torch.Tensor:
|
| 50 |
-
"""One Monte Carlo sample of L_mask, with one time and one corruption per sequence."""
|
| 51 |
-
t = torch.rand(x0.shape[0], device=x0.device).clamp(min=TIME_FLOOR) # [B] one noise level each.
|
| 52 |
-
zt = corrupt(x0, t[:, None]) # [B, L] the time spreads across the positions of its own sequence.
|
| 53 |
-
logits = model(zt, t) # [B, L, 5] the denoiser reads the whole noisy sequence.
|
| 54 |
-
logits[..., MASK] = -1e9 # The zero-mask rule: no posterior mass belongs on m.
|
| 55 |
-
ce = F.cross_entropy(logits.transpose(1, 2), x0, reduction="none") # [B, L] -log d_theta(x_0^i).
|
| 56 |
-
w = 1.0 / t # [B] the reveal rate, which is -alpha_dot / (1 - alpha) on this schedule.
|
| 57 |
-
return (w[:, None] * ce * (zt == MASK)).sum(-1).mean() # Visible positions contribute zero.
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
# %% 04. The sampler
|
| 61 |
-
#
|
| 62 |
-
# A masked position is revealed with probability r_{s,t} = (alpha_s - alpha_t)/(1 - alpha_t), which
|
| 63 |
-
# is (t - s)/t here, and the letter that appears is one draw from the denoiser.
|
| 64 |
-
@torch.no_grad() # Generation needs no gradient tracking.
|
| 65 |
-
def sample(model: torch.nn.Module, batch: int, length: int, steps: int = 32) -> torch.Tensor:
|
| 66 |
-
"""Generate from all masks down to clean DNA. Returns [B, L] token ids over ACGT."""
|
| 67 |
-
device = next(model.parameters()).device # Sample on the device the weights sit on.
|
| 68 |
-
z = torch.full((batch, length), MASK, device=device) # [B, L] the all-mask start, pi = delta_m.
|
| 69 |
-
ts = torch.linspace(1.0, TIME_FLOOR, steps + 1, device=device) # The reverse grid, t down to 0.
|
| 70 |
-
for t, s in zip(ts[:-1], ts[1:]): # One interval [s, t] at a time.
|
| 71 |
-
logits = model(z, t.expand(batch)) # [B, L, 5] the clean-token prediction at this level.
|
| 72 |
-
logits[..., MASK] = -1e9 # The zero-mask rule again, at sampling time.
|
| 73 |
-
x0 = Categorical(logits=logits).sample() # [B, L] one candidate clean token per position.
|
| 74 |
-
unmask = torch.rand(z.shape, device=device) < (t - s) / t # [B, L] the reveal draw.
|
| 75 |
-
z = torch.where((z == MASK) & unmask, x0, z) # Carry-over unmasking: a revealed base stays.
|
| 76 |
-
logits = model(z, ts[-1].expand(batch)) # The last step runs down to s = 0, where r = 1.
|
| 77 |
-
logits[..., MASK] = -1e9 # The same rule once more.
|
| 78 |
-
return torch.where(z == MASK, Categorical(logits=logits).sample(), z) # [B, L] clean DNA.
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
# %% 05. A complete reverse step, worked
|
| 82 |
-
#
|
| 83 |
-
# With alpha_s = 0.7 and alpha_t = 0.4, which the linear schedule reaches at s = 0.3 and t = 0.6,
|
| 84 |
-
# the reveal probability is half and the denoiser splits that half between A and C.
|
| 85 |
-
def reveal_probability(alpha_s: float, alpha_t: float) -> float:
|
| 86 |
-
"""r_{s,t} = (alpha_s - alpha_t)/(1 - alpha_t), the lost survival mass over the mask mass."""
|
| 87 |
-
return (alpha_s - alpha_t) / (1.0 - alpha_t) # A function of the schedule and of nothing else.
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
def reverse_step_distribution(alpha_s: float, alpha_t: float, denoiser: list[float]) -> list[float]:
|
| 91 |
-
"""The reverse law over (A, C, G, T, m): r times the denoiser, with 1 - r left on the mask."""
|
| 92 |
-
r = reveal_probability(alpha_s, alpha_t) # The probability of acting at all.
|
| 93 |
-
return [r * d for d in denoiser] + [1.0 - r] # [5] the five outcomes of one step.
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
# %% 06. One term of the loss, and the rate error it comes from
|
| 97 |
-
#
|
| 98 |
-
# At t = 0.5 the weight is w(0.5) = 2, and the clean sequence ACGT corrupted to AmGm contributes the
|
| 99 |
-
# negative log probabilities of C and T. The chapter reaches the same weighted cross-entropy a
|
| 100 |
-
# second time from the rate error, on the reveal figure's denoiser: summing l(A, B) over every
|
| 101 |
-
# destination at one masked position leaves w(t) times the negative log probability of the clean
|
| 102 |
-
# token there, once the staying terms restore the normalization of the denoiser.
|
| 103 |
-
def reveal_rate(t: float) -> float:
|
| 104 |
-
"""w(t) = -alpha_dot / (1 - alpha_t), which is 1/t for the linear schedule."""
|
| 105 |
-
return 1.0 / t # The rate at which a masked position reveals something, whatever the denoiser.
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
def rate_error(target: float, model_rate: float) -> float:
|
| 109 |
-
"""l(A, B) = A log(A/B) - A + B, the Bregman divergence that the path KL divergence produces."""
|
| 110 |
-
if target == 0.0:
|
| 111 |
-
return model_rate # l(0, B) = B, which is the staying term on its own.
|
| 112 |
-
return target * math.log(target / model_rate) - target + model_rate # Zero exactly at B = A.
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
# %% 07. The report
|
| 116 |
-
def main() -> None:
|
| 117 |
-
parser = common_parser(__doc__.split("\n")[0])
|
| 118 |
-
# The motif needs a longer budget than the GC fraction, so the default trains for 1000 steps
|
| 119 |
-
# and measures on 64 sequences. The whole run stays inside about a minute on one core.
|
| 120 |
-
parser.set_defaults(steps=1000, sample_steps=64, samples=64)
|
| 121 |
-
args = parser.parse_args()
|
| 122 |
-
set_seed(args.seed) # The same seed gives the same training batches and the same samples.
|
| 123 |
-
device = pick_device(args.device)
|
| 124 |
-
talk = (lambda *a: None) if args.quiet else print # --quiet keeps only the report block.
|
| 125 |
-
|
| 126 |
-
# Section 05, the worked reverse step at alpha_s = 0.7 and alpha_t = 0.4.
|
| 127 |
-
r = reveal_probability(0.7, 0.4) # 0.3 of recovered mass over 0.6 of mask mass.
|
| 128 |
-
step_law = reverse_step_distribution(0.7, 0.4, [0.6, 0.4, 0.0, 0.0]) # [5] over (A, C, G, T, m).
|
| 129 |
-
talk(f"reverse step r = {r:.1f} p(A, C, G, T, m) = {[round(v, 2) for v in step_law]} "
|
| 130 |
-
f"sum {sum(step_law):.1f}")
|
| 131 |
-
|
| 132 |
-
# Section 06, one term of the loss at t = 0.5 on ACGT corrupted to AmGm.
|
| 133 |
-
weight = reveal_rate(0.5) # w(0.5) = 2.
|
| 134 |
-
masked_ce = -math.log(0.7) - math.log(0.5) # The clean tokens C and T at positions 2 and 4.
|
| 135 |
-
worked_term = weight * masked_ce # One sample of the objective.
|
| 136 |
-
position_two = [0.2, 0.6, 0.1, 0.1] # The reveal figure's denoiser at the second position.
|
| 137 |
-
reveal_rates = [weight * d for d in position_two] # [4] rates that sum to w(t) = 2.
|
| 138 |
-
collapse = rate_error(weight, reveal_rates[1]) + sum( # The clean token is C here.
|
| 139 |
-
rate_error(0.0, rate) for i, rate in enumerate(reveal_rates) if i != 1)
|
| 140 |
-
talk(f"loss term at t=0.5 w = {weight:.0f} masked CE = {masked_ce:.5f} "
|
| 141 |
-
f"w CE = {worked_term:.5f}")
|
| 142 |
-
talk(f"reveal rates {[round(v, 1) for v in reveal_rates]} sum {sum(reveal_rates):.0f} "
|
| 143 |
-
f"rate error {collapse:.4f} = w(-log 0.6) = {weight * -math.log(0.6):.4f}")
|
| 144 |
-
|
| 145 |
-
# Train the denoiser on the shared synthetic DNA.
|
| 146 |
-
splits = dna_splits(num_sequences=512, length=args.length, seed=args.seed)
|
| 147 |
-
train_tokens = splits["train_tokens"].to(device) # [N, L] clean sequences over ACGT.
|
| 148 |
-
valid_tokens = splits["valid_tokens"].to(device) # [M, L] held out for the bound below.
|
| 149 |
-
model = SequenceDenoiser(VOCAB, args.length, width=args.width).to(device)
|
| 150 |
-
talk(f"device={device} parameters={count_parameters(model):,} "
|
| 151 |
-
f"train={len(train_tokens)} length={args.length}")
|
| 152 |
-
|
| 153 |
-
def step_loss(step: int) -> torch.Tensor:
|
| 154 |
-
"""One optimizer step draws its own batch of clean sequences."""
|
| 155 |
-
rows = torch.randint(0, len(train_tokens), (args.batch_size,), device=device)
|
| 156 |
-
return loss(model, train_tokens[rows]) # [B, L] clean sequences into one scalar.
|
| 157 |
-
|
| 158 |
-
losses = train(model, step_loss, steps=args.steps, lr=args.lr, quiet=args.quiet)
|
| 159 |
-
|
| 160 |
-
# The validation bound, averaged over several corruptions of the held-out sequences.
|
| 161 |
-
with torch.no_grad():
|
| 162 |
-
bound = sum(float(loss(model, valid_tokens)) for _ in range(4)) / 4.0 # Nats per sequence.
|
| 163 |
-
|
| 164 |
-
# Generate, then measure the structure the data was built with.
|
| 165 |
-
torch.manual_seed(args.seed + 1) # Fix the reveal draws, so the samples repeat.
|
| 166 |
-
samples = sample(model, args.samples, args.length, args.sample_steps) # [B, L] over ACGT.
|
| 167 |
-
report = {
|
| 168 |
-
"reveal probability r": r,
|
| 169 |
-
"reverse step on the mask": step_law[-1],
|
| 170 |
-
"weight w(0.5)": weight,
|
| 171 |
-
"worked loss term": worked_term,
|
| 172 |
-
"rate error at position 2": collapse,
|
| 173 |
-
"training loss, last 50": sum(losses[-50:]) / len(losses[-50:]),
|
| 174 |
-
"validation bound, nats/seq": bound,
|
| 175 |
-
"bound, nats per token": bound / args.length,
|
| 176 |
-
"GC fraction": gc_fraction(samples),
|
| 177 |
-
"GC fraction in the data": gc_fraction(train_tokens),
|
| 178 |
-
"motif fraction": motif_fraction(samples),
|
| 179 |
-
"motif fraction in the data": motif_fraction(train_tokens),
|
| 180 |
-
"distinct samples": unique_fraction(samples),
|
| 181 |
-
"letter entropy, nats": token_entropy(samples, DNA_SIZE),
|
| 182 |
-
}
|
| 183 |
-
print_report("mdlm: masked diffusion on DNA", report, decode_dna(samples))
|
| 184 |
-
save_run(args.out, model=model, config=vars(args), report=report,
|
| 185 |
-
losses=losses, samples=decode_dna(samples))
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
# %% 08. Run the script
|
| 189 |
-
if __name__ == "__main__": # Execute when launched directly.
|
| 190 |
-
main() # Work the numbers, train the denoiser, and generate.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
lecture_4/planner.py
DELETED
|
@@ -1,238 +0,0 @@
|
|
| 1 |
-
"""Choosing the order of the reveals in masked diffusion on DNA.
|
| 2 |
-
|
| 3 |
-
Every masked position leaves at the same total rate w(t), so which positions a step reveals is free
|
| 4 |
-
and the marginal reverse rates stay where they were. A planner spends that freedom on the positions
|
| 5 |
-
the denoiser is most sure about, a corrector returns the least supported visible tokens to the
|
| 6 |
-
mask, and the loss can be reweighted toward the positions the sampler reaches first.
|
| 7 |
-
|
| 8 |
-
Chapter 4, "Choosing the order of the reveals", "One sampling step with a planner" and "Bringing
|
| 9 |
-
the planner into training". Time runs from t = 0 clean to t = 1 all masks, the survival probability
|
| 10 |
-
is alpha_t = 1 - t, and the rates R_t(x, y) act on probability rows, p_dot = p R. Trained on the
|
| 11 |
-
synthetic DNA of cis6270.data. Prints the worked selection, the worked PAPL weights, and the
|
| 12 |
-
sample statistics under both samplers.
|
| 13 |
-
|
| 14 |
-
python lecture_4/planner.py # train with planner weights, sample both ways
|
| 15 |
-
python lecture_4/planner.py --remask 0 # confidence order with no corrector
|
| 16 |
-
python lecture_4/planner.py --steps 20 # a few seconds, for a first look
|
| 17 |
-
"""
|
| 18 |
-
|
| 19 |
-
# %% 01. Imports and configuration
|
| 20 |
-
import torch # Tensors, gradients and the categorical draws.
|
| 21 |
-
import torch.nn.functional as F # Cross-entropy at every position.
|
| 22 |
-
|
| 23 |
-
from cis6270.data import DNA_SIZE, decode_dna, dna_splits # ACGT, K = 4 and the shared sequences.
|
| 24 |
-
from cis6270.metrics import gc_fraction, motif_fraction, token_entropy, unique_fraction
|
| 25 |
-
from cis6270.nets import SequenceDenoiser, count_parameters # The denoiser d_theta(. | z, t).
|
| 26 |
-
from cis6270.runner import common_parser, pick_device, print_report, save_run, set_seed, train
|
| 27 |
-
|
| 28 |
-
MASK = DNA_SIZE # A, C, G, T are 0 to 3, and token 4 is the mask m.
|
| 29 |
-
VOCAB = DNA_SIZE + 1 # The masked alphabet V_m has K + 1 = 5 symbols.
|
| 30 |
-
TIME_FLOOR = 1e-3 # The reveal rate w(t) = 1/t is unbounded at t = 0.
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
# %% 02. The planner score and the selection it drives
|
| 34 |
-
#
|
| 35 |
-
# The score condenses the distribution at a position into one number. The probability of the most
|
| 36 |
-
# likely clean token is high where the denoiser has committed and low where it is spread out, and
|
| 37 |
-
# the negative entropy of the same distribution is the other natural choice.
|
| 38 |
-
def planner_score(probabilities: torch.Tensor, kind: str = "confidence") -> torch.Tensor:
|
| 39 |
-
"""One number per position from d_theta(. | z, t). probabilities: [B, L, K] -> [B, L]."""
|
| 40 |
-
if kind == "entropy":
|
| 41 |
-
return (probabilities * probabilities.clamp(min=1e-20).log()).sum(-1) # Negative entropy.
|
| 42 |
-
return probabilities.max(-1).values # The probability of the most likely clean token.
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
def topk_where(score: torch.Tensor, eligible: torch.Tensor, count: int) -> torch.Tensor:
|
| 46 |
-
"""A boolean mask selecting the ``count`` highest-scoring eligible positions in each row."""
|
| 47 |
-
picked = torch.zeros_like(eligible) # [B, L] nothing selected yet.
|
| 48 |
-
if count <= 0:
|
| 49 |
-
return picked # Taking none is a valid choice, and it recovers the plain sampler.
|
| 50 |
-
ranked = score.masked_fill(~eligible, float("-inf")) # Ineligible positions never win.
|
| 51 |
-
index = ranked.topk(min(count, score.shape[1]), dim=1).indices # [B, count] the best positions.
|
| 52 |
-
return picked.scatter_(1, index, True) & eligible # Rows with too few eligible slots take fewer.
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
# %% 03. One sampling step with a planner
|
| 56 |
-
#
|
| 57 |
-
# The first four lines are the sampler of the implementation section: one forward pass, the
|
| 58 |
-
# zero-mask rule, a softmax, and one categorical draw per position. Nothing in them depends on the
|
| 59 |
-
# planner, so a trained checkpoint can be sampled this way with no retraining.
|
| 60 |
-
def planner_step(model: torch.nn.Module, z: torch.Tensor, t: torch.Tensor,
|
| 61 |
-
n_unmask: int, n_remask: int, kind: str = "confidence") -> torch.Tensor:
|
| 62 |
-
"""One reverse step that reveals the best ``n_unmask`` masks and remasks the worst tokens."""
|
| 63 |
-
logits = model(z, t) # [B, L, 5] the clean-token prediction at this noise level.
|
| 64 |
-
logits[..., MASK] = -1e9 # The zero-mask rule: no posterior mass belongs on m.
|
| 65 |
-
p = logits.softmax(-1) # [B, L, 5] the denoiser as probabilities.
|
| 66 |
-
x0 = torch.distributions.Categorical(probs=p).sample() # [B, L] one candidate per position.
|
| 67 |
-
|
| 68 |
-
score = planner_score(p[..., :DNA_SIZE], kind) # [B, L] how sure the denoiser is here.
|
| 69 |
-
masked = z == MASK # [B, L] the positions a reveal may act on.
|
| 70 |
-
pick = topk_where(score, masked, n_unmask) # [B, L] the best masked positions.
|
| 71 |
-
z = torch.where(pick, x0, z) # Unmask the best.
|
| 72 |
-
|
| 73 |
-
back = topk_where(-score, ~masked, n_remask) # [B, L] the worst visible positions.
|
| 74 |
-
return torch.where(back, torch.full_like(z, MASK), z) # Remask the worst, which is the corrector.
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
# %% 04. The corrector and its flux balance
|
| 78 |
-
#
|
| 79 |
-
# C_t = R_t + R_hat_t is in detailed balance with p_t, so running it at a fixed time holds the
|
| 80 |
-
# marginal in place. Under masking its forward part returns a visible token to the mask at rate
|
| 81 |
-
# beta(t) and its reverse part reveals a masked position at rate w(t).
|
| 82 |
-
def corrector_fluxes(t: float) -> tuple[float, float]:
|
| 83 |
-
"""alpha_t beta(t) and (1 - alpha_t) w(t), the two fluxes across the visible-masked boundary."""
|
| 84 |
-
alpha = 1.0 - t # The linear schedule.
|
| 85 |
-
return alpha * (1.0 / alpha), (1.0 - alpha) * (1.0 / t) # Both equal -alpha_dot, which is one.
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
# %% 05. Bringing the planner into training
|
| 89 |
-
#
|
| 90 |
-
# The MDLM loss weights every masked position equally, which is right when the order of the reveals
|
| 91 |
-
# is uniform. Once a planner chooses the order, the weight lambda_i = M rho_i puts the gradient on
|
| 92 |
-
# the positions the sampler will reach first, and it averages to one over the masked positions.
|
| 93 |
-
def planner_weights(score: torch.Tensor, masked: torch.Tensor, strength: float) -> torch.Tensor:
|
| 94 |
-
"""lambda_i = 1 + strength (M rho_i - 1), with rho the planner's softmax over masked positions."""
|
| 95 |
-
# A large negative number in place of -inf, so a row with nothing masked still has a softmax.
|
| 96 |
-
ranked = score.masked_fill(~masked, -1e4) # [B, L] only masked positions compete.
|
| 97 |
-
rho = ranked.softmax(dim=1) # [B, L] the planner's distribution over which position to fill.
|
| 98 |
-
count = masked.sum(dim=1, keepdim=True).clamp(min=1) # [B, 1] M, the number of masked positions.
|
| 99 |
-
return 1.0 + strength * (count * rho - 1.0) # [B, L] a weight of one at strength zero.
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
def papl_loss(model: torch.nn.Module, x0: torch.Tensor, strength: float) -> torch.Tensor:
|
| 103 |
-
"""The MDLM loss with the planner weight inside the sum over masked positions."""
|
| 104 |
-
t = torch.rand(x0.shape[0], device=x0.device).clamp(min=TIME_FLOOR) # [B] one noise level each.
|
| 105 |
-
keep = torch.rand(x0.shape, device=x0.device) < (1.0 - t)[:, None] # [B, L] the survival draw.
|
| 106 |
-
zt = torch.where(keep, x0, MASK) # [B, L] the corrupted sequence.
|
| 107 |
-
logits = model(zt, t) # [B, L, 5] the clean-token prediction.
|
| 108 |
-
logits[..., MASK] = -1e9 # The zero-mask rule.
|
| 109 |
-
ce = F.cross_entropy(logits.transpose(1, 2), x0, reduction="none") # [B, L].
|
| 110 |
-
masked = zt == MASK # [B, L] where the loss lives.
|
| 111 |
-
score = planner_score(logits.softmax(-1)[..., :DNA_SIZE]).detach() # No gradient through these.
|
| 112 |
-
weights = planner_weights(score, masked, strength).clamp(min=0.0) # [B, L] lambda_i.
|
| 113 |
-
return ((1.0 / t)[:, None] * weights * ce * masked).sum(-1).mean() # Nats per sequence.
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
# %% 06. Sampling with and without the planner
|
| 117 |
-
#
|
| 118 |
-
# The number revealed per step follows the schedule: after a step down to s, the masked count is
|
| 119 |
-
# the one the survival probability calls for, which is s L on the linear schedule.
|
| 120 |
-
@torch.no_grad() # Generation needs no gradient tracking.
|
| 121 |
-
def sample(model: torch.nn.Module, batch: int, length: int, steps: int = 32,
|
| 122 |
-
n_remask: int = 0, kind: str = "confidence", planner: bool = True) -> torch.Tensor:
|
| 123 |
-
"""Generate from all masks. With planner false the reveals are drawn uniformly at random."""
|
| 124 |
-
device = next(model.parameters()).device
|
| 125 |
-
z = torch.full((batch, length), MASK, device=device) # [B, L] the all-mask start.
|
| 126 |
-
ts = torch.linspace(1.0, TIME_FLOOR, steps + 1, device=device) # The reverse grid.
|
| 127 |
-
for t, s in zip(ts[:-1], ts[1:]):
|
| 128 |
-
target = int(round(float(s) * length)) # The masked count the schedule calls for at s.
|
| 129 |
-
current = int(z.eq(MASK).sum(dim=1).max()) # The largest masked count in the batch.
|
| 130 |
-
n_unmask = max(current - target, 0) + n_remask # Enough to reach the target after remasking.
|
| 131 |
-
if planner:
|
| 132 |
-
z = planner_step(model, z, t.expand(batch), n_unmask, n_remask, kind)
|
| 133 |
-
else: # The uniform order of the plain sampler, with the same number of reveals.
|
| 134 |
-
z = _random_step(model, z, t.expand(batch), n_unmask)
|
| 135 |
-
logits = model(z, ts[-1].expand(batch)) # The last step runs down to s = 0, where r = 1.
|
| 136 |
-
logits[..., MASK] = -1e9
|
| 137 |
-
return torch.where(z == MASK, torch.distributions.Categorical(logits=logits).sample(), z)
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
def _random_step(model: torch.nn.Module, z: torch.Tensor, t: torch.Tensor,
|
| 141 |
-
n_unmask: int) -> torch.Tensor:
|
| 142 |
-
"""The same step with the masked positions ranked by a uniform draw in place of the score."""
|
| 143 |
-
logits = model(z, t) # [B, L, 5].
|
| 144 |
-
logits[..., MASK] = -1e9 # The zero-mask rule.
|
| 145 |
-
x0 = torch.distributions.Categorical(logits=logits).sample() # [B, L].
|
| 146 |
-
noise = torch.rand(z.shape, device=z.device) # [B, L] an order with no preference.
|
| 147 |
-
return torch.where(topk_where(noise, z == MASK, n_unmask), x0, z)
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
# %% 07. The report
|
| 151 |
-
def main() -> None:
|
| 152 |
-
parser = common_parser(__doc__.split("\n")[0])
|
| 153 |
-
parser.add_argument("--remask", type=int, default=1, help="positions the corrector sends back")
|
| 154 |
-
parser.add_argument("--planner-strength", type=float, default=1.0,
|
| 155 |
-
help="0 gives the MDLM loss, 1 gives the PAPL weights")
|
| 156 |
-
parser.add_argument("--score", choices=["confidence", "entropy"], default="confidence")
|
| 157 |
-
# Two samplers run on the same weights, so the default trains 1000 steps and measures 64
|
| 158 |
-
# sequences in each order, which keeps the run inside about a minute.
|
| 159 |
-
parser.set_defaults(steps=1000, sample_steps=32, samples=64)
|
| 160 |
-
args = parser.parse_args()
|
| 161 |
-
set_seed(args.seed)
|
| 162 |
-
device = pick_device(args.device)
|
| 163 |
-
talk = (lambda *a: None) if args.quiet else print # --quiet keeps only the report block.
|
| 164 |
-
|
| 165 |
-
# Section 02, the worked selection on a six-position sequence.
|
| 166 |
-
scores = torch.tensor([[0.42, 0.88, 0.91, 0.55, 0.31, 0.78]]) # [1, 6] one score per position.
|
| 167 |
-
masked = torch.tensor([[True, False, True, True, False, True]]) # Positions 1, 3, 4 and 6.
|
| 168 |
-
revealed = topk_where(scores, masked, 2).nonzero()[:, 1] + 1 # The two best masked positions.
|
| 169 |
-
returned = topk_where(-scores, ~masked, 1).nonzero()[:, 1] + 1 # The worst visible position.
|
| 170 |
-
talk(f"planner selection reveals {sorted(int(i) for i in revealed)} remasks "
|
| 171 |
-
f"{sorted(int(i) for i in returned)}")
|
| 172 |
-
|
| 173 |
-
# Section 04, the two fluxes across the boundary between visible and masked.
|
| 174 |
-
talk("corrector flux " + " ".join(
|
| 175 |
-
f"t={t}: beta flux {a:.1f}, w flux {b:.1f}" for t in (0.5, 0.25)
|
| 176 |
-
for a, b in [corrector_fluxes(t)]))
|
| 177 |
-
|
| 178 |
-
# Section 05, the worked PAPL weights on a state with four masked positions.
|
| 179 |
-
rho = torch.tensor([[0.10, 0.50, 0.15, 0.25]]) # [1, 4] the planner's distribution.
|
| 180 |
-
cross_entropies = torch.tensor([[1.2, 0.3, 0.9, 0.5]]) # [1, 4] nats at each masked position.
|
| 181 |
-
lam = 4.0 * rho # [1, 4] lambda_i = M rho_i, which averages to one.
|
| 182 |
-
talk(f"PAPL weights {[round(float(v), 1) for v in lam[0]]} sum {float(lam.sum()):.1f} "
|
| 183 |
-
f"MDLM inner sum {float(cross_entropies.sum()):.2f} PAPL inner sum "
|
| 184 |
-
f"{float((lam * cross_entropies).sum()):.2f}")
|
| 185 |
-
|
| 186 |
-
# Train the denoiser with the planner weights inside the loss.
|
| 187 |
-
splits = dna_splits(num_sequences=512, length=args.length, seed=args.seed)
|
| 188 |
-
train_tokens = splits["train_tokens"].to(device) # [N, L] clean sequences over ACGT.
|
| 189 |
-
valid_tokens = splits["valid_tokens"].to(device) # [M, L] held out for the loss below.
|
| 190 |
-
model = SequenceDenoiser(VOCAB, args.length, width=args.width).to(device)
|
| 191 |
-
talk(f"device={device} parameters={count_parameters(model):,} strength={args.planner_strength}")
|
| 192 |
-
|
| 193 |
-
def step_loss(step: int) -> torch.Tensor:
|
| 194 |
-
rows = torch.randint(0, len(train_tokens), (args.batch_size,), device=device)
|
| 195 |
-
return papl_loss(model, train_tokens[rows], args.planner_strength)
|
| 196 |
-
|
| 197 |
-
losses = train(model, step_loss, steps=args.steps, lr=args.lr, quiet=args.quiet)
|
| 198 |
-
with torch.no_grad():
|
| 199 |
-
weighted = sum(float(papl_loss(model, valid_tokens, args.planner_strength))
|
| 200 |
-
for _ in range(4)) / 4.0
|
| 201 |
-
plain = sum(float(papl_loss(model, valid_tokens, 0.0)) for _ in range(4)) / 4.0
|
| 202 |
-
|
| 203 |
-
# Sample the same weights twice, once in a uniform order and once in the planner's order.
|
| 204 |
-
runs = {}
|
| 205 |
-
for name, planner, remask in (("uniform order", False, 0),
|
| 206 |
-
("planner order", True, args.remask)):
|
| 207 |
-
torch.manual_seed(args.seed + 1) # The same draws for both samplers.
|
| 208 |
-
runs[name] = sample(model, args.samples, args.length, args.sample_steps,
|
| 209 |
-
remask, args.score, planner)
|
| 210 |
-
|
| 211 |
-
report = {
|
| 212 |
-
"revealed positions": sorted(int(i) for i in revealed),
|
| 213 |
-
"remasked position": sorted(int(i) for i in returned),
|
| 214 |
-
"flux at t = 0.5": corrector_fluxes(0.5)[0],
|
| 215 |
-
"flux at t = 0.25": corrector_fluxes(0.25)[1],
|
| 216 |
-
"PAPL weight at the planner's pick": float(lam[0, 1]),
|
| 217 |
-
"MDLM inner sum": float(cross_entropies.sum()),
|
| 218 |
-
"PAPL inner sum": float((lam * cross_entropies).sum()),
|
| 219 |
-
"training loss, last 50": sum(losses[-50:]) / len(losses[-50:]),
|
| 220 |
-
"validation PAPL loss": weighted,
|
| 221 |
-
"validation MDLM loss": plain,
|
| 222 |
-
"corrector remasks per step": args.remask,
|
| 223 |
-
}
|
| 224 |
-
for name, drawn in runs.items():
|
| 225 |
-
report[f"GC fraction, {name}"] = gc_fraction(drawn)
|
| 226 |
-
report[f"motif fraction, {name}"] = motif_fraction(drawn)
|
| 227 |
-
report[f"distinct, {name}"] = unique_fraction(drawn)
|
| 228 |
-
report["GC fraction in the data"] = gc_fraction(train_tokens)
|
| 229 |
-
report["motif fraction in the data"] = motif_fraction(train_tokens)
|
| 230 |
-
report["letter entropy, planner order"] = token_entropy(runs["planner order"], DNA_SIZE)
|
| 231 |
-
print_report("planner: the order of the reveals", report, decode_dna(runs["planner order"]))
|
| 232 |
-
save_run(args.out, model=model, config=vars(args), report=report,
|
| 233 |
-
losses=losses, samples=decode_dna(runs["planner order"]))
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
# %% 08. Run the script
|
| 237 |
-
if __name__ == "__main__": # Execute when launched directly.
|
| 238 |
-
main() # Work the selection, train with the planner weights, and sample both ways.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
lecture_4/search.py
DELETED
|
@@ -1,250 +0,0 @@
|
|
| 1 |
-
"""Guiding masked diffusion on DNA by search over the tree of partial sequences.
|
| 2 |
-
|
| 3 |
-
For a property computed by an external scorer there is no classifier at intermediate noise levels,
|
| 4 |
-
so the value function is estimated by finishing the sequence and scoring the result. The partial
|
| 5 |
-
sequences form a tree whose edges are single reveals, selection walks it by an upper-confidence
|
| 6 |
-
rule, a rollout scores a new leaf, and the reward propagates back up the branch. Two objectives
|
| 7 |
-
that no single number orders are kept in a Pareto archive.
|
| 8 |
-
|
| 9 |
-
Chapter 4, "Guiding generation by search" and "Search over the tree of partial sequences". The
|
| 10 |
-
fine-tuning of "Keeping the results of the search" is left to the chapter. Time runs from t = 0
|
| 11 |
-
clean to t = 1 all masks, the survival probability is alpha_t = 1 - t, and the rates R_t(x, y)
|
| 12 |
-
act on probability rows, p_dot = p R.
|
| 13 |
-
Trained on the synthetic DNA of cis6270.data. Prints the worked value estimate, the worked archive,
|
| 14 |
-
and the archive the search finds.
|
| 15 |
-
|
| 16 |
-
python lecture_4/search.py # train, then search
|
| 17 |
-
python lecture_4/search.py --searches 64 # a larger tree
|
| 18 |
-
python lecture_4/search.py --sample-steps 8 # longer rollouts behind every value
|
| 19 |
-
python lecture_4/search.py --steps 20 # a few seconds, for a first look
|
| 20 |
-
"""
|
| 21 |
-
|
| 22 |
-
# %% 01. Imports and configuration
|
| 23 |
-
import math # The upper-confidence rule is a square root of a logarithm.
|
| 24 |
-
|
| 25 |
-
import torch # Tensors, gradients and the categorical draws.
|
| 26 |
-
import torch.nn.functional as F # Cross-entropy at every position.
|
| 27 |
-
|
| 28 |
-
from cis6270.data import DNA_SIZE, MOTIF, decode_dna, dna_splits, encode_dna
|
| 29 |
-
from cis6270.metrics import gc_fraction, motif_fraction, unique_fraction
|
| 30 |
-
from cis6270.nets import SequenceDenoiser, count_parameters # The denoiser d_theta(. | z, t).
|
| 31 |
-
from cis6270.runner import common_parser, pick_device, print_report, save_run, set_seed, train
|
| 32 |
-
|
| 33 |
-
MASK = DNA_SIZE # A, C, G, T are 0 to 3, and token 4 is the mask m.
|
| 34 |
-
VOCAB = DNA_SIZE + 1 # The masked alphabet V_m has K + 1 = 5 symbols.
|
| 35 |
-
TIME_FLOOR = 1e-3 # The reveal rate w(t) = 1/t is unbounded at t = 0.
|
| 36 |
-
EXPLORATION = 1.0 # c_uct, the weight on the visit-count term of the selection rule.
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
# %% 02. The two toy objectives
|
| 40 |
-
#
|
| 41 |
-
# Binding, solubility and permeability do not reduce to one number without a weighting that fixes
|
| 42 |
-
# the trade-off in advance. Two scores stand in for them here, a GC fraction and the best partial
|
| 43 |
-
# match to the planted motif, both in [0, 1] and neither implying the other.
|
| 44 |
-
def objectives(tokens: torch.Tensor) -> torch.Tensor:
|
| 45 |
-
"""Returns [N, 2]: the GC fraction of each sequence and its best match to the motif."""
|
| 46 |
-
gc = ((tokens == 1) | (tokens == 2)).to(torch.get_default_dtype()).mean(dim=1) # [N] C and G.
|
| 47 |
-
motif = encode_dna([MOTIF])[0].to(tokens.device) # [4] the planted motif ACGT.
|
| 48 |
-
windows = tokens.unfold(1, len(motif), 1) # [N, L - 3, 4] every window of the motif's length.
|
| 49 |
-
hits = (windows == motif).to(torch.get_default_dtype()).mean(dim=2) # [N, L - 3] per window.
|
| 50 |
-
match = hits.max(dim=1).values # [N] the best window of each sequence.
|
| 51 |
-
return torch.stack([gc, match], dim=1) # [N, 2] one row per sequence.
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
# %% 03. Rollouts as a value estimate
|
| 55 |
-
#
|
| 56 |
-
# h_t(z) is the probability that a sequence continuing from z ends with the property, so running
|
| 57 |
-
# the sampler to completion N times and averaging the reward estimates it without bias, with a
|
| 58 |
-
# variance that falls like 1/N.
|
| 59 |
-
@torch.no_grad() # Rollouts need no gradient tracking.
|
| 60 |
-
def complete(model: torch.nn.Module, z: torch.Tensor, t: float, steps: int = 3) -> torch.Tensor:
|
| 61 |
-
"""Run the masked sampler from z at time t down to clean DNA. z: [N, L] -> [N, L]."""
|
| 62 |
-
device = z.device
|
| 63 |
-
ts = torch.linspace(t, TIME_FLOOR, steps + 1, device=device) # The remaining reverse grid.
|
| 64 |
-
for now, nxt in zip(ts[:-1], ts[1:]):
|
| 65 |
-
logits = model(z, now.expand(len(z))) # [N, L, 5] the clean-token prediction.
|
| 66 |
-
logits[..., MASK] = -1e9 # The zero-mask rule: no posterior mass belongs on m.
|
| 67 |
-
x0 = torch.distributions.Categorical(logits=logits).sample() # [N, L].
|
| 68 |
-
unmask = torch.rand(z.shape, device=device) < (now - nxt) / now # [N, L] the reveal draw.
|
| 69 |
-
z = torch.where((z == MASK) & unmask, x0, z) # Carry-over unmasking.
|
| 70 |
-
logits = model(z, ts[-1].expand(len(z))) # The last step runs down to s = 0, where r = 1.
|
| 71 |
-
logits[..., MASK] = -1e9
|
| 72 |
-
return torch.where(z == MASK, torch.distributions.Categorical(logits=logits).sample(), z)
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
def value_estimate(model: torch.nn.Module, state: torch.Tensor, t: float, rollouts: int,
|
| 76 |
-
steps: int = 3) -> tuple[torch.Tensor, torch.Tensor]:
|
| 77 |
-
"""Average the reward over N completions of one partial sequence. Returns [2] and [N, L]."""
|
| 78 |
-
batch = state[None, :].repeat(rollouts, 1) # [N, L] the same state, N independent endings.
|
| 79 |
-
finished = complete(model, batch, 1.0 if t >= 1.0 else t, steps) # [N, L] clean sequences.
|
| 80 |
-
return objectives(finished).mean(dim=0), finished # [2] the value, and what produced it.
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
# %% 04. The Pareto archive
|
| 84 |
-
#
|
| 85 |
-
# A candidate worse on every axis than one already kept is dominated and discarded, and the rest
|
| 86 |
-
# stay, since each of them beats every other on at least one axis.
|
| 87 |
-
class ParetoArchive:
|
| 88 |
-
"""Every nondominated sequence the search has produced, with its objective vector."""
|
| 89 |
-
|
| 90 |
-
def __init__(self) -> None:
|
| 91 |
-
self.entries: list[tuple[tuple[int, ...], tuple[float, ...]]] = []
|
| 92 |
-
|
| 93 |
-
def add(self, sequence: tuple[int, ...], scores: tuple[float, ...]) -> None:
|
| 94 |
-
"""Keep the candidate when nothing in the archive beats it on every axis."""
|
| 95 |
-
for _, kept in self.entries:
|
| 96 |
-
if all(k >= s for k, s in zip(kept, scores)) and kept != scores:
|
| 97 |
-
return # Dominated, so it adds nothing.
|
| 98 |
-
self.entries = [(q, kept) for q, kept in self.entries # Drop what the newcomer dominates.
|
| 99 |
-
if not (all(s >= k for s, k in zip(scores, kept)) and kept != scores)]
|
| 100 |
-
if sequence not in {q for q, _ in self.entries}:
|
| 101 |
-
self.entries.append((sequence, scores))
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
# %% 05. The tree of partial sequences
|
| 105 |
-
#
|
| 106 |
-
# Selection walks from the root to a leaf by the upper-confidence rule, expansion adds children
|
| 107 |
-
# drawn from the denoiser perturbed by Gumbel noise, a rollout scores one of them, and the reward
|
| 108 |
-
# propagates back up the branch so that every node on it accumulates visits.
|
| 109 |
-
class Node:
|
| 110 |
-
"""A partially unmasked sequence, with the visits and rewards the search has accumulated."""
|
| 111 |
-
|
| 112 |
-
def __init__(self, state: torch.Tensor, depth: int, parent=None):
|
| 113 |
-
self.state, self.depth, self.parent = state, depth, parent
|
| 114 |
-
self.children: list[Node] = []
|
| 115 |
-
self.visits, self.total = 0, 0.0 # The visit count and the summed scalar reward.
|
| 116 |
-
|
| 117 |
-
@property
|
| 118 |
-
def mean(self) -> float:
|
| 119 |
-
"""The mean reward at this node, which the selection rule weighs against its visit count."""
|
| 120 |
-
return self.total / self.visits if self.visits else 0.0
|
| 121 |
-
|
| 122 |
-
def best_child(self):
|
| 123 |
-
"""argmax of r_bar_j + c sqrt(log n / n_j), the usual upper-confidence rule."""
|
| 124 |
-
return max(self.children, key=lambda child: child.mean + EXPLORATION * math.sqrt(
|
| 125 |
-
math.log(max(self.visits, 1)) / child.visits) if child.visits else float("inf"))
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
@torch.no_grad() # The expansion needs no gradient tracking.
|
| 129 |
-
def expand(model: torch.nn.Module, node: Node, t: float, branch: int) -> None:
|
| 130 |
-
"""Add children by revealing one masked position, drawn from the denoiser plus Gumbel noise."""
|
| 131 |
-
masked = (node.state == MASK).nonzero().flatten() # [m] the positions a reveal may act on.
|
| 132 |
-
if len(masked) == 0:
|
| 133 |
-
return # A complete sequence has no children.
|
| 134 |
-
logits = model(node.state[None], torch.tensor([t], device=node.state.device))[0] # [L, 5].
|
| 135 |
-
logits[..., MASK] = -1e9 # The zero-mask rule.
|
| 136 |
-
gumbel = -torch.log(-torch.log(torch.rand_like(logits).clamp(min=1e-20))) # [L, 5].
|
| 137 |
-
perturbed = (logits + gumbel).masked_fill( # Taking maxima of this samples the denoiser.
|
| 138 |
-
(node.state != MASK)[:, None], float("-inf"))
|
| 139 |
-
flat = perturbed[:, :DNA_SIZE].flatten() # [L K] one score per position and clean token.
|
| 140 |
-
for index in flat.topk(min(branch, int(len(masked)) * DNA_SIZE)).indices: # Distinct reveals.
|
| 141 |
-
child_state = node.state.clone() # [L] the parent with one more position filled.
|
| 142 |
-
child_state[int(index) // DNA_SIZE] = int(index) % DNA_SIZE
|
| 143 |
-
node.children.append(Node(child_state, node.depth + 1, node))
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
def backup(node: Node, reward: float) -> None:
|
| 147 |
-
"""Add one rollout's reward to every node on the branch from here to the root."""
|
| 148 |
-
while node is not None:
|
| 149 |
-
node.visits, node.total = node.visits + 1, node.total + reward
|
| 150 |
-
node = node.parent
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
# %% 06. The report
|
| 154 |
-
def main() -> None:
|
| 155 |
-
parser = common_parser(__doc__.split("\n")[0])
|
| 156 |
-
parser.add_argument("--searches", type=int, default=24, help="tree expansions")
|
| 157 |
-
parser.add_argument("--branch", type=int, default=3, help="children added at each expansion")
|
| 158 |
-
parser.add_argument("--rollouts", type=int, default=6, help="completions scored per leaf")
|
| 159 |
-
# Every leaf is scored by its own rollouts, so --sample-steps is the length of one rollout and
|
| 160 |
-
# stays short. Training rises to 1000 steps, which keeps the run inside about a minute.
|
| 161 |
-
parser.set_defaults(steps=1000, sample_steps=3)
|
| 162 |
-
args = parser.parse_args()
|
| 163 |
-
set_seed(args.seed)
|
| 164 |
-
device = pick_device(args.device)
|
| 165 |
-
talk = (lambda *a: None) if args.quiet else print # --quiet keeps only the report block.
|
| 166 |
-
|
| 167 |
-
# Section 03, the worked value estimate and the reweighting it produces.
|
| 168 |
-
at_parent = float(torch.tensor([0.1, 0.8, 0.3, 0.2]).mean()) # Four rollouts from z.
|
| 169 |
-
at_child = float(torch.tensor([0.7, 0.9, 0.6, 0.8]).mean()) # Four rollouts from its child.
|
| 170 |
-
factor = at_child / at_parent # The h-transform factor on that edge.
|
| 171 |
-
sibling = 0.2 / at_parent # A child whose rollouts averaged 0.2 is slowed by this factor.
|
| 172 |
-
talk(f"value estimates h(z) = {at_parent:.2f} h(z') = {at_child:.2f} factor "
|
| 173 |
-
f"{factor:.3f} a model rate of 1.2 becomes {1.2 * factor:.3f}, and a sibling at 0.2 is "
|
| 174 |
-
f"slowed by {sibling:.3f}")
|
| 175 |
-
|
| 176 |
-
# Section 04, the worked archive on three candidates scored on two axes.
|
| 177 |
-
worked = ParetoArchive()
|
| 178 |
-
for name, scores in (("P1", (0.8, 0.3)), ("P2", (0.5, 0.7)), ("P3", (0.4, 0.2))):
|
| 179 |
-
worked.add(tuple(ord(c) for c in name), scores) # The name stands in for a sequence.
|
| 180 |
-
kept = [chr(q[0]) + chr(q[1]) for q, _ in worked.entries] # Which candidates survived.
|
| 181 |
-
talk(f"worked archive keeps {kept}, so P3 is dominated by P1 on both axes")
|
| 182 |
-
|
| 183 |
-
# Train the masked diffusion model the search will guide.
|
| 184 |
-
splits = dna_splits(num_sequences=512, length=args.length, seed=args.seed)
|
| 185 |
-
train_tokens = splits["train_tokens"].to(device) # [N, L] clean sequences over ACGT.
|
| 186 |
-
model = SequenceDenoiser(VOCAB, args.length, width=args.width).to(device)
|
| 187 |
-
talk(f"device={device} parameters={count_parameters(model):,} searches={args.searches}")
|
| 188 |
-
|
| 189 |
-
def step_loss(step: int) -> torch.Tensor:
|
| 190 |
-
"""The masked diffusion loss, unchanged: the search reweights rates, not weights."""
|
| 191 |
-
rows = torch.randint(0, len(train_tokens), (args.batch_size,), device=device)
|
| 192 |
-
x0 = train_tokens[rows]
|
| 193 |
-
t = torch.rand(len(x0), device=device).clamp(min=TIME_FLOOR) # [B] one noise level each.
|
| 194 |
-
zt = torch.where(torch.rand(x0.shape, device=device) < (1.0 - t)[:, None], x0, MASK)
|
| 195 |
-
logits = model(zt, t) # [B, L, 5].
|
| 196 |
-
logits[..., MASK] = -1e9 # The zero-mask rule.
|
| 197 |
-
ce = F.cross_entropy(logits.transpose(1, 2), x0, reduction="none") # [B, L].
|
| 198 |
-
return ((1.0 / t)[:, None] * ce * (zt == MASK)).sum(-1).mean()
|
| 199 |
-
|
| 200 |
-
losses = train(model, step_loss, steps=args.steps, lr=args.lr, quiet=args.quiet)
|
| 201 |
-
|
| 202 |
-
# Run the search, keeping every completed rollout that is not dominated.
|
| 203 |
-
torch.manual_seed(args.seed + 1) # Fix the Gumbel draws and the rollouts.
|
| 204 |
-
root = Node(torch.full((args.length,), MASK, device=device), 0)
|
| 205 |
-
archive, scored = ParetoArchive(), [] # The archive and every rollout's objective vector.
|
| 206 |
-
for _ in range(args.searches):
|
| 207 |
-
node = root # Selection walks from the root down the tree already built.
|
| 208 |
-
while node.children:
|
| 209 |
-
node = node.best_child()
|
| 210 |
-
time_here = max(1.0 - node.depth / args.length, TIME_FLOOR) # The level at this depth.
|
| 211 |
-
expand(model, node, time_here, args.branch) # Expansion adds children by one reveal each.
|
| 212 |
-
leaf = node.children[0] if node.children else node # Score the first new child.
|
| 213 |
-
value, finished = value_estimate(model, leaf.state, time_here, args.rollouts,
|
| 214 |
-
args.sample_steps)
|
| 215 |
-
backup(leaf, float(value.mean())) # One scalar reward guides the selection rule.
|
| 216 |
-
for sequence, score in zip(finished.tolist(), objectives(finished).tolist()):
|
| 217 |
-
archive.add(tuple(sequence), tuple(round(v, 4) for v in score)) # Keep the best front.
|
| 218 |
-
scored.append(score)
|
| 219 |
-
|
| 220 |
-
found = torch.tensor([s for _, s in archive.entries]) # [A, 2] the archive's objectives.
|
| 221 |
-
everything = torch.tensor(scored) # [R, 2] every rollout the search scored.
|
| 222 |
-
best = torch.tensor([list(q) for q, _ in archive.entries], device=device) # [A, L] sequences.
|
| 223 |
-
report = {
|
| 224 |
-
"value at the parent": at_parent,
|
| 225 |
-
"value at the child": at_child,
|
| 226 |
-
"reweighting factor": factor,
|
| 227 |
-
"guided rate from 1.2": 1.2 * factor,
|
| 228 |
-
"factor at a sibling scoring 0.2": sibling,
|
| 229 |
-
"worked archive size": len(worked.entries),
|
| 230 |
-
"training loss, last 50": sum(losses[-50:]) / len(losses[-50:]),
|
| 231 |
-
"rollouts scored": len(scored),
|
| 232 |
-
"archive size": len(archive.entries),
|
| 233 |
-
"best GC in the archive": float(found[:, 0].max()),
|
| 234 |
-
"best motif match in the archive": float(found[:, 1].max()),
|
| 235 |
-
"mean GC over all rollouts": float(everything[:, 0].mean()),
|
| 236 |
-
"mean motif match over all rollouts": float(everything[:, 1].mean()),
|
| 237 |
-
"rollout length, steps": args.sample_steps,
|
| 238 |
-
"GC fraction in the data": gc_fraction(train_tokens),
|
| 239 |
-
"motif fraction in the data": motif_fraction(train_tokens),
|
| 240 |
-
"motif fraction in the archive": motif_fraction(best),
|
| 241 |
-
"distinct in the archive": unique_fraction(best),
|
| 242 |
-
}
|
| 243 |
-
print_report("search: a Pareto archive over partial sequences", report, decode_dna(best))
|
| 244 |
-
save_run(args.out, model=model, config=vars(args), report=report,
|
| 245 |
-
losses=losses, samples=decode_dna(best))
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
# %% 07. Run the script
|
| 249 |
-
if __name__ == "__main__": # Execute when launched directly.
|
| 250 |
-
main() # Work the value estimate, train the model, and search under the h-transform.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
lecture_4/sedd.py
DELETED
|
@@ -1,176 +0,0 @@
|
|
| 1 |
-
"""SEDD on DNA, a network that predicts the ratios the reverse rates are built from.
|
| 2 |
-
|
| 3 |
-
The reverse rate at a position is a forward rate times the ratio p_t(z with position i set to a)
|
| 4 |
-
divided by p_t(z), and the two models before this one reached that ratio through a denoiser over
|
| 5 |
-
clean tokens and a sum against the forward kernel. Here the network outputs the ratio itself, and
|
| 6 |
-
the rate error becomes score entropy, whose minimum over the prediction sits at the true ratio.
|
| 7 |
-
|
| 8 |
-
Chapter 4, "The ratio we have to learn" and "Predicting the ratios directly", on the uniform member
|
| 9 |
-
of the pi family. Time runs from t = 0 clean to t = 1 noise, the survival probability is
|
| 10 |
-
alpha_t = exp(-beta t), and the rates R_t(x, y) act on probability rows, p_dot = p R. Trained on the
|
| 11 |
-
synthetic DNA of cis6270.data. Prints the worked score entropy at three predictions and the sample
|
| 12 |
-
statistics.
|
| 13 |
-
|
| 14 |
-
python lecture_4/sedd.py # train and generate
|
| 15 |
-
python lecture_4/sedd.py --steps 20 # a few seconds, for a first look
|
| 16 |
-
"""
|
| 17 |
-
|
| 18 |
-
# %% 01. Imports and configuration
|
| 19 |
-
import math # The worked score entropies are logarithms.
|
| 20 |
-
|
| 21 |
-
import torch # Tensors, gradients and the categorical draws.
|
| 22 |
-
|
| 23 |
-
from cis6270.data import DNA_SIZE, decode_dna, dna_splits # ACGT, K = 4 and the shared sequences.
|
| 24 |
-
from cis6270.metrics import gc_fraction, motif_fraction, token_entropy, unique_fraction
|
| 25 |
-
from cis6270.nets import SequenceDenoiser, count_parameters # Reused as L heads of K outputs.
|
| 26 |
-
from cis6270.runner import common_parser, pick_device, print_report, save_run, set_seed, train
|
| 27 |
-
|
| 28 |
-
BETA = 4.0 # The constant noise rate, so alpha_t = exp(-4t) and the forward rate is BETA/K.
|
| 29 |
-
TIME_FLOOR = 2e-2 # The true ratios grow like 1/(1 - alpha_t), so sampling stops just above zero.
|
| 30 |
-
MAX_RATIO = 1e4 # The predicted ratios are exponentials, kept finite for the sampler.
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
# %% 02. The concrete score
|
| 34 |
-
#
|
| 35 |
-
# s_t(z)_{i,a} = p_t(z^{i <- a}) / p_t(z) holds L(K - 1) numbers per sequence, one per position and
|
| 36 |
-
# per alternative token, which is exactly the shape of a network with L heads of K logits.
|
| 37 |
-
def concrete_score(model: torch.nn.Module, z: torch.Tensor, t: torch.Tensor) -> torch.Tensor:
|
| 38 |
-
"""s_theta(z)_{i,a}, positive and with no normalization constraint. Returns [B, L, K]."""
|
| 39 |
-
return model(z, t).clamp(max=math.log(MAX_RATIO)).exp() # [B, L, K] exponentiate the logits.
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
def kernel_ratio(x0: torch.Tensor, z: torch.Tensor, alpha: torch.Tensor) -> torch.Tensor:
|
| 43 |
-
"""The conditional target r = q_t(a | x_0) / q_t(z^i | x_0), one per destination. [B, L, K]."""
|
| 44 |
-
uniform = (1.0 - alpha) / DNA_SIZE # [B, 1] the redrawn share at this noise level.
|
| 45 |
-
numerator = uniform[..., None] + alpha[..., None] * torch.nn.functional.one_hot(
|
| 46 |
-
x0, DNA_SIZE) # [B, L, K] q_t(a | x_0) at every destination a.
|
| 47 |
-
denominator = numerator.gather(2, z[:, :, None]) # [B, L, 1] the same kernel at the current token.
|
| 48 |
-
return numerator / denominator # [B, L, K] the ratio the network has to reproduce.
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
# %% 03. Denoising score entropy
|
| 52 |
-
#
|
| 53 |
-
# Writing the target rate as lambda r and the model rate as lambda s turns the rate error into
|
| 54 |
-
# lambda [s - r log s + r(log r - 1)]. The first two terms hold every dependence on the prediction,
|
| 55 |
-
# a linear term against a logarithmic one, so the minimum over s at fixed r sits at s = r.
|
| 56 |
-
def score_entropy(target: torch.Tensor, predicted: torch.Tensor,
|
| 57 |
-
forward_rate: float = 1.0) -> torch.Tensor:
|
| 58 |
-
"""l(lambda r, lambda s), elementwise, which vanishes at s = r."""
|
| 59 |
-
constant = target * (target.clamp(min=1e-20).log() - 1.0) # r(log r - 1), free of the prediction.
|
| 60 |
-
return forward_rate * (predicted - target * predicted.clamp(min=1e-20).log() + constant)
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
def sedd_loss(model: torch.nn.Module, x0: torch.Tensor) -> torch.Tensor:
|
| 64 |
-
"""One Monte Carlo sample of the score entropy, summed over destinations and positions."""
|
| 65 |
-
t = torch.rand(x0.shape[0], 1, device=x0.device).clamp(min=TIME_FLOOR) # [B, 1] one time each.
|
| 66 |
-
alpha = torch.exp(-BETA * t) # [B, 1] the survival probability at that time.
|
| 67 |
-
keep = torch.rand(x0.shape, device=x0.device) < alpha # [B, L] survival of the original base.
|
| 68 |
-
fresh = torch.randint(0, DNA_SIZE, x0.shape, device=x0.device) # [B, L] a draw from pi.
|
| 69 |
-
z = torch.where(keep, x0, fresh) # [B, L] uniform replacement, with no marker of the damage.
|
| 70 |
-
target = kernel_ratio(x0, z, alpha) # [B, L, K] the ratio attached to this clean sequence.
|
| 71 |
-
predicted = concrete_score(model, z, t[:, 0]) # [B, L, K] the network's own ratios.
|
| 72 |
-
current = torch.nn.functional.one_hot(z, DNA_SIZE).bool() # [B, L, K] the token already in place.
|
| 73 |
-
error = score_entropy(target, predicted, BETA / DNA_SIZE) # [B, L, K] one term per jump.
|
| 74 |
-
return error.masked_fill(current, 0.0).sum((1, 2)).mean() # Nats per sequence.
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
# %% 04. Sampling from the predicted ratios
|
| 78 |
-
#
|
| 79 |
-
# The reverse rate is the forward rate of the jump that would undo it, times the predicted ratio,
|
| 80 |
-
# and tau-leaping turns each rate into a jump probability over a step of length h.
|
| 81 |
-
@torch.no_grad() # Generation needs no gradient tracking.
|
| 82 |
-
def sample(model: torch.nn.Module, batch: int, length: int, steps: int = 64) -> torch.Tensor:
|
| 83 |
-
"""Generate from uniformly random tokens down to clean DNA. Returns [B, L] over ACGT."""
|
| 84 |
-
device = next(model.parameters()).device
|
| 85 |
-
z = torch.randint(0, DNA_SIZE, (batch, length), device=device) # [B, L] a draw from pi.
|
| 86 |
-
ts = torch.linspace(1.0, TIME_FLOOR, steps + 1, device=device) # The reverse grid.
|
| 87 |
-
for t, s in zip(ts[:-1], ts[1:]):
|
| 88 |
-
rates = (BETA / DNA_SIZE) * concrete_score(model, z, t.expand(batch)) # [B, L, K].
|
| 89 |
-
jump = (t - s) * rates # [B, L, K] the tau-leaping probability of each jump.
|
| 90 |
-
jump.scatter_(2, z[:, :, None], 0.0) # A jump to the current base is not a jump.
|
| 91 |
-
jump = jump / jump.sum(-1, keepdim=True).clamp(min=1.0) # Rescale an oversized step.
|
| 92 |
-
stay = (1.0 - jump.sum(-1, keepdim=True)).clamp(min=0.0) # [B, L, 1] the chance of staying.
|
| 93 |
-
choice = torch.distributions.Categorical(
|
| 94 |
-
probs=torch.cat([jump, stay], dim=-1)).sample() # [B, L] destination, or K for staying.
|
| 95 |
-
z = torch.where(choice == DNA_SIZE, z, choice.clamp(max=DNA_SIZE - 1)) # [B, L].
|
| 96 |
-
return z # [B, L] clean DNA, since uniform replacement never leaves the alphabet.
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
# %% 05. The report
|
| 100 |
-
def main() -> None:
|
| 101 |
-
parser = common_parser(__doc__.split("\n")[0])
|
| 102 |
-
# The ratios need a longer budget than the worked numbers do, so the default trains for 1000
|
| 103 |
-
# steps and measures on 64 sequences, which keeps the run inside about a minute.
|
| 104 |
-
parser.set_defaults(steps=1000, sample_steps=64, samples=64)
|
| 105 |
-
args = parser.parse_args()
|
| 106 |
-
set_seed(args.seed)
|
| 107 |
-
device = pick_device(args.device)
|
| 108 |
-
talk = (lambda *a: None) if args.quiet else print # --quiet keeps only the report block.
|
| 109 |
-
|
| 110 |
-
# Section 02, the target ratio of the running example, reached through the denoiser route.
|
| 111 |
-
posterior = torch.tensor([0.375, 0.625, 0.0, 0.0]) # p(x_0 | X_t = C) at alpha_t = 1/2.
|
| 112 |
-
per_clean = torch.tensor([5.0, 0.2, 1.0, 1.0]) # q_t(A | b) / q_t(C | b) for b over ACGT.
|
| 113 |
-
target_ratio = float(posterior @ per_clean) # The ratio in the A direction, which comes out 2.
|
| 114 |
-
|
| 115 |
-
# Section 03, the score entropy at three predictions of that ratio.
|
| 116 |
-
errors = {s: float(score_entropy(torch.tensor(target_ratio), torch.tensor(s)))
|
| 117 |
-
for s in (1.8, 1.0, 3.0)} # A ten percent error against two larger ones.
|
| 118 |
-
talk(f"target ratio {target_ratio:.1f} from 0.375(5) + 0.625(0.2)")
|
| 119 |
-
at_target = float(score_entropy(torch.tensor(target_ratio), torch.tensor(target_ratio)))
|
| 120 |
-
talk("score entropy " + " ".join(f"s={s}: {v:.4f}" for s, v in errors.items()) +
|
| 121 |
-
f" at s = r it is {at_target:.1e}")
|
| 122 |
-
|
| 123 |
-
# Train the network to output the ratios.
|
| 124 |
-
splits = dna_splits(num_sequences=512, length=args.length, seed=args.seed)
|
| 125 |
-
train_tokens = splits["train_tokens"].to(device) # [N, L] clean sequences over ACGT.
|
| 126 |
-
valid_tokens = splits["valid_tokens"].to(device) # [M, L] held out for the loss below.
|
| 127 |
-
model = SequenceDenoiser(DNA_SIZE, args.length, width=args.width).to(device)
|
| 128 |
-
talk(f"device={device} parameters={count_parameters(model):,} ratios per sequence "
|
| 129 |
-
f"{args.length * (DNA_SIZE - 1)}")
|
| 130 |
-
|
| 131 |
-
def step_loss(step: int) -> torch.Tensor:
|
| 132 |
-
rows = torch.randint(0, len(train_tokens), (args.batch_size,), device=device)
|
| 133 |
-
return sedd_loss(model, train_tokens[rows]) # One clean batch into one scalar.
|
| 134 |
-
|
| 135 |
-
losses = train(model, step_loss, steps=args.steps, lr=args.lr, quiet=args.quiet)
|
| 136 |
-
with torch.no_grad():
|
| 137 |
-
held_out = sum(float(sedd_loss(model, valid_tokens)) for _ in range(4)) / 4.0
|
| 138 |
-
|
| 139 |
-
# The ratios the trained network predicts at a half-corrupted batch, against their targets.
|
| 140 |
-
with torch.no_grad():
|
| 141 |
-
check_t = torch.full((len(valid_tokens), 1), math.log(2.0) / BETA, device=device)
|
| 142 |
-
check_alpha = torch.exp(-BETA * check_t) # alpha_t = 1/2 at this time.
|
| 143 |
-
keep = torch.rand(valid_tokens.shape, device=device) < check_alpha
|
| 144 |
-
noisy = torch.where(keep, valid_tokens,
|
| 145 |
-
torch.randint(0, DNA_SIZE, valid_tokens.shape, device=device))
|
| 146 |
-
predicted = concrete_score(model, noisy, check_t[:, 0]) # [M, L, K] the predicted ratios.
|
| 147 |
-
exact = kernel_ratio(valid_tokens, noisy, check_alpha) # [M, L, K] the conditional targets.
|
| 148 |
-
toward_clean = exact > 1.0 # The jumps that point back at the clean base.
|
| 149 |
-
mean_predicted = float(predicted[toward_clean].mean()) # Should sit below the target 5.
|
| 150 |
-
|
| 151 |
-
torch.manual_seed(args.seed + 1) # Fix the jump draws, so the samples repeat.
|
| 152 |
-
samples = sample(model, args.samples, args.length, args.sample_steps) # [B, L] over ACGT.
|
| 153 |
-
report = {
|
| 154 |
-
"target ratio at C toward A": target_ratio,
|
| 155 |
-
"score entropy at s = 1.8": errors[1.8],
|
| 156 |
-
"score entropy at s = 1.0": errors[1.0],
|
| 157 |
-
"score entropy at s = 3.0": errors[3.0],
|
| 158 |
-
"training score entropy, last 50": sum(losses[-50:]) / len(losses[-50:]),
|
| 159 |
-
"validation score entropy": held_out,
|
| 160 |
-
"mean predicted ratio toward clean": mean_predicted,
|
| 161 |
-
"conditional target toward clean": float(exact[toward_clean].mean()),
|
| 162 |
-
"GC fraction": gc_fraction(samples),
|
| 163 |
-
"GC fraction in the data": gc_fraction(train_tokens),
|
| 164 |
-
"motif fraction": motif_fraction(samples),
|
| 165 |
-
"motif fraction in the data": motif_fraction(train_tokens),
|
| 166 |
-
"distinct samples": unique_fraction(samples),
|
| 167 |
-
"letter entropy, nats": token_entropy(samples, DNA_SIZE),
|
| 168 |
-
}
|
| 169 |
-
print_report("sedd: score entropy on DNA", report, decode_dna(samples))
|
| 170 |
-
save_run(args.out, model=model, config=vars(args), report=report,
|
| 171 |
-
losses=losses, samples=decode_dna(samples))
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
# %% 06. Run the script
|
| 175 |
-
if __name__ == "__main__": # Execute when launched directly.
|
| 176 |
-
main() # Work the score entropy, train the ratios, and generate.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
lecture_4/simplex.py
DELETED
|
@@ -1,246 +0,0 @@
|
|
| 1 |
-
"""Simplex diffusion on DNA, a belief at every position in place of a token.
|
| 2 |
-
|
| 3 |
-
Every model before this one draws a token at each reverse step and discards the rest of the
|
| 4 |
-
denoiser's distribution, so a position the model was unsure about looks the same as one it was
|
| 5 |
-
certain about. Here the state at a position is a probability vector over the four bases, the
|
| 6 |
-
corruption is a Dirichlet around the uniform-replacement kernel, and one reverse step thins the
|
| 7 |
-
current belief, draws an innovation and mixes the two.
|
| 8 |
-
|
| 9 |
-
Chapter 4, "From tokens to beliefs" and "Thinning, an innovation, and a mixture". Time runs from
|
| 10 |
-
t = 0 clean to t = 1 noise, the survival probability is alpha_t = 1 - t, the concentration is
|
| 11 |
-
c_t = gamma K / (1 - alpha_t), and the rates R_t(x, y) act on probability rows, p_dot = p R.
|
| 12 |
-
Trained on the synthetic DNA of cis6270.data. Prints the worked Dirichlet parameters, the worked
|
| 13 |
-
thinning weight and its Monte Carlo check, then the sample statistics.
|
| 14 |
-
|
| 15 |
-
python lecture_4/simplex.py # train and generate
|
| 16 |
-
python lecture_4/simplex.py --mixture-draws 10000 # a coarser check of the mixture
|
| 17 |
-
python lecture_4/simplex.py --steps 20 # a few seconds, for a first look
|
| 18 |
-
"""
|
| 19 |
-
|
| 20 |
-
# %% 01. Imports and configuration
|
| 21 |
-
import torch # Tensors, gradients and the Dirichlet, Beta and categorical draws.
|
| 22 |
-
import torch.nn as nn # The denoiser whose input layer is the one thing that changes.
|
| 23 |
-
import torch.nn.functional as F # Cross-entropy at every position.
|
| 24 |
-
|
| 25 |
-
from cis6270.data import DNA_SIZE, decode_dna, dna_splits # ACGT, K = 4 and the shared sequences.
|
| 26 |
-
from cis6270.metrics import gc_fraction, motif_fraction, token_entropy, unique_fraction
|
| 27 |
-
from cis6270.nets import SequenceDenoiser, count_parameters # The transformer the belief feeds.
|
| 28 |
-
from cis6270.runner import common_parser, pick_device, print_report, save_run, set_seed, train
|
| 29 |
-
|
| 30 |
-
GAMMA = 1.0 # The constant of the concentration schedule c_t = gamma K / (1 - alpha_t).
|
| 31 |
-
TIME_FLOOR = 1e-3 # The concentration grows like 1/(1 - alpha_t), so the grid stops above zero.
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
# %% 02. The forward marginal as a Dirichlet
|
| 35 |
-
#
|
| 36 |
-
# Fact 2 calls for a forward marginal in closed form, and the natural requirement is that the
|
| 37 |
-
# belief fluctuate around the marginal the token models already use,
|
| 38 |
-
# E[P_t | x_0] = alpha_t onehot(x_0) + (1 - alpha_t) pi, with the spread set by c_t.
|
| 39 |
-
def concentration(alpha: float | torch.Tensor) -> float | torch.Tensor:
|
| 40 |
-
"""c_t = gamma K / (1 - alpha_t), the schedule that makes the increment nonnegative."""
|
| 41 |
-
return GAMMA * DNA_SIZE / (1.0 - alpha) # 8 at alpha_t = 0.5 and 20 at alpha_s = 0.8.
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
def belief_mean(alpha: float, x0: int) -> torch.Tensor:
|
| 45 |
-
"""E[P_t | x_0] over (A, C, G, T), the uniform-replacement kernel of the token models. [K]."""
|
| 46 |
-
mean = torch.full((DNA_SIZE,), (1.0 - alpha) / DNA_SIZE) # [K] the share from pi.
|
| 47 |
-
mean[x0] = mean[x0] + alpha # The clean token keeps the survival mass on top of its share.
|
| 48 |
-
return mean # [K] a distribution, since alpha + (1 - alpha) = 1.
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
def dirichlet_parameters(alpha: float, x0: int) -> torch.Tensor:
|
| 52 |
-
"""c_t E[P_t | x_0], the Dirichlet parameters at one position. Returns [K]."""
|
| 53 |
-
return concentration(alpha) * belief_mean(alpha, x0) # (5, 1, 1, 1) at alpha_t = 0.5.
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
def parameter_field(x0: torch.Tensor, alpha: torch.Tensor) -> torch.Tensor:
|
| 57 |
-
"""The same parameters for a whole batch. x0: [B, L], alpha: [B, 1] -> [B, L, K].
|
| 58 |
-
|
| 59 |
-
Every coordinate other than the clean token has parameter gamma, constant in time, and the
|
| 60 |
-
clean coordinate has gamma (1 + K alpha_t/(1 - alpha_t)), the factor of the uniform rates.
|
| 61 |
-
"""
|
| 62 |
-
off = torch.full(x0.shape + (DNA_SIZE,), GAMMA, device=x0.device) # [B, L, K] the pi share.
|
| 63 |
-
clean = GAMMA * DNA_SIZE * alpha / (1.0 - alpha) # [B, 1] the survival mass times c_t.
|
| 64 |
-
return off.scatter_add(2, x0[:, :, None], clean[..., None].expand_as(x0[:, :, None]))
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
def corrupt(x0: torch.Tensor, alpha: torch.Tensor) -> torch.Tensor:
|
| 68 |
-
"""Draw P_t ~ Dir(c_t E[P_t | x_0]) at each position. x0: [B, L], alpha: [B, 1] -> [B, L, K]."""
|
| 69 |
-
return torch.distributions.Dirichlet(parameter_field(x0, alpha)).sample() # [B, L, K] beliefs.
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
# %% 03. Thinning, an innovation, and a mixture
|
| 73 |
-
#
|
| 74 |
-
# A Dirichlet with parameter a + b equals W V + (1 - W) U for V ~ Dir(a), U ~ Dir(b) and
|
| 75 |
-
# W ~ Beta(sum a, sum b). With this concentration schedule the increment b sits entirely on the
|
| 76 |
-
# clean coordinate, so the innovation U is the one-hot vector of the clean token.
|
| 77 |
-
def increment(alpha_t: float, alpha_s: float, x0: int) -> torch.Tensor:
|
| 78 |
-
"""b = c_s E[P_s | x_0] - c_t E[P_t | x_0], nonnegative for alpha_s > alpha_t. Returns [K]."""
|
| 79 |
-
return dirichlet_parameters(alpha_s, x0) - dirichlet_parameters(alpha_t, x0) # (12, 0, 0, 0).
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
def thinning_law(alpha_t: float, alpha_s: float) -> tuple[float, float]:
|
| 83 |
-
"""The two Beta parameters, sum a = c_t and sum b = c_s - c_t, which are 8 and 12 here."""
|
| 84 |
-
return float(concentration(alpha_t)), float(concentration(alpha_s) - concentration(alpha_t))
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
def reverse_step(belief: torch.Tensor, clean: torch.Tensor,
|
| 88 |
-
alpha_t: float, alpha_s: float) -> torch.Tensor:
|
| 89 |
-
"""P_s = W P_t + (1 - W) onehot(x_0), one Beta draw per position. belief: [B, L, K]."""
|
| 90 |
-
first, second = thinning_law(alpha_t, alpha_s) # The two parameters of the thinning weight.
|
| 91 |
-
w = torch.distributions.Beta( # [B, L] one weight per position, with mean c_t / c_s.
|
| 92 |
-
torch.tensor(first, device=belief.device),
|
| 93 |
-
torch.tensor(second, device=belief.device)).sample(belief.shape[:2])
|
| 94 |
-
innovation = F.one_hot(clean, DNA_SIZE).to(belief.dtype) # [B, L, K] the corner it moves to.
|
| 95 |
-
return w[..., None] * belief + (1.0 - w[..., None]) * innovation # [B, L, K] the mixture.
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
# %% 04. The denoiser that reads a belief
|
| 99 |
-
#
|
| 100 |
-
# The only architectural change is at the input. A token is looked up in the embedding table, and
|
| 101 |
-
# a belief is the same K rows of that table combined in its own proportions, so a one-hot input
|
| 102 |
-
# recovers the denoiser of every other file in this lecture exactly.
|
| 103 |
-
class BeliefDenoiser(nn.Module):
|
| 104 |
-
"""d_theta(. | P_t, t), the shared sequence denoiser reading K numbers at every position."""
|
| 105 |
-
|
| 106 |
-
def __init__(self, length: int, width: int = 128):
|
| 107 |
-
super().__init__()
|
| 108 |
-
self.inner = SequenceDenoiser(DNA_SIZE, length, width=width) # Embeddings and transformer.
|
| 109 |
-
|
| 110 |
-
def forward(self, beliefs: torch.Tensor, t: torch.Tensor) -> torch.Tensor:
|
| 111 |
-
"""beliefs: [B, L, K], t: [B] -> logits over the clean tokens [B, L, K]."""
|
| 112 |
-
inner = self.inner
|
| 113 |
-
positions = torch.arange(beliefs.shape[1], device=beliefs.device) # [L].
|
| 114 |
-
h = (beliefs @ inner.token_embedding.weight # [B, L, D] the belief mixes the K embeddings.
|
| 115 |
-
+ inner.position_embedding(positions)[None, :, :] # [1, L, D] where it sits.
|
| 116 |
-
+ inner.time_embedding(t)[:, None, :]) # [B, 1, D] how far along the path we are.
|
| 117 |
-
return inner.head(inner.transformer(h)) # [B, L, K] every position sees every other.
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
# %% 05. The loss
|
| 121 |
-
#
|
| 122 |
-
# Fact 5 is the cross-entropy whose optimum is the posterior over clean tokens. The reverse step
|
| 123 |
-
# carries the denoiser's answer through the mixture of Section 03, whose weight already comes from
|
| 124 |
-
# the concentration schedule, so the time derivative of alpha_t multiplies nothing here.
|
| 125 |
-
def loss(model: nn.Module, x0: torch.Tensor) -> torch.Tensor:
|
| 126 |
-
"""One Monte Carlo sample of E[-log d_theta(x_0 | P_t, t)], summed over positions."""
|
| 127 |
-
t = torch.rand(x0.shape[0], 1, device=x0.device).clamp(min=TIME_FLOOR) # [B, 1] one time each.
|
| 128 |
-
alpha = 1.0 - t # [B, 1] the linear schedule, alpha_0 = 1 and alpha_1 = 0.
|
| 129 |
-
beliefs = corrupt(x0, alpha) # [B, L, K] one Dirichlet draw per position.
|
| 130 |
-
logits = model(beliefs, t[:, 0]) # [B, L, K] the denoiser reads the whole field of beliefs.
|
| 131 |
-
ce = F.cross_entropy(logits.transpose(1, 2), x0, reduction="none") # [B, L].
|
| 132 |
-
return ce.sum(-1).mean() # Nats per sequence, with every position counted.
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
# %% 06. The sampler
|
| 136 |
-
#
|
| 137 |
-
# Generation starts from Dir(gamma, ..., gamma), which at alpha_1 = 0 is the law the corruption
|
| 138 |
-
# ends at. Each step draws one clean token from the denoiser, thins the current belief and mixes
|
| 139 |
-
# the two, so the belief moves through the interior and reaches a corner at the last step.
|
| 140 |
-
@torch.no_grad() # Generation needs no gradient tracking.
|
| 141 |
-
def sample(model: nn.Module, batch: int, length: int, steps: int = 64) -> torch.Tensor:
|
| 142 |
-
"""Generate from beliefs scattered around pi down to clean DNA. Returns [B, L] over ACGT."""
|
| 143 |
-
device = next(model.parameters()).device
|
| 144 |
-
start = torch.full((batch, length, DNA_SIZE), GAMMA, device=device) # [B, L, K] at alpha_1 = 0.
|
| 145 |
-
belief = torch.distributions.Dirichlet(start).sample() # [B, L, K] scattered around pi.
|
| 146 |
-
ts = torch.linspace(1.0, TIME_FLOOR, steps + 1, device=device) # The reverse grid, t down to 0.
|
| 147 |
-
for t, s in zip(ts[:-1], ts[1:]): # One interval [s, t] at a time.
|
| 148 |
-
logits = model(belief, t.expand(batch)) # [B, L, K] the clean-token prediction.
|
| 149 |
-
clean = torch.distributions.Categorical(logits=logits).sample() # [B, L] the innovation.
|
| 150 |
-
belief = reverse_step(belief, clean, float(1.0 - t), float(1.0 - s)) # [B, L, K].
|
| 151 |
-
return belief.argmax(-1) # [B, L] the corner each position has reached.
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
# %% 07. The report
|
| 155 |
-
def main() -> None:
|
| 156 |
-
parser = common_parser(__doc__.split("\n")[0])
|
| 157 |
-
parser.add_argument("--mixture-draws", type=int, default=400_000,
|
| 158 |
-
help="draws in the check of the thinning representation")
|
| 159 |
-
# The belief carries the denoiser's uncertainty between steps, which needs enough reverse steps
|
| 160 |
-
# to show. A default run trains 1000 steps and measures 64 sequences, inside about a minute.
|
| 161 |
-
parser.set_defaults(steps=1000, sample_steps=64, samples=64)
|
| 162 |
-
args = parser.parse_args()
|
| 163 |
-
set_seed(args.seed) # The same seed gives the same training batches and the same samples.
|
| 164 |
-
device = pick_device(args.device)
|
| 165 |
-
talk = (lambda *a: None) if args.quiet else print # --quiet keeps only the report block.
|
| 166 |
-
|
| 167 |
-
# Section 02, the worked Dirichlet at the two noise levels of the chapter, with a clean A.
|
| 168 |
-
at_t = dirichlet_parameters(0.5, 0) # [K] (5, 1, 1, 1) at the later of the two times.
|
| 169 |
-
at_s = dirichlet_parameters(0.8, 0) # [K] (17, 1, 1, 1) at the earlier one.
|
| 170 |
-
total_t, total_s = at_t.sum(), at_s.sum() # c_t and c_s, the totals that set every variance.
|
| 171 |
-
spread_t = (at_t * (total_t - at_t) / (total_t ** 2 * (total_t + 1))).sqrt() # [K] exact.
|
| 172 |
-
spread_s = (at_s * (total_s - at_s) / (total_s ** 2 * (total_s + 1))).sqrt() # [K] exact.
|
| 173 |
-
talk(f"alpha_t = 0.5 c_t {concentration(0.5):.0f} mean "
|
| 174 |
-
f"{[round(float(v), 3) for v in belief_mean(0.5, 0)]} parameters "
|
| 175 |
-
f"{[round(float(v), 0) for v in at_t]} std on A {float(spread_t[0]):.3f}")
|
| 176 |
-
talk(f"alpha_s = 0.8 c_s {concentration(0.8):.0f} mean "
|
| 177 |
-
f"{[round(float(v), 2) for v in belief_mean(0.8, 0)]} parameters "
|
| 178 |
-
f"{[round(float(v), 0) for v in at_s]}")
|
| 179 |
-
|
| 180 |
-
# Section 03, the increment, the thinning weight, and the mean the mixture lands on.
|
| 181 |
-
step = increment(0.5, 0.8, 0) # [K] all of the increase sits on the clean coordinate.
|
| 182 |
-
first, second = thinning_law(0.5, 0.8) # Beta(8, 12), whose mean is 0.4.
|
| 183 |
-
mean_weight = first / (first + second) # c_t / c_s, the average share the old belief keeps.
|
| 184 |
-
mixed = mean_weight * belief_mean(0.5, 0) + (1.0 - mean_weight) * torch.eye(DNA_SIZE)[0] # [K].
|
| 185 |
-
talk(f"increment {[round(float(v), 0) for v in step]} W ~ Beta({first:.0f}, "
|
| 186 |
-
f"{second:.0f}) mean {mean_weight:.1f} mixture mean "
|
| 187 |
-
f"{[round(float(v), 2) for v in mixed]}")
|
| 188 |
-
|
| 189 |
-
# The same mixture by simulation, against the exact standard deviations of Dir(17, 1, 1, 1).
|
| 190 |
-
thinned = torch.distributions.Dirichlet(at_t).sample((args.mixture_draws,)) # [N, K] draw V.
|
| 191 |
-
weight = torch.distributions.Beta( # [N] the Beta draw of the representation.
|
| 192 |
-
torch.tensor(first), torch.tensor(second)).sample((args.mixture_draws,))
|
| 193 |
-
drawn = weight[:, None] * thinned + (1.0 - weight[:, None]) * torch.eye(DNA_SIZE)[0] # [N, K].
|
| 194 |
-
talk(f"mixture check {args.mixture_draws} draws std "
|
| 195 |
-
f"{[round(float(v), 3) for v in drawn.std(0)]} against the exact "
|
| 196 |
-
f"{[round(float(v), 3) for v in spread_s]}")
|
| 197 |
-
|
| 198 |
-
# Train the denoiser on the shared synthetic DNA. Reseeding here keeps the training batches
|
| 199 |
-
# independent of how many draws the check above consumed.
|
| 200 |
-
set_seed(args.seed)
|
| 201 |
-
splits = dna_splits(num_sequences=512, length=args.length, seed=args.seed)
|
| 202 |
-
train_tokens = splits["train_tokens"].to(device) # [N, L] clean sequences over ACGT.
|
| 203 |
-
valid_tokens = splits["valid_tokens"].to(device) # [M, L] held out for the loss below.
|
| 204 |
-
model = BeliefDenoiser(args.length, width=args.width).to(device)
|
| 205 |
-
talk(f"device={device} parameters={count_parameters(model):,} "
|
| 206 |
-
f"train={len(train_tokens)} length={args.length}")
|
| 207 |
-
|
| 208 |
-
def step_loss(step_index: int) -> torch.Tensor:
|
| 209 |
-
"""One optimizer step draws its own batch of clean sequences."""
|
| 210 |
-
rows = torch.randint(0, len(train_tokens), (args.batch_size,), device=device)
|
| 211 |
-
return loss(model, train_tokens[rows]) # [B, L] clean sequences into one scalar.
|
| 212 |
-
|
| 213 |
-
losses = train(model, step_loss, steps=args.steps, lr=args.lr, quiet=args.quiet)
|
| 214 |
-
with torch.no_grad():
|
| 215 |
-
held_out = sum(float(loss(model, valid_tokens)) for _ in range(4)) / 4.0
|
| 216 |
-
|
| 217 |
-
# Generate, then measure the structure the data was built with.
|
| 218 |
-
torch.manual_seed(args.seed + 1) # Fix the Beta and categorical draws, so the samples repeat.
|
| 219 |
-
samples = sample(model, args.samples, args.length, args.sample_steps) # [B, L] over ACGT.
|
| 220 |
-
report = {
|
| 221 |
-
"c_t at alpha_t = 0.5": float(concentration(0.5)),
|
| 222 |
-
"Dirichlet parameter on A at 0.5": float(at_t[0]),
|
| 223 |
-
"standard deviation on A at 0.5": float(spread_t[0]),
|
| 224 |
-
"Dirichlet parameter on A at 0.8": float(at_s[0]),
|
| 225 |
-
"increment on A": float(step[0]),
|
| 226 |
-
"mean thinning weight": mean_weight,
|
| 227 |
-
"simulated standard deviation on A": float(drawn.std(0)[0]),
|
| 228 |
-
"exact standard deviation on A": float(spread_s[0]),
|
| 229 |
-
"training loss, last 50": sum(losses[-50:]) / len(losses[-50:]),
|
| 230 |
-
"validation loss, nats/seq": held_out,
|
| 231 |
-
"loss, nats per token": held_out / args.length,
|
| 232 |
-
"GC fraction": gc_fraction(samples),
|
| 233 |
-
"GC fraction in the data": gc_fraction(train_tokens),
|
| 234 |
-
"motif fraction": motif_fraction(samples),
|
| 235 |
-
"motif fraction in the data": motif_fraction(train_tokens),
|
| 236 |
-
"distinct samples": unique_fraction(samples),
|
| 237 |
-
"letter entropy, nats": token_entropy(samples, DNA_SIZE),
|
| 238 |
-
}
|
| 239 |
-
print_report("simplex: beliefs in place of tokens", report, decode_dna(samples))
|
| 240 |
-
save_run(args.out, model=model, config=vars(args), report=report,
|
| 241 |
-
losses=losses, samples=decode_dna(samples))
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
# %% 08. Run the script
|
| 245 |
-
if __name__ == "__main__": # Execute when launched directly.
|
| 246 |
-
main() # Work the Dirichlet numbers, train the belief denoiser, and generate.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
lecture_4/uniform.py
DELETED
|
@@ -1,240 +0,0 @@
|
|
| 1 |
-
"""Uniform replacement on DNA, the member of the pi family with no mask.
|
| 2 |
-
|
| 3 |
-
Corruption redraws a base from the uniform distribution over ACGT, so a noisy base may be the
|
| 4 |
-
original or a replacement, and no marker of the damage appears in the sequence. Every pair of
|
| 5 |
-
bases is then joined by a reverse rate, the loss keeps the staying term that masking cancelled, and
|
| 6 |
-
a sampler can revise a base it has already written.
|
| 7 |
-
|
| 8 |
-
Chapter 4, "A rate matrix with one free distribution", "The posterior average of conditional reverse
|
| 9 |
-
rates" and "Uniform replacement and the rates it produces". Time runs from t = 0 clean to t = 1
|
| 10 |
-
noise, the survival probability is alpha_t = exp(-beta t), and the rates R_t(x, y) act on
|
| 11 |
-
probability rows, p_dot = p R. Trained on the synthetic DNA of cis6270.data. Prints the worked
|
| 12 |
-
posterior average of 2, the worked rate error, and the sample statistics.
|
| 13 |
-
|
| 14 |
-
python lecture_4/uniform.py # train and generate
|
| 15 |
-
python lecture_4/uniform.py --steps 20 # a few seconds, for a first look
|
| 16 |
-
"""
|
| 17 |
-
|
| 18 |
-
# %% 01. Imports and configuration
|
| 19 |
-
import math # The worked rate errors are logarithms.
|
| 20 |
-
|
| 21 |
-
import torch # Tensors, gradients and the categorical draws.
|
| 22 |
-
|
| 23 |
-
from cis6270.data import DNA_SIZE, decode_dna, dna_splits # ACGT, K = 4 and the shared sequences.
|
| 24 |
-
from cis6270.metrics import gc_fraction, motif_fraction, token_entropy, unique_fraction
|
| 25 |
-
from cis6270.nets import SequenceDenoiser, count_parameters # The denoiser d_theta(. | z, t).
|
| 26 |
-
from cis6270.runner import common_parser, pick_device, print_report, save_run, set_seed, train
|
| 27 |
-
|
| 28 |
-
BETA = 4.0 # The constant noise rate of the chapter's worked example, so alpha_t = exp(-4t).
|
| 29 |
-
TIME_FLOOR = 2e-2 # The reverse rates grow like 1/(1 - alpha_t), so sampling stops just above zero.
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
# %% 02. The uniform kernel and the rates that produce it
|
| 33 |
-
#
|
| 34 |
-
# Under R_pi = 1 pi^T - I with pi uniform, every pair of distinct bases is joined at rate beta/K,
|
| 35 |
-
# and the matrix exponential is the kernel q_t(x | x_0) = alpha_t delta(x, x_0) + (1 - alpha_t)/K.
|
| 36 |
-
def survival(t: torch.Tensor | float) -> torch.Tensor | float:
|
| 37 |
-
"""alpha_t = exp(-tau(t)) for the constant schedule beta(t) = BETA, so tau(t) = BETA t."""
|
| 38 |
-
return torch.exp(-BETA * t) if torch.is_tensor(t) else math.exp(-BETA * t)
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
def kernel(alpha: float | torch.Tensor) -> torch.Tensor:
|
| 42 |
-
"""Q(alpha) over (A, C, G, T), whose (x_0, x) entry is q_t(x | x_0). Returns [K, K]."""
|
| 43 |
-
uniform = (1.0 - alpha) / DNA_SIZE # The share of the redrawn mass that lands on each base.
|
| 44 |
-
return alpha * torch.eye(DNA_SIZE, dtype=torch.float64) + uniform # [K, K] rows sum to one.
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
def corrupt(x0: torch.Tensor, t: torch.Tensor) -> torch.Tensor:
|
| 48 |
-
"""Redraw each position from the uniform distribution with probability 1 - alpha_t."""
|
| 49 |
-
keep = torch.rand(x0.shape, device=x0.device) < survival(t) # [B, L] survival of the original.
|
| 50 |
-
fresh = torch.randint(0, DNA_SIZE, x0.shape, device=x0.device) # [B, L] a draw from pi.
|
| 51 |
-
return torch.where(keep, x0, fresh) # [B, L] nothing marks which positions were redrawn.
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
# %% 03. The conditional reverse rates and their three cases
|
| 55 |
-
#
|
| 56 |
-
# Dividing the conditional reverse rate by the noise rate beta/K leaves one factor, which is
|
| 57 |
-
# 1 + K alpha_t/(1 - alpha_t) toward the clean token, its reciprocal away from it, and one between
|
| 58 |
-
# two tokens that are both wrong.
|
| 59 |
-
def clean_factor(alpha: float) -> float:
|
| 60 |
-
"""1 + K alpha_t / (1 - alpha_t), the factor 5 of the worked example at alpha_t = 1/2."""
|
| 61 |
-
return 1.0 + DNA_SIZE * alpha / (1.0 - alpha) # The ratio q_t(x_0 | x_0) / q_t(x | x_0).
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
def conditional_rates(alpha: float, current: int, clean: int) -> torch.Tensor:
|
| 65 |
-
"""R_hat_t(c, a | x_0) for every destination a, in units where the forward rate is one."""
|
| 66 |
-
Q = kernel(alpha) # [K, K] the forward kernel at this survival probability.
|
| 67 |
-
return Q[clean] / Q[clean, current] # [K] q_t(a | x_0) divided by q_t(c | x_0).
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
# %% 04. A worked posterior average on one base
|
| 71 |
-
#
|
| 72 |
-
# With a clean base A at probability 0.75 and C at 0.25, an observed C at alpha_t = 1/2 has
|
| 73 |
-
# posterior 0.375 and 0.625, and the two conditional rates 5 and 0.2 average to the marginal rate.
|
| 74 |
-
def posterior_over_clean(alpha: float, observed: int, prior: torch.Tensor) -> torch.Tensor:
|
| 75 |
-
"""p(x_0 = b | X_t = observed), Bayes' rule with the forward kernel as the likelihood."""
|
| 76 |
-
weights = prior * kernel(alpha)[:, observed] # [K] prior times q_t(observed | b).
|
| 77 |
-
return weights / weights.sum() # [K] normalized over the candidate clean bases.
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
def marginal_reverse_rate(alpha: float, observed: int, destination: int,
|
| 81 |
-
prior: torch.Tensor) -> float:
|
| 82 |
-
"""The posterior average of the conditional reverse rates, which is the marginal reverse rate."""
|
| 83 |
-
posterior = posterior_over_clean(alpha, observed, prior) # [K] the weights of the average.
|
| 84 |
-
targets = kernel(alpha)[:, destination] / kernel(alpha)[:, observed] # [K] one target per clean b.
|
| 85 |
-
return float(posterior @ targets) # The two together give the rate the sampler uses.
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
# %% 05. The rate error and the staying term
|
| 89 |
-
#
|
| 90 |
-
# l(A, B) = A log(A/B) - A + B is the Bregman divergence the path KL divergence produces. The term
|
| 91 |
-
# -A + B comes from the probability of staying put, and under uniform noise it does not cancel.
|
| 92 |
-
def rate_error(target: torch.Tensor, model_rate: torch.Tensor) -> torch.Tensor:
|
| 93 |
-
"""Elementwise l(A, B), nonnegative and zero exactly at B = A."""
|
| 94 |
-
logarithmic = torch.where(target > 0, target * (target.clamp(min=1e-12) / model_rate).log(),
|
| 95 |
-
torch.zeros_like(target)) # A log(A/B), with l(0, B) = B.
|
| 96 |
-
return logarithmic - target + model_rate # The staying term is the -A + B part.
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
def ratio_table(current: torch.Tensor, alpha: torch.Tensor) -> torch.Tensor:
|
| 100 |
-
"""q_t(a | b) / q_t(c | b) for every clean base b and destination a. Returns [B, L, K, K]."""
|
| 101 |
-
uniform = (1.0 - alpha) / DNA_SIZE # [B, 1] the redrawn share at this noise level.
|
| 102 |
-
Q = (alpha[..., None, None] * torch.eye(DNA_SIZE, device=current.device)
|
| 103 |
-
+ uniform[..., None, None]) # [B, 1, K, K] the kernel q_t(a | b) at each sequence's time.
|
| 104 |
-
denominator = Q.expand(-1, current.shape[1], -1, -1).gather(
|
| 105 |
-
3, current[:, :, None, None].expand(-1, -1, DNA_SIZE, 1)) # [B, L, K, 1] q_t(c | b).
|
| 106 |
-
return Q / denominator # [B, L, K, K] the only quantity the rates need from the kernel.
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
def model_rates(denoiser: torch.Tensor, ratios: torch.Tensor) -> torch.Tensor:
|
| 110 |
-
"""R_hat^theta_t(c, a) = (beta/K) sum_b d_theta(b) q_t(a | b) / q_t(c | b). Returns [B, L, K]."""
|
| 111 |
-
averaged = torch.einsum("nlb,nlba->nla", denoiser, ratios) # [B, L, K] averaged over clean bases.
|
| 112 |
-
return (BETA / DNA_SIZE) * averaged # [B, L, K] the forward rate times the posterior average.
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
def conditional_targets(x0: torch.Tensor, ratios: torch.Tensor) -> torch.Tensor:
|
| 116 |
-
"""R_hat_t(c, a | x_0), the same formula with the posterior collapsed onto the clean token."""
|
| 117 |
-
rows = x0[:, :, None, None].expand(-1, -1, 1, DNA_SIZE) # [B, L, 1, K] select the row b = x_0.
|
| 118 |
-
return (BETA / DNA_SIZE) * ratios.gather(2, rows).squeeze(2) # [B, L, K] one target per jump.
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
def rate_loss(model: torch.nn.Module, x0: torch.Tensor) -> torch.Tensor:
|
| 122 |
-
"""One Monte Carlo sample of L_cond, summed over destinations and positions."""
|
| 123 |
-
t = torch.rand(x0.shape[0], 1, device=x0.device).clamp(min=TIME_FLOOR) # [B, 1] one time each.
|
| 124 |
-
alpha = survival(t) # [B, 1] the survival probability at that time.
|
| 125 |
-
zt = corrupt(x0, t) # [B, L] the noisy sequence, with no marker of which positions moved.
|
| 126 |
-
ratios = ratio_table(zt, alpha) # [B, L, K, K] the kernel ratios at this noise level.
|
| 127 |
-
denoiser = model(zt, t[:, 0]).softmax(-1) # [B, L, K] the posterior over clean tokens.
|
| 128 |
-
predicted = model_rates(denoiser, ratios).clamp(min=1e-8) # [B, L, K] nonnegative by construction.
|
| 129 |
-
target = conditional_targets(x0, ratios) # [B, L, K] the rate attached to this clean sequence.
|
| 130 |
-
staying = torch.nn.functional.one_hot(zt, DNA_SIZE).bool() # [B, L, K] the destination c itself.
|
| 131 |
-
return rate_error(target, predicted).masked_fill(staying, 0.0).sum((1, 2)).mean()
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
# %% 06. Sampling by tau-leaping
|
| 135 |
-
#
|
| 136 |
-
# Over a step of length h a position moves to a with probability h times its reverse rate, and the
|
| 137 |
-
# remaining probability keeps it where it is. The step is valid while h times the total rate is one.
|
| 138 |
-
@torch.no_grad() # Generation needs no gradient tracking.
|
| 139 |
-
def sample(model: torch.nn.Module, batch: int, length: int, steps: int = 64) -> torch.Tensor:
|
| 140 |
-
"""Generate from uniformly random tokens down to clean DNA. Returns [B, L] over ACGT."""
|
| 141 |
-
device = next(model.parameters()).device
|
| 142 |
-
z = torch.randint(0, DNA_SIZE, (batch, length), device=device) # [B, L] a draw from pi.
|
| 143 |
-
ts = torch.linspace(1.0, TIME_FLOOR, steps + 1, device=device) # The reverse grid.
|
| 144 |
-
for t, s in zip(ts[:-1], ts[1:]):
|
| 145 |
-
time_row = t.expand(batch, 1) # [B, 1] the shared time of this step.
|
| 146 |
-
denoiser = model(z, time_row[:, 0]).softmax(-1) # [B, L, K] the clean-token prediction.
|
| 147 |
-
rates = model_rates(denoiser, ratio_table(z, survival(time_row))) # [B, L, K] per destination.
|
| 148 |
-
jump = (t - s) * rates # [B, L, K] the tau-leaping probabilities of this step.
|
| 149 |
-
jump.scatter_(2, z[:, :, None], 0.0) # A jump to the current base is not a jump.
|
| 150 |
-
total = jump.sum(-1, keepdim=True).clamp(min=1.0) # Rescale when the step would exceed one.
|
| 151 |
-
jump = jump / total # [B, L, K] now a sub-probability vector at every position.
|
| 152 |
-
stay = (1.0 - jump.sum(-1, keepdim=True)).clamp(min=0.0) # [B, L, 1] the chance of staying.
|
| 153 |
-
choice = torch.distributions.Categorical(
|
| 154 |
-
probs=torch.cat([jump, stay], dim=-1)).sample() # [B, L] destination, or K for staying.
|
| 155 |
-
z = torch.where(choice == DNA_SIZE, z, choice.clamp(max=DNA_SIZE - 1)) # [B, L].
|
| 156 |
-
final = model(z, ts[-1].expand(batch)).softmax(-1) # The exact posterior step down to t = 0.
|
| 157 |
-
return torch.distributions.Categorical(probs=final).sample() # [B, L] clean DNA.
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
# %% 07. The report
|
| 161 |
-
def main() -> None:
|
| 162 |
-
parser = common_parser(__doc__.split("\n")[0])
|
| 163 |
-
# The rate loss needs a longer budget than the worked numbers do, so the default trains for
|
| 164 |
-
# 1000 steps and measures on 64 sequences, which keeps the run inside about a minute.
|
| 165 |
-
parser.set_defaults(steps=1000, sample_steps=64, samples=64)
|
| 166 |
-
args = parser.parse_args()
|
| 167 |
-
set_seed(args.seed)
|
| 168 |
-
device = pick_device(args.device)
|
| 169 |
-
talk = (lambda *a: None) if args.quiet else print # --quiet keeps only the report block.
|
| 170 |
-
|
| 171 |
-
# Sections 03 and 04, the three cases and the worked posterior average on one base.
|
| 172 |
-
prior = torch.tensor([0.75, 0.25, 0.0, 0.0], dtype=torch.float64) # Clean A at 0.75, C at 0.25.
|
| 173 |
-
posterior = posterior_over_clean(0.5, 1, prior) # [4] after observing a C at alpha_t = 1/2.
|
| 174 |
-
targets = conditional_rates(0.5, current=1, clean=0) # [4] the rates attached to a clean A.
|
| 175 |
-
average = marginal_reverse_rate(0.5, 1, 0, prior) # The posterior average, which comes out 2.
|
| 176 |
-
marginal = prior @ kernel(0.5) # [4] p_t over the four bases, which is (0.5, 0.25, 0.125, 0.125).
|
| 177 |
-
talk(f"kernel at alpha=0.5 clean {float(kernel(0.5)[0, 0]):.3f} other "
|
| 178 |
-
f"{float(kernel(0.5)[0, 1]):.3f} clean factor {clean_factor(0.5):.0f}")
|
| 179 |
-
talk(f"posterior after C {[round(float(v), 3) for v in posterior]} conditional rates "
|
| 180 |
-
f"{float(targets[0]):.0f} under a clean A and "
|
| 181 |
-
f"{float(conditional_rates(0.5, 1, 1)[0]):.1f} under a clean C")
|
| 182 |
-
talk(f"posterior average {average:.1f} marginals {[round(float(v), 3) for v in marginal]} "
|
| 183 |
-
f"ratio {float(marginal[0] / marginal[1]):.1f} to G and T "
|
| 184 |
-
f"{marginal_reverse_rate(0.5, 1, 2, prior):.1f}")
|
| 185 |
-
|
| 186 |
-
# Section 05, the rate error on one base, where the staying term keeps the sum from collapsing.
|
| 187 |
-
worked_denoiser = torch.tensor([[[0.5, 0.3, 0.1, 0.1]]], dtype=torch.float64) # [1, 1, 4].
|
| 188 |
-
worked_current = torch.tensor([[1]]) # The current base is C.
|
| 189 |
-
worked_alpha = torch.tensor([[0.5]], dtype=torch.float64) # alpha_t = 1/2.
|
| 190 |
-
worked_ratios = ratio_table(worked_current, worked_alpha) # [1, 1, K, K] at alpha_t = 1/2.
|
| 191 |
-
predicted = model_rates(worked_denoiser, worked_ratios)[0, 0] / (BETA / DNA_SIZE)
|
| 192 |
-
target = conditional_rates(0.5, current=1, clean=0) # The clean base is A.
|
| 193 |
-
error = float(rate_error(target[[0, 2, 3]], predicted[[0, 2, 3]]).sum()) # Skip the current base.
|
| 194 |
-
talk(f"model rates from d {[round(float(v), 2) for v in predicted]} targets "
|
| 195 |
-
f"{[round(float(v), 1) for v in target]} rate error {error:.3f}")
|
| 196 |
-
|
| 197 |
-
# Train the denoiser against the conditional rate targets.
|
| 198 |
-
splits = dna_splits(num_sequences=512, length=args.length, seed=args.seed)
|
| 199 |
-
train_tokens = splits["train_tokens"].to(device) # [N, L] clean sequences over ACGT.
|
| 200 |
-
valid_tokens = splits["valid_tokens"].to(device) # [M, L] held out for the loss below.
|
| 201 |
-
model = SequenceDenoiser(DNA_SIZE, args.length, width=args.width).to(device)
|
| 202 |
-
talk(f"device={device} parameters={count_parameters(model):,} train={len(train_tokens)}")
|
| 203 |
-
|
| 204 |
-
def step_loss(step: int) -> torch.Tensor:
|
| 205 |
-
rows = torch.randint(0, len(train_tokens), (args.batch_size,), device=device)
|
| 206 |
-
return rate_loss(model, train_tokens[rows]) # One clean batch into one scalar.
|
| 207 |
-
|
| 208 |
-
losses = train(model, step_loss, steps=args.steps, lr=args.lr, quiet=args.quiet)
|
| 209 |
-
with torch.no_grad():
|
| 210 |
-
held_out = sum(float(rate_loss(model, valid_tokens)) for _ in range(4)) / 4.0
|
| 211 |
-
|
| 212 |
-
torch.manual_seed(args.seed + 1) # Fix the jump draws, so the samples repeat.
|
| 213 |
-
samples = sample(model, args.samples, args.length, args.sample_steps) # [B, L] over ACGT.
|
| 214 |
-
report = {
|
| 215 |
-
"q_t(x_0 | x_0) at alpha=0.5": float(kernel(0.5)[0, 0]),
|
| 216 |
-
"clean factor at alpha=0.5": clean_factor(0.5),
|
| 217 |
-
"posterior on A after a C": float(posterior[0]),
|
| 218 |
-
"conditional rate under A": float(targets[0]),
|
| 219 |
-
"conditional rate under C": float(conditional_rates(0.5, 1, 1)[0]),
|
| 220 |
-
"posterior average rate": average,
|
| 221 |
-
"rate from the marginals": float(marginal[0] / marginal[1]),
|
| 222 |
-
"worked model rate to A": float(predicted[0]),
|
| 223 |
-
"worked rate error": error,
|
| 224 |
-
"training rate loss, last 50": sum(losses[-50:]) / len(losses[-50:]),
|
| 225 |
-
"validation rate loss": held_out,
|
| 226 |
-
"GC fraction": gc_fraction(samples),
|
| 227 |
-
"GC fraction in the data": gc_fraction(train_tokens),
|
| 228 |
-
"motif fraction": motif_fraction(samples),
|
| 229 |
-
"motif fraction in the data": motif_fraction(train_tokens),
|
| 230 |
-
"distinct samples": unique_fraction(samples),
|
| 231 |
-
"letter entropy, nats": token_entropy(samples, DNA_SIZE),
|
| 232 |
-
}
|
| 233 |
-
print_report("uniform: replacement diffusion on DNA", report, decode_dna(samples))
|
| 234 |
-
save_run(args.out, model=model, config=vars(args), report=report,
|
| 235 |
-
losses=losses, samples=decode_dna(samples))
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
# %% 08. Run the script
|
| 239 |
-
if __name__ == "__main__": # Execute when launched directly.
|
| 240 |
-
main() # Work the posterior average, train against the rate loss, and generate.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|