File size: 16,215 Bytes
0ba9d09
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
"""Flow Map Language Models, the composition identity in denoiser coordinates.

We lift each token to a one-hot vector, the source is Gaussian over that space, and the state moves
through R^{L x K}, so no atom has to be split. If we step by the full remaining time 1 - s in place
of the elapsed time t - s, the average velocity becomes a two-time denoiser that lands on the
simplex. The map is then a convex combination of staying put and jumping to the prediction, and the
cocycle is a convex combination of two shorter predictions. A KL divergence measures the gap.

Chapter 6, Sections 6.25 to 6.27 of the notes. Trained on the synthetic DNA of
``cis6270.data.synthetic_dna``. Prints the convex weights, the composition weight gamma = 0.2 at
(0.2, 0.5, 0.8), the worked KL of 0.030747 nats, the two gradients that decide the loss, the
decoding clock of a 30,522-token vocabulary and the four steps it produces, and samples from the
trained map.

    python lecture_6/fmlm.py                 # 1500 steps, about half a minute
    python lecture_6/fmlm.py --steps 20      # a few seconds, for a first look
"""

# %% 01. Imports and configuration
import math

import torch

from cis6270.data import DNA_SIZE, decode_dna, synthetic_dna
from cis6270.metrics import gc_fraction, motif_fraction, token_entropy, unique_fraction
from cis6270.nets import TwoTimeMLP, count_parameters
from cis6270.runner import common_parser, pick_device, print_report, save_run, set_seed, train


torch.set_num_threads(1)  # These networks are small, and splitting one matrix product costs more
# than it saves. ``tests/conftest.py`` sets the same thing for the test session, and this line
# acts when a reader runs the script directly.

BOOK_VOCAB = 30522  # The vocabulary the chapter puts on the clock, a standard tokenizer.
ENDPOINT_FRACTION = 0.25  # Share of each batch drawn with t = 1, the slice the sampler reads.


# %% 02. The convex form and the composition weight
#
# X_{s,t}(x) = (1-t)/(1-s) x + (t-s)/(1-s) delta_{s,t}(x). Both weights are non-negative for
# s <= t <= 1 and they sum to one, so each step is a convex combination of staying put and jumping
# to the predicted endpoint. At t = s the pair is (1, 0) and the identity axiom holds.
def convex_weights(s, t):
    """The pair ((1-t)/(1-s), (t-s)/(1-s)), the weights on the state and on the prediction."""
    remaining = (1.0 - s).clamp(min=1e-6)
    return (1.0 - t) / remaining, (t - s) / remaining


def composition_weight(s, u, t):
    """gamma = (1-t)(u-s)/((1-u)(t-s)), the share of the long prediction made over the first leg."""
    return (1.0 - t) * (u - s) / (((1.0 - u) * (t - s)).clamp(min=1e-6))


# %% 03. The cocycle in denoiser coordinates, checked on the scalar flow
#
# delta_{s,t}(x) = gamma delta_{s,u}(x) + (1-gamma) delta_{u,t}(X_{s,u}(x)). On dx/dt = x the
# two-time denoiser is x[1 + (1-s)(e^{t-s}-1)/(t-s)], so every term is available in closed form.
def scalar_denoiser(x, s, t):
    """delta_{s,t}(x) on the flow dx/dt = x."""
    return x * (1.0 + (1.0 - s) * math.expm1(t - s) / (t - s))


def scalar_cocycle(x: float = 1.0, s: float = 0.2, u: float = 0.5, t: float = 0.8):
    """Every term of the denoiser cocycle at the chapter's triple of times."""
    gamma = (1.0 - t) * (u - s) / ((1.0 - u) * (t - s))
    first = scalar_denoiser(x, s, u)  # The prediction over the first leg.
    reached = x * math.exp(u - s)  # X_{s,u}(x), the state the first leg arrives at.
    second = scalar_denoiser(reached, u, t)  # The prediction over the second leg.
    long = scalar_denoiser(x, s, t)  # The prediction over the whole interval.
    keep, move = (1.0 - t) / (1.0 - s), (t - s) / (1.0 - s)
    return {"gamma": gamma, "delta_s,u": first, "delta_u,t": second, "delta_s,t": long,
            "two legs combined": gamma * first + (1.0 - gamma) * second,
            "convex weights": (round(keep, 4), round(move, 4)),
            "convex form": keep * x + move * long, "X_s,t(x)": x * math.exp(t - s)}


# %% 04. One term of the loss, on the DNA alphabet
#
# The composed target is a convex combination of two simplex points, so it is a distribution and
# the divergence against it needs no projection. Its gradient pushes mass onto the token with more
# weight in the two-leg decomposition than in the long prediction.
def worked_kl():
    """The chapter's four-token example, with the target composed from two student predictions."""
    first = torch.tensor([0.10, 0.20, 0.60, 0.10])  # delta_{0.2,0.5} over (A, C, G, T).
    second = torch.tensor([0.05, 0.10, 0.80, 0.05])  # delta_{0.5,0.8} at the state the leg reaches.
    gamma = (1.0 - 0.8) * (0.5 - 0.2) / ((1.0 - 0.5) * (0.8 - 0.2))
    target = gamma * first + (1.0 - gamma) * second  # [4] the composed target.
    student = torch.tensor([0.10, 0.15, 0.65, 0.10])  # The student's long prediction.
    terms = target * (target / student).log()  # [4] the four terms of the divergence.
    return {"worked gamma": gamma, "composed target": [round(v, 4) for v in target.tolist()],
            "target mass": float(target.sum()), "worked KL": float(terms.sum()),
            "the four terms": [round(v, 6) for v in terms.tolist()]}


# %% 05. The two gradients of a softmax head
#
# With a softmax head the KL gradient in the logits is q - p, so the size of the correction in
# coordinate i is the error in coordinate i. The squared error passes through the softmax Jacobian
# q_j(delta_ij - q_i) and carries a factor q_i in every term, so a token the model has learned to
# rule out gets almost no correction. Here we take both gradients by automatic differentiation.
# That checks the two expressions of Section 6.27 as well as evaluating them.
def logit_gradients(target, student):
    """The gradient of KL(p || q) and of half the squared error in the logits of q. Two [K]."""
    for_kl = student.log().clone().requires_grad_(True)  # Logits whose softmax is the student.
    divergence = (target * (target / torch.softmax(for_kl, -1)).log()).sum()
    kl_gradient, = torch.autograd.grad(divergence, for_kl)  # [K] equal to q - p.
    for_squared = student.log().clone().requires_grad_(True)  # A second graph on the same logits.
    squared = 0.5 * ((torch.softmax(for_squared, -1) - target) ** 2).sum()
    squared_gradient, = torch.autograd.grad(squared, for_squared)  # [K] with a q_i throughout.
    return kl_gradient, squared_gradient


def gradient_comparison(floor: float = 1e-6):
    """The two gradients at the chapter's student, and again once that student has saturated."""
    target = torch.tensor([0.06, 0.12, 0.76, 0.06])  # p, the composed target of Section 6.27.
    student = torch.tensor([0.10, 0.15, 0.65, 0.10])  # q, the student's long prediction.
    kl, squared = logit_gradients(target, student)
    ratio = (squared / kl).abs()  # Between a quarter and a half, coordinate by coordinate.
    rest = student[1:] * (1.0 - floor) / student[1:].sum()  # The other three keep their shares.
    saturated = torch.cat([torch.full_like(student[:1], floor), rest])  # [K], summing to one.
    kl_low, squared_low = logit_gradients(target, saturated)
    return {
        "KL gradient": [round(v, 5) for v in kl.tolist()],
        "squared-error gradient": [round(v, 5) for v in squared.tolist()],
        "squared over KL": [round(v, 4) for v in ratio.tolist()],
        "saturated student": [round(v, 6) for v in saturated.tolist()],
        "saturated KL gradient": float(kl_low[0]),
        # The surviving squared-error gradient is of order 1e-08, below what four printed
        # decimals show, so the report carries it in units of 1e-08.
        "saturated squared-error gradient, in 1e-08": 1e8 * float(squared_low[0]),
        "orders of magnitude between them": math.log10(
            abs(float(kl_low[0])) / abs(float(squared_low[0]))),
    }


# %% 06. The decoding clock
#
# A position decodes correctly when its data coordinate is the largest of the |V| coordinates,
# a shifted standard normal against the maximum of the rest. The reparameterized time tau is the
# fraction of the initial decoding error already removed, and the sampler runs on that clock.
def decoding_error(t: float, vocab_size: int, nodes: int = 4001) -> float:
    """P_e(t), the probability that one position decodes to the wrong token at time t."""
    if t >= 1.0:
        return 0.0
    shift = t / (1.0 - t)  # a = t/(1-t), the gap the data coordinate opens.
    z = torch.linspace(-12.0, 14.0, nodes, dtype=torch.float64)  # Quadrature grid over the draw.
    density = torch.exp(-0.5 * z * z) / math.sqrt(2.0 * math.pi)  # phi(z).
    cumulative = 0.5 * torch.erfc(-(z + shift) / math.sqrt(2.0))  # Phi(z + a).
    log_all = (vocab_size - 1) * cumulative.clamp(min=1e-300).log()  # All the others fall short.
    return 1.0 - float(torch.trapz(density * log_all.exp(), z))


def clock(vocab_size: int):
    """tau(t) = (P_e(0) - P_e(t))/P_e(0), as a callable, with the source error rate beside it."""
    start = decoding_error(0.0, vocab_size)
    return (lambda t: (start - decoding_error(t, vocab_size)) / start), start


def even_in_tau(vocab_size: int, pieces: int = 4):
    """Solve tau(t) = k/pieces by bisection and return the grid with its two endpoints."""
    tau, _ = clock(vocab_size)
    grid = [0.0]
    for k in range(1, pieces):
        low, high = 0.0, 1.0 - 1e-6
        for _ in range(60):  # Bisection; tau is increasing in t.
            middle = 0.5 * (low + high)
            low, high = (middle, high) if tau(middle) < k / pieces else (low, middle)
        grid.append(0.5 * (low + high))
    return grid + [1.0]


# %% 07. The model, a two-time denoiser with a softmax head
def predict(model, x, s, t, length, vocab):
    """delta_{s,t}(x) at every position, a point of the simplex produced by a softmax head."""
    logits = model(x.flatten(1), s, t).view(-1, length, vocab)  # [B, L, K] the network output.
    return torch.softmax(logits, dim=-1)  # [B, L, K] on the simplex at every position.


def step_map(x, prediction, s, t):
    """The convex form, the flow map written in denoiser coordinates."""
    keep, move = convex_weights(s, t)
    return keep[:, None, None] * x + move[:, None, None] * prediction  # [B, L, K].


def make_loss(model, data, length, vocab, batch_size, device):
    eye = torch.eye(vocab, device=device)  # The one-hot lift of the alphabet.

    def loss_fn(step: int) -> torch.Tensor:
        index = torch.randint(0, len(data), (batch_size,), device=device)
        x1 = eye[data[index]]  # [B, L, K] the data, lifted.
        x0 = torch.randn_like(x1)  # [B, L, K] the Gaussian source.
        t_diag = torch.rand(batch_size, device=device)  # [B] a time for the diagonal term.
        xt = (1 - t_diag[:, None, None]) * x0 + t_diag[:, None, None] * x1  # [B, L, K].
        diagonal = -(predict(model, xt, t_diag, t_diag, length, vocab).clamp_min(1e-9).log()
                     .gather(-1, data[index][..., None])).mean()  # Cross-entropy to the token.

        s = torch.rand(batch_size, device=device)  # [B] start time.
        t = s + (1.0 - s) * torch.rand(batch_size, device=device)  # [B] end time.
        # The slice t = 1 is the one-step case the sampler reads, and a continuous draw never
        # lands on it, so we put a fixed share of the batch there on purpose (Section 6.26).
        endpoint = torch.rand(batch_size, device=device) < ENDPOINT_FRACTION  # [B] which rows.
        t = torch.where(endpoint, torch.ones_like(t), t)
        u = s + (t - s) * torch.rand(batch_size, device=device)  # [B] the split point.
        xs = (1 - s[:, None, None]) * x0 + s[:, None, None] * x1  # [B, L, K] the interpolant.
        with torch.no_grad():  # Both legs and the intermediate state are frozen targets.
            first = predict(model, xs, s, u, length, vocab)
            second = predict(model, step_map(xs, first, s, u), u, t, length, vocab)
            gamma = composition_weight(s, u, t)[:, None, None]
            target = gamma * first + (1.0 - gamma) * second  # [B, L, K], still on the simplex.
        student = predict(model, xs, s, t, length, vocab).clamp_min(1e-9)
        off_diagonal = (target * (target.clamp_min(1e-9) / student).log()).sum(-1).mean()
        return diagonal + off_diagonal

    return loss_fn


# %% 08. Sampling on a grid of times
@torch.no_grad()
def sample(model, count, length, vocab, grid, device):
    """Walk the grid with the convex form, and read the alphabet off the last prediction."""
    x = torch.randn(count, length, vocab, device=device)  # [N, L, K] the Gaussian source.
    for a, b in zip(grid[:-1], grid[1:]):
        s = torch.full((count,), a, device=device)
        t = torch.full((count,), b, device=device)
        x = step_map(x, predict(model, x, s, t, length, vocab), s, t)
    return x.argmax(-1)  # [N, L] token ids, read by an argmax at every position.


# %% 09. The report
def main() -> None:
    parser = common_parser(__doc__.split("\n")[0])
    parser.set_defaults(steps=1500, batch_size=128, samples=64, length=16)
    args = parser.parse_args()
    set_seed(args.seed)
    device = pick_device(args.device)
    tokens, _ = synthetic_dna(num_sequences=1024, length=args.length, seed=args.seed)
    data = tokens.to(device)  # [1024, L] the training sequences.

    report = scalar_cocycle()
    report.update(worked_kl())
    report.update(gradient_comparison())
    if not args.quiet:
        print(f"at the saturated student the KL gradient is "
              f"{report['saturated KL gradient']:.4f} and the squared-error gradient "
              f"{1e-8 * report['saturated squared-error gradient, in 1e-08']:.3e}")
    book_tau, book_start = clock(BOOK_VOCAB)
    for t in (0.60, 0.75, 0.80, 0.85):
        report[f"tau({t:.2f}) at |V|=30522"] = book_tau(t)
    book_grid = even_in_tau(BOOK_VOCAB)
    report["four steps on the clock"] = [round(t, 6) for t in book_grid]
    rounded = [round(t, 3) for t in book_grid]  # The chapter quotes the grid to three decimals.
    report["convex weights on that grid"] = [
        (round((1 - b) / (1 - a), 4), round((b - a) / (1 - a), 4))
        for a, b in zip(rounded[:-1], rounded[1:])]
    report["gamma on the last three"] = float(composition_weight(
        *(torch.tensor([v]) for v in rounded[1:4])))
    report["gamma over the long first leg"] = float(composition_weight(
        *(torch.tensor([v]) for v in (0.0, rounded[1], rounded[2]))))
    report["tau on a uniform grid"] = [round(book_tau(t), 5) for t in (0.25, 0.5, 0.75)]
    if not args.quiet:
        print(f"clock at |V|=30522: P_e(0) = {book_start:.6f}, four steps at "
              f"{[round(t, 3) for t in book_grid]}")

    model = TwoTimeMLP(dim=args.length * DNA_SIZE, width=args.width).to(device)
    if not args.quiet:
        print(f"\ntraining the two-time denoiser  parameters={count_parameters(model):,}")
    losses = train(model, make_loss(model, data, args.length, DNA_SIZE, args.batch_size, device),
                   steps=args.steps, lr=args.lr, quiet=args.quiet)

    dna_grid = even_in_tau(DNA_SIZE)  # The same clock, read at the four-letter alphabet.
    report["DNA clock grid"] = [round(t, 4) for t in dna_grid]
    samples: list[str] = []
    for name, grid in (("one step", [0.0, 1.0]), ("four, uniform", [0.0, 0.25, 0.5, 0.75, 1.0]),
                       ("four, on the clock", dna_grid)):
        torch.manual_seed(args.seed + 1)  # The same source noise for every schedule.
        drawn = sample(model, args.samples, args.length, DNA_SIZE, grid, device)
        report[f"{name}: GC fraction"] = gc_fraction(drawn)
        report[f"{name}: motif fraction"] = motif_fraction(drawn)
        report[f"{name}: token entropy"] = token_entropy(drawn, DNA_SIZE)
        report[f"{name}: unique fraction"] = unique_fraction(drawn)
        samples.append(f"{name:<20s} {decode_dna(drawn[:1])[0]}  GC {gc_fraction(drawn):.3f}")
    report["training GC fraction"] = gc_fraction(data)
    report["final loss"] = sum(losses[-20:]) / len(losses[-20:])

    print_report("Flow Map Language Models, Chapter 6", report, samples)
    save_run(args.out, model=model, config=vars(args), report=report, losses=losses, samples=samples)


if __name__ == "__main__":
    main()