File size: 11,903 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
"""Discrete Flow Maps, the derivative identities repaired in logit space.

A curve that stays on the simplex has coordinates summing to one, so its velocity has coordinates
summing to zero, and a target built from that velocity has a negative entry as soon as one
coordinate of the prediction is smaller than the derivative pulling it down. Written in logits the
same derivative becomes a positive multiplicative factor. If we divide by it every coordinate stays
positive, and a softmax normalizes the result, so the Kullback-Leibler divergence against the
repaired target is defined at every state.

Chapter 6, Sections 6.26 and 6.29 of the notes. Trained on the synthetic DNA of
``cis6270.data.synthetic_dna``. Prints the derivative target of Section 6.26, with three of its
four entries negative, the three-token repair, where the additive target (-0.284, 0.828, 0.456)
becomes the distribution (0.0194, 0.6259, 0.3547), and samples from the trained map.

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

# %% 01. Imports and configuration
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.

FLOOR = 1e-3  # We clip the factor away from zero before the logarithm, as the chapter notes.


# %% 02. Why the derivative target leaves the simplex
#
# d delta_{s,t}/dt = (1-s)/((t-s)(1-t)) [D_t(X_{s,t}(x)) - delta_{s,t}(x)]. The bracket is a
# difference of two simplex points, so its coordinates sum to zero, and the prefactor only scales
# it up. The sign pattern is the same for every alphabet and every prior.
def derivative_target(prediction, at_output, s: float, t: float):
    """The end-time derivative of the two-time denoiser, a direction and not a distribution."""
    prefactor = (1.0 - s) / ((t - s) * (1.0 - t))  # 6.6667 at s = 0.2, t = 0.8.
    return prefactor * (at_output - prediction), prefactor


def worked_derivative():
    """The chapter's DNA example at s = 0.2, t = 0.8, with three negative entries in the target."""
    prediction = torch.tensor([0.10, 0.20, 0.60, 0.10])  # delta_{0.2,0.8} over (A, C, G, T).
    at_output = torch.tensor([0.05, 0.10, 0.80, 0.05])  # D_{0.8} at the transported state.
    target, prefactor = derivative_target(prediction, at_output, 0.2, 0.8)
    return {"derivative prefactor": prefactor,
            "bracket": [round(v, 4) for v in (at_output - prediction).tolist()],
            "derivative target": [round(v, 4) for v in target.tolist()],
            "target sum": float(target.sum()),
            "negative entries": int((target < 0).sum())}


# %% 03. Differentiating a softmax
#
# The Jacobian of the softmax is diag(psi) - psi psi^T, so if we contract it with the logit
# derivative we get psi times the centered derivative. Because we centre, the coordinates of
# d psi/dt sum to zero and the identity is insensitive to the shift invariance of the softmax.
def centered(prediction, logit_derivative):
    """c_{s,t} = d z/dt - <psi, d z/dt> 1, the quantity the whole identity is built on."""
    mean = (prediction * logit_derivative).sum(-1, keepdim=True)  # <psi, dz/dt>.
    return logit_derivative - mean  # [..., K] with <psi, c> = 0.


# %% 04. The identity as a multiplicative relation, and the repaired teacher
#
# psi_{t,t}(X_{s,t}(x)) = psi_{s,t}(x) * (1 + gamma c_{s,t}(x)) coordinatewise. If we solve for the
# student we divide by a strictly positive factor, and in logits that division is a subtraction of
# its logarithm, so the target is a softmax of an expression the network already produces.
def gamma_lagrangian(s, t):
    """gamma_{s,t} = (t-s)(1-t)/(1-s), never above 1/4 on the square."""
    return (t - s) * (1.0 - t) / (1.0 - s).clamp(min=1e-6)


def gamma_eulerian(s, t):
    """gamma^E_{s,t} = (1-s)(t-s)/(1-t), larger than the Lagrangian one and growing toward t = 1."""
    return (1.0 - s) * (t - s) / (1.0 - t).clamp(min=1e-6)


def repaired_teacher(teacher_logits, factor):
    """Softmax(z_{t,t}(X_{s,t}(x)) - log(1 + gamma c)), a distribution by construction."""
    return torch.softmax(teacher_logits - factor.clamp(min=FLOOR).log(), dim=-1)


# %% 05. The repair worked on three tokens
def worked_repair(s: float = 0.2, t: float = 0.6):
    """The chapter's three-token repair, with the additive target beside the multiplicative one."""
    prediction = torch.tensor([0.2, 0.5, 0.3])  # psi_{s,t}(x).
    logit_derivative = torch.tensor([9.60, -2.28, -2.60])  # d z_{s,t}/dt.
    gamma = (t - s) * (1.0 - t) / (1.0 - s)
    c = centered(prediction, logit_derivative)  # Already centered on this example.
    derivative = prediction * c  # d psi/dt, with coordinates summing to zero.
    teacher = torch.tensor([0.10, 0.60, 0.30])  # psi_{t,t}(X_{s,t}(x)) during training.
    additive = teacher - gamma * derivative  # Read straight off the identity.
    factor = 1.0 + gamma * c  # The multiplicative factor, positive in every coordinate.
    quotient = teacher / factor  # The teacher divided coordinatewise.
    return {
        "weighted average of dz/dt": float((prediction * logit_derivative).sum()),
        "centered derivative": [round(v, 4) for v in c.tolist()],
        "d psi/dt": [round(v, 4) for v in derivative.tolist()],
        "d psi/dt sum": float(derivative.sum()),
        "additive target": [round(v, 4) for v in additive.tolist()],
        "additive target sum": float(additive.sum()),
        "additive negative entries": int((additive < 0).sum()),
        "multiplicative factor": [round(v, 4) for v in factor.tolist()],
        "quotient": [round(v, 4) for v in quotient.tolist()],
        "quotient mass": float(quotient.sum()),
        "repaired target": [round(v, 4) for v in (quotient / quotient.sum()).tolist()],
        "log of the factor": [round(v, 4) for v in factor.log().tolist()],
        "gamma_s,t": gamma,
        "gamma^E_s,t": (1.0 - s) * (t - s) / (1.0 - t),
    }


# %% 06. The student, and the loss built on the repaired target
def logits_of(model, x, s, t, length, vocab):
    """z_{s,t}(x), one vector of length K per position."""
    return model(x.flatten(1), s, t).view(-1, length, vocab)  # [B, L, K].


def make_loss(model, data, length, vocab, batch_size, device, counter):
    eye = torch.eye(vocab, device=device)

    def loss_fn(step: int) -> torch.Tensor:
        index = torch.randint(0, len(data), (batch_size,), device=device)
        ids = data[index]  # [B, L] the data tokens.
        x1, x0 = eye[ids], torch.randn(batch_size, length, vocab, device=device)
        t_diag = torch.rand(batch_size, device=device)
        xt = (1 - t_diag[:, None, None]) * x0 + t_diag[:, None, None] * x1
        diagonal_logits = logits_of(model, xt, t_diag, t_diag, length, vocab)
        anchor = torch.nn.functional.cross_entropy(  # KL to the one-hot token, the diagonal anchor.
            diagonal_logits.reshape(-1, vocab), ids.reshape(-1))

        s = 0.9 * torch.rand(batch_size, device=device)  # [B] start time.
        t = s + (0.95 - s) * torch.rand(batch_size, device=device)  # [B] end time below one.
        xs = (1 - s[:, None, None]) * x0 + s[:, None, None] * x1  # [B, L, K].
        f = lambda tt: logits_of(model, xs, s, tt, length, vocab)
        z, dz = torch.func.jvp(f, (t,), (torch.ones_like(t),))  # One forward-mode pass in t.
        prediction = torch.softmax(z, dim=-1)  # psi_{s,t}(x).
        gamma = gamma_lagrangian(s, t)[:, None, None]
        factor = 1.0 + gamma * centered(prediction, dz)  # The multiplicative factor.
        keep, move = (1.0 - t) / (1.0 - s), (t - s) / (1.0 - s)
        transported = keep[:, None, None] * xs + move[:, None, None] * prediction  # X_{s,t}(x).
        with torch.no_grad():  # The teacher is the same network read on the diagonal, detached.
            teacher_logits = logits_of(model, transported.detach(), t, t, length, vocab)
            target = repaired_teacher(teacher_logits, factor.detach())
            additive = torch.softmax(teacher_logits, -1) - gamma * prediction * centered(
                prediction, dz)  # The unrepaired target, measured and not trained on.
            counter["negative"] += float((additive < 0).to(torch.get_default_dtype()).mean())
            counter["batches"] += 1.0
        student = torch.log_softmax(z, dim=-1)  # The student in log space.
        return anchor + (target * ((target.clamp_min(1e-9)).log() - student)).sum(-1).mean()

    return loss_fn


# %% 07. Sampling
@torch.no_grad()
def sample(model, count, length, vocab, steps, device):
    """Walk a uniform grid with the convex form and read the alphabet off the last prediction."""
    x = torch.randn(count, length, vocab, device=device)
    grid = torch.linspace(0.0, 1.0, steps + 1)
    for a, b in zip(grid[:-1], grid[1:]):
        s, t = a.expand(count).to(device), b.expand(count).to(device)
        prediction = torch.softmax(logits_of(model, x, s, t, length, vocab), dim=-1)
        keep, move = (1.0 - t) / (1.0 - s), (t - s) / (1.0 - s)
        x = keep[:, None, None] * x + move[:, None, None] * prediction
    return x.argmax(-1)  # [N, L].


# %% 08. The report
def main() -> None:
    parser = common_parser(__doc__.split("\n")[0])
    parser.set_defaults(steps=1500, batch_size=128, samples=64, length=16, sample_steps=4)
    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].

    report = worked_derivative()
    report.update(worked_repair())
    if not args.quiet:
        print(f"derivative target {report['derivative target']} has "
              f"{report['negative entries']} negative entries and sums to "
              f"{report['target sum']:.4f}")
        print(f"additive {report['additive target']} repairs to {report['repaired target']}")

    counter = {"negative": 0.0, "batches": 0.0}
    model = TwoTimeMLP(dim=args.length * DNA_SIZE, width=args.width).to(device)
    if not args.quiet:
        print(f"\ntraining with the repaired target  parameters={count_parameters(model):,}")
    losses = train(model, make_loss(model, data, args.length, DNA_SIZE, args.batch_size, device,
                                    counter), steps=args.steps, lr=args.lr, quiet=args.quiet)
    report["additive target negative share"] = counter["negative"] / max(counter["batches"], 1.0)

    samples: list[str] = []
    for steps in (1, args.sample_steps):
        torch.manual_seed(args.seed + 1)  # The same source noise at every step count.
        drawn = sample(model, args.samples, args.length, DNA_SIZE, steps, device)
        report[f"{steps}-step GC fraction"] = gc_fraction(drawn)
        report[f"{steps}-step motif fraction"] = motif_fraction(drawn)
        report[f"{steps}-step token entropy"] = token_entropy(drawn, DNA_SIZE)
        report[f"{steps}-step unique fraction"] = unique_fraction(drawn)
        samples.append(f"{steps} step(s): {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("Discrete Flow Maps, Chapter 6", report, samples)
    save_run(args.out, model=model, config=vars(args), report=report, losses=losses, samples=samples)


if __name__ == "__main__":
    main()