File size: 13,079 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 | """Categorical Flow Maps, the Lagrangian identity on the simplex and the bound that replaces it.
A softmax head makes the two-time prediction a distribution at every pair of times, and the
Lagrangian identity rewritten in those coordinates reads pi_{s,t} = pi_{t,t}(X_{s,t}) minus a
coefficient times the end-time derivative of the prediction. The derivative has no sign
constraint, so the residual splits into a teacher gap, a difference of two distributions, and a
drift, the scaled derivative. A parallelogram bound and Pinsker's inequality turn the squared
residual into four KL divergences plus two squared drifts, and at the exact map the two pieces
are antiparallel, so the bound is strictly positive where the residual is zero.
Chapter 6, Section 6.28 of the notes. Trained on the four-token phrases of
``cis6270.data.phrases``. Prints the chapter's worked cancellation on three tokens, where the gap
is the exact negative of the drift and the bound reads 0.199 around a residual of zero, and then
two models trained on the residual and on its bound.
python lecture_6/categorical_map.py # two models, 1000 steps each
python lecture_6/categorical_map.py --steps 20 # a few seconds, for a first look
"""
# %% 01. Imports and configuration
import torch
from cis6270.data import phrases
from cis6270.metrics import 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_PRIOR = (0.5, 0.3, 0.2) # The three data probabilities of the chapter's worked example.
BOOK_SOURCE = (-0.5739, -0.8344, -0.0584) # The source draw I_0 the chapter starts from.
BOOK_STATES = 3000 # States the chapter averages the two terms of the bound over.
# %% 02. The exact model on three tokens
#
# With a Gaussian source and the linear interpolant, the posterior over the data token is a
# softmax whose only state dependence is the coordinate of that token. The velocity follows from
# D_t(x) = x + (1-t) b(x,t), and the flow integrates to twelve digits with Runge-Kutta.
def exact_denoiser(x, t, prior):
"""pi_{t,t}(x), the posterior over the data token given the state. [B, K]."""
return torch.softmax(prior.log() + t * x / (1.0 - t) ** 2, dim=-1)
def exact_velocity(x, t, prior):
"""b(x,t) = (pi_{t,t}(x) - x)/(1 - t) for the linear interpolant. [B, K]."""
return (exact_denoiser(x, t, prior) - x) / (1.0 - t)
def exact_flow(x, s, t, prior, n: int = 2000):
"""X_{s,t}(x) by fourth-order Runge-Kutta on the exact velocity."""
h = (t - s) / n
for k in range(n):
u = s + k * h
k1 = exact_velocity(x, u, prior)
k2 = exact_velocity(x + 0.5 * h * k1, u + 0.5 * h, prior)
k3 = exact_velocity(x + 0.5 * h * k2, u + 0.5 * h, prior)
k4 = exact_velocity(x + h * k3, u + h, prior)
x = x + (h / 6.0) * (k1 + 2 * k2 + 2 * k3 + k4)
return x
def exact_two_time(x, s, t, prior):
"""pi_{s,t}(x) read off the convex parameterization, [(1-s) X_{s,t}(x) - (1-t) x]/(t-s)."""
return ((1.0 - s) * exact_flow(x, s, t, prior) - (1.0 - t) * x) / (t - s)
# %% 03. The coefficient, the gap and the drift
#
# gamma_{s,t} = (t-s)(1-t)/(1-s) vanishes on the diagonal and at t = 1 and never exceeds 1/4 on
# the square. The residual scaled by the remaining time splits as (1-t) r = a + b, with a the gap
# between the student's prediction and the prediction at the transported state.
def gamma_coefficient(s, t):
"""gamma_{s,t} = (t-s)(1-t)/(1-s)."""
return (t - s) * (1.0 - t) / (1.0 - s).clamp(min=1e-6)
def end_time_derivative(x, s, t, prior, n: int = 2000):
"""d pi_{s,t}/dt from the integral representation, (1-s)/((t-s)(1-t)) times the bracket."""
transported = exact_flow(x, s, t, prior, n)
prediction = ((1.0 - s) * transported - (1.0 - t) * x) / (t - s)
at_output = exact_denoiser(transported, t, prior)
return (1.0 - s) / ((t - s) * (1.0 - t)) * (at_output - prediction), prediction, at_output
# %% 04. The worked cancellation of Section 6.28
#
# The derivative above comes from the closed form of Section 6.26, and that form already carries
# the cancellation, so the central difference of pi_{s,t} in t below checks the two pieces
# against each other without assuming the identity.
def worked_cancellation(s: float = 0.2, t: float = 0.6):
"""The three-token example, with every number integrated or differentiated here."""
prior = torch.tensor(BOOK_PRIOR, dtype=torch.float64)
source = torch.tensor(BOOK_SOURCE, dtype=torch.float64)
x = (1.0 - s) * source + s * torch.eye(3, dtype=torch.float64)[0] # Data token is the first.
derivative, prediction, at_output = end_time_derivative(x, s, t, prior)
gamma = (t - s) * (1.0 - t) / (1.0 - s)
gap = prediction - at_output # a_{s,t}, a difference of two simplex points.
drift = gamma * derivative # b_{s,t}, the scaled derivative of the prediction.
divergence = (at_output * (at_output / prediction).log()).sum() # KL(teacher || student).
difference = (exact_two_time(x, s, t + 1e-5, prior)
- exact_two_time(x, s, t - 1e-5, prior)) / 2e-5 # A central check.
return {
"state x": [round(v, 4) for v in x.tolist()],
"X_s,t(x)": [round(v, 4) for v in exact_flow(x, s, t, prior).tolist()],
"pi_s,t(x)": [round(v, 4) for v in prediction.tolist()],
"pi_t,t(X_s,t(x))": [round(v, 4) for v in at_output.tolist()],
"gamma_s,t": gamma,
"d pi / dt": [round(v, 4) for v in derivative.tolist()],
"central difference": [round(v, 4) for v in difference.tolist()],
"gap a": [round(v, 3) for v in gap.tolist()],
"drift b": [round(v, 3) for v in drift.tolist()],
"residual |a + b|^2": float(((gap + drift) ** 2).sum()),
"4 KL": 4.0 * float(divergence),
"2 |b|^2": 2.0 * float((drift ** 2).sum()),
"the bound": 4.0 * float(divergence) + 2.0 * float((drift ** 2).sum()),
"teacher entropy": -float((at_output * at_output.log()).sum()),
"cross-entropy": -float((at_output * prediction.log()).sum()),
}
def averaged_bound(count: int = BOOK_STATES, s: float = 0.2, t: float = 0.6, seed: int = 0,
n: int = 400):
"""The same two terms averaged over states drawn from the interpolant at the same times."""
prior = torch.tensor(BOOK_PRIOR, dtype=torch.float64)
generator = torch.Generator().manual_seed(seed)
which = torch.multinomial(prior.expand(count, 3), 1, generator=generator).squeeze(1)
x1 = torch.eye(3, dtype=torch.float64)[which] # [N, 3] the lifted data token.
x0 = torch.randn(count, 3, generator=generator, dtype=torch.float64) # [N, 3] the source.
x = (1.0 - s) * x0 + s * x1 # [N, 3] the interpolant at the start time.
derivative, prediction, at_output = end_time_derivative(x, s, t, prior, n)
drift = ((t - s) * (1.0 - t) / (1.0 - s)) * derivative
divergence = (at_output * (at_output / prediction).log()).sum(-1).mean()
return {"averaged 4 KL": 4.0 * float(divergence),
"averaged 2 |b|^2": 2.0 * float((drift ** 2).sum(-1).mean()),
"averaged residual": float(((prediction - at_output + drift) ** 2).sum(-1).mean())}
# %% 05. The student, and the two losses it can be trained on
def predict(model, x, s, t, length, vocab):
"""pi_{s,t}(x) at every position, a softmax head over the vocabulary."""
return torch.softmax(model(x.flatten(1), s, t).view(-1, length, vocab), dim=-1)
def pieces(model, xs, s, t, length, vocab):
"""The gap a and the drift b of the split, with the teacher detached."""
f = lambda tt: predict(model, xs, s, tt, length, vocab)
prediction, derivative = torch.func.jvp(f, (t,), (torch.ones_like(t),)) # One forward mode pass.
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).
teacher = predict(model, transported.detach(), t, t, length, vocab).detach() # pi_{t,t}.
drift = gamma_coefficient(s, t)[:, None, None] * derivative
return prediction - teacher, drift, teacher, prediction
def make_loss(model, data, length, vocab, batch_size, device, bounded: bool):
eye = torch.eye(vocab, device=device)
def loss_fn(step: int) -> torch.Tensor:
index = torch.randint(0, len(data), (batch_size,), device=device)
x1, ids = eye[data[index]], data[index] # [B, L, K] and [B, L].
x0 = torch.randn_like(x1) # [B, L, K] the Gaussian source.
t_diag = torch.rand(batch_size, device=device)
xt = (1 - t_diag[:, None, None]) * x0 + t_diag[:, None, None] * x1
anchor = -(predict(model, xt, t_diag, t_diag, length, vocab).clamp_min(1e-9).log()
.gather(-1, ids[..., None])).mean() # Cross-entropy on the diagonal.
s = 0.9 * torch.rand(batch_size, device=device) # [B] start time, kept off t = 1.
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
gap, drift, teacher, prediction = pieces(model, xs, s, t, length, vocab)
if bounded: # Four KL divergences plus two squared drifts, the bound of Section 6.28.
divergence = (teacher * (teacher.clamp_min(1e-9)
/ prediction.clamp_min(1e-9)).log()).sum(-1).mean()
return anchor + 4.0 * divergence + 2.0 * (drift ** 2).sum(-1).mean()
return anchor + ((gap + drift) ** 2).sum(-1).mean() # The residual itself.
return loss_fn
# %% 06. Sampling, and the test for a valid phrase
@torch.no_grad()
def sample(model, count, length, vocab, steps, device):
"""Walk a uniform grid with the convex form and read the phrase 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 = predict(model, x, s, t, length, vocab)
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].
def valid_fraction(drawn, group: int = 4):
"""A phrase is in the language when position i holds a token from the i-th quarter."""
slots = torch.arange(drawn.shape[1], device=drawn.device) # [L].
return float(((drawn // group) == slots[None, :]).all(dim=1).to(torch.get_default_dtype()).mean())
# %% 07. The report
def main() -> None:
parser = common_parser(__doc__.split("\n")[0])
parser.set_defaults(steps=1000, batch_size=128, samples=256, sample_steps=4)
args = parser.parse_args()
set_seed(args.seed)
device = pick_device(args.device)
tokens, vocabulary = phrases(num_phrases=1024, seed=args.seed)
data, length, vocab = tokens.to(device), tokens.shape[1], len(vocabulary)
report = worked_cancellation()
report.update(averaged_bound(seed=args.seed)) # The chapter's three thousand states.
if not args.quiet:
print(f"gap {report['gap a']} and drift {report['drift b']} cancel to "
f"{report['residual |a + b|^2']:.2e}, bound {report['the bound']:.3f}")
samples: list[str] = []
for name, bounded in (("the residual", False), ("the bound", True)):
set_seed(args.seed) # Both students start from the same weights and see the same batches.
model = TwoTimeMLP(dim=length * vocab, width=args.width).to(device)
if not args.quiet:
print(f"\ntraining on {name} parameters={count_parameters(model):,}")
losses = train(model, make_loss(model, data, length, vocab, args.batch_size, device,
bounded), steps=args.steps, lr=args.lr, quiet=args.quiet)
for steps in (1, args.sample_steps):
torch.manual_seed(args.seed + 1)
drawn = sample(model, args.samples, length, vocab, steps, device)
report[f"{name}, {steps} step(s): valid"] = valid_fraction(drawn)
report[f"{name}, {steps} step(s): distinct"] = unique_fraction(drawn)
report[f"{name}, {steps} step(s): entropy"] = token_entropy(drawn, vocab)
report[f"{name}: final loss"] = sum(losses[-20:]) / len(losses[-20:])
drawn = sample(model, 4, length, vocab, args.sample_steps, device)
samples.append(f"{name:<14s} " + " | ".join(
" ".join(vocabulary[int(i)] for i in row) for row in drawn[:2]))
print_report("Categorical Flow Maps, Chapter 6", report, samples)
save_run(args.out, config=vars(args), report=report, samples=samples)
if __name__ == "__main__":
main()
|