pranamanam commited on
Commit
14eaced
·
verified ·
1 Parent(s): ce385c8

Delete lecture_4

Browse files
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.